Commit bde9e7c4 authored by Alex Kalderimis's avatar Alex Kalderimis Committed by Alex Kalderimis

Add Group.mergeRequests field

This allows one to request the merge requests for a particular group,
including all of its projects and subgroups.

This functionality is not available in any other way at present.

To achieve this, a new resolver is added: GroupMergeRequestResolver,
which is tested with request tests.

The following minor additions are made:

- The merge requests factory now is able to build merge requests with
  unique authors (this is helpful to distinguish querying by project and
  by author, since otherwise they are the same).

- Abstract out include_subgroups, author and assignee filters
  this moves some filter definitions to specialised DSL
  methods, so they can be used where it makes sense without repeating the
  field definitions.
parent bc55f75f
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
module Resolvers module Resolvers
class AssignedMergeRequestsResolver < UserMergeRequestsResolver class AssignedMergeRequestsResolver < UserMergeRequestsResolver
accept_author
def user_role def user_role
:assignee :assignee
end end
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
module Resolvers module Resolvers
class AuthoredMergeRequestsResolver < UserMergeRequestsResolver class AuthoredMergeRequestsResolver < UserMergeRequestsResolver
accept_assignee
def user_role def user_role
:author :author
end end
......
# frozen_string_literal: true
module GroupIssuableResolver
extend ActiveSupport::Concern
class_methods do
def include_subgroups(name_of_things)
argument :include_subgroups, GraphQL::BOOLEAN_TYPE,
required: false,
default_value: false,
description: "Include #{name_of_things} belonging to subgroups"
end
end
end
...@@ -12,7 +12,7 @@ module ResolvesMergeRequests ...@@ -12,7 +12,7 @@ module ResolvesMergeRequests
def resolve_with_lookahead(**args) def resolve_with_lookahead(**args)
mr_finder = MergeRequestsFinder.new(current_user, args.compact) mr_finder = MergeRequestsFinder.new(current_user, args.compact)
finder = Gitlab::Graphql::Loaders::IssuableLoader.new(project, mr_finder) finder = Gitlab::Graphql::Loaders::IssuableLoader.new(mr_parent, mr_finder)
select_result(finder.batching_find_all { |query| apply_lookahead(query) }) select_result(finder.batching_find_all { |query| apply_lookahead(query) })
end end
...@@ -29,6 +29,10 @@ module ResolvesMergeRequests ...@@ -29,6 +29,10 @@ module ResolvesMergeRequests
private private
def mr_parent
project
end
def unconditional_includes def unconditional_includes
[:target_project] [:target_project]
end end
......
...@@ -2,9 +2,8 @@ ...@@ -2,9 +2,8 @@
module Resolvers module Resolvers
class GroupIssuesResolver < IssuesResolver class GroupIssuesResolver < IssuesResolver
argument :include_subgroups, GraphQL::BOOLEAN_TYPE, include GroupIssuableResolver
required: false,
default_value: false, include_subgroups 'issues'
description: 'Include issues belonging to subgroups.'
end end
end end
# frozen_string_literal: true
module Resolvers
class GroupMergeRequestsResolver < MergeRequestsResolver
include GroupIssuableResolver
alias_method :group, :synchronized_object
include_subgroups 'merge requests'
accept_assignee
accept_author
def project
nil
end
def mr_parent
group
end
def no_results_possible?(args)
group.nil? || some_argument_is_empty?(args)
end
end
end
...@@ -6,6 +6,18 @@ module Resolvers ...@@ -6,6 +6,18 @@ module Resolvers
alias_method :project, :synchronized_object alias_method :project, :synchronized_object
def self.accept_assignee
argument :assignee_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of the assignee'
end
def self.accept_author
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of the author'
end
argument :iids, [GraphQL::STRING_TYPE], argument :iids, [GraphQL::STRING_TYPE],
required: false, required: false,
description: 'Array of IIDs of merge requests, for example `[1, 2]`' description: 'Array of IIDs of merge requests, for example `[1, 2]`'
......
...@@ -2,11 +2,7 @@ ...@@ -2,11 +2,7 @@
module Resolvers module Resolvers
class ProjectMergeRequestsResolver < MergeRequestsResolver class ProjectMergeRequestsResolver < MergeRequestsResolver
argument :assignee_username, GraphQL::STRING_TYPE, accept_assignee
required: false, accept_author
description: 'Username of the assignee'
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of the author'
end end
end end
...@@ -46,9 +46,15 @@ module Types ...@@ -46,9 +46,15 @@ module Types
field :issues, field :issues,
Types::IssueType.connection_type, Types::IssueType.connection_type,
null: true, null: true,
description: 'Issues of the group', description: 'Issues for projects in this group',
resolver: Resolvers::GroupIssuesResolver resolver: Resolvers::GroupIssuesResolver
field :merge_requests,
Types::MergeRequestType.connection_type,
null: true,
description: 'Merge requests for projects in this group',
resolver: Resolvers::GroupMergeRequestsResolver
field :milestones, Types::MilestoneType.connection_type, null: true, field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Milestones of the group', description: 'Milestones of the group',
resolver: Resolvers::GroupMilestonesResolver resolver: Resolvers::GroupMilestonesResolver
......
---
title: Enable querying for merge requests within a group
merge_request: 43863
author:
type: added
...@@ -7349,7 +7349,7 @@ type Group { ...@@ -7349,7 +7349,7 @@ type Group {
isTemporaryStorageIncreaseEnabled: Boolean! isTemporaryStorageIncreaseEnabled: Boolean!
""" """
Issues of the group Issues for projects in this group
""" """
issues( issues(
""" """
...@@ -7418,7 +7418,7 @@ type Group { ...@@ -7418,7 +7418,7 @@ type Group {
iids: [String!] iids: [String!]
""" """
Include issues belonging to subgroups. Include issues belonging to subgroups
""" """
includeSubgroups: Boolean = false includeSubgroups: Boolean = false
...@@ -7585,6 +7585,91 @@ type Group { ...@@ -7585,6 +7585,91 @@ type Group {
""" """
mentionsDisabled: Boolean mentionsDisabled: Boolean
"""
Merge requests for projects in this group
"""
mergeRequests(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Username of the assignee
"""
assigneeUsername: String
"""
Username of the author
"""
authorUsername: 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
"""
Array of IIDs of merge requests, for example `[1, 2]`
"""
iids: [String!]
"""
Include merge requests belonging to subgroups
"""
includeSubgroups: Boolean = false
"""
Array of label names. All resolved merge requests will have all of these labels.
"""
labels: [String!]
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Merge requests merged after this date
"""
mergedAfter: Time
"""
Merge requests merged before this date
"""
mergedBefore: Time
"""
Title of the milestone
"""
milestoneTitle: String
"""
Sort merge requests by this criteria
"""
sort: MergeRequestSort = created_desc
"""
Array of source branch names. All resolved merge requests will have one of these branches as their source.
"""
sourceBranches: [String!]
"""
A merge request state. If provided, all resolved merge requests will have this state.
"""
state: MergeRequestState
"""
Array of target branch names. All resolved merge requests will have one of these branches as their target.
"""
targetBranches: [String!]
): MergeRequestConnection
""" """
Milestones of the group Milestones of the group
""" """
...@@ -19124,6 +19209,11 @@ type User { ...@@ -19124,6 +19209,11 @@ type User {
""" """
after: String after: String
"""
Username of the author
"""
authorUsername: String
""" """
Returns the elements in the list that come before the specified cursor. Returns the elements in the list that come before the specified cursor.
""" """
...@@ -19204,6 +19294,11 @@ type User { ...@@ -19204,6 +19294,11 @@ type User {
""" """
after: String after: String
"""
Username of the assignee
"""
assigneeUsername: String
""" """
Returns the elements in the list that come before the specified cursor. Returns the elements in the list that come before the specified cursor.
""" """
......
...@@ -20302,7 +20302,7 @@ ...@@ -20302,7 +20302,7 @@
}, },
{ {
"name": "issues", "name": "issues",
"description": "Issues of the group", "description": "Issues for projects in this group",
"args": [ "args": [
{ {
"name": "iid", "name": "iid",
...@@ -20532,7 +20532,7 @@ ...@@ -20532,7 +20532,7 @@
}, },
{ {
"name": "includeSubgroups", "name": "includeSubgroups",
"description": "Include issues belonging to subgroups.", "description": "Include issues belonging to subgroups",
"type": { "type": {
"kind": "SCALAR", "kind": "SCALAR",
"name": "Boolean", "name": "Boolean",
...@@ -20830,6 +20830,211 @@ ...@@ -20830,6 +20830,211 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "mergeRequests",
"description": "Merge requests for projects in this group",
"args": [
{
"name": "iids",
"description": "Array of IIDs of merge requests, for example `[1, 2]`",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "sourceBranches",
"description": "Array of source branch names. All resolved merge requests will have one of these branches as their source.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "targetBranches",
"description": "Array of target branch names. All resolved merge requests will have one of these branches as their target.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "state",
"description": "A merge request state. If provided, all resolved merge requests will have this state.",
"type": {
"kind": "ENUM",
"name": "MergeRequestState",
"ofType": null
},
"defaultValue": null
},
{
"name": "labels",
"description": "Array of label names. All resolved merge requests will have all of these labels.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "mergedAfter",
"description": "Merge requests merged after this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "mergedBefore",
"description": "Merge requests merged before this date",
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"defaultValue": null
},
{
"name": "milestoneTitle",
"description": "Title of the milestone",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "sort",
"description": "Sort merge requests by this criteria",
"type": {
"kind": "ENUM",
"name": "MergeRequestSort",
"ofType": null
},
"defaultValue": "created_desc"
},
{
"name": "includeSubgroups",
"description": "Include merge requests belonging to subgroups",
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": "false"
},
{
"name": "assigneeUsername",
"description": "Username of the assignee",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "authorUsername",
"description": "Username of the author",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"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
}
],
"type": {
"kind": "OBJECT",
"name": "MergeRequestConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "milestones", "name": "milestones",
"description": "Milestones of the group", "description": "Milestones of the group",
...@@ -55847,6 +56052,16 @@ ...@@ -55847,6 +56052,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "authorUsername",
"description": "Username of the author",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "after", "name": "after",
"description": "Returns the elements in the list that come after the specified cursor.", "description": "Returns the elements in the list that come after the specified cursor.",
...@@ -56042,6 +56257,16 @@ ...@@ -56042,6 +56257,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "assigneeUsername",
"description": "Username of the assignee",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "after", "name": "after",
"description": "Returns the elements in the list that come after the specified cursor.", "description": "Returns the elements in the list that come after the specified cursor.",
...@@ -164,6 +164,10 @@ FactoryBot.define do ...@@ -164,6 +164,10 @@ FactoryBot.define do
target_branch { generate(:branch) } target_branch { generate(:branch) }
end end
trait :unique_author do
author { association(:user) }
end
trait :with_coverage_reports do trait :with_coverage_reports do
after(:build) do |merge_request| after(:build) do |merge_request|
merge_request.head_pipeline = build( merge_request.head_pipeline = build(
......
...@@ -17,6 +17,7 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -17,6 +17,7 @@ RSpec.describe GitlabSchema.types['Group'] do
subgroup_creation_level require_two_factor_authentication subgroup_creation_level require_two_factor_authentication
two_factor_grace_period auto_devops_enabled emails_disabled two_factor_grace_period auto_devops_enabled emails_disabled
mentions_disabled parent boards milestones group_members mentions_disabled parent boards milestones group_members
merge_requests
] ]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
......
# frozen_string_literal: true
require 'spec_helper'
# Based on ee/spec/requests/api/epics_spec.rb
# Should follow closely in order to ensure all situations are covered
RSpec.describe 'Query.group.mergeRequests' do
include GraphqlHelpers
let_it_be(:group) { create(:group) }
let_it_be(:sub_group) { create(:group, parent: group) }
let_it_be(:project_a) { create(:project, :repository, group: group) }
let_it_be(:project_b) { create(:project, :repository, group: group) }
let_it_be(:project_c) { create(:project, :repository, group: sub_group) }
let_it_be(:project_x) { create(:project, :repository) }
let_it_be(:user) { create(:user, developer_projects: [project_x]) }
let_it_be(:mr_attrs) do
{ target_branch: 'master' }
end
let_it_be(:mr_traits) do
[:unique_branches, :unique_author]
end
let_it_be(:mrs_a, reload: true) { create_list(:merge_request, 2, *mr_traits, **mr_attrs, source_project: project_a) }
let_it_be(:mrs_b, reload: true) { create_list(:merge_request, 2, *mr_traits, **mr_attrs, source_project: project_b) }
let_it_be(:mrs_c, reload: true) { create_list(:merge_request, 2, *mr_traits, **mr_attrs, source_project: project_c) }
let_it_be(:other_mr) { create(:merge_request, source_project: project_x) }
let(:mrs_data) { graphql_data_at(:group, :merge_requests, :nodes) }
before do
group.add_developer(user)
end
def expected_mrs(mrs)
mrs.map { |mr| a_hash_including('id' => global_id_of(mr)) }
end
describe 'not passing any arguments' do
let(:query) do
<<~GQL
query($path: ID!) {
group(fullPath: $path) {
mergeRequests { nodes { id } }
}
}
GQL
end
it 'can find all merge requests in the group, excluding sub-groups' do
post_graphql(query, current_user: user, variables: { path: group.full_path })
expect(mrs_data).to match_array(expected_mrs(mrs_a + mrs_b))
end
end
describe 'restricting by author' do
let(:query) do
<<~GQL
query($path: ID!, $user: String) {
group(fullPath: $path) {
mergeRequests(authorUsername: $user) { nodes { id author { username } } }
}
}
GQL
end
let(:author) { mrs_b.first.author }
it 'can find all merge requests with user as author' do
post_graphql(query, current_user: user, variables: { user: author.username, path: group.full_path })
expect(mrs_data).to match_array(expected_mrs([mrs_b.first]))
end
end
describe 'restricting by assignee' do
let(:query) do
<<~GQL
query($path: ID!, $user: String) {
group(fullPath: $path) {
mergeRequests(assigneeUsername: $user) { nodes { id } }
}
}
GQL
end
let_it_be(:assignee) { create(:user) }
before_all do
mrs_b.second.assignees << assignee
mrs_a.first.assignees << assignee
end
it 'can find all merge requests assigned to user' do
post_graphql(query, current_user: user, variables: { user: assignee.username, path: group.full_path })
expect(mrs_data).to match_array(expected_mrs([mrs_a.first, mrs_b.second]))
end
end
describe 'passing include_subgroups: true' do
let(:query) do
<<~GQL
query($path: ID!) {
group(fullPath: $path) {
mergeRequests(includeSubgroups: true) { nodes { id } }
}
}
GQL
end
it 'can find all merge requests in the group, including sub-groups' do
post_graphql(query, current_user: user, variables: { path: group.full_path })
expect(mrs_data).to match_array(expected_mrs(mrs_a + mrs_b + mrs_c))
end
end
end
...@@ -29,15 +29,15 @@ RSpec.describe 'getting user information' do ...@@ -29,15 +29,15 @@ RSpec.describe 'getting user information' do
let_it_be(:unauthorized_user) { create(:user) } let_it_be(:unauthorized_user) { create(:user) }
let_it_be(:assigned_mr) do let_it_be(:assigned_mr) do
create(:merge_request, :unique_branches, create(:merge_request, :unique_branches, :unique_author,
source_project: project_a, assignees: [user]) source_project: project_a, assignees: [user])
end end
let_it_be(:assigned_mr_b) do let_it_be(:assigned_mr_b) do
create(:merge_request, :unique_branches, create(:merge_request, :unique_branches, :unique_author,
source_project: project_b, assignees: [user]) source_project: project_b, assignees: [user])
end end
let_it_be(:assigned_mr_c) do let_it_be(:assigned_mr_c) do
create(:merge_request, :unique_branches, create(:merge_request, :unique_branches, :unique_author,
source_project: project_b, assignees: [user]) source_project: project_b, assignees: [user])
end end
let_it_be(:authored_mr) do let_it_be(:authored_mr) do
...@@ -133,6 +133,17 @@ RSpec.describe 'getting user information' do ...@@ -133,6 +133,17 @@ RSpec.describe 'getting user information' do
) )
end end
end end
context 'filtering by author' do
let(:author) { assigned_mr_b.author }
let(:mr_args) { { author_username: author.username } }
it 'finds the authored mrs' do
expect(assigned_mrs).to contain_exactly(
a_hash_including('id' => global_id_of(assigned_mr_b))
)
end
end
end end
context 'the current user does not have access' do context 'the current user does not have access' do
...@@ -172,6 +183,23 @@ RSpec.describe 'getting user information' do ...@@ -172,6 +183,23 @@ RSpec.describe 'getting user information' do
end end
end end
context 'filtering by assignee' do
let(:assignee) { create(:user) }
let(:mr_args) { { assignee_username: assignee.username } }
it 'finds the assigned mrs' do
authored_mr.assignees << assignee
authored_mr_c.assignees << assignee
post_graphql(query, current_user: current_user)
expect(authored_mrs).to contain_exactly(
a_hash_including('id' => global_id_of(authored_mr)),
a_hash_including('id' => global_id_of(authored_mr_c))
)
end
end
context 'filtering by project path and IID' do context 'filtering by project path and IID' do
let(:mr_args) do let(:mr_args) do
{ project_path: project_b.full_path, iids: [authored_mr_b.iid.to_s] } { project_path: project_b.full_path, iids: [authored_mr_b.iid.to_s] }
...@@ -253,8 +281,10 @@ RSpec.describe 'getting user information' do ...@@ -253,8 +281,10 @@ RSpec.describe 'getting user information' do
let(:current_user) { user } let(:current_user) { user }
it 'can be found' do it 'can be found' do
expect(assigned_mrs).to include( expect(assigned_mrs).to contain_exactly(
a_hash_including('id' => global_id_of(assigned_mr)) a_hash_including('id' => global_id_of(assigned_mr)),
a_hash_including('id' => global_id_of(assigned_mr_b)),
a_hash_including('id' => global_id_of(assigned_mr_c))
) )
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