diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index cc24847c3f0bb56484b7475d24c413267d02c1f2..6cfaef22b33315b3856d0e427c4d70238b6b66e3 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -7185,6 +7185,29 @@ The edge type for [`IncidentManagementOncallShift`](#incidentmanagementoncallshi | <a id="incidentmanagementoncallshiftedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="incidentmanagementoncallshiftedgenode"></a>`node` | [`IncidentManagementOncallShift`](#incidentmanagementoncallshift) | The item at the end of the edge. | +#### `IssuableResourceLinkConnection` + +The connection type for [`IssuableResourceLink`](#issuableresourcelink). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="issuableresourcelinkconnectionedges"></a>`edges` | [`[IssuableResourceLinkEdge]`](#issuableresourcelinkedge) | A list of edges. | +| <a id="issuableresourcelinkconnectionnodes"></a>`nodes` | [`[IssuableResourceLink]`](#issuableresourcelink) | A list of nodes. | +| <a id="issuableresourcelinkconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `IssuableResourceLinkEdge` + +The edge type for [`IssuableResourceLink`](#issuableresourcelink). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="issuableresourcelinkedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="issuableresourcelinkedgenode"></a>`node` | [`IssuableResourceLink`](#issuableresourcelink) | The item at the end of the edge. | + #### `IssueConnection` The connection type for [`Issue`](#issue). @@ -11341,6 +11364,35 @@ four standard [pagination arguments](#connection-pagination-arguments): | ---- | ---- | ----------- | | <a id="epicissuecurrentusertodosstate"></a>`state` | [`TodoStateEnum`](#todostateenum) | State of the to-do items. | +##### `EpicIssue.issuableResourceLink` + +Issuable resource link of the incident issue. + +Returns [`IssuableResourceLink`](#issuableresourcelink). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="epicissueissuableresourcelinkid"></a>`id` | [`IncidentManagementIssuableResourceLinkID!`](#incidentmanagementissuableresourcelinkid) | ID of the issuable resource link. | +| <a id="epicissueissuableresourcelinkincidentid"></a>`incidentId` | [`IssueID!`](#issueid) | ID of the incident. | + +##### `EpicIssue.issuableResourceLinks` + +Issuable resource links of the incident issue. + +Returns [`IssuableResourceLinkConnection`](#issuableresourcelinkconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="epicissueissuableresourcelinksincidentid"></a>`incidentId` | [`IssueID!`](#issueid) | ID of the incident. | + ##### `EpicIssue.reference` Internal reference of the issue. Returned in shortened format by default. @@ -12714,6 +12766,35 @@ four standard [pagination arguments](#connection-pagination-arguments): | ---- | ---- | ----------- | | <a id="issuecurrentusertodosstate"></a>`state` | [`TodoStateEnum`](#todostateenum) | State of the to-do items. | +##### `Issue.issuableResourceLink` + +Issuable resource link of the incident issue. + +Returns [`IssuableResourceLink`](#issuableresourcelink). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="issueissuableresourcelinkid"></a>`id` | [`IncidentManagementIssuableResourceLinkID!`](#incidentmanagementissuableresourcelinkid) | ID of the issuable resource link. | +| <a id="issueissuableresourcelinkincidentid"></a>`incidentId` | [`IssueID!`](#issueid) | ID of the incident. | + +##### `Issue.issuableResourceLinks` + +Issuable resource links of the incident issue. + +Returns [`IssuableResourceLinkConnection`](#issuableresourcelinkconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="issueissuableresourcelinksincidentid"></a>`incidentId` | [`IssueID!`](#issueid) | ID of the incident. | + ##### `Issue.reference` Internal reference of the issue. Returned in shortened format by default. diff --git a/ee/app/finders/incident_management/issuable_resource_links_finder.rb b/ee/app/finders/incident_management/issuable_resource_links_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..d4b3ab23497689544db7270f8ad542962eb507b1 --- /dev/null +++ b/ee/app/finders/incident_management/issuable_resource_links_finder.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IncidentManagement + class IssuableResourceLinksFinder + def initialize(user, incident, params = {}) + @user = user + @incident = incident + @params = params + end + + def execute + return ::IncidentManagement::IssuableResourceLink.none unless allowed? + + collection = incident.issuable_resource_links + collection = by_id(collection) + sort(collection) + end + + private + + attr_reader :user, :incident, :params + + def allowed? + Ability.allowed?(user, :admin_issuable_resource_link, incident) + end + + def by_id(collection) + return collection unless params[:id] + + collection.id_in(params[:id]) + end + + def sort(collection) + collection.order_by_created_at_asc + end + end +end diff --git a/ee/app/graphql/ee/types/issue_type.rb b/ee/app/graphql/ee/types/issue_type.rb index 1192e38e915da83822e19dbe9d8b7c05981808d1..ecf1d1ca429d759fac909ff0ff32d18dc8e2c24e 100644 --- a/ee/app/graphql/ee/types/issue_type.rb +++ b/ee/app/graphql/ee/types/issue_type.rb @@ -44,6 +44,18 @@ module IssueType field :escalation_policy, ::Types::IncidentManagement::EscalationPolicyType, null: true, description: 'Escalation policy associated with the issue. Available for issues which support escalation.' + field :issuable_resource_link, + ::Types::IncidentManagement::IssuableResourceLinkType, + null: true, + description: 'Issuable resource link of the incident issue.', + resolver: ::Resolvers::IncidentManagement::IssuableResourceLinksResolver.single + + field :issuable_resource_links, + ::Types::IncidentManagement::IssuableResourceLinkType.connection_type, + null: true, + description: 'Issuable resource links of the incident issue.', + resolver: ::Resolvers::IncidentManagement::IssuableResourceLinksResolver + def iteration ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Iteration, object.sprint_id).find end diff --git a/ee/app/graphql/resolvers/incident_management/issuable_resource_links_resolver.rb b/ee/app/graphql/resolvers/incident_management/issuable_resource_links_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..9379fa5df617f47ec51ffa0d1b62ade429269b0f --- /dev/null +++ b/ee/app/graphql/resolvers/incident_management/issuable_resource_links_resolver.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Resolvers + module IncidentManagement + class IssuableResourceLinksResolver < BaseResolver + include LooksAhead + + type ::Types::IncidentManagement::IssuableResourceLinkType.connection_type, null: true + + argument :incident_id, + ::Types::GlobalIDType[::Issue], + required: true, + description: 'ID of the incident.' + + when_single do + argument :id, + ::Types::GlobalIDType[::IncidentManagement::IssuableResourceLink], + required: true, + description: 'ID of the issuable resource link.', + prepare: ->(id, ctx) { id.model_id } + end + + def resolve(**args) + incident = args[:incident_id].find + + apply_lookahead(::IncidentManagement::IssuableResourceLinksFinder.new(current_user, incident, args).execute) + end + end + end +end diff --git a/ee/app/models/incident_management/issuable_resource_link.rb b/ee/app/models/incident_management/issuable_resource_link.rb index 2a8baa663b65696b1990d1ca7f44cf7891b00a85..3a55375b56d7672e6b27084da5c663eaf035766d 100644 --- a/ee/app/models/incident_management/issuable_resource_link.rb +++ b/ee/app/models/incident_management/issuable_resource_link.rb @@ -13,5 +13,7 @@ class IssuableResourceLink < ApplicationRecord validates :issue, presence: true validates :link, presence: true, length: { maximum: 2200 }, addressable_url: { schemes: %w(http https) } validates :link_text, length: { maximum: 255 } + + scope :order_by_created_at_asc, -> { reorder(created_at: :asc) } end end diff --git a/ee/spec/finders/incident_management/issuable_resource_links_finder_spec.rb b/ee/spec/finders/incident_management/issuable_resource_links_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9a29cbc60dbbac58d56cbb2d817df30af962be61 --- /dev/null +++ b/ee/spec/finders/incident_management/issuable_resource_links_finder_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IncidentManagement::IssuableResourceLinksFinder do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:incident) { create(:incident, project: project) } + + let_it_be(:issuable_resource_link_1) do + create(:issuable_resource_link, issue: incident) + end + + let_it_be(:issuable_resource_link_2) do + create(:issuable_resource_link, issue: incident) + end + + let(:params) { {} } + + describe '#execute' do + subject(:execute) { described_class.new(user, incident, params).execute } + + context 'when feature is available' do + before do + stub_licensed_features(issuable_resource_links: true) + end + + context 'when user has permissions' do + before do + project.add_reporter(user) + end + + it 'returns issuable resource links' do + is_expected.to eq([issuable_resource_link_1, issuable_resource_link_2]) + end + + context 'when filtering by ID' do + let(:params) { { id: issuable_resource_link_1 } } + + it 'returns only matched resource link' do + is_expected.to contain_exactly(issuable_resource_link_1) + end + end + + context 'when incident is nil' do + let_it_be(:incident) { nil } + + it { is_expected.to eq(IncidentManagement::IssuableResourceLink.none) } + end + end + + context 'when user has no permissions' do + before do + project.add_guest(user) + end + + it { is_expected.to eq(IncidentManagement::IssuableResourceLink.none) } + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(incident_resource_links_widget: false) + end + + it { is_expected.to eq(IncidentManagement::IssuableResourceLink.none) } + end + end + + context 'when feature is not available' do + before do + stub_licensed_features(issuable_resource_links: false) + end + + it { is_expected.to eq(IncidentManagement::IssuableResourceLink.none) } + end + end +end diff --git a/ee/spec/graphql/resolvers/incident_management/issuable_resource_links_resolver_spec.rb b/ee/spec/graphql/resolvers/incident_management/issuable_resource_links_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d24a9662e66800ab99672d6789f67a891c79c8b1 --- /dev/null +++ b/ee/spec/graphql/resolvers/incident_management/issuable_resource_links_resolver_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::IncidentManagement::IssuableResourceLinksResolver do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:incident) { create(:incident, project: project) } + let_it_be(:first_issuable_resource_link) { create(:issuable_resource_link, issue: incident) } + let_it_be(:second_issuable_resource_link) { create(:issuable_resource_link, issue: incident) } + + let(:args) { { incident_id: incident.to_global_id } } + let(:resolver) { described_class } + + subject(:resolved_issuable_resource_links) do + sync(resolve_issuable_resource_link(args, current_user: current_user).to_a) + end + + before do + stub_licensed_features(issuable_resource_links: true) + project.add_reporter(current_user) + end + + specify do + expect(resolver).to have_nullable_graphql_type( + Types::IncidentManagement::IssuableResourceLinkType.connection_type + ) + end + + it 'returns issuable resource links', :aggregate_failures do + expect(resolved_issuable_resource_links.length).to eq(2) + expect(resolved_issuable_resource_links.first).to be_a(::IncidentManagement::IssuableResourceLink) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(incident_resource_links_widget: false) + end + + it 'returns no resource links' do + expect(resolved_issuable_resource_links.length).to eq(0) + end + end + + context 'when user does not have permissions' do + before do + project.add_guest(current_user) + end + + it 'returns no resource links' do + expect(resolved_issuable_resource_links.length).to eq(0) + end + end + + context 'when resolving a single item' do + let(:resolver) { described_class.single } + + subject(:resolved_issuable_resource_link) do + sync(resolve_issuable_resource_link(args, current_user: current_user)) + end + + context 'when id is given' do + let(:args) { { incident_id: incident.to_global_id, id: first_issuable_resource_link.to_global_id } } + + it 'returns the resource link' do + expect(resolved_issuable_resource_link).to eq(first_issuable_resource_link) + end + end + end + + private + + def resolve_issuable_resource_link(args = {}, context = { current_user: current_user }) + resolve(resolver, obj: incident, args: args, ctx: context, arg_style: :internal_prepared) + end +end diff --git a/ee/spec/policies/issuable_policy_spec.rb b/ee/spec/policies/issuable_policy_spec.rb index a053f3d8f95ffea5d3049b0b7ddfc156ce3c0e2a..a7dbb625229390ea5588d7f2fc06cdd016bbd32a 100644 --- a/ee/spec/policies/issuable_policy_spec.rb +++ b/ee/spec/policies/issuable_policy_spec.rb @@ -36,7 +36,7 @@ def permissions(user, issue) expect(permissions(non_member, issue)).to be_disallowed(:admin_issuable_resource_link) expect(permissions(guest, issue)).to be_disallowed(:admin_issuable_resource_link) expect(permissions(developer, issue)).to be_disallowed(:admin_issuable_resource_link) - expect(permissions(reporter, issue)).to be_disallowed(:admin_issuable_resource_link) + expect(permissions(reporter, issue)).to be_allowed(:admin_issuable_resource_link) end end diff --git a/ee/spec/requests/api/graphql/incident_management/issuable_resource_links_spec.rb b/ee/spec/requests/api/graphql/incident_management/issuable_resource_links_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..538fbe23338756fe9b6242187e6cf34c1516d163 --- /dev/null +++ b/ee/spec/requests/api/graphql/incident_management/issuable_resource_links_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Getting issuable resource links' do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:incident) { create(:incident, project: project) } + let_it_be(:issuable_resource_link_1) { create(:issuable_resource_link, issue: incident) } + let_it_be(:issuable_resource_link_2) { create(:issuable_resource_link, issue: incident) } + + let(:params) { { incident_id: incident.to_global_id.to_s } } + + let(:issuable_resource_link_fields) do + <<~QUERY + nodes { + id + issue { id title } + link + linkType + linkText + } + QUERY + end + + let(:query) do + graphql_query_for( + 'issue', + { 'id' => global_id_of(incident) }, + query_graphql_field('issuableResourceLinks', params, issuable_resource_link_fields) + ) + end + + let(:issuable_resource_links) do + graphql_data.dig('issue', 'issuableResourceLinks', 'nodes') + end + + context 'when feature is available' do + before do + stub_licensed_features(issuable_resource_links: true) + project.add_reporter(current_user) + post_graphql(query, current_user: current_user) + end + + context 'when user has permissions' do + it_behaves_like 'a working graphql query' + + it 'returns the correct number of resource links' do + expect(issuable_resource_links.count).to eq(2) + end + + it 'returns the correct properties of the resource links' do + expect(issuable_resource_links.first).to include( + 'id' => issuable_resource_link_1.to_global_id.to_s, + 'issue' => { + 'id' => incident.to_global_id.to_s, + 'title' => incident.title + }, + 'link' => issuable_resource_link_1.link, + 'linkType' => issuable_resource_link_1.link_type.to_s, + 'linkText' => issuable_resource_link_1.link_text + ) + end + end + + context 'when filtering by id' do + let(:params) { { incident_id: incident.to_global_id.to_s, id: issuable_resource_link_2.to_global_id.to_s } } + + let(:query) do + graphql_query_for( + 'issue', + { 'id' => global_id_of(incident) }, + query_graphql_field('issuableResourceLink', params, 'id issue { id title } link linkType linkText ') + ) + end + + it_behaves_like 'a working graphql query' + + it 'returns a single resource link', :aggregate_failures do + resource_link = graphql_data.dig('issue', 'issuableResourceLink') + + expect(resource_link).to include( + 'id' => issuable_resource_link_2.to_global_id.to_s, + 'issue' => { + 'id' => incident.to_global_id.to_s, + 'title' => incident.title + }, + 'link' => issuable_resource_link_2.link, + 'linkType' => issuable_resource_link_2.link_type.to_s, + 'linkText' => issuable_resource_link_2.link_text + ) + end + end + + context 'when user does not have permission' do + before do + project.add_guest(current_user) + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns empty results' do + expect(issuable_resource_links).to be_empty + end + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(incident_resource_links_widget: false) + project.add_reporter(current_user) + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns empty results' do + expect(issuable_resource_links).to be_empty + end + end + + context 'when feature is unavailable' do + before do + stub_licensed_features(issuable_resource_links: false) + project.add_reporter(current_user) + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns empty results' do + expect(issuable_resource_links).to be_empty + end + end +end