Commit 4500f057 authored by Mark Chao's avatar Mark Chao

Merge branch '233974-promote-issue-to-epic' into 'master'

Add GraphQL mutation to promote an issue to an epic

See merge request gitlab-org/gitlab!46143
parents c7da246b 7a36f6d3
......@@ -13300,6 +13300,7 @@ type Mutation {
prometheusIntegrationCreate(input: PrometheusIntegrationCreateInput!): PrometheusIntegrationCreatePayload
prometheusIntegrationResetToken(input: PrometheusIntegrationResetTokenInput!): PrometheusIntegrationResetTokenPayload
prometheusIntegrationUpdate(input: PrometheusIntegrationUpdateInput!): PrometheusIntegrationUpdatePayload
promoteToEpic(input: PromoteToEpicInput!): PromoteToEpicPayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload @deprecated(reason: "Use awardEmojiRemove. Deprecated in 13.2")
removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload
......@@ -16758,6 +16759,56 @@ Identifier of PrometheusService
"""
scalar PrometheusServiceID
"""
Autogenerated input type of PromoteToEpic
"""
input PromoteToEpicInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The group the promoted epic will belong to
"""
groupPath: ID
"""
The IID of the issue to mutate
"""
iid: String!
"""
The project the issue to mutate is in
"""
projectPath: ID!
}
"""
Autogenerated return type of PromoteToEpic
"""
type PromoteToEpicPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The epic after issue promotion
"""
epic: Epic
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The issue after mutation
"""
issue: Issue
}
type Query {
"""
Get information about current user
......
......@@ -38628,6 +38628,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "promoteToEpic",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "PromoteToEpicInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "PromoteToEpicPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "removeAwardEmoji",
"description": null,
......@@ -48712,6 +48739,146 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "PromoteToEpicInput",
"description": "Autogenerated input type of PromoteToEpic",
"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": "groupPath",
"description": "The group the promoted epic will belong to",
"type": {
"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": "OBJECT",
"name": "PromoteToEpicPayload",
"description": "Autogenerated return type of PromoteToEpic",
"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": "epic",
"description": "The epic after issue promotion",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Epic",
"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": "Query",
......@@ -2407,6 +2407,17 @@ Autogenerated return type of PrometheusIntegrationUpdate.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `integration` | AlertManagementPrometheusIntegration | The newly created integration |
### PromoteToEpicPayload
Autogenerated return type of PromoteToEpic.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `epic` | Epic | The epic after issue promotion |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `issue` | Issue | The issue after mutation |
### Release
Represents a release.
......
......@@ -13,6 +13,7 @@ module EE
mount_mutation ::Mutations::Issues::SetIteration
mount_mutation ::Mutations::Issues::SetWeight
mount_mutation ::Mutations::Issues::SetEpic
mount_mutation ::Mutations::Issues::PromoteToEpic
mount_mutation ::Mutations::Environments::CanaryIngress::Update
mount_mutation ::Mutations::EpicTree::Reorder
mount_mutation ::Mutations::Epics::Update
......
# frozen_string_literal: true
module Mutations
module Issues
class PromoteToEpic < Base
include Mutations::ResolvesGroup
graphql_name 'PromoteToEpic'
argument :group_path, GraphQL::ID_TYPE,
required: false,
description: 'The group the promoted epic will belong to'
field :epic,
Types::EpicType,
null: true,
description: "The epic after issue promotion"
def resolve(project_path:, iid:, group_path: nil)
errors = []
issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project
group = get_group_by_path!(group_path)
begin
epic = ::Epics::IssuePromoteService.new(project, current_user).execute(issue, group)
rescue => error
errors << error.message
end
errors << issue&.errors&.full_messages
errors << epic&.errors&.full_messages
{
issue: issue,
epic: epic,
errors: errors.compact.flatten
}
end
private
def get_group_by_path!(group_path)
return unless group_path
group = resolve_group(full_path: group_path).try(:sync)
raise raise_resource_not_available_error! unless group
group
end
end
end
end
---
title: Add GraphQL mutation to promote an issue to an epic
merge_request: 46143
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Issues::PromoteToEpic do
let(:new_epic_group) { nil }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:user) { create(:user) }
before do
stub_licensed_features(epics: true)
end
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
RSpec.shared_examples 'successfully promotes issue to epic' do
it 'returns the issue and the epic', :aggregate_failures do
expect(mutated_issue).to eq(issue)
expect(mutated_issue.state).to eq('closed')
expect(issue.reload.promoted_to_epic_id).to eq(epic.id)
expect(epic).not_to be_nil
expect(epic.title).to eq(issue.title)
expect(epic.group).to eq(epic_group)
expect(subject[:errors]).to be_empty
end
end
describe '#resolve' do
let(:mutated_issue) { subject[:issue] }
let(:epic) { subject[:epic] }
subject { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, group_path: new_epic_group&.full_path) }
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 issue is accessible to the user' do
before do
project.add_developer(user)
end
context 'when the user cannot promote the issue' do
it 'returns the issue and the errors', :aggregate_failures do
expect(mutated_issue).to eq(issue)
expect(epic).to be_nil
expect(subject[:errors]).to eq(['Cannot promote issue due to insufficient permissions.'])
end
end
context 'when the user can promote the issue' do
before do
group.add_reporter(user)
end
it_behaves_like 'successfully promotes issue to epic' do
let(:epic_group) { group }
end
context 'when destination group does not exist' do
let(:new_epic_group) { double('group', full_path: 'non-existing') }
let(:user) { create(:user, :admin) }
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Setting the epic of an issue' do
include GraphqlHelpers
let(:new_epic_group) { nil }
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:user) { create(:user) }
let(:input) { { group_path: new_epic_group&.full_path } }
let(:mutation) do
graphql_mutation(
:promote_to_epic,
{ project_path: project.full_path, iid: issue.iid.to_s }.merge(input),
<<~GRAPHQL
clientMutationId
errors
issue {
iid
title
state
}
epic {
id
title
group {
id
}
}
GRAPHQL
)
end
def mutation_response
graphql_mutation_response(:promote_to_epic)
end
before_all do
project.add_developer(current_user)
group.add_developer(current_user)
end
before do
stub_licensed_features(epics: true)
end
it 'returns an error if the user is not allowed to update the issue' 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: create(:user))
expect(graphql_errors).to include(a_hash_including('message' => error))
end
it 'returns an error if issue can not be updated' do
issue.update_column(:author_id, nil)
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response["errors"]).to eq(["Author can't be blank"])
end
it 'promotes the issue to epic' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['errors']).to be_empty
expect(mutation_response['issue']['state']).to eq('closed')
expect(mutation_response['epic']['title']).to eq(issue.title)
expect(mutation_response['epic']['group']['id']).to eq(group.to_global_id.to_s)
expect(issue.reload.promoted_to_epic_id.to_s).to eq(GlobalID.parse(mutation_response['epic']['id']).model_id)
end
context 'when epic has to be in a different group' do
let(:new_epic_group) { create(:group) }
context 'when user cannot create epic in new group' do
it 'does not promote the issue to epic' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['issue']['state']).to eq('opened')
expect(mutation_response['errors']).not_to be_empty
expect(mutation_response['errors']).to eq(['Cannot promote issue due to insufficient permissions.'])
end
end
context 'when user can create epic in new group' do
before do
new_epic_group.add_developer(current_user)
end
it 'promotes the issue to epic' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['errors']).to be_empty
expect(mutation_response['issue']['state']).to eq('closed')
expect(mutation_response['epic']['title']).to eq(issue.title)
expect(mutation_response['epic']['group']['id']).to eq(new_epic_group.to_global_id.to_s)
expect(issue.reload.promoted_to_epic_id.to_s).to eq(GlobalID.parse(mutation_response['epic']['id']).model_id)
end
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