diff --git a/app/graphql/resolvers/issues/base_resolver.rb b/app/graphql/resolvers/issues/base_resolver.rb index 88579b09482fdd3d2ce18d09459c1153c2d135c3..9a2c4572abbf18e9ccee74ef985da6f9887cf4b2 100644 --- a/app/graphql/resolvers/issues/base_resolver.rb +++ b/app/graphql/resolvers/issues/base_resolver.rb @@ -97,7 +97,7 @@ def accept_release_tag end def ready?(**args) - if args[:or].present? && ::Feature.disabled?(:or_issuable_queries, resource_parent) + if args[:or].present? && or_issuable_queries_disabled? raise ::Gitlab::Graphql::Errors::ArgumentError, "'or' arguments are only allowed when the `or_issuable_queries` feature flag is enabled." end @@ -115,6 +115,14 @@ def ready?(**args) private + def or_issuable_queries_disabled? + if respond_to?(:resource_parent, true) + ::Feature.disabled?(:or_issuable_queries, resource_parent) + else + ::Feature.disabled?(:or_issuable_queries) + end + end + def prepare_finder_params(args) params = super(args) params[:not] = params[:not].to_h if params[:not] diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..e3102a7d32a46cb86bd7fe9b0f6275a45ed0ef22 --- /dev/null +++ b/app/graphql/resolvers/issues_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class IssuesResolver < Issues::BaseResolver + prepend ::Issues::LookAheadPreloads + include ::Issues::SortArguments + + argument :state, Types::IssuableStateEnum, + required: false, + description: 'Current state of this issue.' + + # see app/graphql/types/issue_connection.rb + type 'Types::IssueConnection', null: true + + def resolve_with_lookahead(**args) + return unless Feature.enabled?(:root_level_issues_query) + + issues = apply_lookahead( + IssuesFinder.new(current_user, prepare_finder_params(args)).execute + ) + + if non_stable_cursor_sort?(args[:sort]) + # Certain complex sorts are not supported by the stable cursor pagination yet. + # In these cases, we use offset pagination, so we return the correct connection. + offset_pagination(issues) + else + issues + end + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 8fe6d364118ba9b5f23635bcb1f3d47e3b4b9571..21cb3f9e06cd6ae65b01e92fd75696d86c216023 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -82,6 +82,13 @@ class QueryType < ::Types::BaseObject field :echo, resolver: Resolvers::EchoResolver + field :issues, + null: true, + alpha: { milestone: '15.6' }, + resolver: Resolvers::IssuesResolver, + description: 'Issues visible by the current user.' \ + ' Returns null if the `root_level_issues_query` feature flag is disabled.' + field :issue, Types::IssueType, null: true, description: 'Find an issue.' do diff --git a/config/feature_flags/development/root_level_issues_query.yml b/config/feature_flags/development/root_level_issues_query.yml new file mode 100644 index 0000000000000000000000000000000000000000..308f916816705e1b4e58419573de8c94a73a91f5 --- /dev/null +++ b/config/feature_flags/development/root_level_issues_query.yml @@ -0,0 +1,8 @@ +--- +name: root_level_issues_query +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102348 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382250 +milestone: '15.6' +type: development +group: group::project management +default_enabled: false diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 721b15db3d946e7250615836062386d831663d29..8c98f0660099fcfd7856752ff91878cc8582d805 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -214,6 +214,57 @@ Returns [`Issue`](#issue). | ---- | ---- | ----------- | | <a id="queryissueid"></a>`id` | [`IssueID!`](#issueid) | Global ID of the issue. | +### `Query.issues` + +Issues visible by the current user. Returns null if the `root_level_issues_query` feature flag is disabled. + +WARNING: +**Introduced** in 15.6. +This feature is in Alpha. It can be changed or removed at any time. + +Returns [`IssueConnection`](#issueconnection). + +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="queryissuesassigneeid"></a>`assigneeId` | [`String`](#string) | ID of a user assigned to the issues. Wildcard values "NONE" and "ANY" are supported. | +| <a id="queryissuesassigneeusername"></a>`assigneeUsername` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.11. Use `assigneeUsernames`. | +| <a id="queryissuesassigneeusernames"></a>`assigneeUsernames` | [`[String!]`](#string) | Usernames of users assigned to the issue. | +| <a id="queryissuesauthorusername"></a>`authorUsername` | [`String`](#string) | Username of the author of the issue. | +| <a id="queryissuesclosedafter"></a>`closedAfter` | [`Time`](#time) | Issues closed after this date. | +| <a id="queryissuesclosedbefore"></a>`closedBefore` | [`Time`](#time) | Issues closed before this date. | +| <a id="queryissuesconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter for confidential issues. If "false", excludes confidential issues. If "true", returns only confidential issues. | +| <a id="queryissuescreatedafter"></a>`createdAfter` | [`Time`](#time) | Issues created after this date. | +| <a id="queryissuescreatedbefore"></a>`createdBefore` | [`Time`](#time) | Issues created before this date. | +| <a id="queryissuescrmcontactid"></a>`crmContactId` | [`String`](#string) | ID of a contact assigned to the issues. | +| <a id="queryissuescrmorganizationid"></a>`crmOrganizationId` | [`String`](#string) | ID of an organization assigned to the issues. | +| <a id="queryissuesepicid"></a>`epicId` | [`String`](#string) | ID of an epic associated with the issues, "none" and "any" values are supported. | +| <a id="queryissueshealthstatusfilter"></a>`healthStatusFilter` | [`HealthStatusFilter`](#healthstatusfilter) | Health status of the issue, "none" and "any" values are supported. | +| <a id="queryissuesiid"></a>`iid` | [`String`](#string) | IID of the issue. For example, "1". | +| <a id="queryissuesiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of issues. For example, `["1", "2"]`. | +| <a id="queryissuesin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. | +| <a id="queryissuesincludesubepics"></a>`includeSubepics` | [`Boolean`](#boolean) | Whether to include subepics when filtering issues by epicId. | +| <a id="queryissuesiterationid"></a>`iterationId` | [`[ID]`](#id) | List of iteration Global IDs applied to the issue. | +| <a id="queryissuesiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. | +| <a id="queryissueslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. | +| <a id="queryissuesmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. | +| <a id="queryissuesmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. | +| <a id="queryissuesmyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported. | +| <a id="queryissuesnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. | +| <a id="queryissuesor"></a>`or` | [`UnionedIssueFilterInput`](#unionedissuefilterinput) | List of arguments with inclusive OR. | +| <a id="queryissuessearch"></a>`search` | [`String`](#string) | Search query for title or description. | +| <a id="queryissuessort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. | +| <a id="queryissuesstate"></a>`state` | [`IssuableState`](#issuablestate) | Current state of this issue. | +| <a id="queryissuestypes"></a>`types` | [`[IssueType!]`](#issuetype) | Filter issues by the given issue types. | +| <a id="queryissuesupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Issues updated after this date. | +| <a id="queryissuesupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Issues updated before this date. | +| <a id="queryissuesweight"></a>`weight` | [`String`](#string) | Weight applied to the issue, "none" and "any" values are supported. | + ### `Query.iteration` Find an iteration. diff --git a/spec/requests/api/graphql/issues_spec.rb b/spec/requests/api/graphql/issues_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8838ad78f72cd78885934bbccde444212bb0823a --- /dev/null +++ b/spec/requests/api/graphql/issues_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting an issue list at root level' do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user) } + let_it_be(:group1) { create(:group).tap { |group| group.add_developer(developer) } } + let_it_be(:group2) { create(:group).tap { |group| group.add_developer(developer) } } + let_it_be(:project_a) { create(:project, :repository, :public, group: group1) } + let_it_be(:project_b) { create(:project, :repository, :private, group: group1) } + let_it_be(:project_c) { create(:project, :repository, :public, group: group2) } + let_it_be(:project_d) { create(:project, :repository, :private, group: group2) } + let_it_be(:early_milestone) { create(:milestone, project: project_d, due_date: 10.days.from_now) } + let_it_be(:late_milestone) { create(:milestone, project: project_c, due_date: 30.days.from_now) } + let_it_be(:priority1) { create(:label, project: project_c, priority: 1) } + let_it_be(:priority2) { create(:label, project: project_d, priority: 5) } + let_it_be(:priority3) { create(:label, project: project_a, priority: 10) } + + let_it_be(:issue_a) { create(:issue, project: project_a, labels: [priority3]) } + let_it_be(:issue_b) { create(:issue, :with_alert, project: project_b, discussion_locked: true) } + let_it_be(:issue_c) do + create( + :issue, + project: project_c, + title: 'title matching issue plus', + labels: [priority1], + milestone: late_milestone + ) + end + + let_it_be(:issue_d) { create(:issue, :with_alert, project: project_d, discussion_locked: true, labels: [priority2]) } + let_it_be(:issue_e) { create(:issue, project: project_d, milestone: early_milestone) } + + let(:issue_filter_params) { {} } + + let(:fields) do + <<~QUERY + nodes { + #{all_graphql_fields_for('issues'.classify)} + } + QUERY + end + + before_all do + group2.add_reporter(reporter) + end + + context 'when the root_level_issues_query feature flag is disabled' do + before do + stub_feature_flags(root_level_issues_query: false) + end + + it 'the field returns null' do + post_graphql(query, current_user: developer) + + expect(graphql_data).to eq('issues' => nil) + end + end + + it_behaves_like 'graphql issue list request spec' do + subject(:post_query) { post_graphql(query, current_user: current_user) } + + let(:current_user) { developer } + let(:another_user) { reporter } + let(:issues_data) { graphql_data['issues']['nodes'] } + let(:issue_ids) { graphql_dig_at(issues_data, :id) } + + # filters + let(:expected_negated_assignee_issues) { [issue_b, issue_c, issue_d, issue_e] } + let(:expected_unioned_assignee_issues) { [issue_a, issue_c] } + let(:voted_issues) { [issue_a, issue_c] } + let(:no_award_issues) { [issue_b, issue_d, issue_e] } + let(:locked_discussion_issues) { [issue_b, issue_d] } + let(:unlocked_discussion_issues) { [issue_a, issue_c, issue_e] } + let(:search_title_term) { 'matching issue' } + let(:title_search_issue) { issue_c } + + # sorting + let(:data_path) { [:issues] } + let(:expected_severity_sorted_asc) { [issue_c, issue_a, issue_b, issue_e, issue_d] } + let(:expected_priority_sorted_asc) { [issue_e, issue_c, issue_d, issue_a, issue_b] } + let(:expected_priority_sorted_desc) { [issue_c, issue_e, issue_a, issue_d, issue_b] } + + before_all do + issue_a.assignee_ids = developer.id + issue_c.assignee_ids = reporter.id + + create(:award_emoji, :upvote, user: developer, awardable: issue_a) + create(:award_emoji, :upvote, user: developer, awardable: issue_c) + + # severity sorting + create(:issuable_severity, issue: issue_a, severity: :unknown) + create(:issuable_severity, issue: issue_b, severity: :low) + create(:issuable_severity, issue: issue_d, severity: :critical) + create(:issuable_severity, issue: issue_e, severity: :high) + end + + def pagination_query(params) + graphql_query_for( + :issues, + params, + "#{page_info} nodes { id }" + ) + end + end + + def query(params = issue_filter_params) + graphql_query_for( + :issues, + params, + fields + ) + end +end diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index 3d09206fa96f0c483892852dd238733b8494c8f5..214165cb1716727ec0bf0044d4978f75f17d22ad 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -8,140 +8,74 @@ let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, :repository, :public, group: group) } let_it_be(:current_user) { create(:user) } - let_it_be(:issue_a, reload: true) { create(:issue, project: project, discussion_locked: true) } - let_it_be(:issue_b, reload: true) { create(:issue, :with_alert, project: project) } - let_it_be(:issues, reload: true) { [issue_a, issue_b] } + let_it_be(:another_user) { create(:user).tap { |u| group.add_reporter(u) } } + let_it_be(:early_milestone) { create(:milestone, project: project, due_date: 10.days.from_now) } + let_it_be(:late_milestone) { create(:milestone, project: project, due_date: 30.days.from_now) } + let_it_be(:priority1) { create(:label, project: project, priority: 1) } + let_it_be(:priority2) { create(:label, project: project, priority: 5) } + let_it_be(:priority3) { create(:label, project: project, priority: 10) } + + let_it_be(:issue_a, reload: true) { create(:issue, project: project, discussion_locked: true, labels: [priority3]) } + let_it_be(:issue_b, reload: true) { create(:issue, :with_alert, project: project, title: 'title matching issue i') } + let_it_be(:issue_c) { create(:issue, project: project, labels: [priority1], milestone: late_milestone) } + let_it_be(:issue_d) { create(:issue, project: project, labels: [priority2]) } + let_it_be(:issue_e) { create(:issue, project: project, milestone: early_milestone) } + let_it_be(:issues, reload: true) { [issue_a, issue_b, issue_c, issue_d, issue_e] } let(:issue_a_gid) { issue_a.to_global_id.to_s } let(:issue_b_gid) { issue_b.to_global_id.to_s } - let(:issues_data) { graphql_data['project']['issues']['edges'] } + let(:issues_data) { graphql_data['project']['issues']['nodes'] } let(:issue_filter_params) { {} } let(:fields) do <<~QUERY - edges { - node { - #{all_graphql_fields_for('issues'.classify)} - } + nodes { + #{all_graphql_fields_for('issues'.classify)} } QUERY end - it_behaves_like 'a working graphql query' do - before do - post_graphql(query, current_user: current_user) - end - end - - it 'includes a web_url' do - post_graphql(query, current_user: current_user) - - expect(issues_data[0]['node']['webUrl']).to be_present - end - - it 'includes discussion locked' do - post_graphql(query, current_user: current_user) - - expect(issues_data[0]['node']['discussionLocked']).to eq(false) - expect(issues_data[1]['node']['discussionLocked']).to eq(true) - end - - context 'when both assignee_username filters are provided' do - let(:issue_filter_params) { { assignee_username: current_user.username, assignee_usernames: [current_user.username] } } - - it 'returns a mutually exclusive param error' do - post_graphql(query, current_user: current_user) - - expect_graphql_errors_to_include('only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.') - end - end - - context 'when filtering by a negated argument' do - let(:issue_filter_params) { { not: { assignee_usernames: current_user.username } } } - - it 'returns correctly filtered issues' do - issue_a.assignee_ids = current_user.id - - post_graphql(query, current_user: current_user) - - expect(issues_ids).to contain_exactly(issue_b_gid) - end - - context 'when argument is blank' do - let(:issue_filter_params) { { not: {} } } - - it 'does not raise an error' do - post_graphql(query, current_user: current_user) - - expect_graphql_errors_to_be_empty - end - end - end - - context 'when filtering by a unioned argument' do - let(:another_user) { create(:user) } - let(:issue_filter_params) { { or: { assignee_usernames: [current_user.username, another_user.username] } } } - - it 'returns correctly filtered issues' do + # All new specs should be added to the shared example if the change also + # affects the `issues` query at the root level of the API. + # Shared example also used in spec/requests/api/graphql/issues_spec.rb + it_behaves_like 'graphql issue list request spec' do + subject(:post_query) { post_graphql(query, current_user: current_user) } + + # filters + let(:expected_negated_assignee_issues) { [issue_b, issue_c, issue_d, issue_e] } + let(:expected_unioned_assignee_issues) { [issue_a, issue_b] } + let(:voted_issues) { [issue_a] } + let(:no_award_issues) { [issue_b, issue_c, issue_d, issue_e] } + let(:locked_discussion_issues) { [issue_a] } + let(:unlocked_discussion_issues) { [issue_b, issue_c, issue_d, issue_e] } + let(:search_title_term) { 'matching issue' } + let(:title_search_issue) { issue_b } + + # sorting + let(:data_path) { [:project, :issues] } + let(:expected_severity_sorted_asc) { [issue_c, issue_a, issue_b, issue_e, issue_d] } + let(:expected_priority_sorted_asc) { [issue_e, issue_c, issue_d, issue_a, issue_b] } + let(:expected_priority_sorted_desc) { [issue_c, issue_e, issue_a, issue_d, issue_b] } + + before_all do issue_a.assignee_ids = current_user.id issue_b.assignee_ids = another_user.id - post_graphql(query, current_user: current_user) - - expect(issues_ids).to contain_exactly(issue_a_gid, issue_b_gid) - end - - context 'when argument is blank' do - let(:issue_filter_params) { { or: {} } } - - it 'does not raise an error' do - post_graphql(query, current_user: current_user) - - expect_graphql_errors_to_be_empty - end - end - - context 'when feature flag is disabled' do - it 'returns an error' do - stub_feature_flags(or_issuable_queries: false) + create(:award_emoji, :upvote, user: current_user, awardable: issue_a) - post_graphql(query, current_user: current_user) - - expect_graphql_errors_to_include("'or' arguments are only allowed when the `or_issuable_queries` feature flag is enabled.") - end + # severity sorting + create(:issuable_severity, issue: issue_a, severity: :unknown) + create(:issuable_severity, issue: issue_b, severity: :low) + create(:issuable_severity, issue: issue_d, severity: :critical) + create(:issuable_severity, issue: issue_e, severity: :high) end - end - - context 'filtering by my_reaction_emoji' do - using RSpec::Parameterized::TableSyntax - - let_it_be(:upvote_award) { create(:award_emoji, :upvote, user: current_user, awardable: issue_a) } - - where(:value, :gids) do - 'thumbsup' | lazy { [issue_a_gid] } - 'ANY' | lazy { [issue_a_gid] } - 'any' | lazy { [issue_a_gid] } - 'AnY' | lazy { [issue_a_gid] } - 'NONE' | lazy { [issue_b_gid] } - 'thumbsdown' | lazy { [] } - end - - with_them do - let(:issue_filter_params) { { my_reaction_emoji: value } } - it 'returns correctly filtered issues' do - post_graphql(query, current_user: current_user) - - expect(issues_ids).to eq(gids) - end - end - end - - context 'when filtering by search' do - it_behaves_like 'query with a search term' do - let(:issuable_data) { issues_data } - let(:user) { current_user } - let_it_be(:issuable) { create(:issue, project: project, description: 'bar') } + def pagination_query(params) + graphql_query_for( + :project, + { full_path: project.full_path }, + query_graphql_field(:issues, params, "#{page_info} nodes { id }") + ) end end @@ -211,10 +145,10 @@ it 'returns issues without confidential issues' do post_graphql(query, current_user: current_user) - expect(issues_data.size).to eq(2) + expect(issues_data.size).to eq(5) issues_data.each do |issue| - expect(issue.dig('node', 'confidential')).to eq(false) + expect(issue['confidential']).to eq(false) end end @@ -234,7 +168,7 @@ it 'returns correctly filtered issues' do post_graphql(query, current_user: current_user) - expect(issues_ids).to contain_exactly(issue_a_gid, issue_b_gid) + expect(issue_ids).to match_array(issues.map { |i| i.to_gid.to_s }) end end end @@ -247,13 +181,13 @@ it 'returns issues with confidential issues' do post_graphql(query, current_user: current_user) - expect(issues_data.size).to eq(3) + expect(issues_data.size).to eq(6) confidentials = issues_data.map do |issue| - issue.dig('node', 'confidential') + issue['confidential'] end - expect(confidentials).to eq([true, false, false]) + expect(confidentials).to contain_exactly(true, false, false, false, false, false) end context 'filtering for confidential issues' do @@ -262,7 +196,7 @@ it 'returns correctly filtered issues' do post_graphql(query, current_user: current_user) - expect(issues_ids).to contain_exactly(confidential_issue_gid) + expect(issue_ids).to contain_exactly(confidential_issue_gid) end end @@ -272,7 +206,7 @@ it 'returns correctly filtered issues' do post_graphql(query, current_user: current_user) - expect(issues_ids).to contain_exactly(issue_a_gid, issue_b_gid) + expect(issue_ids).to match_array([issue_a, issue_b, issue_c, issue_d, issue_e].map { |i| i.to_gid.to_s }) end end end @@ -294,37 +228,7 @@ def pagination_results_data(data) data.map { |issue| issue['iid'].to_i } end - context 'when sorting by severity' do - let_it_be(:severty_issue1) { create(:issue, project: sort_project) } - let_it_be(:severty_issue2) { create(:issue, project: sort_project) } - let_it_be(:severty_issue3) { create(:issue, project: sort_project) } - let_it_be(:severty_issue4) { create(:issue, project: sort_project) } - let_it_be(:severty_issue5) { create(:issue, project: sort_project) } - - before(:all) do - create(:issuable_severity, issue: severty_issue1, severity: :unknown) - create(:issuable_severity, issue: severty_issue2, severity: :low) - create(:issuable_severity, issue: severty_issue4, severity: :critical) - create(:issuable_severity, issue: severty_issue5, severity: :high) - end - - context 'when ascending' do - it_behaves_like 'sorted paginated query' do - let(:sort_param) { :SEVERITY_ASC } - let(:first_param) { 2 } - let(:all_records) { [severty_issue3.iid, severty_issue1.iid, severty_issue2.iid, severty_issue5.iid, severty_issue4.iid] } - end - end - - context 'when descending' do - it_behaves_like 'sorted paginated query' do - let(:sort_param) { :SEVERITY_DESC } - let(:first_param) { 2 } - let(:all_records) { [severty_issue4.iid, severty_issue5.iid, severty_issue2.iid, severty_issue1.iid, severty_issue3.iid] } - end - end - end - + # rubocop:disable RSpec/MultipleMemoizedHelpers context 'when sorting by due date' do let_it_be(:due_issue1) { create(:issue, project: sort_project, due_date: 3.days.from_now) } let_it_be(:due_issue2) { create(:issue, project: sort_project, due_date: nil) } @@ -370,41 +274,6 @@ def pagination_results_data(data) end end - context 'when sorting by priority' do - let_it_be(:on_project) { { project: sort_project } } - let_it_be(:early_milestone) { create(:milestone, **on_project, due_date: 10.days.from_now) } - let_it_be(:late_milestone) { create(:milestone, **on_project, due_date: 30.days.from_now) } - let_it_be(:priority_1) { create(:label, **on_project, priority: 1) } - let_it_be(:priority_2) { create(:label, **on_project, priority: 5) } - let_it_be(:priority_issue1) { create(:issue, **on_project, labels: [priority_1], milestone: late_milestone) } - let_it_be(:priority_issue2) { create(:issue, **on_project, labels: [priority_2]) } - let_it_be(:priority_issue3) { create(:issue, **on_project, milestone: early_milestone) } - let_it_be(:priority_issue4) { create(:issue, **on_project) } - - context 'when ascending' do - it_behaves_like 'sorted paginated query' do - let(:sort_param) { :PRIORITY_ASC } - let(:first_param) { 2 } - let(:all_records) do - [ - priority_issue3.iid, priority_issue1.iid, - priority_issue2.iid, priority_issue4.iid - ] - end - end - end - - context 'when descending' do - it_behaves_like 'sorted paginated query' do - let(:sort_param) { :PRIORITY_DESC } - let(:first_param) { 2 } - let(:all_records) do - [priority_issue1.iid, priority_issue3.iid, priority_issue2.iid, priority_issue4.iid] - end - end - end - end - context 'when sorting by label priority' do let_it_be(:label1) { create(:label, project: sort_project, priority: 1) } let_it_be(:label2) { create(:label, project: sort_project, priority: 5) } @@ -430,6 +299,7 @@ def pagination_results_data(data) end end end + # rubocop:enable RSpec/MultipleMemoizedHelpers context 'when sorting by milestone due date' do let_it_be(:early_milestone) { create(:milestone, project: sort_project, due_date: 10.days.from_now) } @@ -459,8 +329,7 @@ def pagination_results_data(data) context 'when fetching alert management alert' do let(:fields) do <<~QUERY - edges { - node { + nodes { iid alertManagementAlert { title @@ -471,7 +340,6 @@ def pagination_results_data(data) } } } - } QUERY end @@ -491,7 +359,7 @@ def pagination_results_data(data) it 'returns the alert data' do post_graphql(query, current_user: current_user) - alert_titles = issues_data.map { |issue| issue.dig('node', 'alertManagementAlert', 'title') } + alert_titles = issues_data.map { |issue| issue.dig('alertManagementAlert', 'title') } expected_titles = issues.map { |issue| issue.alert_management_alert&.title } expect(alert_titles).to contain_exactly(*expected_titles) @@ -500,7 +368,7 @@ def pagination_results_data(data) it 'returns the alerts data' do post_graphql(query, current_user: current_user) - alert_titles = issues_data.map { |issue| issue.dig('node', 'alertManagementAlerts', 'nodes') } + alert_titles = issues_data.map { |issue| issue.dig('alertManagementAlerts', 'nodes') } expected_titles = issues.map do |issue| issue.alert_management_alerts.map { |alert| { 'title' => alert.title } } end @@ -541,13 +409,11 @@ def clean_state_query context 'when fetching labels' do let(:fields) do <<~QUERY - edges { - node { - id - labels { - nodes { - id - } + nodes { + id + labels { + nodes { + id } } } @@ -563,8 +429,8 @@ def clean_state_query end def response_label_ids(response_data) - response_data.map do |edge| - edge['node']['labels']['nodes'].map { |u| u['id'] } + response_data.map do |node| + node['labels']['nodes'].map { |u| u['id'] } end.flatten end @@ -574,7 +440,7 @@ def labels_as_global_ids(issues) it 'avoids N+1 queries', :aggregate_failures do control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } - expect(issues_data.count).to eq(2) + expect(issues_data.count).to eq(5) expect(response_label_ids(issues_data)).to match_array(labels_as_global_ids(issues)) new_issues = issues + [create(:issue, project: project, labels: [create(:label, project: project)])] @@ -582,8 +448,8 @@ def labels_as_global_ids(issues) expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control) # graphql_data is memoized (see spec/support/helpers/graphql_helpers.rb) # so we have to parse the body ourselves the second time - issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['edges'] - expect(issues_data.count).to eq(3) + issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['nodes'] + expect(issues_data.count).to eq(6) expect(response_label_ids(issues_data)).to match_array(labels_as_global_ids(new_issues)) end end @@ -591,13 +457,11 @@ def labels_as_global_ids(issues) context 'when fetching assignees' do let(:fields) do <<~QUERY - edges { - node { - id - assignees { - nodes { - id - } + nodes { + id + assignees { + nodes { + id } } } @@ -613,8 +477,8 @@ def labels_as_global_ids(issues) end def response_assignee_ids(response_data) - response_data.map do |edge| - edge['node']['assignees']['nodes'].map { |node| node['id'] } + response_data.map do |node| + node['assignees']['nodes'].map { |node| node['id'] } end.flatten end @@ -624,7 +488,7 @@ def assignees_as_global_ids(issues) it 'avoids N+1 queries', :aggregate_failures do control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) } - expect(issues_data.count).to eq(2) + expect(issues_data.count).to eq(5) expect(response_assignee_ids(issues_data)).to match_array(assignees_as_global_ids(issues)) new_issues = issues + [create(:issue, project: project, assignees: [create(:user)])] @@ -632,8 +496,8 @@ def assignees_as_global_ids(issues) expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control) # graphql_data is memoized (see spec/support/helpers/graphql_helpers.rb) # so we have to parse the body ourselves the second time - issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['edges'] - expect(issues_data.count).to eq(3) + issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['nodes'] + expect(issues_data.count).to eq(6) expect(response_assignee_ids(issues_data)).to match_array(assignees_as_global_ids(new_issues)) end end @@ -644,11 +508,9 @@ def assignees_as_global_ids(issues) let(:statuses) { issue_data.to_h { |issue| [issue['iid'], issue['escalationStatus']] } } let(:fields) do <<~QUERY - edges { - node { - id - escalationStatus - } + nodes { + id + escalationStatus } QUERY end @@ -660,9 +522,9 @@ def assignees_as_global_ids(issues) it 'returns the escalation status values' do post_graphql(query, current_user: current_user) - statuses = issues_data.map { |issue| issue.dig('node', 'escalationStatus') } + statuses = issues_data.map { |issue| issue['escalationStatus'] } - expect(statuses).to contain_exactly(escalation_status.status_name.upcase.to_s, nil) + expect(statuses).to contain_exactly(escalation_status.status_name.upcase.to_s, nil, nil, nil, nil) end it 'avoids N+1 queries', :aggregate_failures do @@ -798,8 +660,8 @@ def execute_query end end - def issues_ids - graphql_dig_at(issues_data, :node, :id) + def issue_ids + graphql_dig_at(issues_data, :id) end def query(params = issue_filter_params) diff --git a/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb index 22805cf7aedf4b15f4e9d4eec6048905f6926dab..bb492425fd768b14d3d27db7caa77022f53a4f31 100644 --- a/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true # Requires `query(params)` , `user`, `issuable_data` and `issuable` bindings -RSpec.shared_examples 'query with a search term' do +RSpec.shared_examples 'query with a search term' do |fields = [:DESCRIPTION]| + let(:search_term) { 'bar' } + let(:ids) { graphql_dig_at(issuable_data, :node, :id) } + it 'returns only matching issuables' do - filter_params = { search: 'bar', in: [:DESCRIPTION] } + filter_params = { search: search_term, in: fields } graphql_query = query(filter_params) post_graphql(graphql_query, current_user: user) - ids = graphql_dig_at(issuable_data, :node, :id) expect(ids).to contain_exactly(issuable.to_global_id.to_s) end diff --git a/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..5469fd80a4f144c3d74a3066c2413f04e4b501e6 --- /dev/null +++ b/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'graphql issue list request spec' do + it_behaves_like 'a working graphql query' do + before do + post_query + end + end + + describe 'filters' do + context 'when filtering by assignees' do + context 'when both assignee_username filters are provided' do + let(:issue_filter_params) do + { assignee_username: current_user.username, assignee_usernames: [current_user.username] } + end + + it 'returns a mutually exclusive param error' do + post_query + + expect_graphql_errors_to_include( + 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.' + ) + end + end + + context 'when filtering by a negated argument' do + let(:issue_filter_params) { { not: { assignee_usernames: [current_user.username] } } } + + it 'returns correctly filtered issues' do + post_query + + expect(issue_ids).to match_array(expected_negated_assignee_issues.map { |i| i.to_gid.to_s }) + end + end + end + + context 'when filtering by unioned arguments' do + let(:issue_filter_params) { { or: { assignee_usernames: [current_user.username, another_user.username] } } } + + it 'returns correctly filtered issues' do + post_query + + expect(issue_ids).to match_array(expected_unioned_assignee_issues.map { |i| i.to_gid.to_s }) + end + + context 'when argument is blank' do + let(:issue_filter_params) { { or: {} } } + + it 'does not raise an error' do + post_query + + expect_graphql_errors_to_be_empty + end + end + + context 'when feature flag is disabled' do + it 'returns an error' do + stub_feature_flags(or_issuable_queries: false) + + post_query + + expect_graphql_errors_to_include( + "'or' arguments are only allowed when the `or_issuable_queries` feature flag is enabled." + ) + end + end + end + + context 'when filtering by a blank negated argument' do + let(:issue_filter_params) { { not: {} } } + + it 'does not raise an error' do + post_query + + expect_graphql_errors_to_be_empty + end + end + + context 'when filtering by reaction emoji' do + using RSpec::Parameterized::TableSyntax + + where(:value, :issue_list) do + 'thumbsup' | lazy { voted_issues } + 'ANY' | lazy { voted_issues } + 'any' | lazy { voted_issues } + 'AnY' | lazy { voted_issues } + 'NONE' | lazy { no_award_issues } + 'thumbsdown' | lazy { [] } + end + + with_them do + let(:issue_filter_params) { { my_reaction_emoji: value } } + let(:gids) { to_gid_list(issue_list) } + + it 'returns correctly filtered issues' do + post_query + + expect(issue_ids).to match_array(gids) + end + end + end + + context 'when filtering by search' do + it_behaves_like 'query with a search term', [:TITLE] do + let(:search_term) { search_title_term } + let(:issuable_data) { issues_data } + let(:user) { current_user } + let(:issuable) { title_search_issue } + let(:ids) { issue_ids } + end + end + end + + describe 'sorting and pagination' do + context 'when sorting by severity' do + context 'when ascending' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { :SEVERITY_ASC } + let(:first_param) { 2 } + let(:all_records) { to_gid_list(expected_severity_sorted_asc) } + end + end + + context 'when descending' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { :SEVERITY_DESC } + let(:first_param) { 2 } + let(:all_records) { to_gid_list(expected_severity_sorted_asc.reverse) } + end + end + end + + context 'when sorting by priority' do + context 'when ascending' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { :PRIORITY_ASC } + let(:first_param) { 2 } + let(:all_records) { to_gid_list(expected_priority_sorted_asc) } + end + end + + context 'when descending' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { :PRIORITY_DESC } + let(:first_param) { 2 } + let(:all_records) { to_gid_list(expected_priority_sorted_desc) } + end + end + end + end + + it 'includes a web_url' do + post_query + + expect(issues_data[0]['webUrl']).to be_present + end + + it 'includes discussion locked' do + post_query + + expect(issues_data).to contain_exactly( + *locked_discussion_issues.map { |i| hash_including('id' => i.to_gid.to_s, 'discussionLocked' => true) }, + *unlocked_discussion_issues.map { |i| hash_including('id' => i.to_gid.to_s, 'discussionLocked' => false) } + ) + end + + def to_gid_list(instance_list) + instance_list.map { |instance| instance.to_gid.to_s } + end +end