Commit b429aae0 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '327341-filter-issues-by-release-gql' into 'master'

Filter issues by releaseTag in GraphQL

See merge request gitlab-org/gitlab!71012
parents 137f9b0e df4fad34
# frozen_string_literal: true
module Resolvers
class BaseIssuesResolver < BaseResolver
prepend IssueResolverArguments
argument :state, Types::IssuableStateEnum,
required: false,
description: 'Current state of this issue.'
argument :sort, Types::IssueSortEnum,
description: 'Sort issues by this criteria.',
required: false,
default_value: :created_desc
type Types::IssueType.connection_type, null: true
NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc
popularity_asc popularity_desc
label_priority_asc label_priority_desc
milestone_due_asc milestone_due_desc].freeze
def continue_issue_resolve(parent, finder, **args)
issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) }
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
private
def unconditional_includes
[
{
project: [:project_feature]
},
:author
]
end
def preloads
{
alert_management_alert: [:alert_management_alert],
labels: [:labels],
assignees: [:assignees],
timelogs: [:timelogs],
customer_relations_contacts: { customer_relations_contacts: [:group] }
}
end
def non_stable_cursor_sort?(sort)
NON_STABLE_CURSOR_SORTS.include?(sort)
end
end
end
Resolvers::BaseIssuesResolver.prepend_mod_with('Resolvers::BaseIssuesResolver')
......@@ -55,8 +55,8 @@ module IssueResolverArguments
description: 'Filter issues by the given issue types.',
required: false
argument :milestone_wildcard_id, ::Types::MilestoneWildcardIdEnum,
required: false,
description: 'Filter issues by milestone ID wildcard.'
required: false,
description: 'Filter issues by milestone ID wildcard.'
argument :my_reaction_emoji, GraphQL::Types::String,
required: false,
description: 'Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported.'
......@@ -83,6 +83,7 @@ module IssueResolverArguments
args[:attempt_project_search_optimizations] = true if args[:search].present?
prepare_assignee_username_params(args)
prepare_release_tag_params(args)
finder = IssuesFinder.new(current_user, args)
......@@ -93,6 +94,7 @@ module IssueResolverArguments
params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args)
params_not_mutually_exclusive(args, mutually_exclusive_milestone_args)
params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args)
params_not_mutually_exclusive(args, mutually_exclusive_release_tag_args)
validate_anonymous_search_access! if args[:search].present?
super
......@@ -105,10 +107,30 @@ module IssueResolverArguments
complexity
end
def accept_release_tag
argument :release_tag, [GraphQL::Types::String],
required: false,
description: "Release tag associated with the issue's milestone."
argument :release_tag_wildcard_id, Types::ReleaseTagWildcardIdEnum,
required: false,
description: 'Filter issues by release tag ID wildcard.'
end
end
private
def prepare_release_tag_params(args)
release_tag_wildcard = args.delete(:release_tag_wildcard_id)
return if release_tag_wildcard.blank?
args[:release_tag] ||= release_tag_wildcard
end
def mutually_exclusive_release_tag_args
[:release_tag, :release_tag_wildcard_id]
end
def prepare_assignee_username_params(args)
args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present?
args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present?
......
# frozen_string_literal: true
# rubocop:disable Graphql/ResolverType (inherited from IssuesResolver)
# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver)
module Resolvers
class GroupIssuesResolver < IssuesResolver
class GroupIssuesResolver < BaseIssuesResolver
include GroupIssuableResolver
include_subgroups 'issues'
def ready?(**args)
if args.dig(:not, :release_tag).present?
raise ::Gitlab::Graphql::Errors::ArgumentError, 'releaseTag filter is not allowed when parent is a group.'
end
super
end
end
end
......@@ -5,6 +5,7 @@ module Resolvers
prepend IssueResolverArguments
type Types::IssueStatusCountsType, null: true
accept_release_tag
extras [:lookahead]
......
# frozen_string_literal: true
# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver)
module Resolvers
class IssuesResolver < BaseResolver
prepend IssueResolverArguments
argument :state, Types::IssuableStateEnum,
required: false,
description: 'Current state of this issue.'
argument :sort, Types::IssueSortEnum,
description: 'Sort issues by this criteria.',
required: false,
default_value: :created_desc
type Types::IssueType.connection_type, null: true
NON_STABLE_CURSOR_SORTS = %i[priority_asc priority_desc
popularity_asc popularity_desc
label_priority_asc label_priority_desc
milestone_due_asc milestone_due_desc].freeze
def continue_issue_resolve(parent, finder, **args)
issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all { |q| apply_lookahead(q) }
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
private
def unconditional_includes
[
{
project: [:project_feature]
},
:author
]
end
def preloads
{
alert_management_alert: [:alert_management_alert],
labels: [:labels],
assignees: [:assignees],
timelogs: [:timelogs],
customer_relations_contacts: { customer_relations_contacts: [:group] }
}
end
def non_stable_cursor_sort?(sort)
NON_STABLE_CURSOR_SORTS.include?(sort)
end
class IssuesResolver < BaseIssuesResolver
accept_release_tag
end
end
Resolvers::IssuesResolver.prepend_mod_with('Resolvers::IssuesResolver')
......@@ -14,6 +14,9 @@ module Types
argument :milestone_title, [GraphQL::Types::String],
required: false,
description: 'Milestone not applied to this issue.'
argument :release_tag, [GraphQL::Types::String],
required: false,
description: "Release tag not associated with the issue's milestone. Ignored when parent is a group."
argument :author_username, GraphQL::Types::String,
required: false,
description: "Username of a user who didn't author the issue."
......
# frozen_string_literal: true
module Types
class ReleaseTagWildcardIdEnum < BaseEnum
graphql_name 'ReleaseTagWildcardId'
description 'Release tag ID wildcard values'
value 'NONE', 'No release tag is assigned.'
value 'ANY', 'Release tag is assigned.'
end
end
......@@ -20,7 +20,7 @@ module Milestoneable
scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) }
scope :any_release, -> { joins_milestone_releases }
scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not(milestones: { releases: { tag: tag, project_id: project_id } }) }
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
......
......@@ -12934,6 +12934,8 @@ Returns [`Issue`](#issue).
| <a id="projectissuemilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. |
| <a id="projectissuemyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported. |
| <a id="projectissuenot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. |
| <a id="projectissuereleasetag"></a>`releaseTag` | [`[String!]`](#string) | Release tag associated with the issue's milestone. |
| <a id="projectissuereleasetagwildcardid"></a>`releaseTagWildcardId` | [`ReleaseTagWildcardId`](#releasetagwildcardid) | Filter issues by release tag ID wildcard. |
| <a id="projectissuesearch"></a>`search` | [`String`](#string) | Search query for title or description. |
| <a id="projectissuesort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. |
| <a id="projectissuestate"></a>`state` | [`IssuableState`](#issuablestate) | Current state of this issue. |
......@@ -12968,6 +12970,8 @@ Returns [`IssueStatusCountsType`](#issuestatuscountstype).
| <a id="projectissuestatuscountsmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. |
| <a id="projectissuestatuscountsmyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported. |
| <a id="projectissuestatuscountsnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. |
| <a id="projectissuestatuscountsreleasetag"></a>`releaseTag` | [`[String!]`](#string) | Release tag associated with the issue's milestone. |
| <a id="projectissuestatuscountsreleasetagwildcardid"></a>`releaseTagWildcardId` | [`ReleaseTagWildcardId`](#releasetagwildcardid) | Filter issues by release tag ID wildcard. |
| <a id="projectissuestatuscountssearch"></a>`search` | [`String`](#string) | Search query for title or description. |
| <a id="projectissuestatuscountstypes"></a>`types` | [`[IssueType!]`](#issuetype) | Filter issues by the given issue types. |
| <a id="projectissuestatuscountsupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Issues updated after this date. |
......@@ -13007,6 +13011,8 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="projectissuesmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. |
| <a id="projectissuesmyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported. |
| <a id="projectissuesnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. |
| <a id="projectissuesreleasetag"></a>`releaseTag` | [`[String!]`](#string) | Release tag associated with the issue's milestone. |
| <a id="projectissuesreleasetagwildcardid"></a>`releaseTagWildcardId` | [`ReleaseTagWildcardId`](#releasetagwildcardid) | Filter issues by release tag ID wildcard. |
| <a id="projectissuessearch"></a>`search` | [`String`](#string) | Search query for title or description. |
| <a id="projectissuessort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. |
| <a id="projectissuesstate"></a>`state` | [`IssuableState`](#issuablestate) | Current state of this issue. |
......@@ -16485,6 +16491,15 @@ Values for sorting releases.
| <a id="releasesortreleased_at_asc"></a>`RELEASED_AT_ASC` | Released at by ascending order. |
| <a id="releasesortreleased_at_desc"></a>`RELEASED_AT_DESC` | Released at by descending order. |
### `ReleaseTagWildcardId`
Release tag ID wildcard values.
| Value | Description |
| ----- | ----------- |
| <a id="releasetagwildcardidany"></a>`ANY` | Release tag is assigned. |
| <a id="releasetagwildcardidnone"></a>`NONE` | No release tag is assigned. |
### `RequirementState`
State of a requirement.
......@@ -18191,6 +18206,7 @@ Represents an escalation rule.
| <a id="negatedissuefilterinputmilestonetitle"></a>`milestoneTitle` | [`[String!]`](#string) | Milestone not applied to this issue. |
| <a id="negatedissuefilterinputmilestonewildcardid"></a>`milestoneWildcardId` | [`NegatedMilestoneWildcardId`](#negatedmilestonewildcardid) | Filter by negated milestone wildcard values. |
| <a id="negatedissuefilterinputmyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. |
| <a id="negatedissuefilterinputreleasetag"></a>`releaseTag` | [`[String!]`](#string) | Release tag not associated with the issue's milestone. Ignored when parent is a group. |
| <a id="negatedissuefilterinputtypes"></a>`types` | [`[IssueType!]`](#issuetype) | Filters out issues by the given issue types. |
| <a id="negatedissuefilterinputweight"></a>`weight` | [`String`](#string) | Weight not applied to the issue. |
......
......@@ -2,7 +2,7 @@
module EE
module Resolvers
module IssuesResolver
module BaseIssuesResolver
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
......
......@@ -29,15 +29,72 @@ RSpec.describe Resolvers::GroupIssuesResolver do
describe '#resolve' do
it 'finds all group issues' do
result = resolve(described_class, obj: group, ctx: { current_user: current_user })
expect(result).to contain_exactly(issue1, issue2, issue3)
expect(resolve_issues).to contain_exactly(issue1, issue2, issue3)
end
it 'finds all group and subgroup issues' do
result = resolve(described_class, obj: group, args: { include_subgroups: true }, ctx: { current_user: current_user })
result = resolve_issues(include_subgroups: true)
expect(result).to contain_exactly(issue1, issue2, issue3, subissue1, subissue2, subissue3)
end
it 'returns issues without the specified issue_type' do
result = resolve_issues(not: { types: ['issue'] })
expect(result).to contain_exactly(issue1)
end
context 'confidential issues' do
let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) }
let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) }
context "when user is allowed to view confidential issues" do
it 'returns all viewable issues by default' do
expect(resolve_issues).to contain_exactly(issue1, issue2, issue3, confidential_issue1, confidential_issue2)
end
context 'filtering for confidential issues' do
it 'returns only the non-confidential issues for the group when filter is set to false' do
expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3)
end
it "returns only the confidential issues for the group when filter is set to true" do
expect(resolve_issues({ confidential: true })).to contain_exactly(confidential_issue1, confidential_issue2)
end
end
end
context "when user is not allowed to see confidential issues" do
before do
group.add_guest(current_user)
end
it 'returns all viewable issues by default' do
expect(resolve_issues).to contain_exactly(issue1, issue2, issue3)
end
context 'filtering for confidential issues' do
it 'does not return the confidential issues when filter is set to false' do
expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3)
end
it 'does not return the confidential issues when filter is set to true' do
expect(resolve_issues({ confidential: true })).to be_empty
end
end
end
end
context 'release_tag filter' do
it 'returns an error when trying to filter by negated release_tag' do
expect do
resolve_issues(not: { release_tag: ['v1.0'] })
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'releaseTag filter is not allowed when parent is a group.')
end
end
end
def resolve_issues(args = {}, context = { current_user: current_user })
resolve(described_class, obj: group, args: args, ctx: context)
end
end
......@@ -26,14 +26,7 @@ RSpec.describe Resolvers::IssuesResolver do
expect(described_class).to have_nullable_graphql_type(Types::IssueType.connection_type)
end
shared_context 'filtering for confidential issues' do
let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) }
let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) }
end
context "with a project" do
let(:obj) { project }
before_all do
project.add_developer(current_user)
project.add_reporter(reporter)
......@@ -112,6 +105,54 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
describe 'filter by release' do
let_it_be(:milestone1) { create(:milestone, project: project, start_date: 1.day.from_now, title: 'Version 1') }
let_it_be(:milestone2) { create(:milestone, project: project, start_date: 1.day.from_now, title: 'Version 2') }
let_it_be(:milestone3) { create(:milestone, project: project, start_date: 1.day.from_now, title: 'Version 3') }
let_it_be(:release1) { create(:release, tag: 'v1.0', milestones: [milestone1], project: project) }
let_it_be(:release2) { create(:release, tag: 'v2.0', milestones: [milestone2], project: project) }
let_it_be(:release3) { create(:release, tag: 'v3.0', milestones: [milestone3], project: project) }
let_it_be(:release_issue1) { create(:issue, project: project, milestone: milestone1) }
let_it_be(:release_issue2) { create(:issue, project: project, milestone: milestone2) }
let_it_be(:release_issue3) { create(:issue, project: project, milestone: milestone3) }
describe 'filter by release_tag' do
it 'returns all issues associated with the specified tags' do
expect(resolve_issues(release_tag: [release1.tag, release3.tag])).to contain_exactly(release_issue1, release_issue3)
end
context 'when release_tag_wildcard_id is also provided' do
it 'raises a mutually eclusive argument error' do
expect do
resolve_issues(release_tag: [release1.tag], release_tag_wildcard_id: 'ANY')
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [releaseTag, releaseTagWildcardId] arguments is allowed at the same time.')
end
end
end
describe 'filter by negated release_tag' do
it 'returns all issues not associated with the specified tags' do
expect(resolve_issues(not: { release_tag: [release1.tag, release3.tag] })).to contain_exactly(release_issue2)
end
end
describe 'filter by release_tag_wildcard_id' do
subject { resolve_issues(release_tag_wildcard_id: wildcard_id) }
context 'when filtering by ANY' do
let(:wildcard_id) { 'ANY' }
it { is_expected.to contain_exactly(release_issue1, release_issue2, release_issue3) }
end
context 'when filtering by NONE' do
let(:wildcard_id) { 'NONE' }
it { is_expected.to contain_exactly(issue1, issue2) }
end
end
end
it 'filters by two assignees' do
assignee2 = create(:user)
issue2.update!(assignees: [assignee, assignee2])
......@@ -230,7 +271,8 @@ RSpec.describe Resolvers::IssuesResolver do
end
context 'confidential issues' do
include_context 'filtering for confidential issues'
let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) }
let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) }
context "when user is allowed to view confidential issues" do
it 'returns all viewable issues by default' do
......@@ -561,64 +603,6 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
context "with a group" do
let(:obj) { group }
before do
group.add_developer(current_user)
end
describe '#resolve' do
it 'finds all group issues' do
expect(resolve_issues).to contain_exactly(issue1, issue2, issue3)
end
it 'returns issues without the specified issue_type' do
expect(resolve_issues({ not: { types: ['issue'] } })).to contain_exactly(issue1)
end
context "confidential issues" do
include_context 'filtering for confidential issues'
context "when user is allowed to view confidential issues" do
it 'returns all viewable issues by default' do
expect(resolve_issues).to contain_exactly(issue1, issue2, issue3, confidential_issue1, confidential_issue2)
end
context 'filtering for confidential issues' do
it 'returns only the non-confidential issues for the group when filter is set to false' do
expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3)
end
it "returns only the confidential issues for the group when filter is set to true" do
expect(resolve_issues({ confidential: true })).to contain_exactly(confidential_issue1, confidential_issue2)
end
end
end
context "when user is not allowed to see confidential issues" do
before do
group.add_guest(current_user)
end
it 'returns all viewable issues by default' do
expect(resolve_issues).to contain_exactly(issue1, issue2, issue3)
end
context 'filtering for confidential issues' do
it 'does not return the confidential issues when filter is set to false' do
expect(resolve_issues({ confidential: false })).to contain_exactly(issue1, issue2, issue3)
end
it 'does not return the confidential issues when filter is set to true' do
expect(resolve_issues({ confidential: true })).to be_empty
end
end
end
end
end
end
context "when passing a non existent, batch loaded project" do
let!(:project) do
BatchLoader::GraphQL.for("non-existent-path").batch do |_fake_paths, loader, _|
......@@ -626,8 +610,6 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
let(:obj) { project }
it "returns nil without breaking" do
expect(resolve_issues(iids: ["don't", "break"])).to be_empty
end
......@@ -648,6 +630,6 @@ RSpec.describe Resolvers::IssuesResolver do
end
def resolve_issues(args = {}, context = { current_user: current_user })
resolve(described_class, obj: obj, args: args, ctx: context)
resolve(described_class, obj: project, args: args, ctx: context)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment