diff --git a/app/graphql/resolvers/work_item_references_resolver.rb b/app/graphql/resolvers/work_item_references_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..4aa071519db6f763adfd6150a8c585168367e88f --- /dev/null +++ b/app/graphql/resolvers/work_item_references_resolver.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Resolvers + class WorkItemReferencesResolver < BaseResolver + prepend ::WorkItems::LookAheadPreloads + include Gitlab::Graphql::Authorize::AuthorizeResource + + REFERENCES_LIMIT = 10 + + authorize :read_work_item + + type ::Types::WorkItemType.connection_type, null: true + + argument :context_namespace_path, GraphQL::Types::ID, + required: false, + description: 'Full path of the context namespace (project or group).' + + argument :refs, [GraphQL::Types::String], required: true, + description: 'Work item references. Can be either a short reference or URL.' + + def ready?(**args) + if args[:refs].size > REFERENCES_LIMIT + raise Gitlab::Graphql::Errors::ArgumentError, + format( + _('Number of references exceeds the limit. ' \ + 'Please provide no more than %{refs_limit} references at the same time.'), + refs_limit: REFERENCES_LIMIT + ) + end + + super + end + + def resolve_with_lookahead(context_namespace_path: nil, refs: []) + return WorkItem.none if refs.empty? + + @container = authorized_find!(context_namespace_path) + # Only ::Project is supported at the moment, future iterations will include ::Group. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/432555 + return WorkItem.none if container.is_a?(::Group) + + apply_lookahead(find_work_items(refs)) + end + + private + + attr_reader :container + + # rubocop: disable CodeReuse/ActiveRecord -- #references is not an ActiveRecord method + def find_work_items(references) + links, short_references = references.partition { |r| r.include?('/work_items/') } + + item_ids = references_extractor(short_references).references(:issue, ids_only: true) + item_ids << references_extractor(links).references(:work_item, ids_only: true) if links.any? + + WorkItem.id_in(item_ids.flatten) + end + # rubocop: enable CodeReuse/ActiveRecord + + def references_extractor(refs) + extractor = ::Gitlab::ReferenceExtractor.new(container, context[:current_user]) + extractor.analyze(refs.join(' '), {}) + + extractor + end + + def find_object(full_path) + Routable.find_by_full_path(full_path) + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 9c64e056f87826ea4e830c29f2480c7d7d566c29..0e39ff2c030909c6585063b7fd3bd2225e978bc6 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -216,6 +216,13 @@ class QueryType < ::Types::BaseObject description: 'Find machine learning models.', resolver: Resolvers::Ml::ModelDetailResolver + field :work_items_by_reference, + null: true, + alpha: { milestone: '16.7' }, + description: 'Find work items by their reference.', + extras: [:lookahead], + resolver: Resolvers::WorkItemReferencesResolver + def design_management DesignManagementObject.new(nil) end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a8dc50834d46698362258e8746fa1c252a3c049a..757bc559c29df4afef4c70bcc554157621c780f9 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1033,6 +1033,27 @@ Returns [`WorkItem`](#workitem). | ---- | ---- | ----------- | | <a id="queryworkitemid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. | +### `Query.workItemsByReference` + +Find work items by their reference. + +WARNING: +**Introduced** in 16.7. +This feature is an Experiment. It can be changed or removed at any time. + +Returns [`WorkItemConnection`](#workitemconnection). + +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="queryworkitemsbyreferencecontextnamespacepath"></a>`contextNamespacePath` | [`ID`](#id) | Full path of the context namespace (project or group). | +| <a id="queryworkitemsbyreferencerefs"></a>`refs` | [`[String!]!`](#string) | Work item references. Can be either a short reference or URL. | + ### `Query.workspace` Find a workspace. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3254a800c53694d34166e7c2b5079a93a3532dd3..af4930a341b82442c0a49892413459bb5c6b6b7d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -32845,6 +32845,9 @@ msgstr "" msgid "Number of files touched" msgstr "" +msgid "Number of references exceeds the limit. Please provide no more than %{refs_limit} references at the same time." +msgstr "" + msgid "Number of replicas" msgstr "" diff --git a/rubocop/cop/graphql/id_type.rb b/rubocop/cop/graphql/id_type.rb index 53d79751fa8e6cc50a5dff5532d663be720cc298..deed68da0b33e912009be0c094565780a3fb841c 100644 --- a/rubocop/cop/graphql/id_type.rb +++ b/rubocop/cop/graphql/id_type.rb @@ -6,7 +6,10 @@ module Graphql class IDType < RuboCop::Cop::Base MSG = 'Do not use GraphQL::Types::ID, use a specific GlobalIDType instead' - ALLOWLISTED_ARGUMENTS = %i[iid full_path project_path group_path target_project_path namespace_path].freeze + ALLOWLISTED_ARGUMENTS = %i[ + iid full_path project_path group_path target_project_path namespace_path + context_namespace_path + ].freeze def_node_search :graphql_id_type?, <<~PATTERN (send nil? :argument (_ #does_not_match?) (const (const (const nil? :GraphQL) :Types) :ID) ...) diff --git a/spec/requests/api/graphql/work_items_by_reference_spec.rb b/spec/requests/api/graphql/work_items_by_reference_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ad2303a81e7a5f453bad6f566c461322f558bca9 --- /dev/null +++ b/spec/requests/api/graphql/work_items_by_reference_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'find work items by reference', feature_category: :portfolio_management do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:group2) { create(:group, :public) } + let_it_be(:project2) { create(:project, :repository, :public, group: group2) } + let_it_be(:private_project2) { create(:project, :repository, :private, group: group2) } + let_it_be(:work_item) { create(:work_item, :task, project: project2) } + let_it_be(:private_work_item) { create(:work_item, :task, project: private_project2) } + + let(:references) { [work_item.to_reference(full: true), private_work_item.to_reference(full: true)] } + + shared_examples 'response with matching work items' do + it 'returns accessible work item' do + post_graphql(query, current_user: current_user) + + expected_items = items.map { |item| a_graphql_entity_for(item) } + expect(graphql_data_at('workItemsByReference', 'nodes')).to match(expected_items) + end + end + + context 'when user has access only to public work items' do + it_behaves_like 'a working graphql query that returns data' do + before do + post_graphql(query, current_user: current_user) + end + end + + it_behaves_like 'response with matching work items' do + let(:items) { [work_item] } + end + + it 'avoids N+1 queries', :use_sql_query_cache do + post_graphql(query, current_user: current_user) # warm up + + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, current_user: current_user) + end + expect(graphql_data_at('workItemsByReference', 'nodes').size).to eq(1) + + extra_work_items = create_list(:work_item, 2, :task, project: project2) + refs = references + extra_work_items.map { |item| item.to_reference(full: true) } + + expect do + post_graphql(query(refs: refs), current_user: current_user) + end.not_to exceed_all_query_limit(control_count) + expect(graphql_data_at('workItemsByReference', 'nodes').size).to eq(3) + end + end + + context 'when user has access to work items in private project' do + before_all do + private_project2.add_guest(current_user) + end + + it_behaves_like 'response with matching work items' do + let(:items) { [private_work_item, work_item] } + end + end + + context 'when refs includes links' do + let_it_be(:work_item_with_url) { create(:work_item, :task, project: project2) } + let(:references) { [work_item.to_reference(full: true), Gitlab::UrlBuilder.build(work_item_with_url)] } + + it_behaves_like 'response with matching work items' do + let(:items) { [work_item_with_url, work_item] } + end + end + + context 'when refs includes a short reference present in the context project' do + let_it_be(:same_project_work_item) { create(:work_item, :task, project: project) } + let(:references) { ["##{same_project_work_item.iid}"] } + + it_behaves_like 'response with matching work items' do + let(:items) { [same_project_work_item] } + end + end + + context 'when user cannot access context namespace' do + it 'returns error' do + post_graphql(query(namespace_path: private_project2.full_path), current_user: current_user) + + expect(graphql_data_at('workItemsByReference')).to be_nil + expect(graphql_errors).to contain_exactly(a_hash_including( + 'message' => a_string_including("you don't have permission to perform this action"), + 'path' => %w[workItemsByReference] + )) + end + end + + context 'when the context is a group' do + it 'returns empty result' do + group2.add_guest(current_user) + post_graphql(query(namespace_path: group2.full_path), current_user: current_user) + + expect_graphql_errors_to_be_empty + expect(graphql_data_at('workItemsByReference', 'nodes')).to be_empty + end + end + + context 'when there are more than the max allowed references' do + let(:references_limit) { ::Resolvers::WorkItemReferencesResolver::REFERENCES_LIMIT } + let(:references) { (0..references_limit).map { |n| "##{n}" } } + let(:error_msg) do + "Number of references exceeds the limit. " \ + "Please provide no more than #{references_limit} references at the same time." + end + + it 'returns an error message' do + post_graphql(query, current_user: current_user) + + expect_graphql_errors_to_include(error_msg) + end + end + + def query(namespace_path: project.full_path, refs: references) + fields = <<~GRAPHQL + nodes { + #{all_graphql_fields_for('WorkItem', max_depth: 2)} + } + GRAPHQL + + graphql_query_for('workItemsByReference', { contextNamespacePath: namespace_path, refs: refs }, fields) + end +end diff --git a/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb b/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb index 257ccc553fe6f09cdcb6eefcd124c41ba605192b..6ab41d87f442b827fbd1283ddb88174b752d5650 100644 --- a/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb +++ b/spec/support/shared_contexts/graphql/types/query_type_shared_context.rb @@ -43,6 +43,7 @@ :user, :users, :work_item, + :work_items_by_reference, :audit_event_definitions, :abuse_report, :abuse_report_labels