Allow negating terms in IssuableFinder

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
上级 0bdee4c2
...@@ -48,8 +48,9 @@ class IssuableFinder ...@@ -48,8 +48,9 @@ class IssuableFinder
attr_accessor :current_user, :params attr_accessor :current_user, :params
def self.scalar_params class << self
@scalar_params ||= %i[ def scalar_params
@scalar_params ||= %i[
assignee_id assignee_id
assignee_username assignee_username
author_id author_id
...@@ -60,14 +61,30 @@ class IssuableFinder ...@@ -60,14 +61,30 @@ class IssuableFinder
search search
in in
] ]
end end
def self.array_params def array_params
@array_params ||= { label_name: [], assignee_username: [] } @array_params ||= { label_name: [], assignee_username: [] }
end 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 # This should not be used in controller strong params!
@valid_params ||= scalar_params + [array_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 +96,9 @@ class IssuableFinder ...@@ -79,6 +96,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
...@@ -351,6 +371,33 @@ class IssuableFinder ...@@ -351,6 +371,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 %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 # 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: 31134
author:
type: added
...@@ -57,7 +57,8 @@ GET /issues?confidential=true ...@@ -57,7 +57,8 @@ GET /issues?confidential=true
| `created_before` | datetime | no | Return issues created on or before the given time | | `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_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
...@@ -207,6 +208,7 @@ GET /groups/:id/issues?confidential=true ...@@ -207,6 +208,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
...@@ -356,6 +358,7 @@ GET /projects/:id/issues?confidential=true ...@@ -356,6 +358,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,
...@@ -32,7 +35,9 @@ module API ...@@ -32,7 +35,9 @@ module API
args.delete(:id) args.delete(:id)
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]
IssuesFinder.new(current_user, args) IssuesFinder.new(current_user, args)
......
...@@ -9,28 +9,35 @@ module API ...@@ -9,28 +9,35 @@ 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'
mutually_exclusive :author_id, :author_username mutually_exclusive :author_id, :author_username
optional :assignee_id, types: [Integer, String], integer_none_any: true, 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, optional :assignee_username, type: Array[String], check_assignees_count: true,
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,15 +21,28 @@ describe IssuesFinder do ...@@ -21,15 +21,28 @@ 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
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 end
it_behaves_like 'no assignee filter' do it_behaves_like 'no assignee filter' do
...@@ -62,6 +75,26 @@ describe IssuesFinder do ...@@ -62,6 +75,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 } }
...@@ -70,6 +103,14 @@ describe IssuesFinder do ...@@ -70,6 +103,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 } }
...@@ -78,6 +119,14 @@ describe IssuesFinder do ...@@ -78,6 +119,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) }
...@@ -93,6 +142,14 @@ describe IssuesFinder do ...@@ -93,6 +142,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
...@@ -134,10 +191,10 @@ describe IssuesFinder do ...@@ -134,10 +191,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
[ [
...@@ -151,7 +208,7 @@ describe IssuesFinder do ...@@ -151,7 +208,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
...@@ -160,6 +217,18 @@ describe IssuesFinder do ...@@ -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.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
...@@ -169,10 +238,10 @@ describe IssuesFinder do ...@@ -169,10 +238,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
[ [
...@@ -198,6 +267,16 @@ describe IssuesFinder 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.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
...@@ -206,6 +285,33 @@ describe IssuesFinder do ...@@ -206,6 +285,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
...@@ -219,6 +325,14 @@ describe IssuesFinder do ...@@ -219,6 +325,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
...@@ -226,18 +340,28 @@ describe IssuesFinder do ...@@ -226,18 +340,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
...@@ -259,6 +383,14 @@ describe IssuesFinder do ...@@ -259,6 +383,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
...@@ -267,6 +399,14 @@ describe IssuesFinder do ...@@ -267,6 +399,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
...@@ -275,6 +415,14 @@ describe IssuesFinder do ...@@ -275,6 +415,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
...@@ -416,6 +564,14 @@ describe IssuesFinder do ...@@ -416,6 +564,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
...@@ -426,6 +582,14 @@ describe IssuesFinder do ...@@ -426,6 +582,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
...@@ -434,6 +598,14 @@ describe IssuesFinder do ...@@ -434,6 +598,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
......
...@@ -429,17 +429,21 @@ describe API::Issues do ...@@ -429,17 +429,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
......
...@@ -423,9 +423,12 @@ describe API::Issues do ...@@ -423,9 +423,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
...@@ -493,46 +496,74 @@ describe API::Issues do ...@@ -493,46 +496,74 @@ describe API::Issues do
end end
end end
it 'returns an empty array if no issue matches milestone' do context 'filter by milestone' do
get api("/issues?milestone=#{empty_milestone.title}", user) it 'returns an empty array if no issue matches milestone' do
get api("/issues?milestone=#{empty_milestone.title}", user)
expect_paginated_array_response([]) expect_paginated_array_response([])
end end
it 'returns an empty array if milestone does not exist' do it 'returns an empty array if milestone does not exist' do
get api('/issues?milestone=foo', user) get api('/issues?milestone=foo', user)
expect_paginated_array_response([]) expect_paginated_array_response([])
end end
it 'returns an array of issues in given milestone' do it 'returns an array of issues in given milestone' do
get api("/issues?milestone=#{milestone.title}", user) get api("/issues?milestone=#{milestone.title}", user)
expect_paginated_array_response([issue.id, closed_issue.id]) expect_paginated_array_response([issue.id, closed_issue.id])
end end
it 'returns an array of issues in given milestone_title param' do it 'returns an array of issues in given milestone_title param' do
get api("/issues?milestone_title=#{milestone.title}", user) get api("/issues?milestone_title=#{milestone.title}", user)
expect_paginated_array_response([issue.id, closed_issue.id]) expect_paginated_array_response([issue.id, closed_issue.id])
end end
it 'returns an array of issues matching state in milestone' do it 'returns an array of issues matching state in milestone' do
get api("/issues?milestone=#{milestone.title}&state=closed", user) get api("/issues?milestone=#{milestone.title}&state=closed", user)
expect_paginated_array_response(closed_issue.id) expect_paginated_array_response(closed_issue.id)
end end
it 'returns an array of issues with no milestone' do it 'returns an array of issues with no milestone' do
get api("/issues?milestone=#{no_milestone_title}", author) get api("/issues?milestone=#{no_milestone_title}", author)
expect_paginated_array_response(confidential_issue.id) expect_paginated_array_response(confidential_issue.id)
end end
it 'returns an array of issues with no milestone using milestone_title param' do it 'returns an array of issues with no milestone using milestone_title param' do
get api("/issues?milestone_title=#{no_milestone_title}", author) 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 end
it 'returns an array of issues found by iids' do it 'returns an array of issues found by iids' do
......
...@@ -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% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册