Commit a6e8d5c9 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch '64213-not_filtering' into 'master'

Allow negating terms in IssuableFinder

See merge request gitlab-org/gitlab!16748
parents f6bcbb43 617050bd
...@@ -46,9 +46,12 @@ class IssuableFinder ...@@ -46,9 +46,12 @@ class IssuableFinder
# This is used in unassigning users # This is used in unassigning users
NONE = '0' NONE = '0'
NEGATABLE_PARAMS_HELPER_KEYS = %i[include_subgroups in].freeze
attr_accessor :current_user, :params attr_accessor :current_user, :params
def self.scalar_params class << self
def scalar_params
@scalar_params ||= %i[ @scalar_params ||= %i[
assignee_id assignee_id
assignee_username assignee_username
...@@ -62,12 +65,28 @@ class IssuableFinder ...@@ -62,12 +65,28 @@ class IssuableFinder
] ]
end end
def self.array_params def array_params
@array_params ||= { label_name: [], assignee_username: [] } @array_params ||= { label_name: [], assignee_username: [] }
end end
def self.valid_params # This should not be used in controller strong params!
@valid_params ||= scalar_params + [array_params] def negatable_scalar_params
@negatable_scalar_params ||= scalar_params + %i[project_id group_id]
end
# 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 end
def initialize(current_user, params = {}) def initialize(current_user, params = {})
...@@ -79,6 +98,9 @@ class IssuableFinder ...@@ -79,6 +98,9 @@ class IssuableFinder
items = init_collection items = init_collection
items = filter_items(items) 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 # 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 # for counts by passing the force_cte param and enabling the
# attempt_group_search_optimizations feature flag # attempt_group_search_optimizations feature flag
...@@ -366,6 +388,33 @@ class IssuableFinder ...@@ -366,6 +388,33 @@ class IssuableFinder
Array(value).last.to_sym Array(value).last.to_sym
end 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 NEGATABLE_PARAMS_HELPER_KEYS.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(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].slice(*NEGATABLE_PARAMS_HELPER_KEYS))
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 # rubocop: disable CodeReuse/ActiveRecord
def by_scope(items) def by_scope(items)
return items.none if current_user_related? && !current_user return items.none if current_user_related? && !current_user
......
---
title: Add not param to Issues API endpoint
merge_request: 16748
author:
type: added
...@@ -58,6 +58,7 @@ GET /issues?confidential=true ...@@ -58,6 +58,7 @@ GET /issues?confidential=true
| `updated_after` | datetime | no | Return issues updated on or after 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 | | `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 ```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/issues curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/issues
...@@ -206,6 +207,7 @@ GET /groups/:id/issues?confidential=true ...@@ -206,6 +207,7 @@ GET /groups/:id/issues?confidential=true
| `updated_after` | datetime | no | Return issues updated on or after 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 | | `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 ```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/4/issues curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/4/issues
...@@ -354,6 +356,7 @@ GET /projects/:id/issues?confidential=true ...@@ -354,6 +356,7 @@ GET /projects/:id/issues?confidential=true
| `updated_after` | datetime | no | Return issues updated on or after 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 | | `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 ```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/issues curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/issues
......
...@@ -11,6 +11,9 @@ module API ...@@ -11,6 +11,9 @@ module API
params :optional_issues_params_ee do params :optional_issues_params_ee do
end end
params :optional_issue_not_params_ee do
end
def self.update_params_at_least_one_of def self.update_params_at_least_one_of
[ [
:assignee_id, :assignee_id,
...@@ -35,8 +38,11 @@ module API ...@@ -35,8 +38,11 @@ module API
args = declared_params.merge(args) args = declared_params.merge(args)
args.delete(:id) args.delete(:id)
args[:not] ||= {}
args[:milestone_title] ||= args.delete(:milestone) args[:milestone_title] ||= args.delete(:milestone)
args[:not][:milestone_title] ||= args[:not]&.delete(:milestone)
args[:label_name] ||= args.delete(:labels) args[:label_name] ||= args.delete(:labels)
args[:not][:label_name] ||= args[:not]&.delete(:labels)
args[:scope] = args[:scope].underscore if args[:scope] args[:scope] = args[:scope].underscore if args[:scope]
args[:sort] = "#{args[:order_by]}_#{args[:sort]}" args[:sort] = "#{args[:order_by]}_#{args[:sort]}"
......
...@@ -9,17 +9,12 @@ module API ...@@ -9,17 +9,12 @@ module API
before { authenticate_non_get! } before { authenticate_non_get! }
helpers do 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 :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: '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 :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 :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 :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_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' optional :author_username, type: String, desc: 'Return issues which are authored by the user with the given username'
...@@ -31,6 +26,18 @@ module API ...@@ -31,6 +26,18 @@ module API
coerce_with: Validations::CheckAssigneesCount.coerce, coerce_with: Validations::CheckAssigneesCount.coerce,
desc: 'Return issues which are assigned to the user with the given username' desc: 'Return issues which are assigned to the user with the given username'
mutually_exclusive :assignee_id, :assignee_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], 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`' desc: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`'
......
...@@ -21,17 +21,30 @@ describe IssuesFinder do ...@@ -21,17 +21,30 @@ describe IssuesFinder do
let(:expected_issuables) { [issue1, issue2] } let(:expected_issuables) { [issue1, issue2] }
end 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 before do
project2.add_developer(user3) project2.add_developer(user3)
issue3.assignees = [user2, user3] issue3.assignees = [user2, user3]
end end
set(:user3) { create(:user) } it_behaves_like 'assignee username filter' do
let(:params) { { assignee_username: [user2.username, user3.username] } } let(:params) { { assignee_username: [user2.username, user3.username] } }
let(:expected_issuables) { [issue3] } let(:expected_issuables) { [issue3] }
end 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 it_behaves_like 'no assignee filter' do
set(:user3) { create(:user) } set(:user3) { create(:user) }
let(:expected_issuables) { [issue4] } let(:expected_issuables) { [issue4] }
...@@ -112,6 +125,26 @@ describe IssuesFinder do ...@@ -112,6 +125,26 @@ describe IssuesFinder do
end end
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 context 'filtering by author ID' do
let(:params) { { author_id: user2.id } } let(:params) { { author_id: user2.id } }
...@@ -120,6 +153,14 @@ describe IssuesFinder do ...@@ -120,6 +153,14 @@ describe IssuesFinder do
end end
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 context 'filtering by milestone' do
let(:params) { { milestone_title: milestone.title } } let(:params) { { milestone_title: milestone.title } }
...@@ -128,6 +169,14 @@ describe IssuesFinder do ...@@ -128,6 +169,14 @@ describe IssuesFinder do
end end
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 context 'filtering by group milestone' do
let!(:group) { create(:group, :public) } let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) } let(:group_milestone) { create(:milestone, group: group) }
...@@ -143,6 +192,14 @@ describe IssuesFinder do ...@@ -143,6 +192,14 @@ describe IssuesFinder do
it 'returns issues assigned to that group milestone' do it 'returns issues assigned to that group milestone' do
expect(issues).to contain_exactly(issue2, issue3) expect(issues).to contain_exactly(issue2, issue3)
end 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 end
context 'filtering by no milestone' do context 'filtering by no milestone' do
...@@ -184,10 +241,10 @@ describe IssuesFinder do ...@@ -184,10 +241,10 @@ describe IssuesFinder do
let(:project_next_8_8) { create(:project, :public) } let(:project_next_8_8) { create(:project, :public) }
let(:project_in_group) { create(:project, :public, namespace: group) } let(:project_in_group) { create(:project, :public, namespace: group) }
let(:yesterday) { Date.today - 1.day } let(:yesterday) { Date.current - 1.day }
let(:tomorrow) { Date.today + 1.day } let(:tomorrow) { Date.current + 1.day }
let(:two_days_from_now) { Date.today + 2.days } let(:two_days_from_now) { Date.current + 2.days }
let(:ten_days_from_now) { Date.today + 10.days } let(:ten_days_from_now) { Date.current + 10.days }
let(:milestones) do let(:milestones) do
[ [
...@@ -201,7 +258,7 @@ describe IssuesFinder do ...@@ -201,7 +258,7 @@ describe IssuesFinder do
end end
before do 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]) create(:issue, project: milestone.project || project_in_group, milestone: milestone, author: user, assignees: [user])
end end
end end
...@@ -210,6 +267,18 @@ describe IssuesFinder do ...@@ -210,6 +267,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.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) expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now, tomorrow)
end 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 end
context 'filtering by started milestone' do context 'filtering by started milestone' do
...@@ -219,10 +288,10 @@ describe IssuesFinder do ...@@ -219,10 +288,10 @@ describe IssuesFinder do
let(:project_started_1_and_2) { create(:project, :public) } let(:project_started_1_and_2) { create(:project, :public) }
let(:project_started_8) { create(:project, :public) } let(:project_started_8) { create(:project, :public) }
let(:yesterday) { Date.today - 1.day } let(:yesterday) { Date.current - 1.day }
let(:tomorrow) { Date.today + 1.day } let(:tomorrow) { Date.current + 1.day }
let(:two_days_ago) { Date.today - 2.days } let(:two_days_ago) { Date.current - 2.days }
let(:three_days_ago) { Date.today - 3.days } let(:three_days_ago) { Date.current - 3.days }
let(:milestones) do let(:milestones) do
[ [
...@@ -248,6 +317,16 @@ describe IssuesFinder do ...@@ -248,6 +317,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.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) expect(issues.map { |issue| issue.milestone.start_date }).to contain_exactly(two_days_ago, yesterday, yesterday)
end 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 end
context 'filtering by label' do context 'filtering by label' do
...@@ -256,6 +335,33 @@ describe IssuesFinder do ...@@ -256,6 +335,33 @@ describe IssuesFinder do
it 'returns issues with that label' do it 'returns issues with that label' do
expect(issues).to contain_exactly(issue2) expect(issues).to contain_exactly(issue2)
end 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 end
context 'filtering by multiple labels' do context 'filtering by multiple labels' do
...@@ -269,6 +375,14 @@ describe IssuesFinder do ...@@ -269,6 +375,14 @@ describe IssuesFinder do
it 'returns the unique issues with all those labels' do it 'returns the unique issues with all those labels' do
expect(issues).to contain_exactly(issue2) expect(issues).to contain_exactly(issue2)
end 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 end
context 'filtering by a label that includes any or none in the title' do context 'filtering by a label that includes any or none in the title' do
...@@ -276,18 +390,28 @@ describe IssuesFinder do ...@@ -276,18 +390,28 @@ describe IssuesFinder do
let(:label) { create(:label, title: 'any foo', project: project2) } let(:label) { create(:label, title: 'any foo', project: project2) }
let(:label2) { create(:label, title: 'bar none', 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) create(:label_link, label: label2, target: issue2)
end
it 'returns the unique issues with all those labels' do
expect(issues).to contain_exactly(issue2) expect(issues).to contain_exactly(issue2)
end 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 end
context 'filtering by no label' do context 'filtering by no label' do
let(:params) { { label_name: described_class::FILTER_NONE } } let(:params) { { label_name: described_class::FILTER_NONE } }
it 'returns issues with no labels' do it 'returns issues with no labels' do
expect(issues).to contain_exactly(issue1, issue3, issue4) expect(issues).to contain_exactly(issue1, issue4)
end end
end end
...@@ -309,6 +433,14 @@ describe IssuesFinder do ...@@ -309,6 +433,14 @@ describe IssuesFinder do
it 'returns issues with title and description match for search term' do it 'returns issues with title and description match for search term' do
expect(issues).to contain_exactly(issue1, issue2) expect(issues).to contain_exactly(issue1, issue2)
end 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 end
context 'filtering by issue term in title' do context 'filtering by issue term in title' do
...@@ -317,6 +449,14 @@ describe IssuesFinder do ...@@ -317,6 +449,14 @@ describe IssuesFinder do
it 'returns issues with title match for search term' do it 'returns issues with title match for search term' do
expect(issues).to contain_exactly(issue1) expect(issues).to contain_exactly(issue1)
end 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 end
context 'filtering by issues iids' do context 'filtering by issues iids' do
...@@ -325,6 +465,14 @@ describe IssuesFinder do ...@@ -325,6 +465,14 @@ describe IssuesFinder do
it 'returns issues with iids match' do it 'returns issues with iids match' do
expect(issues).to contain_exactly(issue3) expect(issues).to contain_exactly(issue3)
end 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 end
context 'filtering by state' do context 'filtering by state' do
...@@ -466,6 +614,14 @@ describe IssuesFinder do ...@@ -466,6 +614,14 @@ describe IssuesFinder do
it 'returns issues that the user thumbsup to' do it 'returns issues that the user thumbsup to' do
expect(issues).to contain_exactly(issue1) expect(issues).to contain_exactly(issue1)
end 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 end
context 'user2 searches by "thumbsup" reaction' do context 'user2 searches by "thumbsup" reaction' do
...@@ -476,6 +632,14 @@ describe IssuesFinder do ...@@ -476,6 +632,14 @@ describe IssuesFinder do
it 'returns issues that the user2 thumbsup to' do it 'returns issues that the user2 thumbsup to' do
expect(issues).to contain_exactly(issue2) expect(issues).to contain_exactly(issue2)
end 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 end
context 'user searches by "thumbsdown" reaction' do context 'user searches by "thumbsdown" reaction' do
...@@ -484,6 +648,14 @@ describe IssuesFinder do ...@@ -484,6 +648,14 @@ describe IssuesFinder do
it 'returns issues that the user thumbsdown to' do it 'returns issues that the user thumbsdown to' do
expect(issues).to contain_exactly(issue3) expect(issues).to contain_exactly(issue3)
end 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
end end
......
...@@ -437,17 +437,21 @@ describe API::Issues do ...@@ -437,17 +437,21 @@ describe API::Issues do
end end
context 'with labeled issues' do 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_b) { create(:label, title: 'foo', project: group_project) }
let(:label_c) { create(:label, title: 'bar', project: group_project) } let(:label_c) { create(:label, title: 'bar', project: group_project) }
before do 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_issue)
create(:label_link, label: label_b, target: group_issue2)
create(:label_link, label: label_c, target: group_issue) create(:label_link, label: label_c, target: group_issue)
get api(base_url, user), params: params get api(base_url, user), params: params
end end
let(:issue) { group_issue } let(:issue) { group_issue }
let(:issue2) { group_issue2 }
let(:label) { group_label } let(:label) { group_label }
it_behaves_like 'labeled issues with labels and label_name params' it_behaves_like 'labeled issues with labels and label_name params'
......
...@@ -283,11 +283,14 @@ describe API::Issues do ...@@ -283,11 +283,14 @@ describe API::Issues do
end end
context 'with labeled issues' do context 'with labeled issues' do
let(:issue2) { create :issue, project: project }
let(:label_b) { create(:label, title: 'foo', project: project) } let(:label_b) { create(:label, title: 'foo', project: project) }
let(:label_c) { create(:label, title: 'bar', project: project) } let(:label_c) { create(:label, title: 'bar', project: project) }
before do before do
create(:label_link, label: label, target: issue2)
create(:label_link, label: label_b, target: issue) create(:label_link, label: label_b, target: issue)
create(:label_link, label: label_b, target: issue2)
create(:label_link, label: label_c, target: issue) create(:label_link, label: label_c, target: issue)
get api('/issues', user), params: params get api('/issues', user), params: params
......
...@@ -427,9 +427,12 @@ describe API::Issues do ...@@ -427,9 +427,12 @@ describe API::Issues do
context 'with labeled issues' do context 'with labeled issues' do
let(:label_b) { create(:label, title: 'foo', project: project) } let(:label_b) { create(:label, title: 'foo', project: project) }
let(:label_c) { create(:label, title: 'bar', project: project) } let(:label_c) { create(:label, title: 'bar', project: project) }
let(:issue2) { create(:issue, author: user, project: project) }
before do before do
create(:label_link, label: label, target: issue2)
create(:label_link, label: label_b, target: issue) create(:label_link, label: label_b, target: issue)
create(:label_link, label: label_b, target: issue2)
create(:label_link, label: label_c, target: issue) create(:label_link, label: label_c, target: issue)
get api('/issues', user), params: params get api('/issues', user), params: params
...@@ -497,6 +500,7 @@ describe API::Issues do ...@@ -497,6 +500,7 @@ describe API::Issues do
end end
end end
context 'filter by milestone' do
it 'returns an empty array if no issue matches milestone' do it 'returns an empty array if no issue matches milestone' do
get api("/issues?milestone=#{empty_milestone.title}", user) get api("/issues?milestone=#{empty_milestone.title}", user)
...@@ -539,6 +543,33 @@ describe API::Issues do ...@@ -539,6 +543,33 @@ describe API::Issues do
expect_paginated_array_response(confidential_issue.id) expect_paginated_array_response(confidential_issue.id)
end 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 it 'returns an array of issues found by iids' do
get api('/issues', user), params: { iids: [closed_issue.iid] } get api('/issues', user), params: { iids: [closed_issue.iid] }
......
...@@ -12,6 +12,7 @@ RSpec.shared_context 'IssuesFinder context' do ...@@ -12,6 +12,7 @@ RSpec.shared_context 'IssuesFinder context' do
set(:project3) { create(:project, group: subgroup) } set(:project3) { create(:project, group: subgroup) }
set(:milestone) { create(:milestone, project: project1) } set(:milestone) { create(:milestone, project: project1) }
set(:label) { create(:label, project: project2) } 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(: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(: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) } 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 ...@@ -24,6 +25,7 @@ end
RSpec.shared_context 'IssuesFinder#execute context' do RSpec.shared_context 'IssuesFinder#execute context' do
let!(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } 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_link) { create(:label_link, label: label, target: issue2) }
let!(:label_link2) { create(:label_link, label: label2, target: issue3) }
let(:search_user) { user } let(:search_user) { user }
let(:params) { {} } let(:params) { {} }
let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute }
......
...@@ -6,12 +6,24 @@ shared_examples 'assignee ID filter' do ...@@ -6,12 +6,24 @@ shared_examples 'assignee ID filter' do
end end
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 shared_examples 'assignee username filter' do
it 'returns issuables assigned to those users' do it 'returns issuables assigned to those users' do
expect(issuables).to contain_exactly(*expected_issuables) expect(issuables).to contain_exactly(*expected_issuables)
end end
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 shared_examples 'no assignee filter' do
let(:params) { { assignee_id: 'None' } } let(:params) { { assignee_id: 'None' } }
......
...@@ -8,6 +8,13 @@ shared_examples 'labeled issues with labels and label_name params' do ...@@ -8,6 +8,13 @@ shared_examples 'labeled issues with labels and label_name params' do
end end
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 shared_examples 'returns basic label entity' do
it 'returns basic label entity' do it 'returns basic label entity' do
expect_paginated_array_response(issue.id) expect_paginated_array_response(issue.id)
...@@ -28,6 +35,20 @@ shared_examples 'labeled issues with labels and label_name params' do ...@@ -28,6 +35,20 @@ shared_examples 'labeled issues with labels and label_name params' do
it_behaves_like 'returns label names' it_behaves_like 'returns label names'
end 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 'when with_labels_details provided' do
context 'array of labeled issues when all labels match' 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 } } let(:params) { { labels: "#{label.title},#{label_b.title},#{label_c.title}", with_labels_details: true } }
......
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