From 94ca3bf5460162a2543bb9f9f4a35e45fb78c49f Mon Sep 17 00:00:00 2001 From: Mario Celi <mcelicalderon@gitlab.com> Date: Wed, 2 Nov 2022 12:40:18 -0500 Subject: [PATCH] Add issues root level query to GraphQL API A new Query.issues query will be available in alpha state while we make sure the query is stable. The query will allow fetching issues by different filters and not necessarily scoped to a project or group as the other existing queries did. --- app/graphql/resolvers/issues/base_resolver.rb | 10 +- app/graphql/resolvers/issues_resolver.rb | 31 ++ app/graphql/types/query_type.rb | 7 + .../development/root_level_issues_query.yml | 8 + doc/api/graphql/reference/index.md | 51 +++ spec/requests/api/graphql/issues_spec.rb | 117 +++++++ .../api/graphql/project/issues_spec.rb | 320 +++++------------- .../issuable_search_shared_examples.rb | 8 +- .../api/graphql/issue_list_shared_examples.rb | 170 ++++++++++ 9 files changed, 489 insertions(+), 233 deletions(-) create mode 100644 app/graphql/resolvers/issues_resolver.rb create mode 100644 config/feature_flags/development/root_level_issues_query.yml create mode 100644 spec/requests/api/graphql/issues_spec.rb create mode 100644 spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb diff --git a/app/graphql/resolvers/issues/base_resolver.rb b/app/graphql/resolvers/issues/base_resolver.rb index 88579b09482f..9a2c4572abbf 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 000000000000..e3102a7d32a4 --- /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 8fe6d364118b..21cb3f9e06cd 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 000000000000..308f91681670 --- /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 721b15db3d94..8c98f0660099 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 000000000000..8838ad78f72c --- /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 3d09206fa96f..214165cb1716 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 22805cf7aedf..bb492425fd76 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 000000000000..5469fd80a4f1 --- /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 -- GitLab