diff --git a/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml b/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml
new file mode 100644
index 00000000000..3b057373e7d
--- /dev/null
+++ b/changelogs/unreleased/adamco-gitlab-ce-move-issue-command.yml
@@ -0,0 +1,5 @@
+---
+title: Add slash command for moving issues
+merge_request:
+author: Adam Pahlevi
+type: added
diff --git a/doc/integration/slash_commands.md b/doc/integration/slash_commands.md
index 36a8844e953..7d73026a6c6 100644
--- a/doc/integration/slash_commands.md
+++ b/doc/integration/slash_commands.md
@@ -15,9 +15,10 @@ Taking the trigger term as `project-name`, the commands are:
| `/project-name issue new
` | Creates a new issue with title `` and description `` |
| `/project-name issue show ` | Shows the issue with id `` |
| `/project-name issue search ` | Shows up to 5 issues matching `` |
+| `/project-name issue move to ` | Moves issue ID `` to `` |
| `/project-name deploy to ` | Deploy from the `` environment to the `` environment |
-Note that if you are using the [GitLab Slack application](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html) for
+Note that if you are using the [GitLab Slack application](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html) for
your GitLab.com projects, you need to [add the `gitlab` keyword at the beginning of the command](https://docs.gitlab.com/ee/user/project/integrations/gitlab_slack_application.html#usage).
## Issue commands
diff --git a/lib/gitlab/slash_commands/command.rb b/lib/gitlab/slash_commands/command.rb
index 85aaa6b0eba..bb778f37096 100644
--- a/lib/gitlab/slash_commands/command.rb
+++ b/lib/gitlab/slash_commands/command.rb
@@ -5,6 +5,7 @@ module Gitlab
Gitlab::SlashCommands::IssueShow,
Gitlab::SlashCommands::IssueNew,
Gitlab::SlashCommands::IssueSearch,
+ Gitlab::SlashCommands::IssueMove,
Gitlab::SlashCommands::Deploy
].freeze
diff --git a/lib/gitlab/slash_commands/issue_move.rb b/lib/gitlab/slash_commands/issue_move.rb
new file mode 100644
index 00000000000..3985e635983
--- /dev/null
+++ b/lib/gitlab/slash_commands/issue_move.rb
@@ -0,0 +1,45 @@
+module Gitlab
+ module SlashCommands
+ class IssueMove < IssueCommand
+ def self.match(text)
+ %r{
+ \A # the beginning of a string
+ issue\s+move\s+ # the command
+ \#?(?\d+)\s+ # the issue id, may preceded by hash sign
+ (to\s+)? # aid the command to be much more human-ly
+ (?[^\s]+) # named group for id of dest. project
+ }x.match(text)
+ end
+
+ def self.help_message
+ 'issue move (to)? '
+ end
+
+ def self.allowed?(project, user)
+ can?(user, :admin_issue, project)
+ end
+
+ def execute(match)
+ old_issue = find_by_iid(match[:iid])
+ target_project = Project.find_by_full_path(match[:project_path])
+
+ unless current_user.can?(:read_project, target_project) && old_issue
+ return Gitlab::SlashCommands::Presenters::Access.new.not_found
+ end
+
+ new_issue = Issues::MoveService.new(project, current_user)
+ .execute(old_issue, target_project)
+
+ presenter(new_issue).present(old_issue)
+ rescue Issues::MoveService::MoveError => e
+ presenter(old_issue).display_move_error(e.message)
+ end
+
+ private
+
+ def presenter(issue)
+ Gitlab::SlashCommands::Presenters::IssueMove.new(issue)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/slash_commands/presenters/issue_move.rb b/lib/gitlab/slash_commands/presenters/issue_move.rb
new file mode 100644
index 00000000000..03921729941
--- /dev/null
+++ b/lib/gitlab/slash_commands/presenters/issue_move.rb
@@ -0,0 +1,53 @@
+# coding: utf-8
+module Gitlab
+ module SlashCommands
+ module Presenters
+ class IssueMove < Presenters::Base
+ include Presenters::IssueBase
+
+ def present(old_issue)
+ in_channel_response(moved_issue(old_issue))
+ end
+
+ def display_move_error(error)
+ message = header_with_list("The action was not successful, because:", [error])
+
+ ephemeral_response(text: message)
+ end
+
+ private
+
+ def moved_issue(old_issue)
+ {
+ attachments: [
+ {
+ title: "#{@resource.title} ยท #{@resource.to_reference}",
+ title_link: resource_url,
+ author_name: author.name,
+ author_icon: author.avatar_url,
+ fallback: "Issue #{@resource.to_reference}: #{@resource.title}",
+ pretext: pretext(old_issue),
+ color: color(@resource),
+ fields: fields,
+ mrkdwn_in: [
+ :title,
+ :pretext,
+ :text,
+ :fields
+ ]
+ }
+ ]
+ }
+ end
+
+ def pretext(old_issue)
+ "Moved issue *#{issue_link(old_issue)}* to *#{issue_link(@resource)}*"
+ end
+
+ def issue_link(issue)
+ "[#{issue.to_reference}](#{project_issue_url(issue.project, issue)})"
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb
index e3447d974aa..194cae8c645 100644
--- a/spec/lib/gitlab/slash_commands/command_spec.rb
+++ b/spec/lib/gitlab/slash_commands/command_spec.rb
@@ -108,5 +108,10 @@ describe Gitlab::SlashCommands::Command do
it { is_expected.to eq(Gitlab::SlashCommands::IssueSearch) }
end
+
+ context 'IssueMove is triggered' do
+ let(:params) { { text: 'issue move #78291 to gitlab/gitlab-ci' } }
+ it { is_expected.to eq(Gitlab::SlashCommands::IssueMove) }
+ end
end
end
diff --git a/spec/lib/gitlab/slash_commands/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/issue_move_spec.rb
new file mode 100644
index 00000000000..d41441c9472
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/issue_move_spec.rb
@@ -0,0 +1,117 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::IssueMove, service: true do
+ describe '#match' do
+ shared_examples_for 'move command' do |text_command|
+ it 'can be parsed to extract the needed fields' do
+ match_data = described_class.match(text_command)
+
+ expect(match_data['iid']).to eq('123456')
+ expect(match_data['project_path']).to eq('gitlab/gitlab-ci')
+ end
+ end
+
+ it_behaves_like 'move command', 'issue move #123456 to gitlab/gitlab-ci'
+ it_behaves_like 'move command', 'issue move #123456 gitlab/gitlab-ci'
+ it_behaves_like 'move command', 'issue move #123456 gitlab/gitlab-ci '
+ it_behaves_like 'move command', 'issue move 123456 to gitlab/gitlab-ci'
+ it_behaves_like 'move command', 'issue move 123456 gitlab/gitlab-ci'
+ it_behaves_like 'move command', 'issue move 123456 gitlab/gitlab-ci '
+ end
+
+ describe '#execute' do
+ set(:user) { create(:user) }
+ set(:issue) { create(:issue) }
+ set(:chat_name) { create(:chat_name, user: user) }
+ set(:project) { issue.project }
+ set(:other_project) { create(:project, namespace: project.namespace) }
+
+ before do
+ [project, other_project].each { |prj| prj.add_master(user) }
+ end
+
+ subject { described_class.new(project, chat_name) }
+
+ def process_message(message)
+ subject.execute(described_class.match(message))
+ end
+
+ context 'when the user can move the issue' do
+ context 'when the move fails' do
+ it 'returns the error message' do
+ message = "issue move #{issue.iid} #{project.full_path}"
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('Cannot move issue'))
+ end
+ end
+
+ context 'when the move succeeds' do
+ let(:message) { "issue move #{issue.iid} #{other_project.full_path}" }
+
+ it 'moves the issue to the new destination' do
+ expect { process_message(message) }.to change { Issue.count }.by(1)
+
+ new_issue = issue.reload.moved_to
+
+ expect(new_issue.state).to eq('opened')
+ expect(new_issue.project_id).to eq(other_project.id)
+ expect(new_issue.author_id).to eq(issue.author_id)
+
+ expect(issue.state).to eq('closed')
+ expect(issue.project_id).to eq(project.id)
+ end
+
+ it 'returns the new issue' do
+ expect(process_message(message))
+ .to include(response_type: :in_channel,
+ attachments: [a_hash_including(title_link: a_string_including(other_project.full_path))])
+ end
+
+ it 'mentions the old issue' do
+ expect(process_message(message))
+ .to include(attachments: [a_hash_including(pretext: a_string_including(project.full_path))])
+ end
+ end
+ end
+
+ context 'when the issue does not exist' do
+ it 'returns not found' do
+ message = "issue move #{issue.iid.succ} #{other_project.full_path}"
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('not found'))
+ end
+ end
+
+ context 'when the target project does not exist' do
+ it 'returns not found' do
+ message = "issue move #{issue.iid} #{other_project.full_path}/foo"
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('not found'))
+ end
+ end
+
+ context 'when the user cannot see the target project' do
+ it 'returns not found' do
+ message = "issue move #{issue.iid} #{other_project.full_path}"
+ other_project.team.truncate
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('not found'))
+ end
+ end
+
+ context 'when the user does not have the required permissions on the target project' do
+ it 'returns the error message' do
+ message = "issue move #{issue.iid} #{other_project.full_path}"
+ other_project.team.truncate
+ other_project.team.add_guest(user)
+
+ expect(process_message(message)).to include(response_type: :ephemeral,
+ text: a_string_matching('Cannot move issue'))
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb
new file mode 100644
index 00000000000..58c341a284e
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Presenters::IssueMove do
+ set(:admin) { create(:admin) }
+ set(:project) { create(:project) }
+ set(:other_project) { create(:project) }
+ set(:old_issue) { create(:issue, project: project) }
+ set(:new_issue) { Issues::MoveService.new(project, admin).execute(old_issue, other_project) }
+ let(:attachment) { subject[:attachments].first }
+
+ subject { described_class.new(new_issue).present(old_issue) }
+
+ it { is_expected.to be_a(Hash) }
+
+ it 'shows the new issue' do
+ expect(subject[:response_type]).to be(:in_channel)
+ expect(subject).to have_key(:attachments)
+ expect(attachment[:title]).to start_with(new_issue.title)
+ expect(attachment[:title_link]).to include(other_project.full_path)
+ end
+
+ it 'mentions the old issue and the new issue in the pretext' do
+ expect(attachment[:pretext]).to include(project.full_path)
+ expect(attachment[:pretext]).to include(other_project.full_path)
+ end
+end