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 @@
module Resolvers
class AssignedMergeRequestsResolver < UserMergeRequestsResolver
accept_author
def user_role
:assignee
end
......
......@@ -2,6 +2,8 @@
module Resolvers
class AuthoredMergeRequestsResolver < UserMergeRequestsResolver
accept_assignee
def user_role
:author
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
def resolve_with_lookahead(**args)
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) })
end
......@@ -29,6 +29,10 @@ module ResolvesMergeRequests
private
def mr_parent
project
end
def unconditional_includes
[:target_project]
end
......
......@@ -2,9 +2,8 @@
module Resolvers
class GroupIssuesResolver < IssuesResolver
argument :include_subgroups, GraphQL::BOOLEAN_TYPE,
required: false,
default_value: false,
description: 'Include issues belonging to subgroups.'
include GroupIssuableResolver
include_subgroups 'issues'
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
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],
required: false,
description: 'Array of IIDs of merge requests, for example `[1, 2]`'
......
......@@ -2,11 +2,7 @@
module Resolvers
class ProjectMergeRequestsResolver < MergeRequestsResolver
argument :assignee_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of the assignee'
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of the author'
accept_assignee
accept_author
end
end
......@@ -46,9 +46,15 @@ module Types
field :issues,
Types::IssueType.connection_type,
null: true,
description: 'Issues of the group',
description: 'Issues for projects in this group',
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,
description: 'Milestones of the group',
resolver: Resolvers::GroupMilestonesResolver
......
---
title: Enable querying for merge requests within a group
merge_request: 43863
author:
type: added
......@@ -7349,7 +7349,7 @@ type Group {
isTemporaryStorageIncreaseEnabled: Boolean!
"""
Issues of the group
Issues for projects in this group
"""
issues(
"""
......@@ -7418,7 +7418,7 @@ type Group {
iids: [String!]
"""
Include issues belonging to subgroups.
Include issues belonging to subgroups
"""
includeSubgroups: Boolean = false
......@@ -7585,6 +7585,91 @@ type Group {
"""
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
"""
......@@ -19124,6 +19209,11 @@ type User {
"""
after: String
"""
Username of the author
"""
authorUsername: String
"""
Returns the elements in the list that come before the specified cursor.
"""
......@@ -19204,6 +19294,11 @@ type User {
"""
after: String
"""
Username of the assignee
"""
assigneeUsername: String
"""
Returns the elements in the list that come before the specified cursor.
"""
......
......@@ -20302,7 +20302,7 @@
},
{
"name": "issues",
"description": "Issues of the group",
"description": "Issues for projects in this group",
"args": [
{
"name": "iid",
......@@ -20532,7 +20532,7 @@
},
{
"name": "includeSubgroups",
"description": "Include issues belonging to subgroups.",
"description": "Include issues belonging to subgroups",
"type": {
"kind": "SCALAR",
"name": "Boolean",
......@@ -20830,6 +20830,211 @@
"isDeprecated": false,
"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",
"description": "Milestones of the group",
......@@ -55847,6 +56052,16 @@
},
"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.",
......@@ -56042,6 +56257,16 @@
},
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of the assignee",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
......@@ -164,6 +164,10 @@ FactoryBot.define do
target_branch { generate(:branch) }
end
trait :unique_author do
author { association(:user) }
end
trait :with_coverage_reports do
after(:build) do |merge_request|
merge_request.head_pipeline = build(
......
......@@ -17,6 +17,7 @@ RSpec.describe GitlabSchema.types['Group'] do
subgroup_creation_level require_two_factor_authentication
two_factor_grace_period auto_devops_enabled emails_disabled
mentions_disabled parent boards milestones group_members
merge_requests
]
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
let_it_be(:unauthorized_user) { create(:user) }
let_it_be(:assigned_mr) do
create(:merge_request, :unique_branches,
create(:merge_request, :unique_branches, :unique_author,
source_project: project_a, assignees: [user])
end
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])
end
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])
end
let_it_be(:authored_mr) do
......@@ -133,6 +133,17 @@ RSpec.describe 'getting user information' do
)
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
context 'the current user does not have access' do
......@@ -172,6 +183,23 @@ RSpec.describe 'getting user information' do
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
let(:mr_args) do
{ project_path: project_b.full_path, iids: [authored_mr_b.iid.to_s] }
......@@ -253,8 +281,10 @@ RSpec.describe 'getting user information' do
let(:current_user) { user }
it 'can be found' do
expect(assigned_mrs).to include(
a_hash_including('id' => global_id_of(assigned_mr))
expect(assigned_mrs).to contain_exactly(
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
......
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