Commit 58e25f20 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'jp-repics-rest' into 'master'

Add create endpoint for related epics

See merge request gitlab-org/gitlab!82985
parents 08d27d70 1ee01f0f
...@@ -25,7 +25,7 @@ module IssuableLinks ...@@ -25,7 +25,7 @@ module IssuableLinks
end end
@errors = [] @errors = []
create_links references = create_links
if @errors.present? if @errors.present?
return error(@errors.join('. '), 422) return error(@errors.join('. '), 422)
...@@ -33,7 +33,7 @@ module IssuableLinks ...@@ -33,7 +33,7 @@ module IssuableLinks
track_event track_event
success success(created_references: references)
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
...@@ -66,7 +66,7 @@ module IssuableLinks ...@@ -66,7 +66,7 @@ module IssuableLinks
end end
def link_issuables(target_issuables) def link_issuables(target_issuables)
target_issuables.each do |referenced_object| target_issuables.map do |referenced_object|
link = relate_issuables(referenced_object) link = relate_issuables(referenced_object)
unless link.valid? unless link.valid?
...@@ -75,6 +75,8 @@ module IssuableLinks ...@@ -75,6 +75,8 @@ module IssuableLinks
error: link.errors.messages.values.flatten.to_sentence error: link.errors.messages.values.flatten.to_sentence
} }
end end
link
end end
end end
......
...@@ -88,3 +88,120 @@ Example response: ...@@ -88,3 +88,120 @@ Example response:
} }
] ]
``` ```
## Create a related epic link
Create a two-way relation between two epics. The user must be allowed to
update both epics to succeed.
```plaintext
POST /groups/:id/epics/:epic_iid/related_epics
```
Supported attributes:
| Attribute | Type | Required | Description |
|---------------------|----------------|-----------------------------|---------------------------------------|
| `epic_iid` | integer | **{check-circle}** Yes | Internal ID of a group's epic. |
| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) owned by the authenticated user. |
| `target_epic_iid` | integer/string | **{check-circle}** Yes | Internal ID of a target group's epic. |
| `target_group_id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the target group](index.md#namespaced-path-encoding). |
| `link_type` | string | **{dotted-circle}** No | Type of the relation (`relates_to`, `blocks`, `is_blocked_by`), defaults to `relates_to`. |
Example request:
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/26/epics/1/related_epics?target_group_id=26&target_epic_iid=5"
```
Example response:
```json
{
"source_epic": {
"id": 21,
"iid": 1,
"color": "#1068bf",
"text_color": "#FFFFFF",
"group_id": 26,
"parent_id": null,
"parent_iid": null,
"title": "Aspernatur recusandae distinctio omnis et qui est iste.",
"description": "some description",
"confidential": false,
"author": {
"id": 15,
"username": "trina",
"name": "Theresia Robel",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/085e28df717e16484cbf6ceca75e9a93?s=80&d=identicon",
"web_url": "http://gitlab.example.com/trina"
},
"start_date": null,
"end_date": null,
"due_date": null,
"state": "opened",
"web_url": "http://gitlab.example.com/groups/flightjs/-/epics/1",
"references": {
"short": "&1",
"relative": "&1",
"full": "flightjs&1"
},
"created_at": "2022-01-31T15:10:44.988Z",
"updated_at": "2022-03-16T09:32:35.712Z",
"closed_at": null,
"labels": [],
"upvotes": 0,
"downvotes": 0,
"_links": {
"self": "http://gitlab.example.com/api/v4/groups/26/epics/1",
"epic_issues": "http://gitlab.example.com/api/v4/groups/26/epics/1/issues",
"group": "http://gitlab.example.com/api/v4/groups/26",
"parent": null
}
},
"target_epic": {
"id": 25,
"iid": 5,
"color": "#1068bf",
"text_color": "#FFFFFF",
"group_id": 26,
"parent_id": null,
"parent_iid": null,
"title": "Aut assumenda id nihil distinctio fugiat vel numquam est.",
"description": "some description",
"confidential": false,
"author": {
"id": 3,
"username": "valerie",
"name": "Erika Wolf",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/9ef7666abb101418a4716a8ed4dded80?s=80&d=identicon",
"web_url": "http://gitlab.example.com/valerie"
},
"start_date": null,
"end_date": null,
"due_date": null,
"state": "opened",
"web_url": "http://gitlab.example.com/groups/flightjs/-/epics/5",
"references": {
"short": "&5",
"relative": "&5",
"full": "flightjs&5"
},
"created_at": "2022-01-31T15:10:45.080Z",
"updated_at": "2022-03-16T09:32:35.842Z",
"closed_at": null,
"labels": [],
"upvotes": 0,
"downvotes": 0,
"_links": {
"self": "http://gitlab.example.com/api/v4/groups/26/epics/5",
"epic_issues": "http://gitlab.example.com/api/v4/groups/26/epics/5/issues",
"group": "http://gitlab.example.com/api/v4/groups/26",
"parent": null
}
},
"link_type": "relates_to"
}
```
...@@ -14,12 +14,12 @@ module EE ...@@ -14,12 +14,12 @@ module EE
# see EpicLinks::EpicIssues#relate_issuables # see EpicLinks::EpicIssues#relate_issuables
affected_epics = affected_epics(objects) affected_epics = affected_epics(objects)
super super.tap do
if !params[:skip_epic_dates_update] && affected_epics.present? if !params[:skip_epic_dates_update] && affected_epics.present?
Epics::UpdateDatesService.new(affected_epics).execute Epics::UpdateDatesService.new(affected_epics).execute
end end
end end
end
def affected_epics(_issues) def affected_epics(_issues)
[] []
......
...@@ -25,7 +25,7 @@ module EpicLinks ...@@ -25,7 +25,7 @@ module EpicLinks
if linkable_epic?(child_epic) && set_child_epic(child_epic) if linkable_epic?(child_epic) && set_child_epic(child_epic)
create_notes(child_epic) create_notes(child_epic)
success success(created_references: [child_epic])
else else
error(child_epic.errors.values.flatten.to_sentence, 409) error(child_epic.errors.values.flatten.to_sentence, 409)
end end
......
# frozen_string_literal: true
module API
module Entities
class RelatedEpicLink < Grape::Entity
expose :source, as: :source_epic, using: ::EE::API::Entities::Epic
expose :target, as: :target_epic, using: ::EE::API::Entities::Epic
expose :link_type
end
end
end
...@@ -14,6 +14,15 @@ module API ...@@ -14,6 +14,15 @@ module API
def authorize_related_epics_feature_flag! def authorize_related_epics_feature_flag!
not_found! unless Feature.enabled?(:related_epics_widget, user_group, default_enabled: :yaml) not_found! unless Feature.enabled?(:related_epics_widget, user_group, default_enabled: :yaml)
end end
def find_permissioned_epic!(iid, group_id: nil, permission: :admin_related_epic_link)
group = group_id ? find_group!(group_id) : user_group
epic = group.epics.find_by_iid!(iid)
authorize!(permission, epic)
epic
end
end end
helpers ::API::Helpers::EpicsHelpers helpers ::API::Helpers::EpicsHelpers
...@@ -45,6 +54,36 @@ module API ...@@ -45,6 +54,36 @@ module API
present related_epics, presenter_options present related_epics, presenter_options
end end
desc 'Relate epics' do
success Entities::RelatedEpicLink
end
params do
requires :target_group_id, type: String, desc: 'The ID of the target group'
requires :target_epic_iid, type: Integer, desc: 'The IID of the target epic'
optional :link_type, type: String, values: ::Epic::RelatedEpicLink.link_types.keys,
desc: 'The type of the relation'
end
post ':id/epics/:epic_iid/related_epics' do
source_epic = find_permissioned_epic!(params[:epic_iid])
target_epic = find_permissioned_epic!(declared_params[:target_epic_iid],
group_id: declared_params[:target_group_id],
permission: :admin_epic)
create_params = { target_issuable: target_epic, link_type: declared_params[:link_type] }
result = ::Epics::RelatedEpicLinks::CreateService
.new(source_epic, current_user, create_params)
.execute
if result[:status] == :success
# If status is success, there should be always a created link, so
# we can rely on it.
present result[:created_references].first, with: Entities::RelatedEpicLink
else
render_api_error!(result[:message], result[:http_status])
end
end
end end
end end
end end
...@@ -3,12 +3,12 @@ ...@@ -3,12 +3,12 @@
"properties" : { "properties" : {
"source_epic": { "source_epic": {
"allOf": [ "allOf": [
{ "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/epic.json" } { "$ref": "epic.json" }
] ]
}, },
"target_epic": { "target_epic": {
"allOf": [ "allOf": [
{ "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/epic.json" } { "$ref": "epic.json" }
] ]
}, },
"link_type": { "link_type": {
......
...@@ -14,8 +14,6 @@ RSpec.describe API::RelatedEpicLinks do ...@@ -14,8 +14,6 @@ RSpec.describe API::RelatedEpicLinks do
end end
shared_examples 'a not available endpoint' do shared_examples 'a not available endpoint' do
subject { perform_request(user) }
context 'when epics feature is not available' do context 'when epics feature is not available' do
before do before do
stub_licensed_features(epics: false, related_epics: true) stub_licensed_features(epics: false, related_epics: true)
...@@ -47,6 +45,8 @@ RSpec.describe API::RelatedEpicLinks do ...@@ -47,6 +45,8 @@ RSpec.describe API::RelatedEpicLinks do
get api("/groups/#{group.id}/epics/#{epic.iid}/related_epics", user), params: params get api("/groups/#{group.id}/epics/#{epic.iid}/related_epics", user), params: params
end end
subject { perform_request(user) }
context 'when user cannot read epics' do context 'when user cannot read epics' do
it 'returns 404' do it 'returns 404' do
perform_request perform_request
...@@ -87,4 +87,115 @@ RSpec.describe API::RelatedEpicLinks do ...@@ -87,4 +87,115 @@ RSpec.describe API::RelatedEpicLinks do
end end
end end
end end
describe 'POST /related_epics' do
let_it_be(:target_group) { create(:group, :private) }
let_it_be(:target_epic) { create(:epic, group: target_group) }
let(:target_epic_iid) { target_epic.iid }
subject { perform_request(user, target_group_id: target_group.id, target_epic_iid: target_epic_iid) }
def perform_request(user = nil, params = {})
post api("/groups/#{group.id}/epics/#{epic.iid}/related_epics", user), params: params
end
shared_examples 'not found resource' do |message|
it 'returns 404' do
subject
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq(message)
end
end
shared_examples 'forbidden resource' do |message|
it 'returns 403' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'when unauthenticated' do
it 'returns 401' do
perform_request
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when user can not access source epic' do
before do
target_group.add_reporter(user)
end
it_behaves_like 'not found resource', '404 Group Not Found'
end
context 'when user can only read source epic' do
before do
group.add_guest(user)
target_group.add_reporter(user)
end
it_behaves_like 'forbidden resource'
end
context 'when user can manage source epic' do
before do
group.add_reporter(user)
end
it_behaves_like 'not found resource', '404 Group Not Found'
context 'when user is guest in target group' do
before do
target_group.add_guest(user)
end
it_behaves_like 'forbidden resource'
context 'when target epic is confidential' do
let_it_be(:confidential_target_epic) { create(:epic, :confidential, group: target_group) }
let(:target_epic_iid) { confidential_target_epic.iid }
it_behaves_like 'forbidden resource'
end
end
context 'when user can relate epics' do
before do
target_group.add_reporter(user)
end
it_behaves_like 'a not available endpoint'
it 'returns 201 status and contains the expected link response' do
subject
expect_link_response
end
it 'returns 201 when sending full path of target group' do
perform_request(user, target_group_id: target_group.full_path, target_epic_iid: target_epic.iid, link_type: 'blocks')
expect_link_response(link_type: 'blocks')
end
context 'when target epic is not found' do
let(:target_epic_iid) { non_existing_record_iid }
it_behaves_like 'not found resource', '404 Not found'
end
def expect_link_response(link_type: 'relates_to')
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('public_api/v4/related_epic_link')
expect(json_response['link_type']).to eq(link_type)
end
end
end
end
end end
...@@ -50,8 +50,10 @@ RSpec.describe IssueLinks::CreateService do ...@@ -50,8 +50,10 @@ RSpec.describe IssueLinks::CreateService do
end end
end end
it 'returns success status' do it 'returns success status and created links', :aggregate_failures do
is_expected.to eq(status: :success) expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references]).not_to be_empty
end end
it_behaves_like 'issuable link creation with blocking link_type' do it_behaves_like 'issuable link creation with blocking link_type' do
...@@ -84,8 +86,8 @@ RSpec.describe IssueLinks::CreateService do ...@@ -84,8 +86,8 @@ RSpec.describe IssueLinks::CreateService do
end end
it 'sets the same type of relation for selected references' do it 'sets the same type of relation for selected references' do
expect(subject).to eq(status: :success) expect(subject[:status]).to eq(:success)
expect(subject[:created_references].count).to eq(3)
expect(IssueLink.where(target: [issue_a, issue_b, issue_c]).pluck(:link_type)) expect(IssueLink.where(target: [issue_a, issue_b, issue_c]).pluck(:link_type))
.to eq([IssueLink::TYPE_BLOCKS, IssueLink::TYPE_BLOCKS, IssueLink::TYPE_BLOCKS]) .to eq([IssueLink::TYPE_BLOCKS, IssueLink::TYPE_BLOCKS, IssueLink::TYPE_BLOCKS])
end end
......
...@@ -37,8 +37,10 @@ RSpec.describe EpicIssues::CreateService do ...@@ -37,8 +37,10 @@ RSpec.describe EpicIssues::CreateService do
expect(created_link.relative_position).to be < existing_link.reload.relative_position expect(created_link.relative_position).to be < existing_link.reload.relative_position
end end
it 'returns success status' do it 'returns success status and created links', :aggregate_failures do
expect(subject).to eq(status: :success) expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references].count).to eq(1)
end end
describe 'async actions', :sidekiq_inline do describe 'async actions', :sidekiq_inline do
...@@ -216,8 +218,10 @@ RSpec.describe EpicIssues::CreateService do ...@@ -216,8 +218,10 @@ RSpec.describe EpicIssues::CreateService do
.to all(be < existing_link.reset.relative_position) .to all(be < existing_link.reset.relative_position)
end end
it 'returns success status' do it 'returns success status and created links', :aggregate_failures do
expect(subject).to eq(status: :success) expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references].count).to eq(2)
end end
it 'creates 2 system notes for each issue', :sidekiq_inline do it 'creates 2 system notes for each issue', :sidekiq_inline do
...@@ -322,8 +326,10 @@ RSpec.describe EpicIssues::CreateService do ...@@ -322,8 +326,10 @@ RSpec.describe EpicIssues::CreateService do
expect { subject }.to change { EpicIssue.last.epic }.from(epic).to(another_epic) expect { subject }.to change { EpicIssue.last.epic }.from(epic).to(another_epic)
end end
it 'returns success status' do it 'returns success status and created links', :aggregate_failures do
is_expected.to eq(status: :success) expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references].count).to eq(1)
end end
it 'creates 3 system notes', :sidekiq_inline do it 'creates 3 system notes', :sidekiq_inline do
......
...@@ -36,8 +36,10 @@ RSpec.describe EpicLinks::CreateService do ...@@ -36,8 +36,10 @@ RSpec.describe EpicLinks::CreateService do
expect(epic_to_add.reload.relative_position).to be < existing_child_epic.reload.relative_position expect(epic_to_add.reload.relative_position).to be < existing_child_epic.reload.relative_position
end end
it 'returns success status' do it 'returns success status and created links', :aggregate_failures do
expect(subject).to eq(status: :success) expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references]).to match_array([epic_to_add])
end end
end end
...@@ -323,8 +325,10 @@ RSpec.describe EpicLinks::CreateService do ...@@ -323,8 +325,10 @@ RSpec.describe EpicLinks::CreateService do
expect { subject }.to change { Note.system.count }.from(0).to(4) expect { subject }.to change { Note.system.count }.from(0).to(4)
end end
it 'returns success status' do it 'returns success status and created links', :aggregate_failures do
expect(subject).to eq(status: :success) expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references]).to match_array([epic_to_add, another_epic])
end end
it 'avoids un-necessary database queries' do it 'avoids un-necessary database queries' do
...@@ -367,8 +371,10 @@ RSpec.describe EpicLinks::CreateService do ...@@ -367,8 +371,10 @@ RSpec.describe EpicLinks::CreateService do
expect { subject }.to change { Note.system.count }.from(0).to(2) expect { subject }.to change { Note.system.count }.from(0).to(2)
end end
it 'returns success status' do it 'returns success status and created links', :aggregate_failures do
expect(subject).to eq(status: :success) expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references]).to match_array([another_epic])
end end
end end
......
...@@ -70,8 +70,10 @@ shared_examples 'issuable link creation' do ...@@ -70,8 +70,10 @@ shared_examples 'issuable link creation' do
expect(issuable_link_class.find_by!(target: issuable3)).to have_attributes(source: issuable, link_type: 'relates_to') expect(issuable_link_class.find_by!(target: issuable3)).to have_attributes(source: issuable, link_type: 'relates_to')
end end
it 'returns success status' do it 'returns success status and created links', :aggregate_failures do
is_expected.to eq(status: :success) expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references].map(&:target_id)).to match_array([issuable2.id, issuable3.id])
end end
it 'creates notes' do it 'creates notes' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment