Commit 5e39832d authored by Alex Kalderimis's avatar Alex Kalderimis

Expose queries over a users merge requests

We expose the graph:

  User -[authored]-----> MergeRequest
       -[assigned to] -> MergeRequest

Via two new edges on `User`: `authoredMergeRequests` and
`assignedMergeRequests`.

As an incidental improvement, `User.state` is made into an `enum` - this
change is backwards compatible since the output is the same: a string.
parent 19efcb2a
......@@ -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