Commit d03f50ca authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch 'ajk-GQL-user-mrs' into 'master'

[GraphQL] Add query support for user's merge requests

See merge request gitlab-org/gitlab!31227
parents 74370c3d 5e39832d
# frozen_string_literal: true
module Resolvers
class AssignedMergeRequestsResolver < UserMergeRequestsResolver
def user_role
:assignee
end
end
end
# frozen_string_literal: true
module Resolvers
class AuthoredMergeRequestsResolver < UserMergeRequestsResolver
def user_role
:author
end
end
end
......@@ -13,10 +13,10 @@ module ResolvesMergeRequests
args[:iids] = Array.wrap(args[:iids]) if args[:iids]
args.compact!
if args.keys == [:iids]
if project && args.keys == [:iids]
batch_load_merge_requests(args[:iids])
else
args[:project_id] = project.id
args[:project_id] ||= project
MergeRequestsFinder.new(current_user, args).execute
end.then(&(single? ? :first : :itself))
......
......@@ -34,7 +34,11 @@ module Resolvers
end
def no_results_possible?(args)
project.nil? || args.values.any? { |v| v.is_a?(Array) && v.empty? }
project.nil? || some_argument_is_empty?(args)
end
def some_argument_is_empty?(args)
args.values.any? { |v| v.is_a?(Array) && v.empty? }
end
end
end
# frozen_string_literal: true
module Resolvers
class UserMergeRequestsResolver < MergeRequestsResolver
include ResolvesProject
argument :project_path, GraphQL::STRING_TYPE,
required: false,
description: 'The full-path of the project the authored merge requests should be in. Incompatible with projectId.'
argument :project_id, GraphQL::ID_TYPE,
required: false,
description: 'The global ID of the project the authored merge requests should be in. Incompatible with projectPath.'
attr_reader :project
alias_method :user, :synchronized_object
def ready?(project_id: nil, project_path: nil, **args)
return early_return unless can_read_profile?
if project_id || project_path
load_project(project_path, project_id)
return early_return unless can_read_project?
elsif args[:iids].present?
raise ::Gitlab::Graphql::Errors::ArgumentError,
'iids requires projectPath or projectId'
end
super(**args)
end
def resolve(**args)
prepare_args(args)
key = :"#{user_role}_id"
super(key => user.id, **args)
end
def user_role
raise NotImplementedError
end
private
def can_read_profile?
Ability.allowed?(current_user, :read_user_profile, user)
end
def can_read_project?
Ability.allowed?(current_user, :read_merge_request, project)
end
def load_project(project_path, project_id)
@project = resolve_project(full_path: project_path, project_id: project_id)
@project = @project.sync if @project.respond_to?(:sync)
end
def no_results_possible?(args)
some_argument_is_empty?(args)
end
# These arguments are handled in load_project, and should not be passed to
# the finder directly.
def prepare_args(args)
args.delete(:project_id)
args.delete(:project_path)
end
end
end
......@@ -4,6 +4,8 @@ module Resolvers
class UserResolver < BaseResolver
description 'Retrieve a single user'
type Types::UserType, null: true
argument :id, GraphQL::ID_TYPE,
required: false,
description: 'ID of the User'
......@@ -12,13 +14,6 @@ module Resolvers
required: false,
description: 'Username of the User'
def resolve(id: nil, username: nil)
id_or_username = GitlabSchema.parse_gid(id, expected_type: ::User).model_id if id
id_or_username ||= username
::UserFinder.new(id_or_username).find_by_id_or_username
end
def ready?(id: nil, username: nil)
unless id.present? ^ username.present?
raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a single username or id'
......@@ -26,5 +21,23 @@ module Resolvers
super
end
def resolve(id: nil, username: nil)
if id
GitlabSchema.object_from_id(id, expected_type: User)
else
batch_load(username)
end
end
private
def batch_load(username)
BatchLoader::GraphQL.for(username).batch do |usernames, loader|
User.by_username(usernames).each do |user|
loader.call(user.username, user)
end
end
end
end
end
......@@ -56,6 +56,10 @@ module Types
description: 'Text to echo back',
resolver: Resolvers::EchoResolver
field :user, Types::UserType, null: true,
description: 'Find a user on this instance',
resolver: Resolvers::UserResolver
def design_management
DesignManagementObject.new(nil)
end
......
# frozen_string_literal: true
module Types
class UserStateEnum < BaseEnum
graphql_name 'UserState'
description 'Possible states of a user'
value 'active', 'The user is active and is able to use the system', value: 'active'
value 'blocked', 'The user has been blocked and is prevented from using the system', value: 'blocked'
value 'deactivated', 'The user is no longer active and is unable to use the system', value: 'deactivated'
end
end
......@@ -12,12 +12,12 @@ module Types
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the user'
field :name, GraphQL::STRING_TYPE, null: false,
description: 'Human-readable name of the user'
field :state, GraphQL::STRING_TYPE, null: false,
description: 'State of the issue'
field :username, GraphQL::STRING_TYPE, null: false,
description: 'Username of the user. Unique within this instance of GitLab'
field :name, GraphQL::STRING_TYPE, null: false,
description: 'Human-readable name of the user'
field :state, Types::UserStateEnum, null: false,
description: 'State of the user'
field :avatar_url, GraphQL::STRING_TYPE, null: true,
description: "URL of the user's avatar"
field :web_url, GraphQL::STRING_TYPE, null: false,
......@@ -26,6 +26,14 @@ module Types
resolver: Resolvers::TodoResolver,
description: 'Todos of the user'
# Merge request field: MRs can be either authored or assigned:
field :authored_merge_requests, Types::MergeRequestType.connection_type, null: true,
resolver: Resolvers::AuthoredMergeRequestsResolver,
description: 'Merge Requests authored by the user'
field :assigned_merge_requests, Types::MergeRequestType.connection_type, null: true,
resolver: Resolvers::AssignedMergeRequestsResolver,
description: 'Merge Requests assigned to the user'
field :snippets,
Types::SnippetType.connection_type,
null: true,
......
---
title: Add GraphQL support for authored and assigned Merge Requests
merge_request: 31227
author:
type: added
......@@ -9348,7 +9348,7 @@ type Query {
): SnippetConnection
"""
Find a user
Find a user on this instance
"""
user(
"""
......@@ -12053,6 +12053,126 @@ type UpdateSnippetPayload {
scalar Upload
type User {
"""
Merge Requests assigned to the user
"""
assignedMergeRequests(
"""
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
"""
Array of IIDs of merge requests, for example `[1, 2]`
"""
iids: [String!]
"""
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
"""
The global ID of the project the authored merge requests should be in. Incompatible with projectPath.
"""
projectId: ID
"""
The full-path of the project the authored merge requests should be in. Incompatible with projectId.
"""
projectPath: String
"""
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
"""
Merge Requests authored by the user
"""
authoredMergeRequests(
"""
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
"""
Array of IIDs of merge requests, for example `[1, 2]`
"""
iids: [String!]
"""
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
"""
The global ID of the project the authored merge requests should be in. Incompatible with projectPath.
"""
projectId: ID
"""
The full-path of the project the authored merge requests should be in. Incompatible with projectId.
"""
projectPath: String
"""
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
"""
URL of the user's avatar
"""
......@@ -12109,9 +12229,9 @@ type User {
): SnippetConnection
"""
State of the issue
State of the user
"""
state: String!
state: UserState!
"""
Todos of the user
......@@ -12226,6 +12346,26 @@ type UserPermissions {
createSnippet: Boolean!
}
"""
Possible states of a user
"""
enum UserState {
"""
The user is active and is able to use the system
"""
active
"""
The user has been blocked and is prevented from using the system
"""
blocked
"""
The user is no longer active and is unable to use the system
"""
deactivated
}
enum VisibilityLevelsEnum {
internal
private
......
......@@ -27422,7 +27422,7 @@
},
{
"name": "user",
"description": "Find a user",
"description": "Find a user on this instance",
"args": [
{
"name": "id",
......@@ -35622,6 +35622,316 @@
"name": "User",
"description": null,
"fields": [
{
"name": "assignedMergeRequests",
"description": "Merge Requests assigned to the user",
"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": "projectPath",
"description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "projectId",
"description": "The global ID of the project the authored merge requests should be in. Incompatible with projectPath.",
"type": {
"kind": "SCALAR",
"name": "ID",
"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": "authoredMergeRequests",
"description": "Merge Requests authored by the user",
"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": "projectPath",
"description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "projectId",
"description": "The global ID of the project the authored merge requests should be in. Incompatible with projectPath.",
"type": {
"kind": "SCALAR",
"name": "ID",
"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": "avatarUrl",
"description": "URL of the user's avatar",
......@@ -35765,7 +36075,7 @@
},
{
"name": "state",
"description": "State of the issue",
"description": "State of the user",
"args": [
],
......@@ -35773,8 +36083,8 @@
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"kind": "ENUM",
"name": "UserState",
"ofType": null
}
},
......@@ -36151,6 +36461,35 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "UserState",
"description": "Possible states of a user",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "active",
"description": "The user is active and is able to use the system",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "blocked",
"description": "The user has been blocked and is prevented from using the system",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deactivated",
"description": "The user is no longer active and is unable to use the system",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "VisibilityLevelsEnum",
......@@ -1819,7 +1819,7 @@ Autogenerated return type of UpdateSnippet
| `avatarUrl` | String | URL of the user's avatar |
| `id` | ID! | ID of the user |
| `name` | String! | Human-readable name of the user |
| `state` | String! | State of the issue |
| `state` | UserState! | State of the user |
| `userPermissions` | UserPermissions! | Permissions for the current user on the resource |
| `username` | String! | Username of the user. Unique within this instance of GitLab |
| `webUrl` | String! | Web URL of the user |
......
......@@ -40,6 +40,6 @@ describe Resolvers::UserResolver do
private
def resolve_user(args = {})
resolve(described_class, args: args)
sync(resolve(described_class, args: args))
end
end
......@@ -10,6 +10,7 @@ describe GitlabSchema.types['User'] do
it 'has the expected fields' do
expected_fields = %w[
id user_permissions snippets name username avatarUrl webUrl todos state
authoredMergeRequests assignedMergeRequests
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
# frozen_string_literal: true
require 'spec_helper'
describe 'getting user information' do
include GraphqlHelpers
let(:query) do
graphql_query_for(:user, user_params, user_fields)
end
let(:user_fields) { all_graphql_fields_for('User', max_depth: 2) }
context 'no parameters are provided' do
let(:user_params) { nil }
it 'mentions the missing required parameters' do
post_graphql(query)
expect_graphql_errors_to_include(/username/)
end
end
context 'looking up a user by username' do
let_it_be(:project_a) { create(:project, :repository) }
let_it_be(:project_b) { create(:project, :repository) }
let_it_be(:user, reload: true) { create(:user, developer_projects: [project_a, project_b]) }
let_it_be(:authorised_user) { create(:user, developer_projects: [project_a, project_b]) }
let_it_be(:unauthorized_user) { create(:user) }
let_it_be(:assigned_mr) do
create(:merge_request, :unique_branches,
source_project: project_a, assignees: [user])
end
let_it_be(:assigned_mr_b) do
create(:merge_request, :unique_branches,
source_project: project_b, assignees: [user])
end
let_it_be(:assigned_mr_c) do
create(:merge_request, :unique_branches,
source_project: project_b, assignees: [user])
end
let_it_be(:authored_mr) do
create(:merge_request, :unique_branches,
source_project: project_a, author: user)
end
let_it_be(:authored_mr_b) do
create(:merge_request, :unique_branches,
source_project: project_b, author: user)
end
let_it_be(:authored_mr_c) do
create(:merge_request, :unique_branches,
source_project: project_b, author: user)
end
let(:current_user) { authorised_user }
let(:authored_mrs) { graphql_data_at(:user, :authored_merge_requests, :nodes) }
let(:assigned_mrs) { graphql_data_at(:user, :assigned_merge_requests, :nodes) }
let(:user_params) { { username: user.username } }
before do
post_graphql(query, current_user: current_user)
end
context 'the user is an active user' do
it_behaves_like 'a working graphql query'
it 'can access user profile fields' do
presenter = UserPresenter.new(user)
expect(graphql_data['user']).to match(
a_hash_including(
'id' => global_id_of(user),
'state' => presenter.state,
'name' => presenter.name,
'username' => presenter.username,
'webUrl' => presenter.web_url,
'avatarUrl' => presenter.avatar_url
))
end
describe 'assignedMergeRequests' do
let(:user_fields) do
query_graphql_field(:assigned_merge_requests, mr_args, 'nodes { id }')
end
let(:mr_args) { nil }
it_behaves_like 'a working graphql query'
it 'can be found' do
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
context 'applying filters' do
context 'filtering by IID without specifying a project' do
let(:mr_args) do
{ iids: [assigned_mr_b.iid.to_s] }
end
it 'return an argument error that mentions the missing fields' do
expect_graphql_errors_to_include(/projectPath/)
end
end
context 'filtering by project path and IID' do
let(:mr_args) do
{ project_path: project_b.full_path, iids: [assigned_mr_b.iid.to_s] }
end
it 'selects the correct MRs' do
expect(assigned_mrs).to contain_exactly(
a_hash_including('id' => global_id_of(assigned_mr_b))
)
end
end
context 'filtering by project path' do
let(:mr_args) do
{ project_path: project_b.full_path }
end
it 'selects the correct MRs' do
expect(assigned_mrs).to contain_exactly(
a_hash_including('id' => global_id_of(assigned_mr_b)),
a_hash_including('id' => global_id_of(assigned_mr_c))
)
end
end
end
context 'the current user does not have access' do
let(:current_user) { unauthorized_user }
it 'cannot be found' do
expect(assigned_mrs).to be_empty
end
end
end
describe 'authoredMergeRequests' do
let(:user_fields) do
query_graphql_field(:authored_merge_requests, mr_args, 'nodes { id }')
end
let(:mr_args) { nil }
it_behaves_like 'a working graphql query'
it 'can be found' do
expect(authored_mrs).to contain_exactly(
a_hash_including('id' => global_id_of(authored_mr)),
a_hash_including('id' => global_id_of(authored_mr_b)),
a_hash_including('id' => global_id_of(authored_mr_c))
)
end
context 'applying filters' do
context 'filtering by IID without specifying a project' do
let(:mr_args) do
{ iids: [authored_mr_b.iid.to_s] }
end
it 'return an argument error that mentions the missing fields' do
expect_graphql_errors_to_include(/projectPath/)
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] }
end
it 'selects the correct MRs' do
expect(authored_mrs).to contain_exactly(
a_hash_including('id' => global_id_of(authored_mr_b))
)
end
end
context 'filtering by project path' do
let(:mr_args) do
{ project_path: project_b.full_path }
end
it 'selects the correct MRs' do
expect(authored_mrs).to contain_exactly(
a_hash_including('id' => global_id_of(authored_mr_b)),
a_hash_including('id' => global_id_of(authored_mr_c))
)
end
end
end
context 'the current user does not have access' do
let(:current_user) { unauthorized_user }
it 'cannot be found' do
expect(authored_mrs).to be_empty
end
end
end
end
context 'the user is private' do
before do
user.update(private_profile: true)
post_graphql(query, current_user: current_user)
end
context 'we only request basic fields' do
let(:user_fields) { %i[id name username state web_url avatar_url] }
it_behaves_like 'a working graphql query'
end
context 'we request the authoredMergeRequests' do
let(:user_fields) { 'authoredMergeRequests { nodes { id } }' }
it_behaves_like 'a working graphql query'
it 'cannot be found' do
expect(authored_mrs).to be_empty
end
context 'the current user is the user' do
let(:current_user) { user }
it 'can be found' do
expect(authored_mrs).to include(
a_hash_including('id' => global_id_of(authored_mr))
)
end
end
end
context 'we request the assignedMergeRequests' do
let(:user_fields) { 'assignedMergeRequests { nodes { id } }' }
it_behaves_like 'a working graphql query'
it 'cannot be found' do
expect(assigned_mrs).to be_empty
end
context 'the current user is the user' do
let(:current_user) { user }
it 'can be found' do
expect(assigned_mrs).to include(
a_hash_including('id' => global_id_of(assigned_mr))
)
end
end
end
end
end
end
......@@ -153,7 +153,15 @@ module GraphqlHelpers
end
def wrap_fields(fields)
fields = Array.wrap(fields).join("\n")
fields = Array.wrap(fields).map do |field|
case field
when Symbol
GraphqlHelpers.fieldnamerize(field)
else
field
end
end.join("\n")
return unless fields.present?
<<~FIELDS
......
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