diff --git a/.rubocop_todo/gitlab/bounded_contexts.yml b/.rubocop_todo/gitlab/bounded_contexts.yml index ab76b38b46e94dba47e90cf4624b2afdf01bcc79..9e004c73c9ec87221e78adb9638798289052e9c7 100644 --- a/.rubocop_todo/gitlab/bounded_contexts.yml +++ b/.rubocop_todo/gitlab/bounded_contexts.yml @@ -1125,6 +1125,7 @@ Gitlab/BoundedContexts: - 'app/models/preloaders/commit_status_preloader.rb' - 'app/models/preloaders/environments/deployment_preloader.rb' - 'app/models/preloaders/group_policy_preloader.rb' + - 'app/models/preloaders/issuables_preloader.rb' - 'app/models/preloaders/labels_preloader.rb' - 'app/models/preloaders/merge_request_diff_preloader.rb' - 'app/models/preloaders/project_policy_preloader.rb' diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index b92c07bb81b6de568d372ccf60b1efde7785bc0c..e901cae482c4bb244517109bae6a295e0340d1a4 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -24,10 +24,7 @@ class IssuesResolver < Issues::BaseResolver type Types::IssueType.connection_type, null: true before_connection_authorization do |nodes, current_user| - projects = nodes.map(&:project) - ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute - ::Preloaders::GroupPolicyPreloader.new(projects.filter_map(&:group), current_user).execute - ActiveRecord::Associations::Preloader.new(records: projects, associations: project_associations).call + ::Preloaders::IssuablesPreloader.new(nodes, current_user, project_associations).preload_all end def self.project_associations diff --git a/app/graphql/resolvers/work_items/user_work_items_resolver.rb b/app/graphql/resolvers/work_items/user_work_items_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..09161b2b0359115353e69819621a38aab500b0f8 --- /dev/null +++ b/app/graphql/resolvers/work_items/user_work_items_resolver.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Resolvers + module WorkItems + class UserWorkItemsResolver < BaseResolver + prepend ::WorkItems::LookAheadPreloads + include SearchArguments + include ::WorkItems::SharedFilterArguments + + NON_FILTER_ARGUMENTS = %i[sort lookahead].freeze + + argument :sort, + ::Types::WorkItems::SortEnum, + description: 'Sort work items by criteria.', + required: false, + default_value: :created_desc + + type Types::WorkItemType.connection_type, null: true + + before_connection_authorization do |nodes, current_user| + ::Preloaders::IssuablesPreloader.new(nodes, current_user, [:namespace]).preload_all + end + + def ready?(**args) + unless filter_provided?(args) + raise Gitlab::Graphql::Errors::ArgumentError, + _('You must provide at least one filter argument for this query') + end + + super + end + + def resolve_with_lookahead(**args) + apply_lookahead(::WorkItems::WorkItemsFinder.new(current_user, prepare_finder_params(args)).execute) + end + + private + + def filter_provided?(args) + args.except(*NON_FILTER_ARGUMENTS).values.any?(&:present?) + end + end + end +end + +Resolvers::WorkItems::UserWorkItemsResolver.prepend_mod diff --git a/app/graphql/types/current_user_type.rb b/app/graphql/types/current_user_type.rb index d601b637162bd6f67e0a50bd16a390ee9fa19925..3ade06cd02fc043d145fa683fb77901dfe1c5c0b 100644 --- a/app/graphql/types/current_user_type.rb +++ b/app/graphql/types/current_user_type.rb @@ -16,6 +16,12 @@ class CurrentUserType < ::Types::UserType resolver: Resolvers::Users::RecentlyViewedIssuesResolver, description: 'Most-recently viewed issues for the current user.', experiment: { milestone: '17.9' } + + field :work_items, + null: true, + resolver: Resolvers::WorkItems::UserWorkItemsResolver, + description: 'Find work items visible to the current user.', + experiment: { milestone: '17.10' } end # rubocop:enable Graphql/AuthorizeTypes end diff --git a/app/models/preloaders/issuables_preloader.rb b/app/models/preloaders/issuables_preloader.rb new file mode 100644 index 0000000000000000000000000000000000000000..47125ac429982f637241dfdcd6560cf9f507ddff --- /dev/null +++ b/app/models/preloaders/issuables_preloader.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Preloaders + class IssuablesPreloader + attr_reader :projects, :current_user, :associations + + def initialize(nodes, current_user, associations) + @projects = nodes.map(&:project) + @current_user = current_user + @associations = associations + end + + def preload_all + ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute + ::Preloaders::GroupPolicyPreloader.new(projects.filter_map(&:group), current_user).execute + ActiveRecord::Associations::Preloader.new(records: projects, associations: associations).call + end + end +end + +Preloaders::IssuablesPreloader.prepend_mod diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index c93ffc80de6bd9428d3ebb41355f1e8cc7d4e728..318fc5926ba2c6f2bf0f05c12e041179b45deacf 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -23642,6 +23642,51 @@ four standard [pagination arguments](#pagination-arguments): | ---- | ---- | ----------- | | <a id="currentuseruserachievementsincludehidden"></a>`includeHidden` | [`Boolean`](#boolean) | Indicates whether or not achievements hidden from the profile should be included in the result. | +##### `CurrentUser.workItems` + +Find work items visible to the current user. + +{{< details >}} +**Introduced** in GitLab 17.10. +**Status**: Experiment. +{{< /details >}} + +Returns [`WorkItemConnection`](#workitemconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="currentuserworkitemsassigneeusernames"></a>`assigneeUsernames` | [`[String!]`](#string) | Usernames of users assigned to the work item. | +| <a id="currentuserworkitemsassigneewildcardid"></a>`assigneeWildcardId` | [`AssigneeWildcardId`](#assigneewildcardid) | Filter by assignee wildcard. Incompatible with `assigneeUsernames`. | +| <a id="currentuserworkitemsauthorusername"></a>`authorUsername` | [`String`](#string) | Filter work items by author username. | +| <a id="currentuserworkitemsclosedafter"></a>`closedAfter` | [`Time`](#time) | Work items closed after the date. | +| <a id="currentuserworkitemsclosedbefore"></a>`closedBefore` | [`Time`](#time) | Work items closed before the date. | +| <a id="currentuserworkitemsconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter for confidential work items. If `false`, excludes confidential work items. If `true`, returns only confidential work items. | +| <a id="currentuserworkitemscreatedafter"></a>`createdAfter` | [`Time`](#time) | Work items created after the timestamp. | +| <a id="currentuserworkitemscreatedbefore"></a>`createdBefore` | [`Time`](#time) | Work items created before the timestamp. | +| <a id="currentuserworkitemsdueafter"></a>`dueAfter` | [`Time`](#time) | Work items due after the timestamp. | +| <a id="currentuserworkitemsduebefore"></a>`dueBefore` | [`Time`](#time) | Work items due before the timestamp. | +| <a id="currentuserworkitemsiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of work items. For example, `["1", "2"]`. | +| <a id="currentuserworkitemsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | +| <a id="currentuserworkitemslabelname"></a>`labelName` | [`[String!]`](#string) | Labels applied to the work item. | +| <a id="currentuserworkitemsmilestonetitle"></a>`milestoneTitle` | [`[String!]`](#string) | Milestone applied to the work item. | +| <a id="currentuserworkitemsmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter by milestone ID wildcard. Incompatible with `milestoneTitle`. | +| <a id="currentuserworkitemsmyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. Wildcard values `NONE` and `ANY` are supported. | +| <a id="currentuserworkitemsnot"></a>`not` | [`NegatedWorkItemFilterInput`](#negatedworkitemfilterinput) | Negated work item arguments. | +| <a id="currentuserworkitemsor"></a>`or` | [`UnionedWorkItemFilterInput`](#unionedworkitemfilterinput) | List of arguments with inclusive `OR`. | +| <a id="currentuserworkitemssearch"></a>`search` | [`String`](#string) | Search query for title or description. | +| <a id="currentuserworkitemssort"></a>`sort` | [`WorkItemSort`](#workitemsort) | Sort work items by criteria. | +| <a id="currentuserworkitemsstate"></a>`state` | [`IssuableState`](#issuablestate) | Current state of the work item. | +| <a id="currentuserworkitemssubscribed"></a>`subscribed` | [`SubscriptionStatus`](#subscriptionstatus) | Work items the current user is subscribed to. | +| <a id="currentuserworkitemstypes"></a>`types` | [`[IssueType!]`](#issuetype) | Filter work items by the given work item types. | +| <a id="currentuserworkitemsupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Work items updated after the timestamp. | +| <a id="currentuserworkitemsupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Work items updated before the timestamp. | + ##### `CurrentUser.workspaces` Workspaces owned by the current user. diff --git a/spec/graphql/resolvers/work_items/user_work_items_resolver_spec.rb b/spec/graphql/resolvers/work_items/user_work_items_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bae424800f44026f75f1cf7dda88d57c95754c5a --- /dev/null +++ b/spec/graphql/resolvers/work_items/user_work_items_resolver_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::WorkItems::UserWorkItemsResolver, feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:other_group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:other_project) { create(:project, group: group) } + + let_it_be(:current_user) { create(:user, developer_of: project) } + + let(:default_filter) { { created_before: 1.year.from_now } } + + let_it_be(:project_work_item1) do + create( + :work_item, + project: project, + state: :opened, + created_at: 3.hours.ago, + updated_at: 3.hours.ago, + title: 'foo' + ) + end + + let_it_be(:project_work_item2) do + create( + :work_item, + project: project, + state: :closed, + created_at: 1.hour.ago, + updated_at: 1.hour.ago, + closed_at: 1.hour.ago, + title: 'bar' + ) + end + + let_it_be(:other_project_work_item1) do + create( + :work_item, + project: other_project, + state: :closed, + created_at: 1.hour.ago, + updated_at: 1.hour.ago, + closed_at: 1.hour.ago, + title: 'baz' + ) + end + + let_it_be(:other_project_work_item2) do + create( + :work_item, + project: other_project, + confidential: true, + title: 'Baz 2' + ) + end + + let_it_be(:other_group_work_item1) do + create( + :work_item, + :group_level, + :epic, + namespace: other_group, + title: 'Baz 3' + ) + end + + specify do + expect(described_class).to have_nullable_graphql_type(Types::WorkItemType.connection_type) + end + + context "with project access" do + describe '#resolve' do + it 'finds only the items within the project we have access to' do + expect(batch_sync { resolve_items.to_a }).to contain_exactly(project_work_item1, project_work_item2) + end + + it 'respects the confidentiality of work items' do + other_project.add_guest(current_user) + + expect(resolve_items).to contain_exactly(project_work_item1, project_work_item2, other_project_work_item1) + end + end + end + + context "with group access" do + before do + stub_feature_flags(namespace_level_work_items: true, work_item_epics: true) + stub_licensed_features(epics: true) + end + + let_it_be(:developer) { create(:user, developer_of: group) } + + describe '#resolve' do + it 'finds only the items within the group we have access to' do + expect(batch_sync do + resolve_items(default_filter, { current_user: developer }).to_a + end).to contain_exactly(project_work_item1, project_work_item2, other_project_work_item1, + other_project_work_item2) + end + + # TODO: Enable this spec when the work items finder supports returning group level work items across groups + it 'returns group level work items' do + pending('changes in work items finder to support fetching work items at the group level cross group') + + other_group.add_developer(developer) + + expect(batch_sync do + resolve_items(default_filter, { current_user: developer }).to_a + end).to contain_exactly(project_work_item1, project_work_item2, other_project_work_item1, + other_project_work_item2, other_group_work_item1) + end + end + end + + describe '#resolve' do + describe 'sorting' do + context 'when sorting by created' do + it 'sorts items ascending' do + expect(resolve_items(default_filter.merge(sort: :created_asc)).to_a).to eq [project_work_item1, + project_work_item2] + end + + it 'sorts items descending' do + expect(resolve_items(default_filter.merge(sort: :created_desc)).to_a).to eq [project_work_item2, + project_work_item1] + end + end + + context 'when sorting by title' do + it 'sorts items ascending' do + expect(resolve_items(default_filter.merge(sort: :title_asc)).to_a).to eq [project_work_item2, + project_work_item1] + end + + it 'sorts items descending' do + expect(resolve_items(default_filter.merge(sort: :title_desc)).to_a).to eq [project_work_item1, + project_work_item2] + end + end + end + + it 'raises an error if a filter is not provided' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, + 'You must provide at least one filter argument for this query') do + resolve_items({}) + end + end + end + + def resolve_items(args = default_filter, context = { current_user: current_user }) + resolve(described_class, args: args, ctx: context, arg_style: :internal) + end +end diff --git a/spec/graphql/types/current_user_type_spec.rb b/spec/graphql/types/current_user_type_spec.rb index ff7a529a057793bcacb94aa22c15a05a90b1b60e..1f6596ea07916cafd37728e74e617a66fa3fbe67 100644 --- a/spec/graphql/types/current_user_type_spec.rb +++ b/spec/graphql/types/current_user_type_spec.rb @@ -8,4 +8,43 @@ it "inherits authorization policies from the UserType superclass" do expect(described_class).to require_graphql_authorizations(:read_user) end + + describe 'work_items field' do + subject { described_class.fields['workItems'] } + + it "finds work_items" do + expected_fields = %i[after + assigneeUsernames + assigneeWildcardId + authorUsername + before + closedAfter + closedBefore + confidential + createdAfter + createdBefore + dueAfter + dueBefore + first + iids + in + labelName + last + milestoneTitle + milestoneWildcardId + myReactionEmoji + not + or + search + sort + state + subscribed + types + updatedAfter + updatedBefore] + + is_expected.to have_graphql_arguments(expected_fields) + is_expected.to have_graphql_type(Types::WorkItemType.connection_type) + end + end end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index 74e9522c16c7cd6cb7a717da85656480fdfc6ea8..deeed58fe0b5821645a3e86d0c3faaca66f520ab 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -132,7 +132,20 @@ subject { described_class.fields['timelogs'] } it 'returns timelogs' do - is_expected.to have_graphql_arguments(:startDate, :endDate, :startTime, :endTime, :username, :projectId, :groupId, :after, :before, :first, :last, :sort) + is_expected.to have_graphql_arguments( + :startDate, + :endDate, + :startTime, + :endTime, + :username, + :projectId, + :groupId, + :after, + :before, + :first, + :last, + :sort + ) is_expected.to have_graphql_type(Types::TimelogType.connection_type) is_expected.to have_graphql_resolver(Resolvers::TimelogResolver) end @@ -177,4 +190,66 @@ is_expected.to have_graphql_resolver(Resolvers::FeatureFlagResolver) end end + + describe 'issues field' do + subject { described_class.fields['issues'] } + + it "finds issues" do + expected_fields = %i[ + after + assigneeId + assigneeUsername + assigneeUsernames + assigneeWildcardId + authorUsername + before + closedAfter + closedBefore + confidential + createdAfter + createdBefore + crmContactId + crmOrganizationId + dueAfter + dueBefore + first + iid + iids + in + includeArchived + labelName + last + milestoneTitle + milestoneWildcardId + myReactionEmoji + not + or + search + sort + state + subscribed + types + updatedAfter + updatedBefore + ] + + if Gitlab.ee? + expected_fields += %i[ + epicId + epicWildcardId + healthStatusFilter + includeSubepics + iterationCadenceId + iterationId + iterationTitle + iterationWildcardId + weight + weightWildcardId + ] + end + + is_expected.to have_graphql_arguments(*expected_fields) + is_expected.to have_graphql_type(Types::IssueType.connection_type) + end + end end diff --git a/spec/models/preloaders/issuables_preloader_spec.rb b/spec/models/preloaders/issuables_preloader_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0e71780326fa6a26f4a5cbb950fd21cd646f43a6 --- /dev/null +++ b/spec/models/preloaders/issuables_preloader_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::IssuablesPreloader, feature_category: :team_planning do + let_it_be(:user) { create(:user) } + + let_it_be(:projects) { create_list(:project, 3, :public, :repository) } + let_it_be(:issues) { projects.map { |p| create(:issue, project: p) } } + let_it_be(:associations) { [:namespace] } + + it 'does not produce N+1 queries' do + first_issue = issues_with_preloaded_data.first + clean_issues = issues_with_preloaded_data + + expect { access_data(clean_issues) }.to issue_same_number_of_queries_as { access_data([first_issue]) } + end + + private + + def issues_with_preloaded_data + i = Issue.where(id: issues.map(&:id)) + described_class.new(i, user, associations).preload_all + i + end + + def access_data(issues) + issues.each { |i| i.project.namespace } + end +end