diff --git a/app/models/work_items/related_work_item_link.rb b/app/models/work_items/related_work_item_link.rb index fb0069541fb08548b762713918e5472acae660c7..6f05ff8dfbdbe94dda8e021eb6ed01708e90a47e 100644 --- a/app/models/work_items/related_work_item_link.rb +++ b/app/models/work_items/related_work_item_link.rb @@ -3,6 +3,8 @@ module WorkItems class RelatedWorkItemLink < ApplicationRecord include LinkableItem + include CreatedAtFilterable + include UpdatedAtFilterable self.table_name = 'issue_links' diff --git a/ee/app/models/ee/work_items/related_work_item_link.rb b/ee/app/models/ee/work_items/related_work_item_link.rb index 344b06cbbe768dc8e57f9c00fbfee8b4891af341..b0f97ac13a7458e204f9f26dfaf760bd2438f175 100644 --- a/ee/app/models/ee/work_items/related_work_item_link.rb +++ b/ee/app/models/ee/work_items/related_work_item_link.rb @@ -10,6 +10,11 @@ module RelatedWorkItemLink prepended do has_one :related_epic_link, class_name: '::Epic::RelatedEpicLink', foreign_key: 'issue_link_id', inverse_of: :related_work_item_link + + scope :for_source_type, ->(type) { joins(source: [:work_item_type]).where(source: { work_item_type_id: type }) } + scope :for_target_type, ->(type) { joins(target: [:work_item_type]).where(target: { work_item_type_id: type }) } + + scope :preload_for_epic_link, -> { preload(:related_epic_link, source: [:synced_epic], target: [:synced_epic]) } end override :validate_related_link_restrictions diff --git a/ee/app/services/work_items/legacy_epics/related_epic_links/list_service.rb b/ee/app/services/work_items/legacy_epics/related_epic_links/list_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..903682ee0504f6d28688c64c6827171f291d8520 --- /dev/null +++ b/ee/app/services/work_items/legacy_epics/related_epic_links/list_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module WorkItems + module LegacyEpics + module RelatedEpicLinks + class ListService + include Gitlab::Utils::StrongMemoize + + def initialize(legacy_epics, group) + @legacy_epics = legacy_epics + @group = group + end + + def execute + if Feature.enabled?(:related_epic_links_from_work_items, group) + WorkItems::RelatedWorkItemLink + .for_source_or_target(legacy_epics.select(:issue_id)) + .for_source_type(epic_type) + .for_target_type(epic_type) + .preload_for_epic_link + + else + Epic::RelatedEpicLink.for_source_or_target(legacy_epics) + end + end + + private + + def epic_type + ::WorkItems::Type.default_by_type(:epic) + end + strong_memoize_attr :epic_type + + attr_reader :legacy_epics, :group + end + end + end +end diff --git a/ee/config/feature_flags/beta/related_epic_links_from_work_items.yml b/ee/config/feature_flags/beta/related_epic_links_from_work_items.yml new file mode 100644 index 0000000000000000000000000000000000000000..730e73779af1050ec1cf2a3d061502cfe949a19d --- /dev/null +++ b/ee/config/feature_flags/beta/related_epic_links_from_work_items.yml @@ -0,0 +1,9 @@ +--- +name: related_epic_links_from_work_items +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/502553 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/179742 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/516212 +milestone: '17.10' +group: group::product planning +type: beta +default_enabled: false diff --git a/ee/lib/api/entities/related_epic_link.rb b/ee/lib/api/entities/related_epic_link.rb index 2f3afb897e47d365795de4ffcf21f182e46b96ee..40cd82230bb278494e53a729844efe73b09629b0 100644 --- a/ee/lib/api/entities/related_epic_link.rb +++ b/ee/lib/api/entities/related_epic_link.rb @@ -9,6 +9,33 @@ class RelatedEpicLink < Grape::Entity expose :link_type, documentation: { type: "string", example: "relates_to" } expose :created_at, documentation: { type: "dateTime", example: "2022-01-31T15:10:45.080Z" } expose :updated_at, documentation: { type: "dateTime", example: "2022-01-31T15:10:45.080Z" } + + def id + case object + when ::Epic::RelatedEpicLink + object.id + when ::WorkItems::RelatedWorkItemLink + object.related_epic_link.id + end + end + + def source + case object.source + when ::Epic + object.source + when ::WorkItem + object.source.synced_epic + end + end + + def target + case object.target + when ::Epic + object.target + when ::WorkItem + object.target.synced_epic + end + end end end end diff --git a/ee/lib/api/related_epic_links.rb b/ee/lib/api/related_epic_links.rb index f6961be18a7233f50ed757a70334a1ce84ca162f..ce68e8bc7e2c1c3e8574dba884ec73fabde0b4c5 100644 --- a/ee/lib/api/related_epic_links.rb +++ b/ee/lib/api/related_epic_links.rb @@ -65,26 +65,32 @@ def find_permissioned_epic!(iid, group_id: nil, permission: :admin_epic_link_rel end get ':id/related_epic_links' do accessible_epics = EpicsFinder.new(current_user, group_id: user_group.id).execute - related_epic_links = Epic::RelatedEpicLink.for_source_or_target(accessible_epics) - related_epic_links = related_epic_links.updated_before(params[:updated_before]) if params[:updated_before] - related_epic_links = related_epic_links.updated_after(params[:updated_after]) if params[:updated_after] - related_epic_links = related_epic_links.created_before(params[:created_before]) if params[:created_before] - related_epic_links = related_epic_links.created_after(params[:created_after]) if params[:created_after] + related_links = ::WorkItems::LegacyEpics::RelatedEpicLinks::ListService + .new(accessible_epics, user_group).execute - related_epic_links = paginate(related_epic_links).with_api_entity_associations - related_epic_links.each { |link| [link.source, link.target].each(&:lazy_labels) } + related_links = related_links.updated_before(params[:updated_before]) if params[:updated_before] + related_links = related_links.updated_after(params[:updated_after]) if params[:updated_after] + related_links = related_links.created_before(params[:created_before]) if params[:created_before] + related_links = related_links.created_after(params[:created_after]) if params[:created_after] + + related_links = paginate(related_links) + related_links.each { |link| [link.source, link.target].each(&:lazy_labels) } # EpicLinks can link to other Epics the user has no access to. # For these epics we need to check permissions. - related_epic_links = related_epic_links.select do |related_epic_link| - related_epic_link.source.readable_by?(current_user) && related_epic_link.target.readable_by?(current_user) + related_links = related_links.select do |related_link| + related_link.source.readable_by?(current_user) && related_link.target.readable_by?(current_user) end - source_and_target_epics = related_epic_links.reduce(Set.new) { |acc, link| acc << link.source << link.target } + source_and_target_epics = related_links.reduce(Set.new) { |acc, link| acc << link.source << link.target } + + if Feature.enabled?(:related_epic_links_from_work_items, user_group) + source_and_target_epics = source_and_target_epics.map(&:synced_epic) + end epics_metadata = Gitlab::IssuableMetadata.new(current_user, source_and_target_epics).data - present related_epic_links, issuable_metadata: epics_metadata, with: Entities::RelatedEpicLink + present related_links, issuable_metadata: epics_metadata, with: Entities::RelatedEpicLink end desc 'Get related epics' do diff --git a/ee/spec/factories/related_epic_links.rb b/ee/spec/factories/related_epic_links.rb index 0a98abbca5a102a9073f78c3bd753bbc06965143..21d28f72f44966e784374341e89bb1db78fb76c2 100644 --- a/ee/spec/factories/related_epic_links.rb +++ b/ee/spec/factories/related_epic_links.rb @@ -7,7 +7,13 @@ trait :with_related_work_item_link do related_work_item_link do - association(:work_item_link, source: source&.work_item, target: target&.work_item, link_type: link_type) + association(:work_item_link, + source: source&.work_item, + target: target&.work_item, + link_type: link_type, + created_at: created_at, + updated_at: updated_at + ) end end end diff --git a/ee/spec/lib/ee/api/entities/related_epic_link_spec.rb b/ee/spec/lib/ee/api/entities/related_epic_link_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bac054dd592d7675a4a7057890052dbac8758063 --- /dev/null +++ b/ee/spec/lib/ee/api/entities/related_epic_link_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::API::Entities::RelatedEpicLink, feature_category: :team_planning do + subject(:entity) { described_class.new(object).as_json } + + let_it_be(:source_epic) { create(:epic) } + let_it_be(:target_epic) { create(:epic) } + + let_it_be(:related_work_item_link) do + create(:work_item_link, id: 999, source: source_epic.work_item, target: target_epic.work_item) + end + + let_it_be(:related_epic_link) do + # We want to ensure the `related_epic_link.id` gets used, so we set the id to a static value + create(:related_epic_link, id: 100, source: source_epic, target: target_epic, + related_work_item_link: related_work_item_link) + end + + shared_examples 'exposes data correctly' do + it 'uses the data from the related epic link', :aggregate_failures do + expect(entity.keys).to contain_exactly(:id, :source_epic, :target_epic, :link_type, :created_at, :updated_at) + + expect(entity[:id]).to eq(100) + expect(entity[:source_epic][:id]).to eq(source_epic.id) + expect(entity[:target_epic][:id]).to eq(target_epic.id) + expect(entity[:link_type]).to eq('relates_to') + + expect(entity[:created_at]).to eq(object.created_at) + expect(entity[:updated_at]).to eq(object.updated_at) + end + end + + context 'when related epic link' do + let(:object) { related_epic_link } + + it_behaves_like 'exposes data correctly' + end + + context 'when related work_item link' do + let(:object) { related_work_item_link } + + it_behaves_like 'exposes data correctly' + end +end diff --git a/ee/spec/models/ee/work_items/related_work_item_link_spec.rb b/ee/spec/models/ee/work_items/related_work_item_link_spec.rb index 4e9aed5cd74a806940cefe410d046284cc660d35..bd370e5a7a6eeae457252c0da37787534c9b0100 100644 --- a/ee/spec/models/ee/work_items/related_work_item_link_spec.rb +++ b/ee/spec/models/ee/work_items/related_work_item_link_spec.rb @@ -16,6 +16,40 @@ end end + describe 'scopes' do + let(:epic_type) { ::WorkItems::Type.default_by_type(:epic) } + + let_it_be(:epic_issue_link) do + create(:work_item_link, source: create(:work_item, :epic), target: create(:work_item, :issue)) + end + + let_it_be(:epic_epic_link) do + create(:work_item_link, source: create(:work_item, :epic), target: create(:work_item, :epic)) + end + + let_it_be(:issue_epic_link) do + create(:work_item_link, source: create(:work_item, :issue), target: create(:work_item, :epic)) + end + + context 'when filtered by source type' do + it 'returns only links with the given type on the source' do + expect(described_class.for_source_type(epic_type)).to contain_exactly(epic_issue_link, epic_epic_link) + end + end + + context 'when filtered by target type' do + it 'returns only links with the given type on the target' do + expect(described_class.for_target_type(epic_type)).to contain_exactly(issue_epic_link, epic_epic_link) + end + end + + context 'when combining for_target_type and for_source_type' do + it 'returns only links with the given type on the source and target' do + expect(described_class.for_source_type(epic_type).for_target_type(epic_type)).to contain_exactly(epic_epic_link) + end + end + end + describe 'validations' do describe '#validate_related_link_restrictions' do using RSpec::Parameterized::TableSyntax diff --git a/ee/spec/requests/api/related_epic_links_spec.rb b/ee/spec/requests/api/related_epic_links_spec.rb index 969e43dd01a34e67181f362621f2e728ba4515cb..37786415b24ce771d1034b874eb3e66810cd72ec 100644 --- a/ee/spec/requests/api/related_epic_links_spec.rb +++ b/ee/spec/requests/api/related_epic_links_spec.rb @@ -5,427 +5,445 @@ RSpec.describe API::RelatedEpicLinks, feature_category: :portfolio_management do include ExternalAuthorizationServiceHelpers - let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group, :private) } - let_it_be(:epic) { create(:epic, group: group) } - let_it_be(:source_group) { create(:group, :public) } - let_it_be(:target_group) { create(:group, :public) } - let_it_be(:source_epic) { create(:epic, group: source_group) } - let_it_be(:target_epic) { create(:epic, group: target_group) } - - before do - stub_licensed_features(epics: true, related_epics: true) - end + shared_examples 'related epics API' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :private) } + let_it_be(:epic) { create(:epic, group: group) } + let_it_be(:source_group) { create(:group, :public) } + let_it_be(:target_group) { create(:group, :public) } + let_it_be(:source_epic) { create(:epic, group: source_group) } + let_it_be(:target_epic) { create(:epic, group: target_group) } + + before do + stub_licensed_features(epics: true, related_epics: true) + end - shared_examples 'forbidden resource' do |message| - it 'returns 403' do - subject + shared_examples 'forbidden resource' do |message| + it 'returns 403' do + subject - expect(response).to have_gitlab_http_status(:forbidden) + expect(response).to have_gitlab_http_status(:forbidden) + end end - end - shared_examples 'not found resource' do |message| - it 'returns 404' do - subject + 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) + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq(message) + end end - end - shared_examples 'unauthenticated resource' do - it 'returns 401' do - perform_request + shared_examples 'unauthenticated resource' do + it 'returns 401' do + perform_request - expect(response).to have_gitlab_http_status(:unauthorized) + expect(response).to have_gitlab_http_status(:unauthorized) + end end - end - shared_examples 'successful response' do |status| - it "returns #{status}" do - subject + shared_examples 'successful response' do |status| + it "returns #{status}" do + subject - expect_link_response(status: status) + expect_link_response(status: status) + end end - end - shared_examples 'endpoint with features check' do - context 'when epics feature is not available' do - before do - stub_licensed_features(epics: false, related_epics: true) + shared_examples 'endpoint with features check' do + context 'when epics feature is not available' do + before do + stub_licensed_features(epics: false, related_epics: true) + end + + it { is_expected.to eq(403) } end - it { is_expected.to eq(403) } - end + context 'when related_epics feature is not available ' do + before do + stub_licensed_features(epics: true, related_epics: false) + end - context 'when related_epics feature is not available ' do - before do - stub_licensed_features(epics: true, related_epics: false) + it { is_expected.to eq(403) } end - - it { is_expected.to eq(403) } end - end - describe 'GET /groups/:id/related_epic_links' do - let_it_be(:created_at) { Date.new(2021, 10, 14) } - let_it_be(:updated_at) { Date.new(2021, 10, 14) } - let_it_be(:group_2) { create(:group, :private) } - - let_it_be(:related_epic_link_1) do - create( - :related_epic_link, - source: epic, - target: create(:epic, group: group), - created_at: created_at, - updated_at: updated_at - ) - end + describe 'GET /groups/:id/related_epic_links' do + let_it_be(:created_at) { Date.new(2021, 10, 14) } + let_it_be(:updated_at) { Date.new(2021, 10, 14) } + let_it_be(:group_2) { create(:group, :private) } + + let_it_be(:related_epic_link_1) do + create( + :related_epic_link, + source: epic, + target: create(:epic, group: group), + created_at: created_at, + updated_at: updated_at + ) + end - let_it_be(:related_epic_link_2) do - create( - :related_epic_link, - source: epic, - target: create(:epic, group: group_2), - created_at: created_at, - updated_at: updated_at - ) - end + let_it_be(:related_epic_link_2) do + create( + :related_epic_link, + source: epic, + target: create(:epic, group: group_2), + created_at: created_at, + updated_at: updated_at + ) + end - def perform_request(user = nil, params = {}) - get api("/groups/#{group.id}/related_epic_links", user), params: params - end + def perform_request(user = nil, params = {}) + get api("/groups/#{group.id}/related_epic_links", user), params: params + end - subject { perform_request(user) } + subject { perform_request(user) } - context 'when user has no access to the group' do - it 'returns 404' do - perform_request + context 'when user has no access to the group' do + it 'returns 404' do + perform_request - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:not_found) + end end - end - context 'when user has access to the group' do - before do - group.add_guest(user) - end + context 'when user has access to the group' do + before do + group.add_guest(user) + end - it_behaves_like 'endpoint with features check' + it_behaves_like 'endpoint with features check' - it 'returns only related epics links the user has access to' do - perform_request(user) + it 'returns only related epics links the user has access to' do + perform_request(user) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - expect(json_response.length).to eq(1) - expect(json_response[0]['source_epic']['id']).to eq(related_epic_link_1.source.id) - expect(json_response[0]['target_epic']['id']).to eq(related_epic_link_1.target.id) - expect(response).to match_response_schema('public_api/v4/related_epic_links', dir: 'ee') - end + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response[0]['source_epic']['id']).to eq(related_epic_link_1.source.id) + expect(json_response[0]['target_epic']['id']).to eq(related_epic_link_1.target.id) + expect(response).to match_response_schema('public_api/v4/related_epic_links', dir: 'ee') + end - context 'when filtered by updated_before' do - it 'returns related epic links updated before the given parameter' do - perform_request(user, { updated_before: '2021-10-15=T00:00:00.000Z' }) + context 'when filtered by updated_before' do + it 'returns related epic links updated before the given parameter' do + perform_request(user, { updated_before: '2021-10-15=T00:00:00.000Z' }) - expect(json_response[0]['id']).to eq(related_epic_link_1.id) - end + expect(json_response[0]['id']).to eq(related_epic_link_1.id) + end - it 'returns no related epic links' do - perform_request(user, { updated_before: '2021-10-13=T00:00:00.000Z' }) + it 'returns no related epic links' do + perform_request(user, { updated_before: '2021-10-13=T00:00:00.000Z' }) - expect(json_response.length).to eq(0) + expect(json_response.length).to eq(0) + end end - end - context 'when filtered by updated_after' do - it 'returns related epic links updated before the given parameter' do - perform_request(user, { updated_after: '2021-10-14=T00:00:00.000Z' }) + context 'when filtered by updated_after' do + it 'returns related epic links updated before the given parameter' do + perform_request(user, { updated_after: '2021-10-14=T00:00:00.000Z' }) - expect(json_response[0]['id']).to eq(related_epic_link_1.id) - end + expect(json_response[0]['id']).to eq(related_epic_link_1.id) + end - it 'returns no related epic links' do - perform_request(user, { updated_after: '2021-10-15=T00:00:00.000Z' }) + it 'returns no related epic links' do + perform_request(user, { updated_after: '2021-10-15=T00:00:00.000Z' }) - expect(json_response.length).to eq(0) + expect(json_response.length).to eq(0) + end end - end - context 'when filtered by created_after' do - it 'returns related epic links created after the given parameter' do - perform_request(user, { created_after: '2021-10-14=T00:00:00.000Z' }) + context 'when filtered by created_after' do + it 'returns related epic links created after the given parameter' do + perform_request(user, { created_after: '2021-10-14=T00:00:00.000Z' }) - expect(json_response[0]['id']).to eq(related_epic_link_1.id) - end + expect(json_response[0]['id']).to eq(related_epic_link_1.id) + end - it 'returns no related epic links' do - perform_request(user, { created_after: '2021-10-15=T00:00:00.000Z' }) + it 'returns no related epic links' do + perform_request(user, { created_after: '2021-10-15=T00:00:00.000Z' }) - expect(json_response.length).to eq(0) + expect(json_response.length).to eq(0) + end end - end - context 'when filtered by created_before' do - it 'returns related epic links created before the given parameter' do - perform_request(user, { created_before: '2021-10-15=T00:00:00.000Z' }) + context 'when filtered by created_before' do + it 'returns related epic links created before the given parameter' do + perform_request(user, { created_before: '2021-10-15=T00:00:00.000Z' }) - expect(json_response[0]['id']).to eq(related_epic_link_1.id) + expect(json_response[0]['id']).to eq(related_epic_link_1.id) + end + + it 'returns no related epic links' do + perform_request(user, { created_before: '2021-10-13=T00:00:00.000Z' }) + + expect(json_response.length).to eq(0) + end end - it 'returns no related epic links' do - perform_request(user, { created_before: '2021-10-13=T00:00:00.000Z' }) + context 'when epics links are in a sub-group' do + let_it_be(:sub_group) { create(:group, :private, parent: group) } + let_it_be(:related_sub_epic_link) { create(:related_epic_link, source: create(:epic, group: sub_group), target: create(:epic, group: sub_group)) } - expect(json_response.length).to eq(0) + it 'returns linked epic from sub-group' do + perform_request(user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + end end end - context 'when epics links are in a sub-group' do - let_it_be(:sub_group) { create(:group, :private, parent: group) } - let_it_be(:related_sub_epic_link) { create(:related_epic_link, source: create(:epic, group: sub_group), target: create(:epic, group: sub_group)) } + context 'when user has access to both groups' do + before do + group.add_guest(user) + group_2.add_guest(user) + end - it 'returns linked epic from sub-group' do + it 'returns related epic links' do perform_request(user) expect(response).to have_gitlab_http_status(:ok) expect(json_response).to be_an Array expect(json_response.length).to eq(2) + expect(response).to match_response_schema('public_api/v4/related_epic_links', dir: 'ee') end - end - end - - context 'when user has access to both groups' do - before do - group.add_guest(user) - group_2.add_guest(user) - end - - it 'returns related epic links' do - perform_request(user) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - expect(json_response.length).to eq(2) - expect(response).to match_response_schema('public_api/v4/related_epic_links', dir: 'ee') - end - - it 'returns multiple links without N + 1' do - perform_request(user) + it 'returns multiple links without N + 1' do + perform_request(user) - control = ActiveRecord::QueryRecorder.new(skip_cached: false) { perform_request(user) } + control = ActiveRecord::QueryRecorder.new(skip_cached: false) { perform_request(user) } - create(:related_epic_link, source: epic, target: create(:epic, group: group)) + create(:related_epic_link, source: epic, target: create(:epic, group: group)) - expect { perform_request(user) }.not_to exceed_query_limit(control) - expect(response).to have_gitlab_http_status(:ok) + expect { perform_request(user) }.not_to exceed_query_limit(control) + expect(response).to have_gitlab_http_status(:ok) + end end - end - context 'with pagination' do - let_it_be(:target_epic) { create(:epic, group: group) } - let_it_be(:related_epic_link_3) { create(:related_epic_link, source: epic, target: target_epic) } + context 'with pagination' do + let_it_be(:target_epic) { create(:epic, group: group) } + let_it_be(:related_epic_link_3) { create(:related_epic_link, source: epic, target: target_epic) } - before do - group.add_guest(user) - group_2.add_guest(user) - end + before do + group.add_guest(user) + group_2.add_guest(user) + end - it 'returns first page of related epics' do - perform_request(user, { per_page: 2, page: 1 }) + it 'returns first page of related epics' do + perform_request(user, { per_page: 2, page: 1 }) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - expect(json_response.length).to eq(2) - expect(json_response.pluck("id")).to match_array([related_epic_link_1.id, related_epic_link_2.id]) - end + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.pluck("id")).to match_array([related_epic_link_1.id, related_epic_link_2.id]) + end - it 'returns the last page of related epics' do - perform_request(user, { per_page: 2, page: 2 }) + it 'returns the last page of related epics' do + perform_request(user, { per_page: 2, page: 2 }) - expect(json_response.length).to eq(1) - expect(json_response.pluck("id")).to match_array([related_epic_link_3.id]) + expect(json_response.length).to eq(1) + expect(json_response.pluck("id")).to match_array([related_epic_link_3.id]) + end end end - end - describe 'GET /groups/:id/epics/:epic_id/related_epics' do - def perform_request(user = nil, params = {}) - get api("/groups/#{group.id}/epics/#{epic.iid}/related_epics", user), params: params - end + describe 'GET /groups/:id/epics/:epic_id/related_epics' do + def perform_request(user = nil, params = {}) + get api("/groups/#{group.id}/epics/#{epic.iid}/related_epics", user), params: params + end - subject { perform_request(user) } + subject { perform_request(user) } - context 'when user cannot read epics' do - it 'returns 404' do - perform_request + context 'when user cannot read epics' do + it 'returns 404' do + perform_request - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:not_found) + end end - end - context 'when user can read epics' do - let_it_be(:group_2) { create(:group) } - let_it_be(:related_epic_link_1) { create(:related_epic_link, source: epic, target: create(:epic, group: group)) } - let_it_be(:related_epic_link_2) { create(:related_epic_link, source: epic, target: create(:epic, group: group_2)) } + context 'when user can read epics' do + let_it_be(:group_2) { create(:group) } + let_it_be(:related_epic_link_1) { create(:related_epic_link, source: epic, target: create(:epic, group: group)) } + let_it_be(:related_epic_link_2) { create(:related_epic_link, source: epic, target: create(:epic, group: group_2)) } - before do - group.add_guest(user) - end + before do + group.add_guest(user) + end - it_behaves_like 'endpoint with features check' + it_behaves_like 'endpoint with features check' - it 'returns related epics' do - perform_request(user) + it 'returns related epics' do + perform_request(user) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Array - expect(json_response.length).to eq(2) - expect(response).to match_response_schema('public_api/v4/related_epics', dir: 'ee') - end + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(response).to match_response_schema('public_api/v4/related_epics', dir: 'ee') + end - it 'returns multiple links without N + 1' do - perform_request(user) + it 'returns multiple links without N + 1' do + perform_request(user) - control = ActiveRecord::QueryRecorder.new(skip_cached: false) { perform_request(user) } + control = ActiveRecord::QueryRecorder.new(skip_cached: false) { perform_request(user) } - create(:related_epic_link, source: epic, target: create(:epic, group: group)) + create(:related_epic_link, source: epic, target: create(:epic, group: group)) - expect { perform_request(user) }.not_to exceed_query_limit(control) - expect(response).to have_gitlab_http_status(:ok) + expect { perform_request(user) }.not_to exceed_query_limit(control) + expect(response).to have_gitlab_http_status(:ok) + end end end - end - describe 'POST /groups/:id/epics/:epic_id/related_epics' do - let(:target_epic_iid) { target_epic.iid } + describe 'POST /groups/:id/epics/:epic_id/related_epics' do + let(:target_epic_iid) { target_epic.iid } - subject { perform_request(user, target_group_id: target_group.id, 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/#{source_group.id}/epics/#{source_epic.iid}/related_epics", user), params: params - end + def perform_request(user = nil, params = {}) + post api("/groups/#{source_group.id}/epics/#{source_epic.iid}/related_epics", user), params: params + end - it_behaves_like 'unauthenticated resource' + it_behaves_like 'unauthenticated resource' - context 'when user can not access source epic' do - # user is not a member of the public source group - it_behaves_like 'forbidden resource' - end - - context 'when user can access source epic' do - before do - source_group.add_guest(user) + context 'when user can not access source epic' do + # user is not a member of the public source group + it_behaves_like 'forbidden resource' end - context 'when user cannot access target epic' do - context 'when group is private' do - let(:target_group) { group } - - # user is not a member of the private target group - it_behaves_like 'not found resource', '404 Group Not Found' + context 'when user can access source epic' do + before do + source_group.add_guest(user) end - context 'when epic_relations_for_non_members is disabled' do - before do - stub_feature_flags(epic_relations_for_non_members: false) + context 'when user cannot access target epic' do + context 'when group is private' do + let(:target_group) { group } + + # user is not a member of the private target group + it_behaves_like 'not found resource', '404 Group Not Found' end - # user is not a member of the public target group - it_behaves_like 'forbidden resource' - end - end + context 'when epic_relations_for_non_members is disabled' do + before do + stub_feature_flags(epic_relations_for_non_members: false) + end - context 'when user can access target epic group' do - before do - target_group.add_guest(user) + # user is not a member of the public target group + it_behaves_like 'forbidden resource' + end end - it_behaves_like 'successful response', :created - it_behaves_like 'endpoint with features check' + context 'when user can access target epic group' do + before do + target_group.add_guest(user) + 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') + it_behaves_like 'successful response', :created + it_behaves_like 'endpoint with features check' - expect_link_response(link_type: 'blocks') - expect(json_response['source_epic']['id']).to eq(source_epic.id) - expect(json_response['target_epic']['id']).to eq(target_epic.id) - 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') - it 'returns 201 status for is_blocked_by link and contains the expected link response' do - perform_request(user, target_group_id: target_group.full_path, target_epic_iid: target_epic.iid, link_type: 'is_blocked_by') + expect_link_response(link_type: 'blocks') + expect(json_response['source_epic']['id']).to eq(source_epic.id) + expect(json_response['target_epic']['id']).to eq(target_epic.id) + end - # For `is_blocked_by` we swap the source and target and use `block` as type. - expect_link_response(link_type: 'blocks') - expect(json_response['source_epic']['id']).to eq(target_epic.id) - expect(json_response['target_epic']['id']).to eq(source_epic.id) - end + it 'returns 201 status for is_blocked_by link and contains the expected link response' do + perform_request(user, target_group_id: target_group.full_path, target_epic_iid: target_epic.iid, link_type: 'is_blocked_by') - context 'when target epic is confidential' do - let(:target_epic) { create(:epic, :confidential, group: target_group) } + # For `is_blocked_by` we swap the source and target and use `block` as type. + expect_link_response(link_type: 'blocks') + expect(json_response['source_epic']['id']).to eq(target_epic.id) + expect(json_response['target_epic']['id']).to eq(source_epic.id) + end - it_behaves_like 'forbidden resource' - end + context 'when target epic is confidential' do + let(:target_epic) { create(:epic, :confidential, group: target_group) } - context 'when target epic is not found' do - let(:target_epic_iid) { non_existing_record_iid } + it_behaves_like 'forbidden resource' + end - it_behaves_like 'not found resource', '404 Not found' + 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 end end end - end - describe 'DELETE /groups/:id/epics/:epic_id/related_epics' do - let_it_be(:related_epic_link) { create(:related_epic_link, source: source_epic, target: target_epic) } + describe 'DELETE /groups/:id/epics/:epic_id/related_epics' do + let_it_be(:related_epic_link) { create(:related_epic_link, source: source_epic, target: target_epic) } - subject { perform_request(user) } + subject { perform_request(user) } - def perform_request(user = nil, link_id = related_epic_link.id) - delete api("/groups/#{source_group.id}/epics/#{source_epic.iid}/related_epics/#{link_id}", user) - end + def perform_request(user = nil, link_id = related_epic_link.id) + delete api("/groups/#{source_group.id}/epics/#{source_epic.iid}/related_epics/#{link_id}", user) + end - it_behaves_like 'unauthenticated resource' + it_behaves_like 'unauthenticated resource' - context 'when user can not access source epic' do - it_behaves_like 'forbidden resource' - end - - context 'when user can access source epic' do - before do - source_group.add_guest(user) - target_group.add_guest(user) + context 'when user can not access source epic' do + it_behaves_like 'forbidden resource' end - context 'when target group is private' do - let(:related_epic_link) do - create(:related_epic_link, source: source_epic, target: create(:epic, group: group)) + context 'when user can access source epic' do + before do + source_group.add_guest(user) + target_group.add_guest(user) end - it_behaves_like 'not found resource', 'No Related Epic Link found' - end + context 'when target group is private' do + let(:related_epic_link) do + create(:related_epic_link, source: source_epic, target: create(:epic, group: group)) + end - context 'when user can access target group' do - it_behaves_like 'successful response', :ok - it_behaves_like 'endpoint with features check' + it_behaves_like 'not found resource', 'No Related Epic Link found' + end - context 'when related_epic_link_id belongs to a different epic' do - let_it_be(:other_epic) { create(:epic, group: target_group) } - let_it_be(:other_epic_link) { create(:related_epic_link, source: other_epic, target: target_epic) } + context 'when user can access target group' do + it_behaves_like 'successful response', :ok + it_behaves_like 'endpoint with features check' - subject do - perform_request(user, other_epic_link.id) - end + context 'when related_epic_link_id belongs to a different epic' do + let_it_be(:other_epic) { create(:epic, group: target_group) } + let_it_be(:other_epic_link) { create(:related_epic_link, source: other_epic, target: target_epic) } + + subject do + perform_request(user, other_epic_link.id) + end - it_behaves_like 'not found resource', '404 Not found' + it_behaves_like 'not found resource', '404 Not found' + end end end end + + def expect_link_response(link_type: 'relates_to', status: :created) + expect(response).to have_gitlab_http_status(status) + expect(response).to match_response_schema('public_api/v4/related_epic_link') + expect(json_response['link_type']).to eq(link_type) + end end - def expect_link_response(link_type: 'relates_to', status: :created) - expect(response).to have_gitlab_http_status(status) - expect(response).to match_response_schema('public_api/v4/related_epic_link') - expect(json_response['link_type']).to eq(link_type) + context 'when related_epic_links_from_work_items is disabled' do + before do + stub_feature_flags(related_epic_links_from_work_items: false) + end + + it_behaves_like 'related epics API' + end + + context 'when related_epic_links_from_work_items is enabled' do + before do + stub_feature_flags(related_epic_links_from_work_items: true) + end + + it_behaves_like 'related epics API' end end diff --git a/ee/spec/services/work_items/legacy_epics/related_epic_links/list_service_spec.rb b/ee/spec/services/work_items/legacy_epics/related_epic_links/list_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c03f6b56ff156bd6adb973edcf857fdfd1abcdc3 --- /dev/null +++ b/ee/spec/services/work_items/legacy_epics/related_epic_links/list_service_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::LegacyEpics::RelatedEpicLinks::ListService, feature_category: :team_planning do + let(:epics) { Epic.where(id: [epic1.id, epic2.id, epic3.id]) } + + let_it_be(:group) { create(:group) } + let_it_be(:epic1) { create(:epic, group: group) } + let_it_be(:epic2) { create(:epic, group: group) } + let_it_be(:epic3) { create(:epic, group: group) } + let_it_be(:epic4) { create(:epic, group: group) } + let_it_be(:epic5) { create(:epic, group: group) } + let_it_be(:related_epic_link1) { create(:related_epic_link, source: epic1, target: epic2) } + let_it_be(:related_epic_link2) { create(:related_epic_link, source: epic3, target: epic4) } + let_it_be(:other_related_epic) { create(:related_epic_link, source: epic4, target: epic5) } + let_it_be(:other_work_item_link) do + create(:work_item_link, source: epic1.work_item, target: create(:work_item, :issue, namespace: group)) + end + + subject(:execute) { described_class.new(epics, group).execute } + + describe '#execute' do + context 'when related_epic_links_from_work_items feature flag is enabled' do + before do + stub_feature_flags(related_epic_links_from_work_items: group) + end + + it 'returns related work item links for epics' do + expect(execute).to contain_exactly(related_epic_link1.related_work_item_link, + related_epic_link2.related_work_item_link) + expect(execute.first.class).to eq(::WorkItems::RelatedWorkItemLink) + end + end + + context 'when related_epic_links_from_work_items feature flag is disabled' do + before do + stub_feature_flags(related_epic_links_from_work_items: false) + end + + it 'returns related epic links' do + expect(execute).to contain_exactly(related_epic_link1, related_epic_link2) + expect(execute.first.class).to eq(Epic::RelatedEpicLink) + end + end + end +end