diff options
author | Mario de la Ossa <mariodelaossa@gmail.com> | 2019-07-24 23:43:45 -0600 |
---|---|---|
committer | Mario de la Ossa <mariodelaossa@gmail.com> | 2019-09-15 13:55:36 -0600 |
commit | 7213798ac048b50d4eae2a2382bedf228b48b1f0 (patch) | |
tree | 2c683e2e74729a189200ba453c2e077e8f058a21 | |
parent | 0bdee4c2f885462a55b67a798ec242321dfe67e3 (diff) | |
download | gitlab-ce-64213-not_filtering.tar.gz |
Allow negating terms in IssuableFinder64213-not_filtering
Add a `NOT` param that accepts most of the params we're used to and
allows negation of them. Can be used alone or as part of a query to
further filter the resultset.
It has been enabled in the Issues API endpoints
-rw-r--r-- | app/finders/issuable_finder.rb | 63 | ||||
-rw-r--r-- | changelogs/unreleased/64213-not_filtering.yml | 5 | ||||
-rw-r--r-- | doc/api/issues.md | 5 | ||||
-rw-r--r-- | lib/api/helpers/issues_helpers.rb | 5 | ||||
-rw-r--r-- | lib/api/issues.rb | 25 | ||||
-rw-r--r-- | spec/finders/issues_finder_spec.rb | 202 | ||||
-rw-r--r-- | spec/requests/api/issues/get_group_issues_spec.rb | 4 | ||||
-rw-r--r-- | spec/requests/api/issues/get_project_issues_spec.rb | 3 | ||||
-rw-r--r-- | spec/requests/api/issues/issues_spec.rb | 85 | ||||
-rw-r--r-- | spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb | 2 | ||||
-rw-r--r-- | spec/support/shared_examples/finders/assignees_filter_shared_examples.rb | 12 | ||||
-rw-r--r-- | spec/support/shared_examples/requests/api/issues_shared_examples.rb | 21 |
12 files changed, 372 insertions, 60 deletions
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 1773ac2d508..4e53bf23725 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -48,8 +48,9 @@ class IssuableFinder attr_accessor :current_user, :params - def self.scalar_params - @scalar_params ||= %i[ + class << self + def scalar_params + @scalar_params ||= %i[ assignee_id assignee_username author_id @@ -60,14 +61,30 @@ class IssuableFinder search in ] - end + end - def self.array_params - @array_params ||= { label_name: [], assignee_username: [] } - end + def array_params + @array_params ||= { label_name: [], assignee_username: [] } + end + + # This should not be used in controller strong params! + def negatable_scalar_params + @negatable_scalar_params ||= scalar_params + %i[project_id group_id] + end - def self.valid_params - @valid_params ||= scalar_params + [array_params] + # This should not be used in controller strong params! + def negatable_array_params + @negatable_array_params ||= array_params.keys.append(:iids) + end + + # This should not be used in controller strong params! + def negatable_params + @negatable_params ||= negatable_scalar_params + negatable_array_params + end + + def valid_params + @valid_params ||= scalar_params + [array_params] + [{ not: [] }] + end end def initialize(current_user, params = {}) @@ -79,6 +96,9 @@ class IssuableFinder items = init_collection items = filter_items(items) + # Let's see if we have to negate anything + items = by_negation(items) + # This has to be last as we use a CTE as an optimization fence # for counts by passing the force_cte param and enabling the # attempt_group_search_optimizations feature flag @@ -351,6 +371,33 @@ class IssuableFinder Array(value).last.to_sym end + # Negates all params found in `negatable_params` + # rubocop: disable CodeReuse/ActiveRecord + def by_negation(items) + not_params = params[:not].dup + # API endpoints send in `nil` values so we test if there are any non-nil + return items unless not_params.present? && not_params.values.any? + + not_params.keep_if { |_k, v| v.present? }.each do |(key, value)| + # These aren't negatable params themselves, but rather help other searches, so we skip them. + # They will be added into all the NOT searches. + next if %i[include_subgroups in].include?(key.to_sym) + next unless self.class.negatable_params.include?(key.to_sym) + + # These are "helper" params that are required inside the NOT to get the right results. They usually come in + # at the top-level params, but if they do come in inside the `:not` params, they should take precedence. + not_helpers = params.slice(:include_subgroups, :in).merge(params[:not].slice(:include_subgroups, :in)) + not_param = { key => value }.with_indifferent_access.merge(not_helpers) + + items_to_negate = self.class.new(current_user, not_param).execute + + items = items.where.not(id: items_to_negate) + end + + items + end + # rubocop: enable CodeReuse/ActiveRecord + # rubocop: disable CodeReuse/ActiveRecord def by_scope(items) return items.none if current_user_related? && !current_user diff --git a/changelogs/unreleased/64213-not_filtering.yml b/changelogs/unreleased/64213-not_filtering.yml new file mode 100644 index 00000000000..431363ae0b5 --- /dev/null +++ b/changelogs/unreleased/64213-not_filtering.yml @@ -0,0 +1,5 @@ +--- +title: Add not param to Issues API endpoint +merge_request: 31134 +author: +type: added diff --git a/doc/api/issues.md b/doc/api/issues.md index 96a547551f1..49f587691ca 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -57,7 +57,8 @@ GET /issues?confidential=true | `created_before` | datetime | no | Return issues created on or before the given time | | `updated_after` | datetime | no | Return issues updated on or after the given time | | `updated_before` | datetime | no | Return issues updated on or before the given time | -| `confidential` | Boolean | no | Filter confidential or public issues. | +| `confidential` | Boolean | no | Filter confidential or public issues. | +| `not` | Hash | no | Return issues that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji`, `search`, `in` | ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/issues @@ -207,6 +208,7 @@ GET /groups/:id/issues?confidential=true | `updated_after` | datetime | no | Return issues updated on or after the given time | | `updated_before` | datetime | no | Return issues updated on or before the given time | | `confidential` | Boolean | no | Filter confidential or public issues. | +| `not` | Hash | no | Return issues that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji`, `search`, `in` | ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/4/issues @@ -356,6 +358,7 @@ GET /projects/:id/issues?confidential=true | `updated_after` | datetime | no | Return issues updated on or after the given time | | `updated_before` | datetime | no | Return issues updated on or before the given time | | `confidential` | Boolean | no | Filter confidential or public issues. | +| `not` | Hash | no | Return issues that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji`, `search`, `in` | ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/issues diff --git a/lib/api/helpers/issues_helpers.rb b/lib/api/helpers/issues_helpers.rb index 5b7199fddb0..29d9cd89378 100644 --- a/lib/api/helpers/issues_helpers.rb +++ b/lib/api/helpers/issues_helpers.rb @@ -11,6 +11,9 @@ module API params :optional_issues_params_ee do end + params :optional_issue_not_params_ee do + end + def self.update_params_at_least_one_of [ :assignee_id, @@ -32,7 +35,9 @@ module API args.delete(:id) args[:milestone_title] ||= args.delete(:milestone) + args[:not][:milestone_title] ||= args[:not]&.delete(:milestone) args[:label_name] ||= args.delete(:labels) + args[:not][:label_name] ||= args[:not]&.delete(:labels) args[:scope] = args[:scope].underscore if args[:scope] IssuesFinder.new(current_user, args) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index d687acf3423..92a79051e87 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -9,28 +9,35 @@ module API before { authenticate_non_get! } helpers do - params :issues_stats_params do + params :negatable_issue_filter_params do optional :labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names' optional :milestone, type: String, desc: 'Milestone title' - optional :milestone, type: String, desc: 'Return issues for a specific milestone' optional :iids, type: Array[Integer], desc: 'The IID array of issues' optional :search, type: String, desc: 'Search issues for text present in the title, description, or any combination of these' optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma' - optional :created_after, type: DateTime, desc: 'Return issues created after the specified time' - optional :created_before, type: DateTime, desc: 'Return issues created before the specified time' - optional :updated_after, type: DateTime, desc: 'Return issues updated after the specified time' - optional :updated_before, type: DateTime, desc: 'Return issues updated before the specified time' optional :author_id, type: Integer, desc: 'Return issues which are authored by the user with the given ID' optional :author_username, type: String, desc: 'Return issues which are authored by the user with the given username' mutually_exclusive :author_id, :author_username optional :assignee_id, types: [Integer, String], integer_none_any: true, - desc: 'Return issues which are assigned to the user with the given ID' + desc: 'Return issues which are assigned to the user with the given ID' optional :assignee_username, type: Array[String], check_assignees_count: true, - coerce_with: Validations::CheckAssigneesCount.coerce, - desc: 'Return issues which are assigned to the user with the given username' + coerce_with: Validations::CheckAssigneesCount.coerce, + desc: 'Return issues which are assigned to the user with the given username' mutually_exclusive :assignee_id, :assignee_username + end + + params :issues_stats_params do + use :negatable_issue_filter_params + optional :created_after, type: DateTime, desc: 'Return issues created after the specified time' + optional :created_before, type: DateTime, desc: 'Return issues created before the specified time' + optional :updated_after, type: DateTime, desc: 'Return issues updated after the specified time' + optional :updated_before, type: DateTime, desc: 'Return issues updated before the specified time' + + optional :not, type: Hash do + use :negatable_issue_filter_params + end optional :scope, type: String, values: %w[created-by-me assigned-to-me created_by_me assigned_to_me all], desc: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`' diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 879ff01f294..2c4003068fd 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -21,15 +21,28 @@ describe IssuesFinder do let(:expected_issuables) { [issue1, issue2] } end - it_behaves_like 'assignee username filter' do + it_behaves_like 'assignee NOT ID filter' do + let(:params) { { not: { assignee_id: user.id } } } + let(:expected_issuables) { [issue3, issue4] } + end + + context 'filter by username' do + set(:user3) { create(:user) } + before do project2.add_developer(user3) issue3.assignees = [user2, user3] end - set(:user3) { create(:user) } - let(:params) { { assignee_username: [user2.username, user3.username] } } - let(:expected_issuables) { [issue3] } + it_behaves_like 'assignee username filter' do + let(:params) { { assignee_username: [user2.username, user3.username] } } + let(:expected_issuables) { [issue3] } + end + + it_behaves_like 'assignee NOT username filter' do + let(:params) { { not: { assignee_username: [user2.username, user3.username] } } } + let(:expected_issuables) { [issue1, issue2, issue4] } + end end it_behaves_like 'no assignee filter' do @@ -62,6 +75,26 @@ describe IssuesFinder do end end + context 'filtering by NOT group_id' do + let(:params) { { not: { group_id: group.id } } } + + context 'when include_subgroup param not set' do + it 'returns all other group issues' do + expect(issues).to contain_exactly(issue2, issue3, issue4) + end + end + + context 'when include_subgroup param is true', :nested_groups do + before do + params[:include_subgroups] = true + end + + it 'returns all other group and subgroup issues' do + expect(issues).to contain_exactly(issue2, issue3) + end + end + end + context 'filtering by author ID' do let(:params) { { author_id: user2.id } } @@ -70,6 +103,14 @@ describe IssuesFinder do end end + context 'filtering by not author ID' do + let(:params) { { not: { author_id: user2.id } } } + + it 'returns issues not created by that user' do + expect(issues).to contain_exactly(issue1, issue2, issue4) + end + end + context 'filtering by milestone' do let(:params) { { milestone_title: milestone.title } } @@ -78,6 +119,14 @@ describe IssuesFinder do end end + context 'filtering by not milestone' do + let(:params) { { not: { milestone_title: milestone.title } } } + + it 'returns issues not assigned to that milestone' do + expect(issues).to contain_exactly(issue2, issue3, issue4) + end + end + context 'filtering by group milestone' do let!(:group) { create(:group, :public) } let(:group_milestone) { create(:milestone, group: group) } @@ -93,6 +142,14 @@ describe IssuesFinder do it 'returns issues assigned to that group milestone' do expect(issues).to contain_exactly(issue2, issue3) end + + context 'using NOT' do + let(:params) { { not: { milestone_title: group_milestone.title } } } + + it 'returns issues not assigned to that group milestone' do + expect(issues).to contain_exactly(issue1, issue4) + end + end end context 'filtering by no milestone' do @@ -134,10 +191,10 @@ describe IssuesFinder do let(:project_next_8_8) { create(:project, :public) } let(:project_in_group) { create(:project, :public, namespace: group) } - let(:yesterday) { Date.today - 1.day } - let(:tomorrow) { Date.today + 1.day } - let(:two_days_from_now) { Date.today + 2.days } - let(:ten_days_from_now) { Date.today + 10.days } + let(:yesterday) { Date.current - 1.day } + let(:tomorrow) { Date.current + 1.day } + let(:two_days_from_now) { Date.current + 2.days } + let(:ten_days_from_now) { Date.current + 10.days } let(:milestones) do [ @@ -151,7 +208,7 @@ describe IssuesFinder do end before do - milestones.each do |milestone| + @created_issues = milestones.map do |milestone| create(:issue, project: milestone.project || project_in_group, milestone: milestone, author: user, assignees: [user]) end end @@ -160,6 +217,18 @@ describe IssuesFinder do expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.1', '8.8', '9.9') expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now, tomorrow) end + + context 'using NOT' do + let(:params) { { not: { milestone_title: Milestone::Upcoming.name } } } + + it 'returns issues not in upcoming milestones for each project or group' do + target_issues = @created_issues.reject do |issue| + issue.milestone&.due_date && issue.milestone.due_date > Date.current + end + @created_issues.select { |issue| issue.milestone&.title == '8.9' } + + expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, *target_issues) + end + end end context 'filtering by started milestone' do @@ -169,10 +238,10 @@ describe IssuesFinder do let(:project_started_1_and_2) { create(:project, :public) } let(:project_started_8) { create(:project, :public) } - let(:yesterday) { Date.today - 1.day } - let(:tomorrow) { Date.today + 1.day } - let(:two_days_ago) { Date.today - 2.days } - let(:three_days_ago) { Date.today - 3.days } + let(:yesterday) { Date.current - 1.day } + let(:tomorrow) { Date.current + 1.day } + let(:two_days_ago) { Date.current - 2.days } + let(:three_days_ago) { Date.current - 3.days } let(:milestones) do [ @@ -198,6 +267,16 @@ describe IssuesFinder do expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.0', '2.0', '8.0') expect(issues.map { |issue| issue.milestone.start_date }).to contain_exactly(two_days_ago, yesterday, yesterday) end + + context 'using NOT' do + let(:params) { { not: { milestone_title: Milestone::Started.name } } } + + it 'returns issues not in the started milestones for each project' do + target_issues = Issue.where.not(milestone: Milestone.started) + + expect(issues).to contain_exactly(issue2, issue3, issue4, *target_issues) + end + end end context 'filtering by label' do @@ -206,6 +285,33 @@ describe IssuesFinder do it 'returns issues with that label' do expect(issues).to contain_exactly(issue2) end + + context 'using NOT' do + let(:params) { { not: { label_name: label.title } } } + + it 'returns issues that do not have that label' do + expect(issues).to contain_exactly(issue1, issue3, issue4) + end + + # IssuableFinder first filters using the outer params (the ones not inside the `not` key.) + # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param + # do not take precedence over the outer params with the same name. + context 'shadowing the same outside param' do + let(:params) { { label_name: label2.title, not: { label_name: label.title } } } + + it 'does not take precedence over labels outside NOT' do + expect(issues).to contain_exactly(issue3) + end + end + + context 'further filtering outside params' do + let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } } + + it 'further filters on the returned resultset' do + expect(issues).to be_empty + end + end + end end context 'filtering by multiple labels' do @@ -219,6 +325,14 @@ describe IssuesFinder do it 'returns the unique issues with all those labels' do expect(issues).to contain_exactly(issue2) end + + context 'using NOT' do + let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } + + it 'returns issues that do not have ALL labels provided' do + expect(issues).to contain_exactly(issue1, issue3, issue4) + end + end end context 'filtering by a label that includes any or none in the title' do @@ -226,18 +340,28 @@ describe IssuesFinder do let(:label) { create(:label, title: 'any foo', project: project2) } let(:label2) { create(:label, title: 'bar none', project: project2) } - it 'returns the unique issues with all those labels' do + before do create(:label_link, label: label2, target: issue2) + end + it 'returns the unique issues with all those labels' do expect(issues).to contain_exactly(issue2) end + + context 'using NOT' do + let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } + + it 'returns issues that do not have ALL labels provided' do + expect(issues).to contain_exactly(issue1, issue3, issue4) + end + end end context 'filtering by no label' do let(:params) { { label_name: described_class::FILTER_NONE } } it 'returns issues with no labels' do - expect(issues).to contain_exactly(issue1, issue3, issue4) + expect(issues).to contain_exactly(issue1, issue4) end end @@ -259,6 +383,14 @@ describe IssuesFinder do it 'returns issues with title and description match for search term' do expect(issues).to contain_exactly(issue1, issue2) end + + context 'using NOT' do + let(:params) { { not: { search: 'git' } } } + + it 'returns issues with no title and description match for search term' do + expect(issues).to contain_exactly(issue3, issue4) + end + end end context 'filtering by issue term in title' do @@ -267,6 +399,14 @@ describe IssuesFinder do it 'returns issues with title match for search term' do expect(issues).to contain_exactly(issue1) end + + context 'using NOT' do + let(:params) { { not: { search: 'git', in: 'title' } } } + + it 'returns issues with no title match for search term' do + expect(issues).to contain_exactly(issue2, issue3, issue4) + end + end end context 'filtering by issues iids' do @@ -275,6 +415,14 @@ describe IssuesFinder do it 'returns issues with iids match' do expect(issues).to contain_exactly(issue3) end + + context 'using NOT' do + let(:params) { { not: { iids: issue3.iid } } } + + it 'returns issues with no iids match' do + expect(issues).to contain_exactly(issue1, issue2, issue4) + end + end end context 'filtering by state' do @@ -416,6 +564,14 @@ describe IssuesFinder do it 'returns issues that the user thumbsup to' do expect(issues).to contain_exactly(issue1) end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } } + + it 'returns issues that the user did not thumbsup to' do + expect(issues).to contain_exactly(issue2, issue3, issue4) + end + end end context 'user2 searches by "thumbsup" reaction' do @@ -426,6 +582,14 @@ describe IssuesFinder do it 'returns issues that the user2 thumbsup to' do expect(issues).to contain_exactly(issue2) end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } } + + it 'returns issues that the user2 thumbsup to' do + expect(issues).to contain_exactly(issue3) + end + end end context 'user searches by "thumbsdown" reaction' do @@ -434,6 +598,14 @@ describe IssuesFinder do it 'returns issues that the user thumbsdown to' do expect(issues).to contain_exactly(issue3) end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsdown' } } } + + it 'returns issues that the user thumbsdown to' do + expect(issues).to contain_exactly(issue1, issue2, issue4) + end + end end end diff --git a/spec/requests/api/issues/get_group_issues_spec.rb b/spec/requests/api/issues/get_group_issues_spec.rb index 5916bb11516..7ff8140bbd9 100644 --- a/spec/requests/api/issues/get_group_issues_spec.rb +++ b/spec/requests/api/issues/get_group_issues_spec.rb @@ -429,17 +429,21 @@ describe API::Issues do end context 'with labeled issues' do + let(:group_issue2) { create :issue, project: group_project } let(:label_b) { create(:label, title: 'foo', project: group_project) } let(:label_c) { create(:label, title: 'bar', project: group_project) } before do + create(:label_link, label: group_label, target: group_issue2) create(:label_link, label: label_b, target: group_issue) + create(:label_link, label: label_b, target: group_issue2) create(:label_link, label: label_c, target: group_issue) get api(base_url, user), params: params end let(:issue) { group_issue } + let(:issue2) { group_issue2 } let(:label) { group_label } it_behaves_like 'labeled issues with labels and label_name params' diff --git a/spec/requests/api/issues/get_project_issues_spec.rb b/spec/requests/api/issues/get_project_issues_spec.rb index f7ca6fd1e0a..e7077018f73 100644 --- a/spec/requests/api/issues/get_project_issues_spec.rb +++ b/spec/requests/api/issues/get_project_issues_spec.rb @@ -283,11 +283,14 @@ describe API::Issues do end context 'with labeled issues' do + let(:issue2) { create :issue, project: project } let(:label_b) { create(:label, title: 'foo', project: project) } let(:label_c) { create(:label, title: 'bar', project: project) } before do + create(:label_link, label: label, target: issue2) create(:label_link, label: label_b, target: issue) + create(:label_link, label: label_b, target: issue2) create(:label_link, label: label_c, target: issue) get api('/issues', user), params: params diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index d195f54be11..12ab5680b07 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -423,9 +423,12 @@ describe API::Issues do context 'with labeled issues' do let(:label_b) { create(:label, title: 'foo', project: project) } let(:label_c) { create(:label, title: 'bar', project: project) } + let(:issue2) { create(:issue, author: user, project: project) } before do + create(:label_link, label: label, target: issue2) create(:label_link, label: label_b, target: issue) + create(:label_link, label: label_b, target: issue2) create(:label_link, label: label_c, target: issue) get api('/issues', user), params: params @@ -493,46 +496,74 @@ describe API::Issues do end end - it 'returns an empty array if no issue matches milestone' do - get api("/issues?milestone=#{empty_milestone.title}", user) + context 'filter by milestone' do + it 'returns an empty array if no issue matches milestone' do + get api("/issues?milestone=#{empty_milestone.title}", user) - expect_paginated_array_response([]) - end + expect_paginated_array_response([]) + end - it 'returns an empty array if milestone does not exist' do - get api('/issues?milestone=foo', user) + it 'returns an empty array if milestone does not exist' do + get api('/issues?milestone=foo', user) - expect_paginated_array_response([]) - end + expect_paginated_array_response([]) + end - it 'returns an array of issues in given milestone' do - get api("/issues?milestone=#{milestone.title}", user) + it 'returns an array of issues in given milestone' do + get api("/issues?milestone=#{milestone.title}", user) - expect_paginated_array_response([issue.id, closed_issue.id]) - end + expect_paginated_array_response([issue.id, closed_issue.id]) + end - it 'returns an array of issues in given milestone_title param' do - get api("/issues?milestone_title=#{milestone.title}", user) + it 'returns an array of issues in given milestone_title param' do + get api("/issues?milestone_title=#{milestone.title}", user) - expect_paginated_array_response([issue.id, closed_issue.id]) - end + expect_paginated_array_response([issue.id, closed_issue.id]) + end - it 'returns an array of issues matching state in milestone' do - get api("/issues?milestone=#{milestone.title}&state=closed", user) + it 'returns an array of issues matching state in milestone' do + get api("/issues?milestone=#{milestone.title}&state=closed", user) - expect_paginated_array_response(closed_issue.id) - end + expect_paginated_array_response(closed_issue.id) + end - it 'returns an array of issues with no milestone' do - get api("/issues?milestone=#{no_milestone_title}", author) + it 'returns an array of issues with no milestone' do + get api("/issues?milestone=#{no_milestone_title}", author) - expect_paginated_array_response(confidential_issue.id) - end + expect_paginated_array_response(confidential_issue.id) + end - it 'returns an array of issues with no milestone using milestone_title param' do - get api("/issues?milestone_title=#{no_milestone_title}", author) + it 'returns an array of issues with no milestone using milestone_title param' do + get api("/issues?milestone_title=#{no_milestone_title}", author) - expect_paginated_array_response(confidential_issue.id) + expect_paginated_array_response(confidential_issue.id) + end + + context 'negated' do + it 'returns all issues if milestone does not exist' do + get api('/issues?not[milestone]=foo', user) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns all issues that do not belong to a milestone but have a milestone' do + get api("/issues?not[milestone]=#{empty_milestone.title}", user) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns an array of issues with any milestone' do + get api("/issues?not[milestone]=#{no_milestone_title}", user) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns an array of issues matching state not in milestone' do + get api("/issues?not[milestone]=#{empty_milestone.title}&state=closed", user) + + expect_paginated_array_response(closed_issue.id) + end + end end it 'returns an array of issues found by iids' do diff --git a/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb index 26ab6fbd400..6c96b18d834 100644 --- a/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb +++ b/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb @@ -12,6 +12,7 @@ RSpec.shared_context 'IssuesFinder context' do set(:project3) { create(:project, group: subgroup) } set(:milestone) { create(:milestone, project: project1) } set(:label) { create(:label, project: project2) } + set(:label2) { create(:label, project: project2) } set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab', created_at: 1.week.ago, updated_at: 1.week.ago) } set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab', created_at: 1.week.from_now, updated_at: 1.week.from_now) } set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki', created_at: 2.weeks.from_now, updated_at: 2.weeks.from_now) } @@ -24,6 +25,7 @@ end RSpec.shared_context 'IssuesFinder#execute context' do let!(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } let!(:label_link) { create(:label_link, label: label, target: issue2) } + let!(:label_link2) { create(:label_link, label: label2, target: issue3) } let(:search_user) { user } let(:params) { {} } let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } diff --git a/spec/support/shared_examples/finders/assignees_filter_shared_examples.rb b/spec/support/shared_examples/finders/assignees_filter_shared_examples.rb index a931c4df99f..f1df1052ef2 100644 --- a/spec/support/shared_examples/finders/assignees_filter_shared_examples.rb +++ b/spec/support/shared_examples/finders/assignees_filter_shared_examples.rb @@ -6,12 +6,24 @@ shared_examples 'assignee ID filter' do end end +shared_examples 'assignee NOT ID filter' do + it 'returns issuables not assigned to that user' do + expect(issuables).to contain_exactly(*expected_issuables) + end +end + shared_examples 'assignee username filter' do it 'returns issuables assigned to those users' do expect(issuables).to contain_exactly(*expected_issuables) end end +shared_examples 'assignee NOT username filter' do + it 'returns issuables not assigned to those users' do + expect(issuables).to contain_exactly(*expected_issuables) + end +end + shared_examples 'no assignee filter' do let(:params) { { assignee_id: 'None' } } diff --git a/spec/support/shared_examples/requests/api/issues_shared_examples.rb b/spec/support/shared_examples/requests/api/issues_shared_examples.rb index 1133e95e44e..d22210edf99 100644 --- a/spec/support/shared_examples/requests/api/issues_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/issues_shared_examples.rb @@ -8,6 +8,13 @@ shared_examples 'labeled issues with labels and label_name params' do end end + shared_examples 'returns negated label names' do + it 'returns label names' do + expect_paginated_array_response(issue2.id) + expect(json_response.first['labels']).to eq([label_b.title, label.title]) + end + end + shared_examples 'returns basic label entity' do it 'returns basic label entity' do expect_paginated_array_response(issue.id) @@ -28,6 +35,20 @@ shared_examples 'labeled issues with labels and label_name params' do it_behaves_like 'returns label names' end + context 'negation' do + context 'array of labeled issues when all labels match with negation' do + let(:params) { { labels: "#{label.title},#{label_b.title}", not: { labels: "#{label_c.title}" } } } + + it_behaves_like 'returns negated label names' + end + + context 'array of labeled issues when all labels match with negation with label params as array' do + let(:params) { { labels: [label.title, label_b.title], not: { labels: [label_c.title] } } } + + it_behaves_like 'returns negated label names' + end + end + context 'when with_labels_details provided' do context 'array of labeled issues when all labels match' do let(:params) { { labels: "#{label.title},#{label_b.title},#{label_c.title}", with_labels_details: true } } |