Commit 5a83a8b6 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '300115-support-negated-issue-filters-gql' into 'master'

Support negated filtering in IssueResolvers

See merge request gitlab-org/gitlab!58154
parents 19c6e0e8 c5f768a4
...@@ -395,8 +395,6 @@ class IssuableFinder ...@@ -395,8 +395,6 @@ class IssuableFinder
# We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB" # We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB"
if not_params.assignees.present? if not_params.assignees.present?
items.not_assigned_to(not_params.assignees) items.not_assigned_to(not_params.assignees)
elsif not_params.assignee_id? || not_params.assignee_username? # assignee not found
items.none
else else
items items
end end
......
...@@ -12,10 +12,10 @@ module IssueResolverArguments ...@@ -12,10 +12,10 @@ module IssueResolverArguments
argument :iids, [GraphQL::STRING_TYPE], argument :iids, [GraphQL::STRING_TYPE],
required: false, required: false,
description: 'List of IIDs of issues. For example, [1, 2].' description: 'List of IIDs of issues. For example, [1, 2].'
argument :label_name, GraphQL::STRING_TYPE.to_list_type, argument :label_name, [GraphQL::STRING_TYPE, null: true],
required: false, required: false,
description: 'Labels applied to this issue.' description: 'Labels applied to this issue.'
argument :milestone_title, GraphQL::STRING_TYPE.to_list_type, argument :milestone_title, [GraphQL::STRING_TYPE, null: true],
required: false, required: false,
description: 'Milestone applied to this issue.' description: 'Milestone applied to this issue.'
argument :author_username, GraphQL::STRING_TYPE, argument :author_username, GraphQL::STRING_TYPE,
...@@ -55,6 +55,10 @@ module IssueResolverArguments ...@@ -55,6 +55,10 @@ module IssueResolverArguments
as: :issue_types, as: :issue_types,
description: 'Filter issues by the given issue types.', description: 'Filter issues by the given issue types.',
required: false required: false
argument :not, Types::Issues::NegatedIssueFilterInputType,
description: 'List of negated params.',
prepare: ->(negated_args, ctx) { negated_args.to_h },
required: false
end end
def resolve_with_lookahead(**args) def resolve_with_lookahead(**args)
...@@ -69,11 +73,22 @@ module IssueResolverArguments ...@@ -69,11 +73,22 @@ module IssueResolverArguments
args[:iids] ||= [args.delete(:iid)].compact if args[:iid] args[:iids] ||= [args.delete(:iid)].compact if args[:iid]
args[:attempt_project_search_optimizations] = true if args[:search].present? args[:attempt_project_search_optimizations] = true if args[:search].present?
prepare_assignee_username_params(args)
finder = IssuesFinder.new(current_user, args) finder = IssuesFinder.new(current_user, args)
continue_issue_resolve(parent, finder, **args) continue_issue_resolve(parent, finder, **args)
end end
def ready?(**args)
if args.slice(*mutually_exclusive_assignee_username_args).compact.size > 1
arg_str = mutually_exclusive_assignee_username_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
raise Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time."
end
super
end
class_methods do class_methods do
def resolver_complexity(args, child_complexity:) def resolver_complexity(args, child_complexity:)
complexity = super complexity = super
...@@ -82,4 +97,15 @@ module IssueResolverArguments ...@@ -82,4 +97,15 @@ module IssueResolverArguments
complexity complexity
end end
end end
private
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?
end
def mutually_exclusive_assignee_username_args
[:assignee_usernames, :assignee_username]
end
end end
...@@ -4,7 +4,7 @@ module Types ...@@ -4,7 +4,7 @@ module Types
module Boards module Boards
# Common arguments that we can be used to filter boards epics and issues # Common arguments that we can be used to filter boards epics and issues
class BoardIssuableInputBaseType < BaseInputObject class BoardIssuableInputBaseType < BaseInputObject
argument :label_name, GraphQL::STRING_TYPE.to_list_type, argument :label_name, [GraphQL::STRING_TYPE, null: true],
required: false, required: false,
description: 'Filter by label name.' description: 'Filter by label name.'
......
...@@ -8,7 +8,7 @@ module Types ...@@ -8,7 +8,7 @@ module Types
required: false, required: false,
description: 'Filter by milestone title.' description: 'Filter by milestone title.'
argument :assignee_username, GraphQL::STRING_TYPE.to_list_type, argument :assignee_username, [GraphQL::STRING_TYPE, null: true],
required: false, required: false,
description: 'Filter by assignee username.' description: 'Filter by assignee username.'
......
# frozen_string_literal: true
module Types
module Issues
class NegatedIssueFilterInputType < BaseInputObject
graphql_name 'NegatedIssueFilterInput'
argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'List of IIDs of issues to exclude. For example, [1, 2].'
argument :label_name, [GraphQL::STRING_TYPE],
required: false,
description: 'Labels not applied to this issue.'
argument :milestone_title, [GraphQL::STRING_TYPE],
required: false,
description: 'Milestone not applied to this issue.'
argument :assignee_usernames, [GraphQL::STRING_TYPE],
required: false,
description: 'Usernames of users not assigned to the issue.'
argument :assignee_id, GraphQL::STRING_TYPE,
required: false,
description: 'ID of a user not assigned to the issues.'
end
end
end
Types::Issues::NegatedIssueFilterInputType.prepend_if_ee('::EE::Types::Issues::NegatedIssueFilterInputType')
---
title: Support negated filtering of issues by iids, label_name, milestone_title, assignee_usernames and assignee_id in GraphQL
merge_request: 58154
author:
type: added
...@@ -19,7 +19,7 @@ module EE ...@@ -19,7 +19,7 @@ module EE
end end
def weights? def weights?
params[:weight].present? && params[:weight] != ::Issue::WEIGHT_ALL params[:weight].present? && params[:weight].to_s.casecmp(::Issue::WEIGHT_ALL) != 0
end end
def filter_by_no_weight? def filter_by_no_weight?
......
...@@ -7,13 +7,15 @@ module EE ...@@ -7,13 +7,15 @@ module EE
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
prepended do prepended do
argument :iteration_id, ::GraphQL::ID_TYPE.to_list_type, argument :iteration_id, [::GraphQL::ID_TYPE, null: true],
required: false, required: false,
description: 'Iterations applied to the issue.' description: 'Iterations applied to the issue.'
argument :epic_id, GraphQL::STRING_TYPE, argument :epic_id, GraphQL::STRING_TYPE,
required: false, required: false,
description: 'ID of an epic associated with the issues, "none" and "any" values are supported.' description: 'ID of an epic associated with the issues, "none" and "any" values are supported.'
argument :weight, GraphQL::STRING_TYPE,
required: false,
description: 'Weight applied to the issue, "none" and "any" values are supported.'
end end
private private
......
# frozen_string_literal: true
module EE
module Types
module Issues
module NegatedIssueFilterInputType
extend ActiveSupport::Concern
prepended do
argument :epic_id, GraphQL::STRING_TYPE,
required: false,
description: 'ID of an epic not associated with the issues.'
argument :weight, GraphQL::STRING_TYPE,
required: false,
description: 'Weight not applied to the issue.'
end
end
end
end
end
---
title: Support negated filtering of issues by epic_id and weight in GraphQL
merge_request: 58154
author:
type: added
...@@ -11,36 +11,39 @@ RSpec.describe Resolvers::IssuesResolver do ...@@ -11,36 +11,39 @@ RSpec.describe Resolvers::IssuesResolver do
context "with a project" do context "with a project" do
describe '#resolve' do describe '#resolve' do
let_it_be(:epic1) { create :epic, group: group }
let_it_be(:epic2) { create :epic, group: group }
let_it_be(:iteration1) { create(:iteration, group: group, start_date: 2.weeks.ago, due_date: 1.week.ago) }
let_it_be(:current_iteration) { create(:iteration, :started, group: group, start_date: Date.today, due_date: 1.day.from_now) }
let_it_be(:issue1) { create :issue, project: project, epic: epic1, iteration: iteration1 }
let_it_be(:issue2) { create :issue, project: project, epic: epic2, weight: 1 }
let_it_be(:issue3) { create :issue, project: project, weight: 3, iteration: current_iteration }
let_it_be(:issue4) { create :issue, :published, project: project }
before do before do
project.add_developer(current_user) project.add_developer(current_user)
end end
describe 'sorting' do describe 'sorting' do
context 'when sorting by weight' do context 'when sorting by weight' do
let_it_be(:weight_issue1) { create(:issue, project: project, weight: 5) }
let_it_be(:weight_issue2) { create(:issue, project: project, weight: nil) }
let_it_be(:weight_issue3) { create(:issue, project: project, weight: 1) }
let_it_be(:weight_issue4) { create(:issue, project: project, weight: nil) }
it 'sorts issues ascending' do it 'sorts issues ascending' do
expect(resolve_issues(sort: :weight_asc).to_a).to eq [weight_issue3, weight_issue1, weight_issue4, weight_issue2] expect(resolve_issues(sort: :weight_asc).to_a).to eq [issue2, issue3, issue4, issue1]
end end
it 'sorts issues descending' do it 'sorts issues descending' do
expect(resolve_issues(sort: :weight_desc).to_a).to eq [weight_issue1, weight_issue3, weight_issue4, weight_issue2] expect(resolve_issues(sort: :weight_desc).to_a).to eq [issue3, issue2, issue4, issue1]
end end
end end
context 'when sorting by published' do context 'when sorting by published' do
let_it_be(:not_published) { create(:issue, project: project) }
let_it_be(:published) { create(:issue, :published, project: project) }
it 'sorts issues ascending' do it 'sorts issues ascending' do
expect(resolve_issues(sort: :published_asc).to_a).to eq [not_published, published] expect(resolve_issues(sort: :published_asc).to_a).to eq [issue3, issue2, issue1, issue4]
end end
it 'sorts issues descending' do it 'sorts issues descending' do
expect(resolve_issues(sort: :published_desc).to_a).to eq [published, not_published] expect(resolve_issues(sort: :published_desc).to_a).to eq [issue4, issue3, issue2, issue1]
end end
end end
...@@ -64,32 +67,56 @@ RSpec.describe Resolvers::IssuesResolver do ...@@ -64,32 +67,56 @@ RSpec.describe Resolvers::IssuesResolver do
end end
describe 'filtering by iteration' do describe 'filtering by iteration' do
let_it_be(:iteration1) { create(:iteration, group: group) }
let_it_be(:issue_with_iteration) { create(:issue, project: project, iteration: iteration1) }
let_it_be(:issue_without_iteration) { create(:issue, project: project) }
it 'returns issues with iteration' do it 'returns issues with iteration' do
expect(resolve_issues(iteration_id: [iteration1.id])).to contain_exactly(issue_with_iteration) expect(resolve_issues(iteration_id: [iteration1.id.to_s])).to contain_exactly(issue1)
end end
end end
describe 'filter by epic' do describe 'filter by epic' do
let_it_be(:epic) { create :epic, group: group }
let_it_be(:epic2) { create :epic, group: group }
let_it_be(:issue1) { create :issue, project: project, epic: epic }
let_it_be(:issue2) { create :issue, project: project, epic: epic2 }
let_it_be(:issue3) { create :issue, project: project }
it 'returns issues without epic when epic_id is "none"' do it 'returns issues without epic when epic_id is "none"' do
expect(resolve_issues(epic_id: 'none')).to match_array([issue3]) expect(resolve_issues(epic_id: 'none')).to contain_exactly(issue4, issue3)
end end
it 'returns issues with any epic when epic_id is "any"' do it 'returns issues with any epic when epic_id is "any"' do
expect(resolve_issues(epic_id: 'any')).to match_array([issue1, issue2]) expect(resolve_issues(epic_id: 'any')).to contain_exactly(issue1, issue2)
end end
it 'returns issues with any epic when epic_id is specific' do it 'returns issues with any epic when epic_id is specific' do
expect(resolve_issues(epic_id: epic.id)).to match_array([issue1]) expect(resolve_issues(epic_id: epic1.id.to_s)).to contain_exactly(issue1)
end
end
describe 'filter by weight' do
context 'when filtering by any weight' do
it 'only returns issues that have a weight assigned' do
expect(resolve_issues(weight: 'any')).to contain_exactly(issue2, issue3)
end
end
context 'when filtering by no weight' do
it 'only returns issues that have no weight assigned' do
expect(resolve_issues(weight: 'none')).to contain_exactly(issue1, issue4)
end
end
context 'when filtering by specific weight' do
it 'only returns issues that have the specified weight assigned' do
expect(resolve_issues(weight: '3')).to contain_exactly(issue3)
end
end
end
describe 'filtering by negated params' do
describe 'filter by negated epic' do
it 'returns issues without the specified epic_id' do
expect(resolve_issues(not: { epic_id: epic2.id.to_s })).to contain_exactly(issue1, issue3, issue4)
end
end
describe 'filtering by negated weight' do
it 'only returns issues that do not have the specified weight assigned' do
expect(resolve_issues(not: { weight: '3' })).to contain_exactly(issue1, issue2, issue4)
end
end end
end end
end end
......
...@@ -49,6 +49,13 @@ RSpec.describe IssuesFinder do ...@@ -49,6 +49,13 @@ RSpec.describe IssuesFinder do
let(:expected_issuables) { [issue3, issue4] } let(:expected_issuables) { [issue3, issue4] }
end end
context 'when assignee_id does not exist' do
it_behaves_like 'assignee NOT ID filter' do
let(:params) { { not: { assignee_id: -100 } } }
let(:expected_issuables) { [issue1, issue2, issue3, issue4, issue5] }
end
end
context 'filter by username' do context 'filter by username' do
let_it_be(:user3) { create(:user) } let_it_be(:user3) { create(:user) }
...@@ -71,6 +78,17 @@ RSpec.describe IssuesFinder do ...@@ -71,6 +78,17 @@ RSpec.describe IssuesFinder do
let(:params) { { not: { assignee_username: [user.username, user2.username] } } } let(:params) { { not: { assignee_username: [user.username, user2.username] } } }
let(:expected_issuables) { [issue3, issue4] } let(:expected_issuables) { [issue3, issue4] }
end end
context 'when assignee_username does not exist' do
it_behaves_like 'assignee NOT username filter' do
before do
issue2.assignees = [user2]
end
let(:params) { { not: { assignee_username: 'non_existent_username' } } }
let(:expected_issuables) { [issue1, issue2, issue3, issue4, issue5] }
end
end
end end
it_behaves_like 'no assignee filter' do it_behaves_like 'no assignee filter' do
......
...@@ -69,6 +69,14 @@ RSpec.describe Resolvers::IssueStatusCountsResolver do ...@@ -69,6 +69,14 @@ RSpec.describe Resolvers::IssueStatusCountsResolver do
expect(result.closed).to eq 1 expect(result.closed).to eq 1
end end
context 'when both assignee_username and assignee_usernames are provided' do
it 'raises a mutually exclusive filter error' do
expect do
resolve_issue_status_counts(assignee_usernames: [current_user.username], assignee_username: current_user.username)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.')
end
end
private private
def resolve_issue_status_counts(args = {}, context = { current_user: current_user }) def resolve_issue_status_counts(args = {}, context = { current_user: current_user })
......
...@@ -46,10 +46,6 @@ RSpec.describe Resolvers::IssuesResolver do ...@@ -46,10 +46,6 @@ RSpec.describe Resolvers::IssuesResolver do
expect(resolve_issues(milestone_title: [milestone.title])).to contain_exactly(issue1) expect(resolve_issues(milestone_title: [milestone.title])).to contain_exactly(issue1)
end end
it 'filters by assignee_username' do
expect(resolve_issues(assignee_username: [assignee.username])).to contain_exactly(issue2)
end
it 'filters by two assignees' do it 'filters by two assignees' do
assignee2 = create(:user) assignee2 = create(:user)
issue2.update!(assignees: [assignee, assignee2]) issue2.update!(assignees: [assignee, assignee2])
...@@ -78,6 +74,24 @@ RSpec.describe Resolvers::IssuesResolver do ...@@ -78,6 +74,24 @@ RSpec.describe Resolvers::IssuesResolver do
expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2) expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2)
end end
describe 'filters by assignee_username' do
it 'filters by assignee_username' do
expect(resolve_issues(assignee_username: [assignee.username])).to contain_exactly(issue2)
end
it 'filters by assignee_usernames' do
expect(resolve_issues(assignee_usernames: [assignee.username])).to contain_exactly(issue2)
end
context 'when both assignee_username and assignee_usernames are provided' do
it 'raises a mutually exclusive filter error' do
expect do
resolve_issues(assignee_usernames: [assignee.username], assignee_username: assignee.username)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.')
end
end
end
describe 'filters by created_at' do describe 'filters by created_at' do
it 'filters by created_before' do it 'filters by created_before' do
expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1) expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1)
...@@ -144,6 +158,29 @@ RSpec.describe Resolvers::IssuesResolver do ...@@ -144,6 +158,29 @@ RSpec.describe Resolvers::IssuesResolver do
end end
end end
describe 'filters by negated params' do
it 'returns issues without the specified iids' do
expect(resolve_issues(not: { iids: [issue1.iid] })).to contain_exactly(issue2)
end
it 'returns issues without the specified label names' do
expect(resolve_issues(not: { label_name: [label1.title] })).to be_empty
expect(resolve_issues(not: { label_name: [label2.title] })).to contain_exactly(issue1)
end
it 'returns issues without the specified milestone' do
expect(resolve_issues(not: { milestone_title: [milestone.title] })).to contain_exactly(issue2)
end
it 'returns issues without the specified assignee_usernames' do
expect(resolve_issues(not: { assignee_usernames: [assignee.username] })).to contain_exactly(issue1)
end
it 'returns issues without the specified assignee_id' do
expect(resolve_issues(not: { assignee_id: [assignee.id] })).to contain_exactly(issue1)
end
end
describe 'sorting' do describe 'sorting' do
context 'when sorting by created' do context 'when sorting by created' do
it 'sorts issues ascending' do it 'sorts issues ascending' do
......
...@@ -12,6 +12,7 @@ RSpec.describe 'getting an issue list for a project' do ...@@ -12,6 +12,7 @@ RSpec.describe 'getting an issue list for a project' do
let_it_be(:issues, reload: true) { [issue_a, issue_b] } let_it_be(:issues, reload: true) { [issue_a, issue_b] }
let(:issues_data) { graphql_data['project']['issues']['edges'] } let(:issues_data) { graphql_data['project']['issues']['edges'] }
let(:issue_filter_params) { {} }
let(:fields) do let(:fields) do
<<~QUERY <<~QUERY
...@@ -27,7 +28,7 @@ RSpec.describe 'getting an issue list for a project' do ...@@ -27,7 +28,7 @@ RSpec.describe 'getting an issue list for a project' do
graphql_query_for( graphql_query_for(
'project', 'project',
{ 'fullPath' => project.full_path }, { 'fullPath' => project.full_path },
query_graphql_field('issues', {}, fields) query_graphql_field('issues', issue_filter_params, fields)
) )
end end
...@@ -50,6 +51,16 @@ RSpec.describe 'getting an issue list for a project' do ...@@ -50,6 +51,16 @@ RSpec.describe 'getting an issue list for a project' do
expect(issues_data[1]['node']['discussionLocked']).to eq(true) expect(issues_data[1]['node']['discussionLocked']).to eq(true)
end 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 limiting the number of results' do context 'when limiting the number of results' do
let(:query) do let(:query) do
<<~GQL <<~GQL
......
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