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
end
@errors = []
create_links
references = create_links
if @errors.present?
return error(@errors.join('. '), 422)
......@@ -33,7 +33,7 @@ module IssuableLinks
track_event
success
success(created_references: references)
end
# rubocop: disable CodeReuse/ActiveRecord
......@@ -66,7 +66,7 @@ module IssuableLinks
end
def link_issuables(target_issuables)
target_issuables.each do |referenced_object|
target_issuables.map do |referenced_object|
link = relate_issuables(referenced_object)
unless link.valid?
......@@ -75,6 +75,8 @@ module IssuableLinks
error: link.errors.messages.values.flatten.to_sentence
}
end
link
end
end
......
......@@ -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,10 +14,10 @@ module EE
# see EpicLinks::EpicIssues#relate_issuables
affected_epics = affected_epics(objects)
super
if !params[:skip_epic_dates_update] && affected_epics.present?
Epics::UpdateDatesService.new(affected_epics).execute
super.tap do
if !params[:skip_epic_dates_update] && affected_epics.present?
Epics::UpdateDatesService.new(affected_epics).execute
end
end
end
......
......@@ -25,7 +25,7 @@ module EpicLinks
if linkable_epic?(child_epic) && set_child_epic(child_epic)
create_notes(child_epic)
success
success(created_references: [child_epic])
else
error(child_epic.errors.values.flatten.to_sentence, 409)
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
def authorize_related_epics_feature_flag!
not_found! unless Feature.enabled?(:related_epics_widget, user_group, default_enabled: :yaml)
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
helpers ::API::Helpers::EpicsHelpers
......@@ -45,6 +54,36 @@ module API
present related_epics, presenter_options
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
......@@ -3,12 +3,12 @@
"properties" : {
"source_epic": {
"allOf": [
{ "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/epic.json" }
{ "$ref": "epic.json" }
]
},
"target_epic": {
"allOf": [
{ "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/epic.json" }
{ "$ref": "epic.json" }
]
},
"link_type": {
......
......@@ -14,8 +14,6 @@ RSpec.describe API::RelatedEpicLinks do
end
shared_examples 'a not available endpoint' do
subject { perform_request(user) }
context 'when epics feature is not available' do
before do
stub_licensed_features(epics: false, related_epics: true)
......@@ -47,6 +45,8 @@ RSpec.describe API::RelatedEpicLinks do
get api("/groups/#{group.id}/epics/#{epic.iid}/related_epics", user), params: params
end
subject { perform_request(user) }
context 'when user cannot read epics' do
it 'returns 404' do
perform_request
......@@ -87,4 +87,115 @@ RSpec.describe API::RelatedEpicLinks do
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
......@@ -50,8 +50,10 @@ RSpec.describe IssueLinks::CreateService do
end
end
it 'returns success status' do
is_expected.to eq(status: :success)
it 'returns success status and created links', :aggregate_failures do
expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references]).not_to be_empty
end
it_behaves_like 'issuable link creation with blocking link_type' do
......@@ -84,8 +86,8 @@ RSpec.describe IssueLinks::CreateService do
end
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))
.to eq([IssueLink::TYPE_BLOCKS, IssueLink::TYPE_BLOCKS, IssueLink::TYPE_BLOCKS])
end
......
......@@ -37,8 +37,10 @@ RSpec.describe EpicIssues::CreateService do
expect(created_link.relative_position).to be < existing_link.reload.relative_position
end
it 'returns success status' do
expect(subject).to eq(status: :success)
it 'returns success status and created links', :aggregate_failures do
expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references].count).to eq(1)
end
describe 'async actions', :sidekiq_inline do
......@@ -216,8 +218,10 @@ RSpec.describe EpicIssues::CreateService do
.to all(be < existing_link.reset.relative_position)
end
it 'returns success status' do
expect(subject).to eq(status: :success)
it 'returns success status and created links', :aggregate_failures do
expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references].count).to eq(2)
end
it 'creates 2 system notes for each issue', :sidekiq_inline do
......@@ -322,8 +326,10 @@ RSpec.describe EpicIssues::CreateService do
expect { subject }.to change { EpicIssue.last.epic }.from(epic).to(another_epic)
end
it 'returns success status' do
is_expected.to eq(status: :success)
it 'returns success status and created links', :aggregate_failures do
expect(subject.keys).to match_array([:status, :created_references])
expect(subject[:status]).to eq(:success)
expect(subject[:created_references].count).to eq(1)
end
it 'creates 3 system notes', :sidekiq_inline 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
end
it 'returns success status' do
expect(subject).to eq(status: :success)
it 'returns success status and created links', :aggregate_failures do
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
......@@ -323,8 +325,10 @@ RSpec.describe EpicLinks::CreateService do
expect { subject }.to change { Note.system.count }.from(0).to(4)
end
it 'returns success status' do
expect(subject).to eq(status: :success)
it 'returns success status and created links', :aggregate_failures do
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
it 'avoids un-necessary database queries' do
......@@ -367,8 +371,10 @@ RSpec.describe EpicLinks::CreateService do
expect { subject }.to change { Note.system.count }.from(0).to(2)
end
it 'returns success status' do
expect(subject).to eq(status: :success)
it 'returns success status and created links', :aggregate_failures do
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
......
......@@ -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')
end
it 'returns success status' do
is_expected.to eq(status: :success)
it 'returns success status and created links', :aggregate_failures do
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
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