summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMario de la Ossa <mariodelaossa@gmail.com>2019-07-24 23:43:45 -0600
committerMario de la Ossa <mariodelaossa@gmail.com>2019-09-15 13:55:36 -0600
commit7213798ac048b50d4eae2a2382bedf228b48b1f0 (patch)
tree2c683e2e74729a189200ba453c2e077e8f058a21
parent0bdee4c2f885462a55b67a798ec242321dfe67e3 (diff)
downloadgitlab-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.rb63
-rw-r--r--changelogs/unreleased/64213-not_filtering.yml5
-rw-r--r--doc/api/issues.md5
-rw-r--r--lib/api/helpers/issues_helpers.rb5
-rw-r--r--lib/api/issues.rb25
-rw-r--r--spec/finders/issues_finder_spec.rb202
-rw-r--r--spec/requests/api/issues/get_group_issues_spec.rb4
-rw-r--r--spec/requests/api/issues/get_project_issues_spec.rb3
-rw-r--r--spec/requests/api/issues/issues_spec.rb85
-rw-r--r--spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb2
-rw-r--r--spec/support/shared_examples/finders/assignees_filter_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/requests/api/issues_shared_examples.rb21
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 } }