Commit c068ac67 authored by Jacopo's avatar Jacopo

Filter by `None`/`Any` for labels in issues/mrs API

By using the parameters `?labels=None|Any` the user can filter
issues/mrs from the API that has `none/any` label.

Affected endpoints are:

- /api/issues
- /api/projects/:id/issues
- /api/groups/:id/issues
- /api/merge_requests
- /api/projects/:id/merge_requests
- /api/groups/:id/merge_requests
parent f0630090
......@@ -210,7 +210,14 @@ class IssuableFinder
end
def filter_by_no_label?
labels? && params[:label_name].include?(Label::None.title)
downcased = label_names.map(&:downcase)
# Label::NONE is deprecated and should be removed in 12.0
downcased.include?(FILTER_NONE) || downcased.include?(Label::NONE)
end
def filter_by_any_label?
label_names.map(&:downcase).include?(FILTER_ANY)
end
def labels
......@@ -465,6 +472,8 @@ class IssuableFinder
items =
if filter_by_no_label?
items.without_label
elsif filter_by_any_label?
items.any_label
else
items.with_label(label_names, params[:sort])
end
......
......@@ -90,6 +90,7 @@ module Issuable
scope :order_milestone_due_asc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') }
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :any_label, -> { joins(:label_links).group(:id) }
scope :join_project, -> { joins(:project) }
scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
scope :references_project, -> { references(:project) }
......
......@@ -9,15 +9,10 @@ class Label < ActiveRecord::Base
include Sortable
include FromUnion
# Represents a "No Label" state used for filtering Issues and Merge
# Requests that have no label assigned.
LabelStruct = Struct.new(:title, :name)
None = LabelStruct.new('No Label', 'No Label')
Any = LabelStruct.new('Any Label', '')
cache_markdown_field :description, pipeline: :single_line
DEFAULT_COLOR = '#428BCA'.freeze
DEFAULT_COLOR = '#428BCA'
NONE = 'no label'
default_value_for :color, DEFAULT_COLOR
......
---
title: Filter by None/Any for labels in issues/mrs API
merge_request: 22622
author: Jacopo Beschi @jacopo-beschi
type: added
---
title: removes partially matching of No Label filter and makes it case-insensitive
merge_request: 22622
author: Jacopo Beschi @jacopo-beschi
type: changed
......@@ -36,7 +36,7 @@ GET /issues?my_reaction_emoji=star
| Attribute | Type | Required | Description |
| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. |
| `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. |
| `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. Defaults to `created_by_me`<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ |
| `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned_to_me`. _([Introduced][ce-13004] in GitLab 9.5)_ |
......@@ -149,7 +149,7 @@ GET /groups/:id/issues?my_reaction_emoji=star
| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. |
| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` |
| `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. |
| `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`.<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ |
......@@ -264,7 +264,7 @@ GET /projects/:id/issues?my_reaction_emoji=star
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. |
| `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. |
| `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`.<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ |
| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
......
......@@ -35,7 +35,7 @@ Parameters:
| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
| `milestone` | string | no | Return merge requests for a specific milestone. `None` returns merge requests with no milestone. `Any` returns merge requests that have an assigned milestone. |
| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
| `labels` | string | no | Return merge requests matching a comma separated list of labels |
| `labels` | string | no | Return merge requests matching a comma separated list of labels. `None` lists all merge requests with no labels. `Any` lists all merge requests with at least one label. `No+Label` (Deprecated) lists all merge requests with no labels. Predefined names are case-insensitive. |
| `created_after` | datetime | no | Return merge requests created on or after the given time |
| `created_before` | datetime | no | Return merge requests created on or before the given time |
| `updated_after` | datetime | no | Return merge requests updated on or after the given time |
......@@ -170,7 +170,7 @@ Parameters:
| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
| `milestone` | string | no | Return merge requests for a specific milestone. `None` returns merge requests with no milestone. `Any` returns merge requests that have an assigned milestone. |
| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
| `labels` | string | no | Return merge requests matching a comma separated list of labels |
| `labels` | string | no | Return merge requests matching a comma separated list of labels. `None` lists all merge requests with no labels. `Any` lists all merge requests with at least one label. `No+Label` (Deprecated) lists all merge requests with no labels. Predefined names are case-insensitive. |
| `created_after` | datetime | no | Return merge requests created on or after the given time |
| `created_before` | datetime | no | Return merge requests created on or before the given time |
| `updated_after` | datetime | no | Return merge requests updated on or after the given time |
......@@ -294,7 +294,7 @@ Parameters:
| `sort` | string | no | Return merge requests sorted in `asc` or `desc` order. Default is `desc` |
| `milestone` | string | no | Return merge requests for a specific milestone. `None` returns merge requests with no milestone. `Any` returns merge requests that have an assigned milestone. |
| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
| `labels` | string | no | Return merge requests matching a comma separated list of labels |
| `labels` | string | no | Return merge requests matching a comma separated list of labels. `None` lists all merge requests with no labels. `Any` lists all merge requests with at least one label. `No+Label` (Deprecated) lists all merge requests with no labels. Predefined names are case-insensitive. |
| `created_after` | datetime | no | Return merge requests created on or after the given time |
| `created_before` | datetime | no | Return merge requests created on or before the given time |
| `updated_after` | datetime | no | Return merge requests updated on or after the given time |
......
......@@ -256,19 +256,51 @@ describe IssuesFinder do
create(:label_link, label: label2, target: issue2)
end
it 'returns the unique issues with any of those labels' do
it 'returns the unique issues with all those labels' do
expect(issues).to contain_exactly(issue2)
end
end
context 'filtering by a label that includes any or none in the title' do
let(:params) { { label_name: [label.title, label2.title].join(',') } }
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
create(:label_link, label: label2, target: issue2)
expect(issues).to contain_exactly(issue2)
end
end
context 'filtering by no label' do
let(:params) { { label_name: Label::None.title } }
let(:params) { { label_name: described_class::FILTER_NONE } }
it 'returns issues with no labels' do
expect(issues).to contain_exactly(issue1, issue3, issue4)
end
end
context 'filtering by legacy No+Label' do
let(:params) { { label_name: Label::NONE } }
it 'returns issues with no labels' do
expect(issues).to contain_exactly(issue1, issue3, issue4)
end
end
context 'filtering by any label' do
let(:params) { { label_name: described_class::FILTER_ANY } }
it 'returns issues that have one or more label' do
2.times do
create(:label_link, label: create(:label, project: project2), target: issue3)
end
expect(issues).to contain_exactly(issue2, issue3)
end
end
context 'filtering by issue term' do
let(:params) { { search: 'git' } }
......
......@@ -300,17 +300,31 @@ describe API::Issues do
expect(json_response.first['state']).to eq('opened')
end
it 'returns unlabeled issues for "No Label" label' do
get api("/issues", user), labels: 'No Label'
it 'returns an empty array if no issue matches labels and state filters' do
get api("/issues", user), labels: label.title, state: :closed
expect_paginated_array_response(size: 0)
end
it 'returns an array of issues with any label' do
get api("/issues", user), labels: IssuesFinder::FILTER_ANY
expect_paginated_array_response(size: 1)
expect(json_response.first['labels']).to be_empty
expect(json_response.first['id']).to eq(issue.id)
end
it 'returns an empty array if no issue matches labels and state filters' do
get api("/issues?labels=#{label.title}&state=closed", user)
it 'returns an array of issues with no label' do
get api("/issues", user), labels: IssuesFinder::FILTER_NONE
expect_paginated_array_response(size: 0)
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(closed_issue.id)
end
it 'returns an array of issues with no label when using the legacy No+Label filter' do
get api("/issues", user), labels: "No Label"
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(closed_issue.id)
end
it 'returns an empty array if no issue matches milestone' do
......@@ -492,58 +506,58 @@ describe API::Issues do
end
it 'returns group issues without confidential issues for non project members' do
get api("#{base_url}?state=opened", non_member)
get api(base_url, non_member), state: :opened
expect_paginated_array_response(size: 1)
expect(json_response.first['title']).to eq(group_issue.title)
end
it 'returns group confidential issues for author' do
get api("#{base_url}?state=opened", author)
get api(base_url, author), state: :opened
expect_paginated_array_response(size: 2)
end
it 'returns group confidential issues for assignee' do
get api("#{base_url}?state=opened", assignee)
get api(base_url, assignee), state: :opened
expect_paginated_array_response(size: 2)
end
it 'returns group issues with confidential issues for project members' do
get api("#{base_url}?state=opened", user)
get api(base_url, user), state: :opened
expect_paginated_array_response(size: 2)
end
it 'returns group confidential issues for admin' do
get api("#{base_url}?state=opened", admin)
get api(base_url, admin), state: :opened
expect_paginated_array_response(size: 2)
end
it 'returns an array of labeled group issues' do
get api("#{base_url}?labels=#{group_label.title}", user)
get api(base_url, user), labels: group_label.title
expect_paginated_array_response(size: 1)
expect(json_response.first['labels']).to eq([group_label.title])
end
it 'returns an array of labeled group issues where all labels match' do
get api("#{base_url}?labels=#{group_label.title},foo,bar", user)
get api(base_url, user), labels: "#{group_label.title},foo,bar"
expect_paginated_array_response(size: 0)
end
it 'returns issues matching given search string for title' do
get api("#{base_url}?search=#{group_issue.title}", user)
get api(base_url, user), search: group_issue.title
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(group_issue.id)
end
it 'returns issues matching given search string for description' do
get api("#{base_url}?search=#{group_issue.description}", user)
get api(base_url, user), search: group_issue.description
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(group_issue.id)
......@@ -556,7 +570,7 @@ describe API::Issues do
create(:label_link, label: label_b, target: group_issue)
create(:label_link, label: label_c, target: group_issue)
get api("#{base_url}", user), labels: "#{group_label.title},#{label_b.title},#{label_c.title}"
get api(base_url, user), labels: "#{group_label.title},#{label_b.title},#{label_c.title}"
expect_paginated_array_response(size: 1)
expect(json_response.first['labels']).to eq([label_c.title, label_b.title, group_label.title])
......@@ -576,40 +590,55 @@ describe API::Issues do
end
it 'returns an empty array if no group issue matches labels' do
get api("#{base_url}?labels=foo,bar", user)
get api(base_url, user), labels: 'foo,bar'
expect_paginated_array_response(size: 0)
end
it 'returns an array of group issues with any label' do
get api(base_url, user), labels: IssuesFinder::FILTER_ANY
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(group_issue.id)
end
it 'returns an array of group issues with no label' do
get api(base_url, user), labels: IssuesFinder::FILTER_NONE
response_ids = json_response.map { |issue| issue['id'] }
expect_paginated_array_response(size: 2)
expect(response_ids).to contain_exactly(group_closed_issue.id, group_confidential_issue.id)
end
it 'returns an empty array if no issue matches milestone' do
get api("#{base_url}?milestone=#{group_empty_milestone.title}", user)
get api(base_url, user), milestone: group_empty_milestone.title
expect_paginated_array_response(size: 0)
end
it 'returns an empty array if milestone does not exist' do
get api("#{base_url}?milestone=foo", user)
get api(base_url, user), milestone: 'foo'
expect_paginated_array_response(size: 0)
end
it 'returns an array of issues in given milestone' do
get api("#{base_url}?state=opened&milestone=#{group_milestone.title}", user)
get api(base_url, user), state: :opened, milestone: group_milestone.title
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(group_issue.id)
end
it 'returns an array of issues matching state in milestone' do
get api("#{base_url}?milestone=#{group_milestone.title}"\
'&state=closed', user)
get api(base_url, user), milestone: group_milestone.title, state: :closed
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(group_closed_issue.id)
end
it 'returns an array of issues with no milestone' do
get api("#{base_url}?milestone=#{no_milestone_title}", user)
get api(base_url, user), milestone: no_milestone_title
expect(response).to have_gitlab_http_status(200)
......@@ -645,7 +674,7 @@ describe API::Issues do
end
it 'sorts by updated_at ascending when requested' do
get api("#{base_url}?order_by=updated_at&sort=asc", user)
get api(base_url, user), order_by: :updated_at, sort: :asc
response_dates = json_response.map { |issue| issue['updated_at'] }
......@@ -748,7 +777,7 @@ describe API::Issues do
end
it 'returns an array of labeled project issues' do
get api("#{base_url}/issues?labels=#{label.title}", user)
get api("#{base_url}/issues", user), labels: label.title
expect_paginated_array_response(size: 1)
expect(json_response.first['labels']).to eq([label.title])
......@@ -800,26 +829,42 @@ describe API::Issues do
expect_paginated_array_response(size: 0)
end
it 'returns an array of project issues with any label' do
get api("#{base_url}/issues", user), labels: IssuesFinder::FILTER_ANY
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(issue.id)
end
it 'returns an array of project issues with no label' do
get api("#{base_url}/issues", user), labels: IssuesFinder::FILTER_NONE
response_ids = json_response.map { |issue| issue['id'] }
expect_paginated_array_response(size: 2)
expect(response_ids).to contain_exactly(closed_issue.id, confidential_issue.id)
end
it 'returns an empty array if no project issue matches labels' do
get api("#{base_url}/issues?labels=foo,bar", user)
get api("#{base_url}/issues", user), labels: 'foo,bar'
expect_paginated_array_response(size: 0)
end
it 'returns an empty array if no issue matches milestone' do
get api("#{base_url}/issues?milestone=#{empty_milestone.title}", user)
get api("#{base_url}/issues", user), milestone: empty_milestone.title
expect_paginated_array_response(size: 0)
end
it 'returns an empty array if milestone does not exist' do
get api("#{base_url}/issues?milestone=foo", user)
get api("#{base_url}/issues", user), milestone: :foo
expect_paginated_array_response(size: 0)
end
it 'returns an array of issues in given milestone' do
get api("#{base_url}/issues?milestone=#{milestone.title}", user)
get api("#{base_url}/issues", user), milestone: milestone.title
expect_paginated_array_response(size: 2)
expect(json_response.first['id']).to eq(issue.id)
......@@ -827,21 +872,21 @@ describe API::Issues do
end
it 'returns an array of issues matching state in milestone' do
get api("#{base_url}/issues?milestone=#{milestone.title}&state=closed", user)
get api("#{base_url}/issues", user), milestone: milestone.title, state: :closed
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(closed_issue.id)
end
it 'returns an array of issues with no milestone' do
get api("#{base_url}/issues?milestone=#{no_milestone_title}", user)
get api("#{base_url}/issues", user), milestone: no_milestone_title
expect_paginated_array_response(size: 1)
expect(json_response.first['id']).to eq(confidential_issue.id)
end
it 'returns an array of issues with any milestone' do
get api("#{base_url}/issues?milestone=#{any_milestone_title}", user)
get api("#{base_url}/issues", user), milestone: any_milestone_title
response_ids = json_response.map { |issue| issue['id'] }
......@@ -859,7 +904,7 @@ describe API::Issues do
end
it 'sorts ascending when requested' do
get api("#{base_url}/issues?sort=asc", user)
get api("#{base_url}/issues", user), sort: :asc
response_dates = json_response.map { |issue| issue['created_at'] }
......@@ -868,7 +913,7 @@ describe API::Issues do
end
it 'sorts by updated_at descending when requested' do
get api("#{base_url}/issues?order_by=updated_at", user)
get api("#{base_url}/issues", user), order_by: :updated_at
response_dates = json_response.map { |issue| issue['updated_at'] }
......@@ -877,7 +922,7 @@ describe API::Issues do
end
it 'sorts by updated_at ascending when requested' do
get api("#{base_url}/issues?order_by=updated_at&sort=asc", user)
get api("#{base_url}/issues", user), order_by: :updated_at, sort: :asc
response_dates = json_response.map { |issue| issue['updated_at'] }
......
......@@ -186,6 +186,23 @@ shared_examples 'merge requests list' do
expect(json_response.length).to eq(0)
end
it 'returns an array of merge requests with any label when filtering by any label' do
get api(endpoint_path, user), labels: IssuesFinder::FILTER_ANY
expect_paginated_array_response
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(merge_request.id)
end
it 'returns an array of merge requests without a label when filtering by no label' do
get api(endpoint_path, user), labels: IssuesFinder::FILTER_NONE
response_ids = json_response.map { |merge_request| merge_request['id'] }
expect_paginated_array_response
expect(response_ids).to contain_exactly(merge_request_closed.id, merge_request_merged.id, merge_request_locked.id)
end
it 'returns an array of labeled merge requests that are merged for a milestone' do
bug_label = create(:label, title: 'bug', color: '#FFAABB', project: project)
......
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