Commit 2d7d9ef3 authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch '9421-clone-quickaction' into 'master'

Implement Clone quick_action

See merge request gitlab-org/gitlab!48394
parents 44f8e5d5 0c5fe53f
......@@ -38,7 +38,8 @@ module SystemNoteHelper
'status' => 'status',
'alert_issue_added' => 'issues',
'new_alert_added' => 'warning',
'severity' => 'information-o'
'severity' => 'information-o',
'cloned' => 'documents'
}.freeze
def system_note_icon_name(note)
......
......@@ -308,6 +308,7 @@ class Issue < ApplicationRecord
!moved? && persisted? &&
user.can?(:admin_issue, self.project)
end
alias_method :can_clone?, :can_move?
def to_branch_name
if self.confidential?
......
......@@ -14,12 +14,13 @@ class SystemNoteMetadata < ApplicationRecord
moved merge
label milestone
relate unrelate
cloned
].freeze
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
designs_added designs_modified designs_removed designs_discussion_added
title time_tracking branch milestone discussion task moved
title time_tracking branch milestone discussion task moved cloned
opened closed merged duplicate locked unlocked outdated reviewer
tag due_date pinned_embed cherry_pick health_status approved unapproved
status alert_issue_added relate unrelate new_alert_added severity
......
# frozen_string_literal: true
module Issues
class CloneService < Issuable::Clone::BaseService
CloneError = Class.new(StandardError)
def execute(issue, target_project)
@target_project = target_project
unless issue.can_clone?(current_user, @target_project)
raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!')
end
if target_project.pending_delete?
raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.')
end
super(issue, target_project)
queue_copy_designs
new_entity
end
private
attr_reader :target_project
def update_new_entity
# we don't call `super` because we want to be able to decide whether or not to copy all comments over.
update_new_entity_description
update_new_entity_attributes
copy_award_emoji
end
def update_old_entity
# no-op
# The base_service closes the old issue, we don't want that, so we override here so nothing happens.
end
def create_new_entity
new_params = {
id: nil,
iid: nil,
project: target_project,
author: original_entity.author,
assignee_ids: original_entity.assignee_ids
}
new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
# Skip creation of system notes for existing attributes of the issue. The system notes of the old
# issue are copied over so we don't want to end up with duplicate notes.
CreateService.new(@target_project, @current_user, new_params).execute(skip_system_notes: true)
end
def queue_copy_designs
return unless original_entity.designs.present?
response = DesignManagement::CopyDesignCollection::QueueService.new(
current_user,
original_entity,
new_entity
).execute
log_error(response.message) if response.error?
end
def add_note_from
SystemNoteService.noteable_cloned(new_entity, target_project,
original_entity, current_user,
direction: :from)
end
def add_note_to
SystemNoteService.noteable_cloned(original_entity, old_project,
new_entity, current_user,
direction: :to)
end
end
end
......@@ -9,7 +9,7 @@ module Issues
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
move_issue_to_new_project(issue) || update_task_event(issue) || update(issue)
move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
end
def update(issue)
......@@ -127,6 +127,17 @@ module Issues
private
def clone_issue(issue)
target_project = params.delete(:target_clone_project)
return unless target_project &&
issue.can_clone?(current_user, target_project)
# we've pre-empted this from running in #execute, so let's go ahead and update the Issue now.
update(issue)
Issues::CloneService.new(project, current_user).execute(issue, target_project)
end
def create_merge_request_from_quick_action
create_merge_request_params = params.delete(:create_merge_request)
return unless create_merge_request_params
......
......@@ -226,6 +226,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction)
end
def noteable_cloned(noteable, project, noteable_ref, author, direction:)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction)
end
def mark_duplicate_issue(noteable, project, author, canonical_issue)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_duplicate_issue(canonical_issue)
end
......
......@@ -242,6 +242,27 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
# Called when noteable has been cloned
#
# noteable_ref - Referenced noteable
# direction - symbol, :to or :from
#
# Example Note text:
#
# "cloned to some_namespace/project_new#11"
#
# Returns the created Note object
def noteable_cloned(noteable_ref, direction)
unless [:to, :from].include?(direction)
raise ArgumentError, "Invalid direction `#{direction}`"
end
cross_reference = noteable_ref.to_reference(project)
body = "cloned #{direction} #{cross_reference}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned'))
end
# Called when the confidentiality changes
#
# Example Note text:
......
---
title: Implement a /clone quick-action to quickly clone an Issue
merge_request: 48394
author:
type: added
......@@ -34,6 +34,7 @@ The following quick actions are applicable to descriptions, discussions and thre
| `/award :emoji:` | ✓ | ✓ | ✓ | Toggle emoji award. |
| `/child_epic <epic>` | | | ✓ | Add child epic to `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic ([introduced in GitLab 12.0](https://gitlab.com/gitlab-org/gitlab/-/issues/7330)). **(ULTIMATE)** |
| `/clear_weight` | ✓ | | | Clear weight. **(STARTER)** |
| `/clone <path/to/project>` | ✓ | | | Clone the issue to given project, or the current one if no arguments are given ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9421) in GitLab 13.7). Copies as much data as possible as long as the target project contains equivalent labels, milestones, etc. Does not copy comments or system notes. |
| `/close` | ✓ | ✓ | ✓ | Close. |
| `/confidential` | ✓ | | | Make confidential. |
| `/copy_metadata <!merge_request>` | ✓ | ✓ | | Copy labels and milestone from another merge request in the project. |
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Issues::CloneService do
let_it_be(:user) { create(:user) }
let_it_be(:author) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:sub_group_1) { create(:group, :private, parent: group) }
let_it_be(:old_project) { create(:project, namespace: group) }
let_it_be(:new_project) { create(:project, namespace: sub_group_1) }
let_it_be(:old_issue) { create(:issue, project: old_project, author: author, epic: epic) }
subject(:clone_service) do
described_class.new(old_project, user)
end
let(:new_issue) { clone_service.execute(old_issue, new_project) }
context 'user has enough permissions' do
before do
old_project.add_reporter(user)
new_project.add_reporter(user)
end
it 'does not copy epic' do
expect(new_issue.epic).to be_nil
end
end
end
......@@ -102,6 +102,30 @@ module Gitlab
@execution_message[:duplicate] = message
end
desc _('Clone this issue')
explanation do |project = quick_action_target.project.full_path|
_("Clones this issue, without comments, to %{project}.") % { project: project }
end
params 'path/to/project'
types Issue
condition do
quick_action_target.persisted? &&
current_user.can?(:"admin_#{quick_action_target.to_ability_name}", project)
end
command :clone do |target_project_path = nil|
target_project = target_project_path.present? ? Project.find_by_full_path(target_project_path) : quick_action_target.project
if target_project.present?
@updates[:target_clone_project] = target_project
message = _("Cloned this issue to %{path_to_project}.") % { path_to_project: target_project_path || quick_action_target.project.full_path }
else
message = _("Failed to clone this issue because target project doesn't exist.")
end
@execution_message[:clone] = message
end
desc _('Move this issue to another project.')
explanation do |path_to_project|
_("Moves this issue to %{path_to_project}.") % { path_to_project: path_to_project }
......
......@@ -5665,6 +5665,9 @@ msgstr ""
msgid "Clone repository"
msgstr ""
msgid "Clone this issue"
msgstr ""
msgid "Clone with %{http_label}"
msgstr ""
......@@ -5677,6 +5680,18 @@ msgstr ""
msgid "Clone with SSH"
msgstr ""
msgid "CloneIssue|Cannot clone issue due to insufficient permissions!"
msgstr ""
msgid "CloneIssue|Cannot clone issue to target project as it is pending deletion."
msgstr ""
msgid "Cloned this issue to %{path_to_project}."
msgstr ""
msgid "Clones this issue, without comments, to %{project}."
msgstr ""
msgid "Close"
msgstr ""
......@@ -11424,6 +11439,9 @@ msgstr ""
msgid "Failed to check related branches."
msgstr ""
msgid "Failed to clone this issue because target project doesn't exist."
msgstr ""
msgid "Failed to create Merge Request. Please try again."
msgstr ""
......
......@@ -43,5 +43,6 @@ RSpec.describe 'Issues > User uses quick actions', :js do
it_behaves_like 'create_merge_request quick action'
it_behaves_like 'move quick action'
it_behaves_like 'zoom quick actions'
it_behaves_like 'clone quick action'
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Issues::CloneService do
include DesignManagementTestHelpers
let_it_be(:user) { create(:user) }
let_it_be(:author) { create(:user) }
let_it_be(:title) { 'Some issue' }
let_it_be(:description) { "Some issue description with mention to #{user.to_reference}" }
let_it_be(:group) { create(:group, :private) }
let_it_be(:sub_group_1) { create(:group, :private, parent: group) }
let_it_be(:sub_group_2) { create(:group, :private, parent: group) }
let_it_be(:old_project) { create(:project, namespace: sub_group_1) }
let_it_be(:new_project) { create(:project, namespace: sub_group_2) }
let(:old_issue) do
create(:issue, title: title, description: description, project: old_project, author: author)
end
subject(:clone_service) do
described_class.new(old_project, user)
end
shared_context 'user can clone issue' do
before do
old_project.add_reporter(user)
new_project.add_reporter(user)
end
end
describe '#execute' do
context 'issue movable' do
include_context 'user can clone issue'
context 'generic issue' do
let!(:new_issue) { clone_service.execute(old_issue, new_project) }
it 'creates a new issue in the selected project' do
expect do
clone_service.execute(old_issue, new_project)
end.to change { new_project.issues.count }.by(1)
end
it 'copies issue title' do
expect(new_issue.title).to eq title
end
it 'copies issue description' do
expect(new_issue.description).to eq description
end
it 'adds system note to old issue at the end' do
expect(old_issue.notes.last.note).to start_with 'cloned to'
end
it 'adds system note to new issue at the end' do
expect(new_issue.notes.last.note).to start_with 'cloned from'
end
it 'keeps old issue open' do
expect(old_issue.open?).to be true
end
it 'persists new issue' do
expect(new_issue.persisted?).to be true
end
it 'persists all changes' do
expect(old_issue.changed?).to be false
expect(new_issue.changed?).to be false
end
it 'preserves author' do
expect(new_issue.author).to eq author
end
it 'creates a new internal id for issue' do
expect(new_issue.iid).to be_present
end
it 'preserves create time' do
expect(old_issue.created_at.strftime('%D')).to eq new_issue.created_at.strftime('%D')
end
it 'does not copy system notes' do
expect(new_issue.notes.count).to eq(1)
end
it 'does not set moved_issue' do
expect(old_issue.moved?).to eq(false)
end
end
context 'issue with award emoji' do
let!(:award_emoji) { create(:award_emoji, awardable: old_issue) }
it 'copies the award emoji' do
old_issue.reload
new_issue = clone_service.execute(old_issue, new_project)
expect(old_issue.award_emoji.first.name).to eq new_issue.reload.award_emoji.first.name
end
end
context 'issue with milestone' do
let(:milestone) { create(:milestone, group: sub_group_1) }
let(:new_project) { create(:project, namespace: sub_group_1) }
let(:old_issue) do
create(:issue, title: title, description: description, project: old_project, author: author, milestone: milestone)
end
before do
create(:resource_milestone_event, issue: old_issue, milestone: milestone, action: :add)
end
it 'does not create extra milestone events' do
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.resource_milestone_events.count).to eq(old_issue.resource_milestone_events.count)
end
end
context 'issue with due date' do
let(:date) { Date.parse('2020-01-10') }
let(:old_issue) do
create(:issue, title: title, description: description, project: old_project, author: author, due_date: date)
end
before do
SystemNoteService.change_due_date(old_issue, old_project, author, old_issue.due_date)
end
it 'keeps the same due date' do
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.due_date).to eq(date)
end
end
context 'issue with assignee' do
let_it_be(:assignee) { create(:user) }
before do
old_issue.assignees = [assignee]
end
it 'preserves assignee with access to the new issue' do
new_project.add_reporter(assignee)
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.assignees).to eq([assignee])
end
it 'ignores assignee without access to the new issue' do
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.assignees).to be_empty
end
end
context 'issue is confidential' do
before do
old_issue.update_columns(confidential: true)
end
it 'preserves the confidential flag' do
new_issue = clone_service.execute(old_issue, new_project)
expect(new_issue.confidential).to be true
end
end
context 'moving to same project' do
it 'also works' do
new_issue = clone_service.execute(old_issue, old_project)
expect(new_issue.project).to eq(old_project)
expect(new_issue.iid).not_to eq(old_issue.iid)
end
end
context 'project issue hooks' do
let!(:hook) { create(:project_hook, project: old_project, issues_events: true) }
it 'executes project issue hooks' do
allow_next_instance_of(WebHookService) do |instance|
allow(instance).to receive(:execute)
end
# Ideally, we'd test that `WebHookWorker.jobs.size` increased by 1,
# but since the entire spec run takes place in a transaction, we never
# actually get to the `after_commit` hook that queues these jobs.
expect { clone_service.execute(old_issue, new_project) }
.not_to raise_error # Sidekiq::Worker::EnqueueFromTransactionError
end
end
context 'issue with a design', :clean_gitlab_redis_shared_state do
let_it_be(:new_project) { create(:project) }
let!(:design) { create(:design, :with_lfs_file, issue: old_issue) }
let!(:note) { create(:diff_note_on_design, noteable: design, issue: old_issue, project: old_issue.project) }
let(:subject) { clone_service.execute(old_issue, new_project) }
before do
enable_design_management
end
it 'calls CopyDesignCollection::QueueService' do
expect(DesignManagement::CopyDesignCollection::QueueService).to receive(:new)
.with(user, old_issue, kind_of(Issue))
.and_call_original
subject
end
it 'logs if QueueService returns an error', :aggregate_failures do
error_message = 'error'
expect_next_instance_of(DesignManagement::CopyDesignCollection::QueueService) do |service|
expect(service).to receive(:execute).and_return(
ServiceResponse.error(message: error_message)
)
end
expect(Gitlab::AppLogger).to receive(:error).with(error_message)
subject
end
# Perform a small integration test to ensure the services and worker
# can correctly create designs.
it 'copies the design and its notes', :sidekiq_inline, :aggregate_failures do
new_issue = subject
expect(new_issue.designs.size).to eq(1)
expect(new_issue.designs.first.notes.size).to eq(1)
end
end
end
describe 'clone permissions' do
let(:clone) { clone_service.execute(old_issue, new_project) }
context 'target project is pending deletion' do
include_context 'user can clone issue'
before do
new_project.update_columns(pending_delete: true)
end
after do
new_project.update_columns(pending_delete: false)
end
it { expect { clone }.to raise_error(Issues::CloneService::CloneError, /pending deletion/) }
end
context 'user is reporter in both projects' do
include_context 'user can clone issue'
it { expect { clone }.not_to raise_error }
end
context 'user is reporter only in new project' do
before do
new_project.add_reporter(user)
end
it { expect { clone }.to raise_error(StandardError, /permissions/) }
end
context 'user is reporter only in old project' do
before do
old_project.add_reporter(user)
end
it { expect { clone }.to raise_error(StandardError, /permissions/) }
end
context 'user is reporter in one project and guest in another' do
before do
new_project.add_guest(user)
old_project.add_reporter(user)
end
it { expect { clone }.to raise_error(StandardError, /permissions/) }
end
context 'issue is not persisted' do
include_context 'user can clone issue'
let(:old_issue) { build(:issue, project: old_project, author: author) }
it { expect { clone }.to raise_error(StandardError, /permissions/) }
end
end
end
end
......@@ -968,6 +968,26 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
context 'clone an issue' do
context 'valid project' do
let(:target_project) { create(:project) }
before do
target_project.add_maintainer(user)
end
it 'calls the move service with the proper issue and project' do
clone_stub = instance_double(Issues::CloneService)
allow(Issues::CloneService).to receive(:new).and_return(clone_stub)
allow(clone_stub).to receive(:execute).with(issue, target_project).and_return(issue)
expect(clone_stub).to receive(:execute).with(issue, target_project)
update_issue(target_clone_project: target_project)
end
end
end
context 'when moving an issue ' do
it 'raises an error for invalid move ids within a project' do
opts = { move_between_ids: [9000, non_existing_record_id] }
......
......@@ -333,6 +333,19 @@ RSpec.describe SystemNoteService do
end
end
describe '.noteable_cloned' do
let(:noteable_ref) { double }
let(:direction) { double }
it 'calls IssuableService' do
expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
expect(service).to receive(:noteable_cloned).with(noteable_ref, direction)
end
described_class.noteable_cloned(double, double, noteable_ref, double, direction: direction)
end
end
describe 'Jira integration' do
include JiraServiceHelper
......
......@@ -522,6 +522,67 @@ RSpec.describe ::SystemNotes::IssuablesService do
end
end
describe '#noteable_cloned' do
let(:new_project) { create(:project) }
let(:new_noteable) { create(:issue, project: new_project) }
subject do
service.noteable_cloned(new_noteable, direction)
end
shared_examples 'cross project mentionable' do
include MarkupHelper
it 'contains cross reference to new noteable' do
expect(subject.note).to include cross_project_reference(new_project, new_noteable)
end
it 'mentions referenced noteable' do
expect(subject.note).to include new_noteable.to_reference
end
it 'mentions referenced project' do
expect(subject.note).to include new_project.full_path
end
end
context 'cloned to' do
let(:direction) { :to }
it_behaves_like 'cross project mentionable'
it_behaves_like 'a system note' do
let(:action) { 'cloned' }
end
it 'notifies about noteable being cloned to' do
expect(subject.note).to match('cloned to')
end
end
context 'cloned from' do
let(:direction) { :from }
it_behaves_like 'cross project mentionable'
it_behaves_like 'a system note' do
let(:action) { 'cloned' }
end
it 'notifies about noteable being cloned from' do
expect(subject.note).to match('cloned from')
end
end
context 'invalid direction' do
let(:direction) { :invalid }
it 'raises error' do
expect { subject }.to raise_error StandardError, /Invalid direction/
end
end
end
describe '#mark_duplicate_issue' do
subject { service.mark_duplicate_issue(canonical_issue) }
......
# frozen_string_literal: true
RSpec.shared_examples 'clone quick action' do
context 'clone the issue to another project' do
let(:target_project) { create(:project, :public) }
context 'when no target is given' do
it 'clones the issue in the current project' do
add_note("/clone")
expect(page).to have_content "Cloned this issue to #{project.full_path}."
expect(issue.reload).to be_open
visit project_issue_path(project, issue)
expect(page).to have_content 'Issues 2'
end
end
context 'when the project is valid' do
before do
target_project.add_maintainer(user)
end
it 'clones the issue' do
add_note("/clone #{target_project.full_path}")
expect(page).to have_content "Cloned this issue to #{target_project.full_path}."
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
expect(page).to have_content 'Issues 1'
end
end
context 'when the project is valid but the user not authorized' do
let(:project_unauthorized) { create(:project, :public) }
it 'does not clone the issue' do
add_note("/clone #{project_unauthorized.full_path}")
wait_for_requests
expect(page).to have_content "Cloned this issue to #{project_unauthorized.full_path}."
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
expect(page).not_to have_content 'Issues 1'
end
end
context 'when the project is invalid' do
it 'does not clone the issue' do
add_note("/clone not/valid")
wait_for_requests
expect(page).to have_content "Failed to clone this issue because target project doesn't exist."
expect(issue.reload).to be_open
end
end
context 'when the user issues multiple commands' do
let(:milestone) { create(:milestone, title: '1.0', project: project) }
let(:bug) { create(:label, project: project, title: 'bug') }
let(:wontfix) { create(:label, project: project, title: 'wontfix') }
let!(:target_milestone) { create(:milestone, title: '1.0', project: target_project) }
before do
target_project.add_maintainer(user)
end
shared_examples 'applies the commands to issues in both projects, target and source' do
it "applies quick actions" do
expect(page).to have_content "Cloned this issue to #{target_project.full_path}."
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
expect(page).to have_content 'bug'
expect(page).to have_content 'wontfix'
expect(page).to have_content '1.0'
visit project_issue_path(project, issue)
expect(page).to have_content 'bug'
expect(page).to have_content 'wontfix'
expect(page).to have_content '1.0'
end
end
context 'applies multiple commands with clone command in the end' do
before do
add_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/clone #{target_project.full_path}")
end
it_behaves_like 'applies the commands to issues in both projects, target and source'
end
context 'applies multiple commands with clone command in the begining' do
before do
add_note("/clone #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"")
end
it_behaves_like 'applies the commands to issues in both projects, target and source'
end
end
context 'when editing comments' do
let(:target_project) { create(:project, :public) }
before do
target_project.add_maintainer(user)
sign_in(user)
visit project_issue_path(project, issue)
wait_for_all_requests
end
it 'clones the issue after quickcommand note was updated' do
# misspelled quick action
add_note("test note.\n/cloe #{target_project.full_path}")
expect(issue.reload).not_to be_closed
edit_note("/cloe #{target_project.full_path}", "test note.\n/clone #{target_project.full_path}")
wait_for_all_requests
expect(page).to have_content 'test note.'
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
wait_for_all_requests
expect(page).to have_content 'Issues 1'
end
it 'deletes the note if it was updated to just contain a command' do
# missspelled quick action
add_note("test note.\n/cloe #{target_project.full_path}")
expect(page).not_to have_content 'Commands applied'
edit_note("/cloe #{target_project.full_path}", "/clone #{target_project.full_path}")
wait_for_all_requests
expect(page).not_to have_content "/clone #{target_project.full_path}"
expect(issue.reload).to be_open
visit project_issue_path(target_project, issue)
wait_for_all_requests
expect(page).to have_content 'Issues 1'
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