Commit 9ccadbcf authored by Jan Provaznik's avatar Jan Provaznik Committed by Heinrich Lee Yu

Support moving issue between epics

On an issue board, epics act as "horizontal lists"/swimlanes.
This enables moving an issue between epic swimlanes by extending
an existing iseueMoveList mutation.
parent 4d8bc1ac
...@@ -50,6 +50,8 @@ module Mutations ...@@ -50,6 +50,8 @@ module Mutations
end end
def resolve(board:, **args) def resolve(board:, **args)
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/-/issues/247861')
raise_resource_not_available_error! unless board raise_resource_not_available_error! unless board
authorize_board!(board) authorize_board!(board)
...@@ -89,3 +91,5 @@ module Mutations ...@@ -89,3 +91,5 @@ module Mutations
end end
end end
end end
Mutations::Boards::Issues::IssueMoveList.prepend_if_ee('EE::Mutations::Boards::Issues::IssueMoveList')
...@@ -71,12 +71,16 @@ module Boards ...@@ -71,12 +71,16 @@ module Boards
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def moving_from_list def moving_from_list
return unless params[:from_list_id].present?
@moving_from_list ||= board.lists.find_by(id: params[:from_list_id]) @moving_from_list ||= board.lists.find_by(id: params[:from_list_id])
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def moving_to_list def moving_to_list
return unless params[:to_list_id].present?
@moving_to_list ||= board.lists.find_by(id: params[:to_list_id]) @moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
......
...@@ -5677,6 +5677,11 @@ type EpicHealthStatus { ...@@ -5677,6 +5677,11 @@ type EpicHealthStatus {
issuesOnTrack: Int issuesOnTrack: Int
} }
"""
Identifier of Epic
"""
scalar EpicID
""" """
Relationship between an epic and an issue Relationship between an epic and an issue
""" """
...@@ -8066,6 +8071,11 @@ input IssueMoveListInput { ...@@ -8066,6 +8071,11 @@ input IssueMoveListInput {
""" """
clientMutationId: String clientMutationId: String
"""
The ID of the parent epic. NULL when removing the association
"""
epicId: EpicID
""" """
ID of the board list that the issue will be moved from ID of the board list that the issue will be moved from
""" """
......
...@@ -15890,6 +15890,16 @@ ...@@ -15890,6 +15890,16 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "SCALAR",
"name": "EpicID",
"description": "Identifier of Epic",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "EpicIssue", "name": "EpicIssue",
...@@ -22348,6 +22358,16 @@ ...@@ -22348,6 +22358,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "epicId",
"description": "The ID of the parent epic. NULL when removing the association",
"type": {
"kind": "SCALAR",
"name": "EpicID",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "clientMutationId", "name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.", "description": "A unique identifier for the client performing the mutation.",
# frozen_string_literal: true
module EE
module Mutations
module Boards
module Issues
module IssueMoveList
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
prepended do
argument :epic_id, ::Types::GlobalIDType[::Epic],
required: false,
description: 'The ID of the parent epic. NULL when removing the association'
end
override :move_issue
def move_issue(board, issue, move_params)
super
rescue ::Issues::BaseService::EpicAssignmentError => e
issue.errors.add(:epic_issue, e.message)
# because we can't be sure if these exceptions were raised because of epic
# we return just a generic error here for now
# https://gitlab.com/gitlab-org/gitlab/-/issues/247096
rescue ::Gitlab::Access::AccessDeniedError, ActiveRecord::RecordNotFound
issue.errors.add(:base, 'Resource not found')
end
override :move_arguments
def move_arguments(args)
allowed_args = super
allowed_args[:epic_id] = args[:epic_id]&.model_id if args.has_key?(:epic_id)
allowed_args
end
end
end
end
end
end
...@@ -8,9 +8,10 @@ module EE ...@@ -8,9 +8,10 @@ module EE
override :issue_params override :issue_params
def issue_params(issue) def issue_params(issue)
return super unless move_between_lists?
args = super args = super
args[:epic_id] = params[:epic_id] if params.has_key?(:epic_id)
return args unless move_between_lists?
unless both_are_same_type? || !moving_to_list.movable? unless both_are_same_type? || !moving_to_list.movable?
args.delete(:remove_label_ids) args.delete(:remove_label_ids)
......
...@@ -16,7 +16,9 @@ module EE ...@@ -16,7 +16,9 @@ module EE
super super
Epics::UpdateDatesService.new(affected_epics).execute unless affected_epics.blank? if !params[:skip_epic_dates_update] && affected_epics.present?
Epics::UpdateDatesService.new(affected_epics).execute
end
end end
def affected_epics(_issues) def affected_epics(_issues)
......
...@@ -5,45 +5,46 @@ module EE ...@@ -5,45 +5,46 @@ module EE
module BaseService module BaseService
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
def filter_params(issue) class EpicAssignmentError < ::ArgumentError; end
set_epic_param(issue)
super
end
def handle_epic(issue) def handle_epic(issue)
set_epic_param(issue) return unless epic_param_present?
return unless params.key?(:epic) epic = epic_param(issue)
result = epic ? assign_epic(issue, epic) : unassign_epic(issue)
issue.reload_epic
if epic_param if result[:status] == :error
EpicIssues::CreateService.new(epic_param, current_user, { target_issuable: issue }).execute raise EpicAssignmentError, result[:message]
else end
link = EpicIssue.find_by_issue_id(issue.id) end
return unless link def assign_epic(issue, epic)
issue.confidential = true if !issue.persisted? && epic.confidential
EpicIssues::DestroyService.new(link, current_user).execute link_params = { target_issuable: issue, skip_epic_dates_update: true }
end
params.delete(:epic) EpicIssues::CreateService.new(epic, current_user, link_params).execute
end end
def set_epic_param(issue) def unassign_epic(issue)
return unless epic_param_present? link = EpicIssue.find_by_issue_id(issue.id)
return success unless link
EpicIssues::DestroyService.new(link, current_user).execute
end
def epic_param(issue)
epic_id = params.delete(:epic_id) epic_id = params.delete(:epic_id)
epic = epic_param || find_epic(issue, epic_id) epic = params.delete(:epic) || find_epic(issue, epic_id)
unless epic return unless epic
params[:epic] = nil
return
end
unless can?(current_user, :admin_epic, epic) unless can?(current_user, :admin_epic, epic)
raise ::Gitlab::Access::AccessDeniedError raise ::Gitlab::Access::AccessDeniedError
end end
params[:epic] = epic epic
end end
def find_epic(issue, epic_id) def find_epic(issue, epic_id)
...@@ -56,10 +57,6 @@ module EE ...@@ -56,10 +57,6 @@ module EE
include_ancestor_groups: true).find(epic_id) include_ancestor_groups: true).find(epic_id)
end end
def epic_param
params[:epic]
end
def epic_param_present? def epic_param_present?
params.key?(:epic) || params.key?(:epic_id) params.key?(:epic) || params.key?(:epic_id)
end end
......
...@@ -5,17 +5,20 @@ module EE ...@@ -5,17 +5,20 @@ module EE
module CreateService module CreateService
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
override :before_create override :filter_params
def before_create(issue) def filter_params(issue)
handle_epic(issue) handle_epic(issue)
super super
end end
def handle_epic(issue) override :execute
issue.confidential = true if epic_param&.confidential def execute(skip_system_notes: false)
super.tap do |issue|
super if issue.previous_changes.include?(:milestone_id) && issue.epic_issue
::Epics::UpdateDatesService.new([issue.epic_issue.epic]).execute
end
end
end end
end end
end end
......
...@@ -6,9 +6,15 @@ module EE ...@@ -6,9 +6,15 @@ module EE
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
include ::Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
override :filter_params
def filter_params(issue)
handle_epic(issue)
super
end
override :execute override :execute
def execute(issue) def execute(issue)
handle_epic(issue)
handle_promotion(issue) handle_promotion(issue)
result = super result = super
......
---
title: Support moving issue between epics in GraphQL
merge_request: 41790
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Boards::Issues::IssueMoveList do
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:issue1) { create(:labeled_issue, project: project, relative_position: 3) }
let_it_be(:existing_issue1) { create(:labeled_issue, project: project, relative_position: 10) }
let_it_be(:existing_issue2) { create(:labeled_issue, project: project, relative_position: 50) }
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
let(:params) { { board: board, project_path: project.full_path, iid: issue1.iid } }
let(:move_params) do
{
epic_id: epic.to_global_id,
move_before_id: existing_issue2.id,
move_after_id: existing_issue1.id
}
end
before do
stub_licensed_features(epics: true)
project.add_maintainer(user)
end
subject do
mutation.resolve(params.merge(move_params))
end
describe '#resolve' do
context 'when user has access to the epic' do
before do
group.add_developer(user)
end
it 'moves and repositions issue' do
subject
expect(issue1.reload.epic).to eq(epic)
expect(issue1.relative_position).to be < existing_issue2.relative_position
expect(issue1.relative_position).to be > existing_issue1.relative_position
end
end
context 'when user does not have access to the epic' do
it 'does not update issue' do
subject
expect(issue1.reload.epic).to be_nil
expect(issue1.relative_position).to eq(3)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Reposition and move issue within board lists' do
include GraphqlHelpers
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:user) { create(:user) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:existing_issue1) { create(:labeled_issue, project: project, relative_position: 10) }
let_it_be(:existing_issue2) { create(:labeled_issue, project: project, relative_position: 50) }
let_it_be(:issue1) { create(:labeled_issue, project: project, relative_position: 3) }
let_it_be(:development) { create(:label, project: project, name: 'Development') }
let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
let_it_be(:list1) { create(:list, board: board, label: development, position: 0) }
let_it_be(:list2) { create(:list, board: board, label: testing, position: 1) }
let(:mutation_class) { Mutations::Boards::Issues::IssueMoveList }
let(:mutation_name) { mutation_class.graphql_name }
let(:params) { { board_id: board.to_global_id.to_s, project_path: project.full_path, iid: issue1.iid.to_s } }
let(:issue_move_params) do
{
epic_id: epic.to_global_id.to_s,
move_before_id: existing_issue2.id,
move_after_id: existing_issue1.id,
from_list_id: list1.id,
to_list_id: list2.id
}
end
before do
stub_licensed_features(epics: true)
project.add_maintainer(user)
end
context 'when user has access to the epic' do
before do
group.add_maintainer(user)
end
it 'updates issue position and epic' do
post_graphql_mutation(mutation(params), current_user: user)
expect(response).to have_gitlab_http_status(:success)
response_issue = graphql_mutation_response(:issue_move_list)['issue']
expect(response_issue['iid']).to eq(issue1.iid.to_s)
expect(response_issue['relativePosition']).to be > existing_issue1.relative_position
expect(response_issue['relativePosition']).to be < existing_issue2.relative_position
expect(response_issue['epic']['id']).to eq(epic.to_global_id.to_s)
expect(response_issue['labels']['nodes'].first['title']).to eq(testing.title)
end
context 'when user sets nil epic' do
let_it_be(:epic_issue) { create(:epic_issue, issue: issue1, epic: epic) }
let(:issue_move_params) do
{
epic_id: nil,
move_before_id: existing_issue2.id,
move_after_id: existing_issue1.id
}
end
it 'updates issue position and epic is unassigned' do
post_graphql_mutation(mutation(params), current_user: user)
expect(response).to have_gitlab_http_status(:success)
response_issue = graphql_mutation_response(:issue_move_list)['issue']
expect(response_issue['iid']).to eq(issue1.iid.to_s)
expect(response_issue['relativePosition']).to be > existing_issue1.relative_position
expect(response_issue['relativePosition']).to be < existing_issue2.relative_position
expect(response_issue['epic']).to be_nil
end
end
end
context 'when user can not admin epic' do
it 'fails with error' do
post_graphql_mutation(mutation(params), current_user: user)
mutation_response = graphql_mutation_response(:issue_move_list)
expect(mutation_response['errors']).to eq(['Resource not found'])
expect(mutation_response['issue']['epic']).to eq(nil)
expect(mutation_response['issue']['relativePosition']).to eq(3)
end
end
def mutation(additional_params = {})
graphql_mutation(mutation_name, issue_move_params.merge(additional_params),
<<-QL.strip_heredoc
clientMutationId
issue {
iid,
relativePosition
epic {
id
}
labels {
nodes {
title
}
}
}
errors
QL
)
end
end
...@@ -60,7 +60,7 @@ RSpec.describe Issues::CreateService do ...@@ -60,7 +60,7 @@ RSpec.describe Issues::CreateService do
issue = service.execute issue = service.execute
expect(issue).to be_persisted expect(issue).to be_persisted
expect(issue.epic).to eq(epic) expect(issue.reload.epic).to eq(epic)
expect(issue.confidential).to eq(false) expect(issue.confidential).to eq(false)
end end
end end
...@@ -78,7 +78,7 @@ RSpec.describe Issues::CreateService do ...@@ -78,7 +78,7 @@ RSpec.describe Issues::CreateService do
issue = service.execute issue = service.execute
expect(issue.milestone).to eq(milestone) expect(issue.milestone).to eq(milestone)
expect(issue.epic).to eq(epic) expect(issue.reload.epic).to eq(epic)
expect(epic.reload.start_date).to eq(milestone.start_date) expect(epic.reload.start_date).to eq(milestone.start_date)
expect(epic.due_date).to eq(milestone.due_date) expect(epic.due_date).to eq(milestone.due_date)
end end
......
...@@ -211,9 +211,10 @@ RSpec.describe Issues::UpdateService do ...@@ -211,9 +211,10 @@ RSpec.describe Issues::UpdateService do
it 'calls EpicIssues::CreateService' do it 'calls EpicIssues::CreateService' do
link_sevice = double link_sevice = double
expect(EpicIssues::CreateService).to receive(:new).with(epic, user, { target_issuable: issue }) expect(EpicIssues::CreateService).to receive(:new)
.with(epic, user, { target_issuable: issue, skip_epic_dates_update: true })
.and_return(link_sevice) .and_return(link_sevice)
expect(link_sevice).to receive(:execute) expect(link_sevice).to receive(:execute).and_return({ status: :success })
subject subject
end end
...@@ -232,9 +233,10 @@ RSpec.describe Issues::UpdateService do ...@@ -232,9 +233,10 @@ RSpec.describe Issues::UpdateService do
it 'calls EpicIssues::CreateService' do it 'calls EpicIssues::CreateService' do
link_sevice = double link_sevice = double
expect(EpicIssues::CreateService).to receive(:new).with(epic, user, { target_issuable: issue }) expect(EpicIssues::CreateService).to receive(:new)
.with(epic, user, { target_issuable: issue, skip_epic_dates_update: true })
.and_return(link_sevice) .and_return(link_sevice)
expect(link_sevice).to receive(:execute) expect(link_sevice).to receive(:execute).and_return({ status: :success })
subject subject
end end
...@@ -273,7 +275,7 @@ RSpec.describe Issues::UpdateService do ...@@ -273,7 +275,7 @@ RSpec.describe Issues::UpdateService do
link_sevice = double link_sevice = double
expect(EpicIssues::DestroyService).to receive(:new).with(EpicIssue.last, user) expect(EpicIssues::DestroyService).to receive(:new).with(EpicIssue.last, user)
.and_return(link_sevice) .and_return(link_sevice)
expect(link_sevice).to receive(:execute) expect(link_sevice).to receive(:execute).and_return({ status: :success })
subject subject
end end
...@@ -311,7 +313,7 @@ RSpec.describe Issues::UpdateService do ...@@ -311,7 +313,7 @@ RSpec.describe Issues::UpdateService do
link_sevice = double link_sevice = double
expect(EpicIssues::DestroyService).to receive(:new).with(epic_issue, user) expect(EpicIssues::DestroyService).to receive(:new).with(epic_issue, user)
.and_return(link_sevice) .and_return(link_sevice)
expect(link_sevice).to receive(:execute) expect(link_sevice).to receive(:execute).and_return({ status: :success })
subject subject
end end
......
...@@ -55,7 +55,7 @@ RSpec.shared_examples 'issue with epic_id parameter' do ...@@ -55,7 +55,7 @@ RSpec.shared_examples 'issue with epic_id parameter' do
it 'calls EpicIssues::CreateService' do it 'calls EpicIssues::CreateService' do
link_sevice = double link_sevice = double
expect(EpicIssues::CreateService).to receive(:new).and_return(link_sevice) expect(EpicIssues::CreateService).to receive(:new).and_return(link_sevice)
expect(link_sevice).to receive(:execute) expect(link_sevice).to receive(:execute).and_return({ status: :success })
execute execute
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