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 ...@@ -13,10 +13,10 @@ module ResolvesMergeRequests
args[:iids] = Array.wrap(args[:iids]) if args[:iids] args[:iids] = Array.wrap(args[:iids]) if args[:iids]
args.compact! args.compact!
if args.keys == [:iids] if project && args.keys == [:iids]
batch_load_merge_requests(args[:iids]) batch_load_merge_requests(args[:iids])
else else
args[:project_id] = project.id args[:project_id] ||= project
MergeRequestsFinder.new(current_user, args).execute MergeRequestsFinder.new(current_user, args).execute
end.then(&(single? ? :first : :itself)) end.then(&(single? ? :first : :itself))
......
...@@ -34,7 +34,11 @@ module Resolvers ...@@ -34,7 +34,11 @@ module Resolvers
end end
def no_results_possible?(args) 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 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 ...@@ -4,6 +4,8 @@ module Resolvers
class UserResolver < BaseResolver class UserResolver < BaseResolver
description 'Retrieve a single user' description 'Retrieve a single user'
type Types::UserType, null: true
argument :id, GraphQL::ID_TYPE, argument :id, GraphQL::ID_TYPE,
required: false, required: false,
description: 'ID of the User' description: 'ID of the User'
...@@ -12,13 +14,6 @@ module Resolvers ...@@ -12,13 +14,6 @@ module Resolvers
required: false, required: false,
description: 'Username of the User' 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) def ready?(id: nil, username: nil)
unless id.present? ^ username.present? unless id.present? ^ username.present?
raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a single username or id' raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a single username or id'
...@@ -26,5 +21,23 @@ module Resolvers ...@@ -26,5 +21,23 @@ module Resolvers
super super
end 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
end end
...@@ -56,6 +56,10 @@ module Types ...@@ -56,6 +56,10 @@ module Types
description: 'Text to echo back', description: 'Text to echo back',
resolver: Resolvers::EchoResolver resolver: Resolvers::EchoResolver
field :user, Types::UserType, null: true,
description: 'Find a user on this instance',
resolver: Resolvers::UserResolver
def design_management def design_management
DesignManagementObject.new(nil) DesignManagementObject.new(nil)
end 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 ...@@ -12,12 +12,12 @@ module Types
field :id, GraphQL::ID_TYPE, null: false, field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the user' 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, field :username, GraphQL::STRING_TYPE, null: false,
description: 'Username of the user. Unique within this instance of GitLab' 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, field :avatar_url, GraphQL::STRING_TYPE, null: true,
description: "URL of the user's avatar" description: "URL of the user's avatar"
field :web_url, GraphQL::STRING_TYPE, null: false, field :web_url, GraphQL::STRING_TYPE, null: false,
...@@ -26,6 +26,14 @@ module Types ...@@ -26,6 +26,14 @@ module Types
resolver: Resolvers::TodoResolver, resolver: Resolvers::TodoResolver,
description: 'Todos of the user' 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, field :snippets,
Types::SnippetType.connection_type, Types::SnippetType.connection_type,
null: true, null: true,
......
---
title: Add GraphQL support for authored and assigned Merge Requests
merge_request: 31227
author:
type: added
...@@ -9348,7 +9348,7 @@ type Query { ...@@ -9348,7 +9348,7 @@ type Query {
): SnippetConnection ): SnippetConnection
""" """
Find a user Find a user on this instance
""" """
user( user(
""" """
...@@ -12053,6 +12053,126 @@ type UpdateSnippetPayload { ...@@ -12053,6 +12053,126 @@ type UpdateSnippetPayload {
scalar Upload scalar Upload
type User { 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 URL of the user's avatar
""" """
...@@ -12109,9 +12229,9 @@ type User { ...@@ -12109,9 +12229,9 @@ type User {
): SnippetConnection ): SnippetConnection
""" """
State of the issue State of the user
""" """
state: String! state: UserState!
""" """
Todos of the user Todos of the user
...@@ -12226,6 +12346,26 @@ type UserPermissions { ...@@ -12226,6 +12346,26 @@ type UserPermissions {
createSnippet: Boolean! 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 { enum VisibilityLevelsEnum {
internal internal
private private
......
...@@ -1819,7 +1819,7 @@ Autogenerated return type of UpdateSnippet ...@@ -1819,7 +1819,7 @@ Autogenerated return type of UpdateSnippet
| `avatarUrl` | String | URL of the user's avatar | | `avatarUrl` | String | URL of the user's avatar |
| `id` | ID! | ID of the user | | `id` | ID! | ID of the user |
| `name` | String! | Human-readable name 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 | | `userPermissions` | UserPermissions! | Permissions for the current user on the resource |
| `username` | String! | Username of the user. Unique within this instance of GitLab | | `username` | String! | Username of the user. Unique within this instance of GitLab |
| `webUrl` | String! | Web URL of the user | | `webUrl` | String! | Web URL of the user |
......
...@@ -40,6 +40,6 @@ describe Resolvers::UserResolver do ...@@ -40,6 +40,6 @@ describe Resolvers::UserResolver do
private private
def resolve_user(args = {}) def resolve_user(args = {})
resolve(described_class, args: args) sync(resolve(described_class, args: args))
end end
end end
...@@ -10,6 +10,7 @@ describe GitlabSchema.types['User'] do ...@@ -10,6 +10,7 @@ describe GitlabSchema.types['User'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %w[ expected_fields = %w[
id user_permissions snippets name username avatarUrl webUrl todos state id user_permissions snippets name username avatarUrl webUrl todos state
authoredMergeRequests assignedMergeRequests
] ]
expect(described_class).to have_graphql_fields(*expected_fields) 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 ...@@ -153,7 +153,15 @@ module GraphqlHelpers
end end
def wrap_fields(fields) 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? return unless fields.present?
<<~FIELDS <<~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