Commit b6538ccd authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Support filtering by scoped label wildcards

Allows filtering of issues, merge requests, and epics using `scope::*`
which matches scoped labels under that scope.
parent 46811085
......@@ -89,17 +89,25 @@ module Issuables
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def find_label_ids(label_names)
group_labels = Label
.where(project_id: nil)
.where(title: label_names)
.where(group_id: root_namespace.self_and_descendant_ids)
find_label_ids_uncached(label_names)
end
# Avoid repeating label queries times when the finder is instantiated multiple times during the request.
request_cache(:find_label_ids) { root_namespace.id }
project_labels = Label
.where(group_id: nil)
.where(title: label_names)
.where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
# This returns an array of label IDs per label name. It is possible for a label name
# to have multiple IDs because we allow labels with the same name if they are on a different
# project or group.
#
# For example, if we pass in `['bug', 'feature']`, this will return something like:
# `[ [1, 2], [3] ]`
#
# rubocop: disable CodeReuse/ActiveRecord
def find_label_ids_uncached(label_names)
return [] if label_names.empty?
group_labels = group_labels_for_root_namespace.where(title: label_names)
project_labels = project_labels_for_root_namespace.where(title: label_names)
Label
.from_union([group_labels, project_labels], remove_duplicates: false)
......@@ -109,8 +117,18 @@ module Issuables
.values
.map { |labels| labels.map(&:last) }
end
# Avoid repeating label queries times when the finder is instantiated multiple times during the request.
request_cache(:find_label_ids) { root_namespace.id }
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def group_labels_for_root_namespace
Label.where(project_id: nil).where(group_id: root_namespace.self_and_descendant_ids)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def project_labels_for_root_namespace
Label.where(group_id: nil).where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
......@@ -153,3 +171,5 @@ module Issuables
end
end
end
Issuables::LabelFilter.prepend_mod
......@@ -180,6 +180,19 @@ For example:
1. GitLab automatically removes the `priority::low` label, as an issue should not
have two priority labels at the same time.
### Filter by scoped labels
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12285) in GitLab 14.4.
To filter issue, merge request, or epic lists for ones with labels that belong to a given scope, enter
`<scope>::*` in the searched label name.
For example, filtering by the `platform::*` label returns issues that have `platform::iOS`,
`platform::Android`, or `platform::Linux` labels.
NOTE:
This is not available on the [issues or merge requests dashboard pages](../search/index.md#issues-and-merge-requests).
### Workflows with scoped labels
Suppose you wanted a custom field in issues to track the operating system platform
......
# frozen_string_literal: true
module EE
module Issuables
module LabelFilter
extend ::Gitlab::Utils::Override
SCOPED_LABEL_WILDCARD = '*'
private
override :find_label_ids_uncached
def find_label_ids_uncached(label_names)
return super unless root_namespace.licensed_feature_available?(:scoped_labels)
scoped_label_wildcards, label_names = extract_scoped_label_wildcards(label_names)
find_wildcard_label_ids(scoped_label_wildcards) + super(label_names)
end
def extract_scoped_label_wildcards(label_names)
label_names.partition { |name| name.ends_with?(::Label::SCOPED_LABEL_SEPARATOR + SCOPED_LABEL_WILDCARD) }
end
# This is similar to the CE version of `#find_label_ids_uncached` but the results
# are grouped by the wildcard prefix. With nested scoped labels, a label can match multiple prefixes.
# So a label_id can be present multiple times.
#
# For example, if we pass in `['workflow::*', 'workflow::backend::*']`, this will return something like:
# `[ [1, 2, 3], [1, 2] ]`
#
# rubocop: disable CodeReuse/ActiveRecord
def find_wildcard_label_ids(scoped_label_wildcards)
return [] if scoped_label_wildcards.empty?
scoped_label_prefixes = scoped_label_wildcards.map { |w| w.delete_suffix(SCOPED_LABEL_WILDCARD) }
relations = scoped_label_prefixes.flat_map do |prefix|
search_term = prefix + '%'
[
group_labels_for_root_namespace.where('title LIKE ?', search_term),
project_labels_for_root_namespace.where('title LIKE ?', search_term)
]
end
labels = ::Label
.from_union(relations, remove_duplicates: false)
.reorder(nil)
.pluck(:title, :id)
group_by_prefix(labels, scoped_label_prefixes).values
end
# rubocop: enable CodeReuse/ActiveRecord
def group_by_prefix(labels, prefixes)
labels.each_with_object({}) do |(title, id), ids_by_prefix|
prefixes.each do |prefix|
next unless title.start_with?(prefix)
ids_by_prefix[prefix] ||= []
ids_by_prefix[prefix] << id
end
end
end
end
end
end
......@@ -159,6 +159,22 @@ RSpec.describe EpicsFinder do
it 'returns all epics with given label' do
expect(epics(label_name: label.title)).to contain_exactly(labeled_epic)
end
context 'with scoped label wildcard' do
let_it_be(:scoped_label_1) { create(:group_label, group: group, title: 'devops::plan') }
let_it_be(:scoped_label_2) { create(:group_label, group: group, title: 'devops::create') }
let_it_be(:scoped_labeled_epic_1) { create(:labeled_epic, group: group, labels: [scoped_label_1]) }
let_it_be(:scoped_labeled_epic_2) { create(:labeled_epic, group: group, labels: [scoped_label_2]) }
before do
stub_licensed_features(epics: true, scoped_labels: true)
end
it 'returns all epics that match the wildcard' do
expect(epics(label_name: 'devops::*')).to contain_exactly(scoped_labeled_epic_1, scoped_labeled_epic_2)
end
end
end
context 'by state' do
......
......@@ -10,6 +10,101 @@ RSpec.describe IssuesFinder do
context 'scope: all' do
let(:scope) { 'all' }
describe 'filter by scoped label wildcard' do
let_it_be(:search_user) { create(:user) }
let_it_be(:group_devops_plan_label) { create(:group_label, group: group, title: 'devops::plan') }
let_it_be(:group_wfe_in_dev_label) { create(:group_label, group: group, title: 'workflow::frontend::in dev') }
let_it_be(:group_wfe_in_review_label) { create(:group_label, group: group, title: 'workflow::frontend::in review') }
let_it_be(:subgroup_devops_create_label) { create(:group_label, group: subgroup, title: 'devops::create') }
let_it_be(:project_wbe_in_dev_label) { create(:label, project: project3, title: 'workflow::backend::in dev') }
let_it_be(:project_label) { create(:label, project: project3) }
let_it_be(:devops_plan_be_in_dev_issue) { create(:labeled_issue, project: project3, labels: [group_devops_plan_label, project_wbe_in_dev_label]) }
let_it_be(:project_fe_in_dev_issue) { create(:labeled_issue, project: project3, labels: [project_label, group_wfe_in_dev_label]) }
let_it_be(:devops_create_issue) { create(:labeled_issue, project: project3, labels: [subgroup_devops_create_label]) }
let_it_be(:be_in_dev_issue) { create(:labeled_issue, project: project3, labels: [project_wbe_in_dev_label]) }
let_it_be(:project_fe_in_review_issue) { create(:labeled_issue, project: project3, labels: [project_label, group_wfe_in_review_label]) }
before_all do
project3.add_developer(search_user)
end
before do
stub_licensed_features(scoped_labels: true)
end
let(:base_params) { { project_id: project3.id } }
context 'when scoped labels are unavailable' do
let(:params) { base_params.merge(label_name: 'devops::*') }
before do
stub_licensed_features(scoped_labels: false)
end
it 'does not return any results' do
expect(issues).to be_empty
end
end
context 'when project scope is not given' do
let(:params) { { label_name: 'devops::*' } }
it 'does not return any results' do
expect(issues).to be_empty
end
end
context 'with a single wildcard filter' do
let(:params) { base_params.merge(label_name: 'devops::*') }
it 'returns issues that have labels that match the wildcard' do
expect(issues).to contain_exactly(devops_plan_be_in_dev_issue, devops_create_issue)
end
end
context 'with multiple wildcard filters' do
let(:params) { base_params.merge(label_name: ['devops::*', 'workflow::backend::*']) }
it 'returns issues that have labels that match both wildcards' do
expect(issues).to contain_exactly(devops_plan_be_in_dev_issue)
end
end
context 'combined with a regular label filter' do
let(:params) { base_params.merge(label_name: [project_label.name, 'workflow::frontend::*']) }
it 'returns issues that have labels that match the wildcard and the regular label' do
expect(issues).to contain_exactly(project_fe_in_dev_issue, project_fe_in_review_issue)
end
end
context 'with nested prefix' do
let(:params) { base_params.merge(label_name: 'workflow::*') }
it 'returns issues that have labels that match the prefix' do
expect(issues).to contain_exactly(devops_plan_be_in_dev_issue, be_in_dev_issue, project_fe_in_dev_issue, project_fe_in_review_issue)
end
end
context 'with overlapping prefixes' do
let(:params) { base_params.merge(label_name: ['workflow::*', 'workflow::backend::*']) }
it 'returns issues that have labels that match both prefixes' do
expect(issues).to contain_exactly(devops_plan_be_in_dev_issue, be_in_dev_issue)
end
end
context 'using NOT' do
let(:params) { base_params.merge(not: { label_name: 'devops::*' }) }
it 'returns issues that do not have labels that match the wildcard' do
expect(issues).to contain_exactly(issue4, project_fe_in_dev_issue, project_fe_in_review_issue, be_in_dev_issue)
end
end
end
describe 'filter by weight' do
let_it_be(:issue_with_weight_1) { create(:issue, project: project3, weight: 1) }
let_it_be(:issue_with_weight_42) { create(:issue, project: project3, weight: 42) }
......
......@@ -30,5 +30,23 @@ RSpec.describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merged_merge_request)
end
end
context 'filtering by scoped label wildcard' do
let_it_be(:scoped_label_1) { create(:label, project: project4, title: 'devops::plan') }
let_it_be(:scoped_label_2) { create(:label, project: project4, title: 'devops::create') }
let_it_be(:scoped_labeled_merge_request_1) { create(:labeled_merge_request, source_project: project4, source_branch: 'branch1', labels: [scoped_label_1]) }
let_it_be(:scoped_labeled_merge_request_2) { create(:labeled_merge_request, source_project: project4, source_branch: 'branch2', labels: [scoped_label_2]) }
before do
stub_licensed_features(scoped_labels: true)
end
it 'returns all merge requests that match the wildcard' do
merge_requests = described_class.new(user, { project_id: project4.id, label_name: 'devops::*' }).execute
expect(merge_requests).to contain_exactly(scoped_labeled_merge_request_1, scoped_labeled_merge_request_2)
end
end
end
end
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