Commit 04813972 authored by Stan Hu's avatar Stan Hu

Merge branch '31919-graphql-MR-assignee-mutation' into 'master'

Add MergeRequestSetAssignees GraphQL mutation

See merge request gitlab-org/gitlab!19272
parents aae68f52 36c1b2bb
# frozen_string_literal: true
module Mutations
module MergeRequests
class SetAssignees < Base
graphql_name 'MergeRequestSetAssignees'
argument :assignee_usernames,
[GraphQL::STRING_TYPE],
required: true,
description: <<~DESC
The usernames to assign to the merge request. Replaces existing assignees by default.
DESC
argument :operation_mode,
Types::MutationOperationModeEnum,
required: false,
description: <<~DESC
The operation to perform. Defaults to REPLACE.
DESC
def resolve(project_path:, iid:, assignee_usernames:, operation_mode: Types::MutationOperationModeEnum.enum[:replace])
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/36098')
merge_request = authorized_find!(project_path: project_path, iid: iid)
project = merge_request.project
assignee_ids = []
assignee_ids += merge_request.assignees.map(&:id) if Types::MutationOperationModeEnum.enum.values_at(:remove, :append).include?(operation_mode)
user_ids = UsersFinder.new(current_user, username: assignee_usernames).execute.map(&:id)
if operation_mode == Types::MutationOperationModeEnum.enum[:remove]
assignee_ids -= user_ids
else
assignee_ids |= user_ids
end
::MergeRequests::UpdateService.new(project, current_user, assignee_ids: assignee_ids)
.execute(merge_request)
{
merge_request: merge_request,
errors: merge_request.errors.full_messages
}
end
end
end
end
# frozen_string_literal: true
module Types
class MutationOperationModeEnum < BaseEnum
graphql_name 'MutationOperationMode'
description 'Different toggles for changing mutator behavior.'
# Suggested param name for the enum: `operation_mode`
value 'REPLACE', 'Performs a replace operation'
value 'APPEND', 'Performs an append operation'
value 'REMOVE', 'Performs a removal operation'
end
end
...@@ -11,6 +11,7 @@ module Types ...@@ -11,6 +11,7 @@ module Types
mount_mutation Mutations::AwardEmojis::Toggle mount_mutation Mutations::AwardEmojis::Toggle
mount_mutation Mutations::MergeRequests::SetMilestone mount_mutation Mutations::MergeRequests::SetMilestone
mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true
mount_mutation Mutations::MergeRequests::SetAssignees
mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true
mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true
mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true
......
---
title: Add MergeRequestSetAssignees GraphQL mutation
merge_request: 19272
author:
type: added
...@@ -3357,6 +3357,56 @@ type MergeRequestPermissions { ...@@ -3357,6 +3357,56 @@ type MergeRequestPermissions {
updateMergeRequest: Boolean! updateMergeRequest: Boolean!
} }
"""
Autogenerated input type of MergeRequestSetAssignees
"""
input MergeRequestSetAssigneesInput {
"""
The usernames to assign to the merge request. Replaces existing assignees by default.
"""
assigneeUsernames: [String!]!
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The iid of the merge request to mutate
"""
iid: String!
"""
The operation to perform. Defaults to REPLACE.
"""
operationMode: MutationOperationMode
"""
The project the merge request to mutate is in
"""
projectPath: ID!
}
"""
Autogenerated return type of MergeRequestSetAssignees
"""
type MergeRequestSetAssigneesPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Reasons why the mutation failed.
"""
errors: [String!]!
"""
The merge request after mutation
"""
mergeRequest: MergeRequest
}
""" """
Autogenerated input type of MergeRequestSetMilestone Autogenerated input type of MergeRequestSetMilestone
""" """
...@@ -3537,6 +3587,7 @@ type Mutation { ...@@ -3537,6 +3587,7 @@ type Mutation {
destroyNote(input: DestroyNoteInput!): DestroyNotePayload destroyNote(input: DestroyNoteInput!): DestroyNotePayload
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
mergeRequestSetAssignees(input: MergeRequestSetAssigneesInput!): MergeRequestSetAssigneesPayload
mergeRequestSetMilestone(input: MergeRequestSetMilestoneInput!): MergeRequestSetMilestonePayload mergeRequestSetMilestone(input: MergeRequestSetMilestoneInput!): MergeRequestSetMilestonePayload
mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload
...@@ -3546,6 +3597,26 @@ type Mutation { ...@@ -3546,6 +3597,26 @@ type Mutation {
updateNote(input: UpdateNoteInput!): UpdateNotePayload updateNote(input: UpdateNoteInput!): UpdateNotePayload
} }
"""
Different toggles for changing mutator behavior.
"""
enum MutationOperationMode {
"""
Performs an append operation
"""
APPEND
"""
Performs a removal operation
"""
REMOVE
"""
Performs a replace operation
"""
REPLACE
}
type Namespace { type Namespace {
""" """
Description of the namespace Description of the namespace
......
...@@ -14736,6 +14736,33 @@ ...@@ -14736,6 +14736,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "mergeRequestSetAssignees",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "MergeRequestSetAssigneesInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "MergeRequestSetAssigneesPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "mergeRequestSetMilestone", "name": "mergeRequestSetMilestone",
"description": null, "description": null,
...@@ -15676,6 +15703,183 @@ ...@@ -15676,6 +15703,183 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "MergeRequestSetAssigneesPayload",
"description": "Autogenerated return type of MergeRequestSetAssignees",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Reasons why the mutation failed.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "mergeRequest",
"description": "The merge request after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "MergeRequest",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "MergeRequestSetAssigneesInput",
"description": "Autogenerated input type of MergeRequestSetAssignees",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "The project the merge request to mutate is in",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "iid",
"description": "The iid of the merge request to mutate",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "assigneeUsernames",
"description": "The usernames to assign to the merge request. Replaces existing assignees by default.\n",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"defaultValue": null
},
{
"name": "operationMode",
"description": "The operation to perform. Defaults to REPLACE.\n",
"type": {
"kind": "ENUM",
"name": "MutationOperationMode",
"ofType": null
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "MutationOperationMode",
"description": "Different toggles for changing mutator behavior.",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "REPLACE",
"description": "Performs a replace operation",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "APPEND",
"description": "Performs an append operation",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "REMOVE",
"description": "Performs a removal operation",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "CreateNotePayload", "name": "CreateNotePayload",
......
...@@ -483,6 +483,14 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph ...@@ -483,6 +483,14 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `cherryPickOnCurrentMergeRequest` | Boolean! | Whether or not a user can perform `cherry_pick_on_current_merge_request` on this resource | | `cherryPickOnCurrentMergeRequest` | Boolean! | Whether or not a user can perform `cherry_pick_on_current_merge_request` on this resource |
| `revertOnCurrentMergeRequest` | Boolean! | Whether or not a user can perform `revert_on_current_merge_request` on this resource | | `revertOnCurrentMergeRequest` | Boolean! | Whether or not a user can perform `revert_on_current_merge_request` on this resource |
### MergeRequestSetAssigneesPayload
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Reasons why the mutation failed. |
| `mergeRequest` | MergeRequest | The merge request after mutation |
### MergeRequestSetMilestonePayload ### MergeRequestSetMilestonePayload
| Name | Type | Description | | Name | Type | Description |
......
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::MergeRequests::SetAssignees do
let(:merge_request) { create(:merge_request) }
let(:user) { create(:user) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
describe '#resolve' do
let(:assignees) { create_list(:user, 3) }
let(:assignee_usernames) { assignees.map(&:username) }
let(:mutated_merge_request) { subject[:merge_request] }
subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: assignee_usernames) }
before do
assignees.each do |user|
merge_request.project.add_developer(user)
end
end
context 'when the user can update the merge request' do
before do
merge_request.project.add_developer(user)
end
it 'sets merge request assignees' do
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request.assignees).to match_array(assignees)
expect(subject[:errors]).to be_empty
end
it 'removes assignees not in the list' do
users = create_list(:user, 2)
users.each do |user|
merge_request.project.add_developer(user)
end
merge_request.assignees = users
merge_request.save!
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request.assignees).to match_array(assignees)
expect(subject[:errors]).to be_empty
end
context 'when passing "append" as true' do
subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: assignee_usernames, operation_mode: Types::MutationOperationModeEnum.enum[:append]) }
let(:existing_assignees) { create_list(:user, 2) }
before do
existing_assignees.each do |user|
merge_request.project.add_developer(user)
end
merge_request.assignees = existing_assignees
merge_request.save!
end
it 'does not remove assignees not in the list' do
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request.assignees).to match_array(assignees + existing_assignees)
expect(subject[:errors]).to be_empty
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Setting assignees of a merge request' do
include GraphqlHelpers
let(:current_user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
let(:assignees) { create_list(:user, 3) }
let(:extra_assignees) { create_list(:user, 2) }
let(:input) { { assignee_usernames: assignees.map(&:username) } }
let(:expected_result) do
assignees.map { |u| { 'username' => u.username } }
end
let(:mutation) do
variables = {
project_path: project.full_path,
iid: merge_request.iid.to_s
}
graphql_mutation(:merge_request_set_assignees, variables.merge(input),
<<-QL.strip_heredoc
clientMutationId
errors
mergeRequest {
id
assignees {
nodes {
username
}
}
}
QL
)
end
def mutation_response
graphql_mutation_response(:merge_request_set_assignees)
end
def mutation_assignee_nodes
mutation_response['mergeRequest']['assignees']['nodes']
end
before do
project.add_developer(current_user)
assignees.each do |user|
project.add_developer(user)
end
extra_assignees.each do |user|
project.add_developer(user)
end
end
it 'adds the assignees to the merge request' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_assignee_nodes).to match_array(expected_result)
end
context 'with assignees already assigned' do
before do
merge_request.assignees = extra_assignees
merge_request.save!
end
it 'removes assignees not in the list' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_assignee_nodes).to match_array(expected_result)
end
end
context 'when passing append as true' do
let(:input) { { assignee_usernames: assignees.map(&:username), operation_mode: Types::MutationOperationModeEnum.enum[:append] } }
let(:expected_result) do
assignees.map { |u| { 'username' => u.username } } + extra_assignees.map { |u| { 'username' => u.username } }
end
before do
merge_request.assignees = extra_assignees
merge_request.save!
end
it 'does not remove users not in the list' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_assignee_nodes).to match_array(expected_result)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::MergeRequests::SetAssignees do
let(:merge_request) { create(:merge_request) }
let(:user) { create(:user) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
describe '#resolve' do
let(:assignee) { create(:user) }
let(:assignee2) { create(:user) }
let(:assignee_usernames) { [assignee.username] }
let(:mutated_merge_request) { subject[:merge_request] }
subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: assignee_usernames) }
before do
merge_request.project.add_developer(assignee)
merge_request.project.add_developer(assignee2)
end
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when the user can update the merge request' do
before do
merge_request.project.add_developer(user)
end
it 'replaces the assignee' do
merge_request.assignees = [assignee2]
merge_request.save!
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request.assignees).to contain_exactly(assignee)
expect(subject[:errors]).to be_empty
end
it 'returns errors merge request could not be updated' do
# Make the merge request invalid
merge_request.allow_broken = true
merge_request.update!(source_project: nil)
expect(subject[:errors]).not_to be_empty
end
context 'when passing an empty assignee list' do
let(:assignee_usernames) { [] }
before do
merge_request.assignees = [assignee]
merge_request.save!
end
it 'removes all assignees' do
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request.assignees).to eq([])
expect(subject[:errors]).to be_empty
end
end
context 'when passing "append" as true' do
subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: assignee_usernames, operation_mode: Types::MutationOperationModeEnum.enum[:append]) }
before do
merge_request.assignees = [assignee2]
merge_request.save!
# In CE, APPEND is a NOOP as you can't have multiple assignees
# We test multiple assignment in EE specs
stub_licensed_features(multiple_merge_request_assignees: false)
end
it 'is a NO-OP in FOSS' do
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request.assignees).to contain_exactly(assignee2)
expect(subject[:errors]).to be_empty
end
end
context 'when passing "remove" as true' do
before do
merge_request.assignees = [assignee]
merge_request.save!
end
it 'removes named assignee' do
mutated_merge_request = mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: assignee_usernames, operation_mode: Types::MutationOperationModeEnum.enum[:remove])[:merge_request]
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request.assignees).to eq([])
expect(subject[:errors]).to be_empty
end
it 'does not remove unnamed assignee' do
mutated_merge_request = mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: [assignee2.username], operation_mode: Types::MutationOperationModeEnum.enum[:remove])[:merge_request]
expect(mutated_merge_request).to eq(merge_request)
expect(mutated_merge_request.assignees).to contain_exactly(assignee)
expect(subject[:errors]).to be_empty
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Setting assignees of a merge request' do
include GraphqlHelpers
let(:current_user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
let(:assignee) { create(:user) }
let(:assignee2) { create(:user) }
let(:input) { { assignee_usernames: [assignee.username] } }
let(:expected_result) do
[{ 'username' => assignee.username }]
end
let(:mutation) do
variables = {
project_path: project.full_path,
iid: merge_request.iid.to_s
}
graphql_mutation(:merge_request_set_assignees, variables.merge(input),
<<-QL.strip_heredoc
clientMutationId
errors
mergeRequest {
id
assignees {
nodes {
username
}
}
}
QL
)
end
def mutation_response
graphql_mutation_response(:merge_request_set_assignees)
end
def mutation_assignee_nodes
mutation_response['mergeRequest']['assignees']['nodes']
end
before do
project.add_developer(current_user)
project.add_developer(assignee)
project.add_developer(assignee2)
end
it 'returns an error if the user is not allowed to update the merge request' do
post_graphql_mutation(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
it 'does not allow members without the right permission to add assignees' do
user = create(:user)
project.add_guest(user)
post_graphql_mutation(mutation, current_user: user)
expect(graphql_errors).not_to be_empty
end
context 'with assignees already assigned' do
before do
merge_request.assignees = [assignee2]
merge_request.save!
end
it 'replaces the assignee' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_assignee_nodes).to match_array(expected_result)
end
end
context 'when passing an empty list of assignees' do
let(:input) { { assignee_usernames: [] } }
before do
merge_request.assignees = [assignee2]
merge_request.save!
end
it 'removes assignee' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_assignee_nodes).to eq([])
end
end
context 'when passing append as true' do
let(:input) { { assignee_usernames: [assignee2.username], operation_mode: Types::MutationOperationModeEnum.enum[:append] } }
before do
# In CE, APPEND is a NOOP as you can't have multiple assignees
# We test multiple assignment in EE specs
stub_licensed_features(multiple_merge_request_assignees: false)
merge_request.assignees = [assignee]
merge_request.save!
end
it 'does not replace the assignee in CE' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_assignee_nodes).to match_array(expected_result)
end
end
context 'when passing remove as true' do
let(:input) { { assignee_usernames: [assignee.username], operation_mode: Types::MutationOperationModeEnum.enum[:remove] } }
let(:expected_result) { [] }
before do
merge_request.assignees = [assignee]
merge_request.save!
end
it 'removes the users in the list, while adding none' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_assignee_nodes).to match_array(expected_result)
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