Commit 6344740e authored by Robert Speicher's avatar Robert Speicher

Merge branch 'ajk-gql-labels' into 'master'

Add support for project and group label queries

See merge request gitlab-org/gitlab!32113
parents d99fd44b 254405be
......@@ -46,7 +46,6 @@ class LabelsFinder < UnionFinder
end
else
if group?
group = Group.find(params[:group_id])
label_ids << Label.where(group_id: group_ids_for(group))
end
......@@ -123,7 +122,11 @@ class LabelsFinder < UnionFinder
end
def group?
params[:group_id].present?
params[:group].present? || params[:group_id].present?
end
def group
strong_memoize(:group) { params[:group].presence || Group.find(params[:group_id]) }
end
def project?
......@@ -169,7 +172,7 @@ class LabelsFinder < UnionFinder
ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute # rubocop: disable CodeReuse/Finder
end
@projects = @projects.in_namespace(params[:group_id]) if group?
@projects = @projects.in_namespace(group.id) if group?
@projects = @projects.where(id: params[:project_ids]) if projects?
@projects = @projects.reorder(nil)
......
......@@ -12,5 +12,9 @@ module Types
def id
GitlabSchema.id_from_object(object)
end
def current_user
context[:current_user]
end
end
end
......@@ -65,6 +65,45 @@ module Types
null: true,
description: 'A single board of the group',
resolver: Resolvers::BoardsResolver.single
field :label,
Types::LabelType,
null: true,
description: 'A label available on this group' do
argument :title, GraphQL::STRING_TYPE,
required: true,
description: 'Title of the label'
end
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder
.new(current_user, group: args[:key], title: titles)
.execute
.each { |label| loader.call(label.title, label) }
end
end
field :labels,
Types::LabelType.connection_type,
null: true,
description: 'Labels available on this group' do
argument :search_term, GraphQL::STRING_TYPE,
required: false,
description: 'A search term to find labels with'
end
def labels(search_term: nil)
LabelsFinder
.new(current_user, group: group, search: search_term)
.execute
end
private
def group
object.respond_to?(:sync) ? object.sync : object
end
end
end
......
......@@ -242,6 +242,45 @@ module Types
Types::ContainerExpirationPolicyType,
null: true,
description: 'The container expiration policy of the project'
field :label,
Types::LabelType,
null: true,
description: 'A label available on this project' do
argument :title, GraphQL::STRING_TYPE,
required: true,
description: 'Title of the label'
end
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
.new(current_user, project: args[:key], title: titles)
.execute
.each { |label| loader.call(label.title, label) }
end
end
field :labels,
Types::LabelType.connection_type,
null: true,
description: 'Labels available on this project' do
argument :search_term, GraphQL::STRING_TYPE,
required: false,
description: 'A search term to find labels with'
end
def labels(search_term: nil)
LabelsFinder
.new(current_user, project: project, search: search_term)
.execute
end
private
def project
@project ||= object.respond_to?(:sync) ? object.sync : object
end
end
end
......
---
title: Add GraphQL support for project and group labels
merge_request: 32113
author:
type: added
......@@ -4652,6 +4652,46 @@ type Group {
title: String
): IterationConnection
"""
A label available on this group
"""
label(
"""
Title of the label
"""
title: String!
): Label
"""
Labels available on this group
"""
labels(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
A search term to find labels with
"""
searchTerm: String
): LabelConnection
"""
Indicates if Large File Storage (LFS) is enabled for namespace
"""
......@@ -8363,6 +8403,46 @@ type Project {
"""
jobsEnabled: Boolean
"""
A label available on this project
"""
label(
"""
Title of the label
"""
title: String!
): Label
"""
Labels available on this project
"""
labels(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
A search term to find labels with
"""
searchTerm: String
): LabelConnection
"""
Timestamp of the project last activity
"""
......
......@@ -12820,6 +12820,96 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "label",
"description": "A label available on this group",
"args": [
{
"name": "title",
"description": "Title of the label",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Label",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "labels",
"description": "Labels available on this group",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "searchTerm",
"description": "A search term to find labels with",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "LabelConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lfsEnabled",
"description": "Indicates if Large File Storage (LFS) is enabled for namespace",
......@@ -24632,6 +24722,96 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "label",
"description": "A label available on this project",
"args": [
{
"name": "title",
"description": "Title of the label",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Label",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "labels",
"description": "Labels available on this project",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "searchTerm",
"description": "A search term to find labels with",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "LabelConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastActivityAt",
"description": "Timestamp of the project last activity",
......@@ -706,6 +706,7 @@ Autogenerated return type of EpicTreeReorder
| `fullPath` | ID! | Full path of the namespace |
| `groupTimelogsEnabled` | Boolean | Indicates if Group timelogs are enabled for namespace |
| `id` | ID! | ID of the namespace |
| `label` | Label | A label available on this group |
| `lfsEnabled` | Boolean | Indicates if Large File Storage (LFS) is enabled for namespace |
| `mentionsDisabled` | Boolean | Indicates if a group is disabled from getting mentioned |
| `name` | String! | Name of the namespace |
......@@ -1219,6 +1220,7 @@ Information about pagination in a connection.
| `issuesEnabled` | Boolean | Indicates if Issues are enabled for the current user |
| `jiraImportStatus` | String | Status of Jira import background job of the project |
| `jobsEnabled` | Boolean | Indicates if CI/CD pipeline jobs are enabled for the current user |
| `label` | Label | A label available on this project |
| `lastActivityAt` | Time | Timestamp of the project last activity |
| `lfsEnabled` | Boolean | Indicates if the project has Large File Storage (LFS) enabled |
| `mergeRequest` | MergeRequest | A single merge request of the project |
......
......@@ -6,6 +6,18 @@ FactoryBot.define do
color { "#990000" }
end
trait :described do
description { "Description of #{title}" }
end
trait :scoped do
transient do
prefix { 'scope' }
end
title { "#{prefix}::#{generate(:label_title)}" }
end
factory :label, traits: [:base_label], class: 'ProjectLabel' do
project
......
......@@ -4,35 +4,35 @@ require 'spec_helper'
describe LabelsFinder do
describe '#execute' do
let(:group_1) { create(:group) }
let(:group_2) { create(:group) }
let(:group_3) { create(:group) }
let(:private_group_1) { create(:group, :private) }
let(:private_subgroup_1) { create(:group, :private, parent: private_group_1) }
let(:project_1) { create(:project, namespace: group_1) }
let(:project_2) { create(:project, namespace: group_2) }
let(:project_3) { create(:project) }
let(:project_4) { create(:project, :public) }
let(:project_5) { create(:project, namespace: group_1) }
let!(:project_label_1) { create(:label, project: project_1, title: 'Label 1', description: 'awesome label') }
let!(:project_label_2) { create(:label, project: project_2, title: 'Label 2') }
let!(:project_label_4) { create(:label, project: project_4, title: 'Label 4') }
let!(:project_label_5) { create(:label, project: project_5, title: 'Label 5') }
let!(:group_label_1) { create(:group_label, group: group_1, title: 'Label 1 (group)') }
let!(:group_label_2) { create(:group_label, group: group_1, title: 'Group Label 2') }
let!(:group_label_3) { create(:group_label, group: group_2, title: 'Group Label 3') }
let!(:private_group_label_1) { create(:group_label, group: private_group_1, title: 'Private Group Label 1') }
let!(:private_subgroup_label_1) { create(:group_label, group: private_subgroup_1, title: 'Private Sub Group Label 1') }
let(:user) { create(:user) }
let_it_be(:group_1) { create(:group) }
let_it_be(:group_2) { create(:group) }
let_it_be(:group_3) { create(:group) }
let_it_be(:private_group_1) { create(:group, :private) }
let_it_be(:private_subgroup_1) { create(:group, :private, parent: private_group_1) }
let_it_be(:project_1, reload: true) { create(:project, namespace: group_1) }
let_it_be(:project_2) { create(:project, namespace: group_2) }
let_it_be(:project_3) { create(:project) }
let_it_be(:project_4) { create(:project, :public) }
let_it_be(:project_5) { create(:project, namespace: group_1) }
let_it_be(:project_label_1) { create(:label, project: project_1, title: 'Label 1', description: 'awesome label') }
let_it_be(:project_label_2) { create(:label, project: project_2, title: 'Label 2') }
let_it_be(:project_label_4) { create(:label, project: project_4, title: 'Label 4') }
let_it_be(:project_label_5) { create(:label, project: project_5, title: 'Label 5') }
let_it_be(:group_label_1) { create(:group_label, group: group_1, title: 'Label 1 (group)') }
let_it_be(:group_label_2) { create(:group_label, group: group_1, title: 'Group Label 2') }
let_it_be(:group_label_3) { create(:group_label, group: group_2, title: 'Group Label 3') }
let_it_be(:private_group_label_1) { create(:group_label, group: private_group_1, title: 'Private Group Label 1') }
let_it_be(:private_subgroup_label_1) { create(:group_label, group: private_subgroup_1, title: 'Private Sub Group Label 1') }
let_it_be(:unused_label) { create(:label, project: project_3, title: 'Label 3') }
let_it_be(:unused_group_label) { create(:group_label, group: group_3, title: 'Group Label 4') }
let_it_be(:user) { create(:user) }
before do
create(:label, project: project_3, title: 'Label 3')
create(:group_label, group: group_3, title: 'Group Label 4')
project_1.add_developer(user)
end
......@@ -54,11 +54,11 @@ describe LabelsFinder do
end
end
context 'filtering by group_id' do
shared_examples 'filtering by group' do
it 'returns labels available for any non-archived project within the group' do
group_1.add_developer(user)
::Projects::UpdateService.new(project_1, user, archived: true).execute
finder = described_class.new(user, group_id: group_1.id)
finder = described_class.new(user, **group_params(group_1))
expect(finder.execute).to eq [group_label_2, group_label_1, project_label_5]
end
......@@ -67,7 +67,7 @@ describe LabelsFinder do
it 'returns only group labels' do
group_1.add_developer(user)
finder = described_class.new(user, group_id: group_1.id, only_group_labels: true)
finder = described_class.new(user, only_group_labels: true, **group_params(group_1))
expect(finder.execute).to eq [group_label_2, group_label_1]
end
......@@ -84,7 +84,7 @@ describe LabelsFinder do
context 'when only group labels is false' do
it 'returns group labels' do
finder = described_class.new(user, group_id: empty_group.id)
finder = described_class.new(user, **group_params(empty_group))
expect(finder.execute).to eq [empty_group_label_1, empty_group_label_2]
end
......@@ -96,7 +96,7 @@ describe LabelsFinder do
private_group_1.add_developer(user)
private_subgroup_1.add_developer(user)
finder = described_class.new(user, group_id: private_subgroup_1.id, only_group_labels: true, include_ancestor_groups: true)
finder = described_class.new(user, **group_params(private_subgroup_1), only_group_labels: true, include_ancestor_groups: true)
expect(finder.execute).to eq [private_group_label_1, private_subgroup_label_1]
end
......@@ -104,7 +104,7 @@ describe LabelsFinder do
it 'ignores labels from groups which user can not read' do
private_subgroup_1.add_developer(user)
finder = described_class.new(user, group_id: private_subgroup_1.id, only_group_labels: true, include_ancestor_groups: true)
finder = described_class.new(user, **group_params(private_subgroup_1), only_group_labels: true, include_ancestor_groups: true)
expect(finder.execute).to eq [private_subgroup_label_1]
end
......@@ -115,7 +115,7 @@ describe LabelsFinder do
private_group_1.add_developer(user)
private_subgroup_1.add_developer(user)
finder = described_class.new(user, group_id: private_group_1.id, only_group_labels: true, include_descendant_groups: true)
finder = described_class.new(user, **group_params(private_group_1), only_group_labels: true, include_descendant_groups: true)
expect(finder.execute).to eq [private_group_label_1, private_subgroup_label_1]
end
......@@ -123,14 +123,14 @@ describe LabelsFinder do
it 'ignores labels from groups which user can not read' do
private_subgroup_1.add_developer(user)
finder = described_class.new(user, group_id: private_group_1.id, only_group_labels: true, include_descendant_groups: true)
finder = described_class.new(user, **group_params(private_group_1), only_group_labels: true, include_descendant_groups: true)
expect(finder.execute).to eq [private_subgroup_label_1]
end
end
context 'when including labels from group projects with limited visibility' do
let(:finder) { described_class.new(user, group_id: group_4.id) }
let(:finder) { described_class.new(user, **group_params(group_4)) }
let(:group_4) { create(:group) }
let(:limited_visibility_project) { create(:project, :public, group: group_4) }
let(:visible_project) { create(:project, :public, group: group_4) }
......@@ -213,6 +213,24 @@ describe LabelsFinder do
end
end
it_behaves_like 'filtering by group' do
def group_params(group)
{ group: group }
end
end
it_behaves_like 'filtering by group' do
def group_params(group)
{ group_id: group.id }
end
end
it_behaves_like 'filtering by group' do
def group_params(group)
{ group: '', group_id: group.id }
end
end
context 'filtering by project_id' do
context 'when include_ancestor_groups is true' do
let!(:sub_project) { create(:project, namespace: private_subgroup_1 ) }
......
......@@ -29,4 +29,6 @@ describe GitlabSchema.types['Group'] do
is_expected.to have_graphql_type(Types::BoardType.connection_type)
end
end
it_behaves_like 'a GraphQL type with labels'
end
......@@ -132,4 +132,6 @@ describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) }
end
it_behaves_like 'a GraphQL type with labels'
end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting group label information' do
include GraphqlHelpers
let_it_be(:group) { create(:group, :public) }
let_it_be(:label_factory) { :group_label }
let_it_be(:label_attrs) { { group: group } }
it_behaves_like 'querying a GraphQL type with labels' do
let(:path_prefix) { ['group'] }
def make_query(fields)
graphql_query_for('group', { full_path: group.full_path }, fields)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting project label information' do
include GraphqlHelpers
let_it_be(:project) { create(:project, :public) }
let_it_be(:label_factory) { :label }
let_it_be(:label_attrs) { { project: project } }
it_behaves_like 'querying a GraphQL type with labels' do
let(:path_prefix) { ['project'] }
def make_query(fields)
graphql_query_for('project', { full_path: project.full_path }, fields)
end
end
end
......@@ -17,7 +17,7 @@ describe API::Labels do
let(:user) { create(:user) }
let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
let!(:label1) { create(:label, title: 'label1', project: project) }
let!(:label1) { create(:label, description: 'the best label', title: 'label1', project: project) }
let!(:priority_label) { create(:label, title: 'bug', project: project, priority: 3) }
route_types = [:deprecated, :rest]
......@@ -219,7 +219,7 @@ describe API::Labels do
'closed_issues_count' => 1,
'open_merge_requests_count' => 0,
'name' => label1.name,
'description' => nil,
'description' => 'the best label',
'color' => a_string_matching(/^#\h{6}$/),
'text_color' => a_string_matching(/^#\h{6}$/),
'priority' => nil,
......
......@@ -82,19 +82,30 @@ RSpec::Matchers.define :have_graphql_mutation do |mutation_class|
end
end
# note: connection arguments do not have to be named, they will be inferred.
RSpec::Matchers.define :have_graphql_arguments do |*expected|
include GraphqlHelpers
def expected_names
def expected_names(field)
@names ||= Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) }
if field.type.try(:ancestors)&.include?(GraphQL::Types::Relay::BaseConnection)
@names | %w(after before first last)
else
@names
end
end
match do |field|
expect(field.arguments.keys).to contain_exactly(*expected_names)
names = expected_names(field)
expect(field.arguments.keys).to contain_exactly(*names)
end
failure_message do |field|
"expected that #{field.name} would have the following fields: #{expected_names.inspect}, but it has #{field.arguments.keys.inspect}."
names = expected_names(field)
"expected that #{field.name} would have the following fields: #{names.inspect}, but it has #{field.arguments.keys.inspect}."
end
end
......
# frozen_string_literal: true
RSpec.shared_examples 'a GraphQL type with labels' do
it 'has label fields' do
expected_fields = %w[label labels]
expect(described_class).to include_graphql_fields(*expected_fields)
end
describe 'label field' do
subject { described_class.fields['label'] }
it { is_expected.to have_graphql_type(Types::LabelType) }
it { is_expected.to have_graphql_arguments(:title) }
end
describe 'labels field' do
subject { described_class.fields['labels'] }
it { is_expected.to have_graphql_type(Types::LabelType.connection_type) }
it { is_expected.to have_graphql_arguments(:search_term) }
end
end
RSpec.shared_examples 'querying a GraphQL type with labels' do
let_it_be(:current_user) { create(:user) }
let_it_be(:label_a) { create(label_factory, :described, **label_attrs) }
let_it_be(:label_b) { create(label_factory, :described, **label_attrs) }
let_it_be(:label_c) { create(label_factory, :described, :scoped, prefix: 'matching', **label_attrs) }
let_it_be(:label_d) { create(label_factory, :described, :scoped, prefix: 'matching', **label_attrs) }
let(:label_title) { label_b.title }
let(:label_params) { { title: label_title } }
let(:labels_params) { nil }
let(:label_response) { graphql_data.dig(*path_prefix, 'label') }
let(:labels_response) { graphql_data.dig(*path_prefix, 'labels', 'nodes') }
let(:query) do
make_query(
[
query_graphql_field(:label, label_params, all_graphql_fields_for(Label)),
query_graphql_field(:labels, labels_params, [
query_graphql_field(:nodes, nil, all_graphql_fields_for(Label))
])
]
)
end
context 'running a query' do
before do
run_query(query)
end
context 'minimum required arguments' do
it 'returns the label information' do
expect(label_response).to include(
'title' => label_title,
'description' => label_b.description
)
end
it 'returns the labels information' do
expect(labels_response.pluck('title')).to contain_exactly(
label_a.title,
label_b.title,
label_c.title,
label_d.title
)
end
end
context 'with a search param' do
let(:labels_params) { { search_term: 'matching' } }
it 'finds the matching labels' do
expect(labels_response.pluck('title')).to contain_exactly(
label_c.title,
label_d.title
)
end
end
context 'the label does not exist' do
let(:label_title) { 'not-a-label' }
it 'returns nil' do
expect(label_response).to be_nil
end
end
end
describe 'performance' do
def query_for(*labels)
selections = labels.map do |label|
%Q[#{label.title.gsub(/:+/, '_')}: label(title: "#{label.title}") { description }]
end
make_query(selections)
end
before do
run_query(query_for(label_a))
end
it 'batches queries for labels by title' do
pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/217767')
multi_selection = query_for(label_b, label_c)
single_selection = query_for(label_d)
expect { run_query(multi_selection) }
.to issue_same_number_of_queries_as { run_query(single_selection) }
end
end
# Run a known good query with the current user
def run_query(query)
post_graphql(query, current_user: current_user)
expect(graphql_errors).not_to be_present
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