Commit 37b08fe6 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '229838-create-issue-graphql' into 'master'

Add graphql mutation to create an issue

See merge request gitlab-org/gitlab!43735
parents 7677855b ebfec71c
# frozen_string_literal: true
module Mutations
module Issues
module CommonMutationArguments
extend ActiveSupport::Concern
included do
argument :description, GraphQL::STRING_TYPE,
required: false,
description: copy_field_description(Types::IssueType, :description)
argument :due_date, GraphQL::Types::ISO8601Date,
required: false,
description: copy_field_description(Types::IssueType, :due_date)
argument :confidential, GraphQL::BOOLEAN_TYPE,
required: false,
description: copy_field_description(Types::IssueType, :confidential)
argument :locked, GraphQL::BOOLEAN_TYPE,
as: :discussion_locked,
required: false,
description: copy_field_description(Types::IssueType, :discussion_locked)
end
end
end
end
Mutations::Issues::CommonMutationArguments.prepend_if_ee('::EE::Mutations::Issues::CommonMutationArguments')
# frozen_string_literal: true
module Mutations
module Issues
class Create < BaseMutation
include ResolvesProject
graphql_name 'CreateIssue'
authorize :create_issue
include CommonMutationArguments
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'Project full path the issue is associated with'
argument :iid, GraphQL::INT_TYPE,
required: false,
description: 'The IID (internal ID) of a project issue. Only admins and project owners can modify'
argument :title, GraphQL::STRING_TYPE,
required: true,
description: copy_field_description(Types::IssueType, :title)
argument :milestone_id, ::Types::GlobalIDType[::Milestone],
required: false,
description: 'The ID of the milestone to assign to the issue. On update milestone will be removed if set to null'
argument :labels, [GraphQL::STRING_TYPE],
required: false,
description: copy_field_description(Types::IssueType, :labels)
argument :label_ids, [::Types::GlobalIDType[::Label]],
required: false,
description: 'The IDs of labels to be added to the issue'
argument :created_at, Types::TimeType,
required: false,
description: 'Timestamp when the issue was created. Available only for admins and project owners'
argument :merge_request_to_resolve_discussions_of, ::Types::GlobalIDType[::MergeRequest],
required: false,
description: 'The IID of a merge request for which to resolve discussions'
argument :discussion_to_resolve, GraphQL::STRING_TYPE,
required: false,
description: 'The ID of a discussion to resolve. Also pass `merge_request_to_resolve_discussions_of`'
argument :assignee_ids, [::Types::GlobalIDType[::User]],
required: false,
description: 'The array of user IDs to assign to the issue'
field :issue,
Types::IssueType,
null: true,
description: 'The issue after mutation'
def ready?(**args)
if args.slice(*mutually_exclusive_label_args).size > 1
arg_str = mutually_exclusive_label_args.map { |x| x.to_s.camelize(:lower) }.join(' or ')
raise Gitlab::Graphql::Errors::ArgumentError, "one and only one of #{arg_str} is required."
end
if args[:discussion_to_resolve].present? && args[:merge_request_to_resolve_discussions_of].blank?
raise Gitlab::Graphql::Errors::ArgumentError,
'to resolve a discussion please also provide `merge_request_to_resolve_discussions_of` parameter'
end
super
end
def resolve(project_path:, **attributes)
project = authorized_find!(full_path: project_path)
params = build_create_issue_params(attributes.merge(author_id: current_user.id))
issue = ::Issues::CreateService.new(project, current_user, params).execute
if issue.spam?
issue.errors.add(:base, 'Spam detected.')
end
{
issue: issue.valid? ? issue : nil,
errors: errors_on_object(issue)
}
end
private
def build_create_issue_params(params)
params[:milestone_id] &&= params[:milestone_id]&.model_id
params[:assignee_ids] &&= params[:assignee_ids].map { |assignee_id| assignee_id&.model_id }
params[:label_ids] &&= params[:label_ids].map { |label_id| label_id&.model_id }
params
end
def mutually_exclusive_label_args
[:labels, :label_ids]
end
def find_object(full_path:)
resolve_project(full_path: full_path)
end
end
end
end
Mutations::Issues::Create.prepend_if_ee('::EE::Mutations::Issues::Create')
......@@ -5,49 +5,26 @@ module Mutations
class Update < Base
graphql_name 'UpdateIssue'
argument :title,
GraphQL::STRING_TYPE,
required: false,
description: copy_field_description(Types::IssueType, :title)
include CommonMutationArguments
argument :description,
GraphQL::STRING_TYPE,
argument :title, GraphQL::STRING_TYPE,
required: false,
description: copy_field_description(Types::IssueType, :description)
argument :due_date,
Types::TimeType,
required: false,
description: copy_field_description(Types::IssueType, :due_date)
argument :confidential,
GraphQL::BOOLEAN_TYPE,
required: false,
description: copy_field_description(Types::IssueType, :confidential)
argument :locked,
GraphQL::BOOLEAN_TYPE,
as: :discussion_locked,
required: false,
description: copy_field_description(Types::IssueType, :discussion_locked)
description: copy_field_description(Types::IssueType, :title)
argument :add_label_ids,
[GraphQL::ID_TYPE],
argument :milestone_id, GraphQL::ID_TYPE,
required: false,
description: 'The IDs of labels to be added to the issue.'
description: 'The ID of the milestone to assign to the issue. On update milestone will be removed if set to null'
argument :remove_label_ids,
[GraphQL::ID_TYPE],
argument :add_label_ids, [GraphQL::ID_TYPE],
required: false,
description: 'The IDs of labels to be removed from the issue.'
description: 'The IDs of labels to be added to the issue'
argument :milestone_id,
GraphQL::ID_TYPE,
argument :remove_label_ids, [GraphQL::ID_TYPE],
required: false,
description: 'The ID of the milestone to be assigned, milestone will be removed if set to null.'
description: 'The IDs of labels to be removed from the issue'
argument :state_event, Types::IssueStateEventEnum,
description: 'Close or reopen an issue.',
description: 'Close or reopen an issue',
required: false
def resolve(project_path:, iid:, **args)
......
......@@ -23,6 +23,7 @@ module Types
mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::Discussions::ToggleResolve
mount_mutation Mutations::Issues::Create
mount_mutation Mutations::Issues::SetAssignees
mount_mutation Mutations::Issues::SetConfidential
mount_mutation Mutations::Issues::SetLocked
......
......@@ -34,6 +34,18 @@ module Issues
private
def filter_params(merge_request)
super
moved_issue = params.delete(:moved_issue)
# Setting created_at, updated_at and iid is allowed only for admins and owners or
# when moving an issue as we preserve the original issue attributes except id and iid.
params.delete(:iid) unless current_user.can?(:set_issue_iid, project)
params.delete(:created_at) unless moved_issue || current_user.can?(:set_issue_created_at, project)
params.delete(:updated_at) unless moved_issue || current_user.can?(:set_issue_updated_at, project)
end
def create_assignee_note(issue, old_assignees)
SystemNoteService.change_issuable_assignees(
issue, issue.project, current_user, old_assignees)
......
......@@ -52,7 +52,8 @@ module Issues
iid: nil,
project: target_project,
author: original_entity.author,
assignee_ids: original_entity.assignee_ids
assignee_ids: original_entity.assignee_ids,
moved_issue: true
}
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
......
---
title: Add GraphQL mutation to create an issue
merge_request: 43735
author:
type: added
......@@ -3422,6 +3422,111 @@ type CreateImageDiffNotePayload {
note: Note
}
"""
Autogenerated input type of CreateIssue
"""
input CreateIssueInput {
"""
The array of user IDs to assign to the issue
"""
assigneeIds: [UserID!]
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Indicates the issue is confidential
"""
confidential: Boolean
"""
Timestamp when the issue was created. Available only for admins and project owners
"""
createdAt: Time
"""
Description of the issue
"""
description: String
"""
The ID of a discussion to resolve. Also pass `merge_request_to_resolve_discussions_of`
"""
discussionToResolve: String
"""
Due date of the issue
"""
dueDate: ISO8601Date
"""
The ID of an epic to associate the issue with
"""
epicId: EpicID
"""
The IID (internal ID) of a project issue. Only admins and project owners can modify
"""
iid: Int
"""
The IDs of labels to be added to the issue
"""
labelIds: [LabelID!]
"""
Labels of the issue
"""
labels: [String!]
"""
Indicates discussion is locked on the issue
"""
locked: Boolean
"""
The IID of a merge request for which to resolve discussions
"""
mergeRequestToResolveDiscussionsOf: MergeRequestID
"""
The ID of the milestone to assign to the issue. On update milestone will be removed if set to null
"""
milestoneId: MilestoneID
"""
Project full path the issue is associated with
"""
projectPath: ID!
"""
Title of the issue
"""
title: String!
}
"""
Autogenerated return type of CreateIssue
"""
type CreateIssuePayload {
"""
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
}
"""
Autogenerated input type of CreateIteration
"""
......@@ -11188,6 +11293,11 @@ type MergeRequestEdge {
node: MergeRequest
}
"""
Identifier of MergeRequest
"""
scalar MergeRequestID
"""
Check permissions for the current user on a merge request
"""
......@@ -11964,6 +12074,7 @@ type Mutation {
createDiffNote(input: CreateDiffNoteInput!): CreateDiffNotePayload
createEpic(input: CreateEpicInput!): CreateEpicPayload
createImageDiffNote(input: CreateImageDiffNoteInput!): CreateImageDiffNotePayload
createIssue(input: CreateIssueInput!): CreateIssuePayload
createIteration(input: CreateIterationInput!): CreateIterationPayload
createNote(input: CreateNoteInput!): CreateNotePayload
createRequirement(input: CreateRequirementInput!): CreateRequirementPayload
......@@ -19452,7 +19563,7 @@ Autogenerated input type of UpdateIssue
"""
input UpdateIssueInput {
"""
The IDs of labels to be added to the issue.
The IDs of labels to be added to the issue
"""
addLabelIds: [ID!]
......@@ -19474,18 +19585,13 @@ input UpdateIssueInput {
"""
Due date of the issue
"""
dueDate: Time
dueDate: ISO8601Date
"""
The ID of the parent epic. NULL when removing the association
"""
epicId: ID
"""
The desired health status
"""
healthStatus: HealthStatus
"""
The IID of the issue to mutate
"""
......@@ -19497,7 +19603,7 @@ input UpdateIssueInput {
locked: Boolean
"""
The ID of the milestone to be assigned, milestone will be removed if set to null.
The ID of the milestone to assign to the issue. On update milestone will be removed if set to null
"""
milestoneId: ID
......@@ -19507,12 +19613,12 @@ input UpdateIssueInput {
projectPath: ID!
"""
The IDs of labels to be removed from the issue.
The IDs of labels to be removed from the issue
"""
removeLabelIds: [ID!]
"""
Close or reopen an issue.
Close or reopen an issue
"""
stateEvent: IssueStateEvent
......
......@@ -537,6 +537,16 @@ Autogenerated return type of CreateImageDiffNote.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `note` | Note | The note after mutation |
### CreateIssuePayload
Autogenerated return type of CreateIssue.
| 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 |
### CreateIterationPayload
Autogenerated return type of CreateIteration.
......
# frozen_string_literal: true
module EE
module Mutations
module Issues
module CommonMutationArguments
extend ActiveSupport::Concern
included do
argument :health_status,
::Types::HealthStatusEnum,
required: false,
description: 'The desired health status'
argument :weight, GraphQL::INT_TYPE,
required: false,
description: 'The weight of the issue'
end
end
end
end
end
# frozen_string_literal: true
module EE
module Mutations
module Issues
module Create
extend ActiveSupport::Concern
prepended do
argument :epic_id, ::Types::GlobalIDType[::Epic],
required: false,
description: 'The ID of an epic to associate the issue with'
end
private
def create_issue_params(params)
params[:epic_id] &&= params[:epic_id]&.model_id
super(params)
end
end
end
end
end
......@@ -7,12 +7,7 @@ module EE
extend ActiveSupport::Concern
prepended do
argument :health_status,
::Types::HealthStatusEnum,
required: false,
description: 'The desired health status'
argument :epic_id,
GraphQL::ID_TYPE,
argument :epic_id, GraphQL::ID_TYPE,
required: false,
description: 'The ID of the parent epic. NULL when removing the association'
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Issues::Create do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:assignee1) { create(:user) }
let_it_be(:assignee2) { create(:user) }
let(:expected_attributes) do
{
title: 'new title',
description: 'new description',
confidential: true,
due_date: Date.tomorrow,
discussion_locked: true,
weight: 10
}
end
let(:mutation_params) do
{
project_path: project.full_path,
assignee_ids: [assignee1.to_global_id, assignee2.to_global_id],
health_status: Issue.health_statuses[:at_risk]
}.merge(expected_attributes)
end
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
let(:mutated_issue) { subject[:issue] }
specify { expect(described_class).to require_graphql_authorizations(:create_issue) }
describe '#resolve' do
before do
project.add_guest(assignee1)
project.add_guest(assignee2)
stub_licensed_features(issuable_health_status: true)
end
subject { mutation.resolve(mutation_params) }
context 'when user can create issues' do
before do
project.add_developer(user)
end
it 'creates issue with correct EE values' do
expect(mutated_issue).to have_attributes(expected_attributes)
expect(mutated_issue.assignees.pluck(:id)).to eq([assignee1.id, assignee2.id])
expect(mutated_issue.health_status).to eq('at_risk')
end
end
end
end
......@@ -16,7 +16,15 @@ RSpec.describe Mutations::Issues::Update do
let_it_be(:epic) { create(:epic, group: group) }
let(:epic_id) { epic.to_global_id.to_s }
let(:params) { { project_path: project.full_path, iid: issue.iid, epic_id: epic_id } }
let(:params) do
{
project_path: project.full_path,
iid: issue.iid,
epic_id: epic_id,
weight: 10
}
end
let(:mutated_issue) { subject[:issue] }
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
......@@ -53,6 +61,7 @@ RSpec.describe Mutations::Issues::Update do
it 'returns the updated issue' do
expect(mutated_issue.epic).to eq(epic)
expect(mutated_issue.weight).to eq(10)
end
end
......
......@@ -231,9 +231,6 @@ module API
authorize! :create_issue, user_project
params.delete(:created_at) unless current_user.can?(:set_issue_created_at, user_project)
params.delete(:iid) unless current_user.can?(:set_issue_iid, user_project)
issue_params = declared_params(include_missing: false)
issue_params[:system_note_timestamp] = params[:created_at]
......@@ -279,8 +276,6 @@ module API
issue = user_project.issues.find_by!(iid: params.delete(:issue_iid))
authorize! :update_issue, issue
# Setting updated_at is allowed only for admins and owners
params.delete(:updated_at) unless current_user.can?(:set_issue_updated_at, user_project)
issue.system_note_timestamp = params[:updated_at]
update_params = declared_params(include_missing: false).merge(request: request, api: true)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Issues::Create do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:assignee1) { create(:user) }
let_it_be(:assignee2) { create(:user) }
let_it_be(:project_label1) { create(:label, project: project) }
let_it_be(:project_label2) { create(:label, project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:new_label1) { FFaker::Lorem.word }
let_it_be(:new_label2) { new_label1 + 'Extra' }
let(:expected_attributes) do
{
title: 'new title',
description: 'new description',
confidential: true,
due_date: Date.tomorrow,
discussion_locked: true
}
end
let(:mutation_params) do
{
project_path: project.full_path,
milestone_id: milestone.to_global_id,
labels: [project_label1.title, project_label2.title, new_label1, new_label2],
assignee_ids: [assignee1.to_global_id, assignee2.to_global_id]
}.merge(expected_attributes)
end
let(:special_params) do
{
iid: non_existing_record_id,
created_at: 2.days.ago
}
end
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
let(:mutated_issue) { subject[:issue] }
specify { expect(described_class).to require_graphql_authorizations(:create_issue) }
describe '#resolve' do
before do
stub_licensed_features(multiple_issue_assignees: false, issue_weights: false)
project.add_guest(assignee1)
project.add_guest(assignee2)
end
subject { mutation.resolve(mutation_params) }
context 'when the user does not have permission to create an issue' do
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user can create an issue' do
context 'when creating an issue a developer' do
before do
project.add_developer(user)
end
it 'creates issue with correct values' do
expect(mutated_issue).to have_attributes(expected_attributes)
expect(mutated_issue.milestone_id).to eq(milestone.id)
expect(mutated_issue.labels.pluck(:title)).to eq([project_label1.title, project_label2.title, new_label1, new_label2])
expect(mutated_issue.assignees.pluck(:id)).to eq([assignee1.id])
end
context 'when passing in label_ids' do
before do
mutation_params.delete(:labels)
mutation_params.merge!(label_ids: [project_label1.to_global_id, project_label2.to_global_id])
end
it 'creates issue with correct values' do
expect(mutated_issue.labels.pluck(:title)).to eq([project_label1.title, project_label2.title])
end
end
context 'when trying to create issue with restricted params' do
before do
mutation_params.merge!(special_params)
end
it 'ignores the special params' do
expect(mutated_issue).not_to be_like_time(special_params[:created_at])
expect(mutated_issue.iid).not_to eq(special_params[:iid])
end
end
end
context 'when creating an issue as owner' do
let_it_be(:user) { project.owner }
before do
mutation_params.merge!(special_params)
end
it 'sets the special params' do
expect(mutated_issue.created_at).to be_like_time(special_params[:created_at])
expect(mutated_issue.iid).to eq(special_params[:iid])
end
end
end
end
describe "#ready?" do
context 'when passing in both labels and label_ids' do
before do
mutation_params.merge!(label_ids: [project_label1.to_global_id, project_label2.to_global_id])
end
it 'raises exception when mutually exclusive params are given' do
expect { mutation.ready?(mutation_params) }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /one and only one of/)
end
end
context 'when passing only `discussion_to_resolve` param' do
before do
mutation_params.merge!(discussion_to_resolve: 'abc')
end
it 'raises exception when mutually exclusive params are given' do
expect { mutation.ready?(mutation_params) }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /to resolve a discussion please also provide `merge_request_to_resolve_discussions_of` parameter/)
end
end
context 'when passing only `merge_request_to_resolve_discussions_of` param' do
before do
mutation_params.merge!(merge_request_to_resolve_discussions_of: 'abc')
end
it 'raises exception when mutually exclusive params are given' do
expect { mutation.ready?(mutation_params) }.not_to raise_error
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Create an issue' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:assignee1) { create(:user) }
let_it_be(:assignee2) { create(:user) }
let_it_be(:project_label1) { create(:label, project: project) }
let_it_be(:project_label2) { create(:label, project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
let_it_be(:new_label1) { FFaker::Lorem.word }
let_it_be(:new_label2) { FFaker::Lorem.word }
let(:input) do
{
'title' => 'new title',
'description' => 'new description',
'confidential' => true,
'dueDate' => Date.tomorrow.strftime('%Y-%m-%d')
}
end
let(:mutation) { graphql_mutation(:createIssue, input.merge('projectPath' => project.full_path, 'locked' => true)) }
let(:mutation_response) { graphql_mutation_response(:create_issue) }
context 'the user is not allowed to create an issue' do
it_behaves_like 'a mutation that returns a top-level access error'
end
context 'when user has permissions to create an issue' do
before do
project.add_developer(current_user)
end
it 'updates the issue' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['issue']).to include(input)
expect(mutation_response['issue']).to include('discussionLocked' => true)
end
end
end
......@@ -10,13 +10,15 @@ RSpec.describe 'Update of an existing issue' do
let_it_be(:issue) { create(:issue, project: project) }
let(:input) do
{
project_path: project.full_path,
iid: issue.iid.to_s,
locked: true
'iid' => issue.iid.to_s,
'title' => 'new title',
'description' => 'new description',
'confidential' => true,
'dueDate' => Date.tomorrow.strftime('%Y-%m-%d')
}
end
let(:mutation) { graphql_mutation(:update_issue, input) }
let(:mutation) { graphql_mutation(:update_issue, input.merge(project_path: project.full_path, locked: true)) }
let(:mutation_response) { graphql_mutation_response(:update_issue) }
context 'the user is not allowed to update issue' do
......@@ -32,9 +34,8 @@ RSpec.describe 'Update of an existing issue' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['issue']).to include(
'discussionLocked' => true
)
expect(mutation_response['issue']).to include(input)
expect(mutation_response['issue']).to include('discussionLocked' => true)
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