Commit 211635ad authored by Felipe Artur's avatar Felipe Artur

Add IssueMove mutation

Allow to move issue between projects with GraphQL
parent 35032389
# frozen_string_literal: true
module Mutations
module Issues
class Move < Base
graphql_name 'IssueMove'
argument :target_project_path,
GraphQL::ID_TYPE,
required: true,
description: 'The project to move the issue to.'
def resolve(project_path:, iid:, target_project_path:)
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/267762')
issue = authorized_find!(project_path: project_path, iid: iid)
source_project = issue.project
target_project = resolve_project(full_path: target_project_path).sync
begin
moved_issue = ::Issues::MoveService.new(source_project, current_user).execute(issue, target_project)
rescue ::Issues::MoveService::MoveError => error
errors = error.message
end
{
issue: moved_issue,
errors: Array.wrap(errors)
}
end
end
end
end
......@@ -31,6 +31,7 @@ module Types
mount_mutation Mutations::Issues::SetSeverity
mount_mutation Mutations::Issues::SetSubscription
mount_mutation Mutations::Issues::Update
mount_mutation Mutations::Issues::Move
mount_mutation Mutations::MergeRequests::Create
mount_mutation Mutations::MergeRequests::Update
mount_mutation Mutations::MergeRequests::SetLabels
......
---
title: Allow to move issues between projects on GraphQL
merge_request: 44491
author:
type: added
......@@ -9187,6 +9187,31 @@ Identifier of Issue
"""
scalar IssueID
"""
Autogenerated input type of IssueMove
"""
input IssueMoveInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The IID of the issue to mutate
"""
iid: String!
"""
The project the issue to mutate is in
"""
projectPath: ID!
"""
The project to move the issue to.
"""
targetProjectPath: ID!
}
"""
Autogenerated input type of IssueMoveList
"""
......@@ -9257,6 +9282,26 @@ type IssueMoveListPayload {
issue: Issue
}
"""
Autogenerated return type of IssueMove
"""
type IssueMovePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The issue after mutation
"""
issue: Issue
}
"""
Check permissions for the current user on a issue
"""
......@@ -12109,6 +12154,7 @@ type Mutation {
epicAddIssue(input: EpicAddIssueInput!): EpicAddIssuePayload
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
issueMove(input: IssueMoveInput!): IssueMovePayload
issueMoveList(input: IssueMoveListInput!): IssueMoveListPayload
issueSetAssignees(input: IssueSetAssigneesInput!): IssueSetAssigneesPayload
issueSetConfidential(input: IssueSetConfidentialInput!): IssueSetConfidentialPayload
......
......@@ -25043,6 +25043,69 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "IssueMoveInput",
"description": "Autogenerated input type of IssueMove",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "The project the issue 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 issue to mutate",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "targetProjectPath",
"description": "The project to move the issue to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"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": "INPUT_OBJECT",
"name": "IssueMoveListInput",
......@@ -25223,6 +25286,73 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "IssueMovePayload",
"description": "Autogenerated return type of IssueMove",
"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": "Errors encountered during execution of the mutation.",
"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": "issue",
"description": "The issue after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "IssuePermissions",
......@@ -34412,6 +34542,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issueMove",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "IssueMoveInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "IssueMovePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issueMoveList",
"description": null,
......@@ -1296,6 +1296,16 @@ Autogenerated return type of IssueMoveList.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue after mutation |
### IssueMovePayload
Autogenerated return type of IssueMove.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue after mutation |
### IssuePermissions
Check permissions for the current user on a issue.
......
......@@ -6,7 +6,7 @@ module RuboCop
class IDType < RuboCop::Cop::Cop
MSG = 'Do not use GraphQL::ID_TYPE, use a specific GlobalIDType instead'
WHITELISTED_ARGUMENTS = %i[iid full_path project_path group_path].freeze
WHITELISTED_ARGUMENTS = %i[iid full_path project_path group_path target_project_path].freeze
def_node_search :graphql_id_type?, <<~PATTERN
(send nil? :argument (_ #does_not_match?) (const (const nil? :GraphQL) :ID_TYPE) ...)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Issues::Move do
let_it_be(:issue) { create(:issue) }
let_it_be(:user) { create(:user) }
let_it_be(:target_project) { create(:project) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
subject(:resolve) { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, target_project_path: target_project.full_path) }
it 'raises an error if the resource is not accessible to the user' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when user does not have permissions' do
before do
issue.project.add_developer(user)
end
it 'returns error message' do
expect(resolve[:issue]).to eq(nil)
expect(resolve[:errors].first).to eq('Cannot move issue due to insufficient permissions!')
end
end
context 'when user has sufficient permissions' do
before do
issue.project.add_developer(user)
target_project.add_developer(user)
end
it 'moves issue' do
expect(resolve[:issue].project).to eq(target_project)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Moving an issue' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:issue) { create(:issue) }
let_it_be(:target_project) { create(:project) }
let(:mutation) do
variables = {
project_path: issue.project.full_path,
target_project_path: target_project.full_path,
iid: issue.iid.to_s
}
graphql_mutation(:issue_move, variables,
<<-QL.strip_heredoc
clientMutationId
errors
issue {
title
}
QL
)
end
def mutation_response
graphql_mutation_response(:issue_move)
end
context 'when the user is not allowed to read source project' do
it 'returns an error' do
error = "The resource that you are attempting to access does not exist or you don't have permission to perform this action"
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors).to include(a_hash_including('message' => error))
end
end
context 'when the user is not allowed to move issue to target project' do
before do
issue.project.add_developer(user)
end
it 'returns an error' do
error = "Cannot move issue due to insufficient permissions!"
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['errors'][0]).to eq(error)
end
end
context 'when the user is allowed to move issue' do
before do
issue.project.add_developer(user)
target_project.add_developer(user)
end
it 'moves the issue' do
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response.dig('issue', 'title')).to eq(issue.title)
expect(issue.reload.state).to eq('closed')
expect(target_project.issues.find_by_title(issue.title)).to be_present
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