Commit 1ee01f0f authored by Jan Provaznik's avatar Jan Provaznik Committed by Heinrich Lee Yu

Add create endpoint for related epics

Allows creating related epic links through REST API, similar to
issue_links endpoint for issues.

Changelog: added
EE: true
parent 3480111a
...@@ -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