Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorRémy Coutable <remy@rymai.me>2016-06-30 18:34:19 +0300
committerRémy Coutable <remy@rymai.me>2016-08-13 01:05:57 +0300
commit0eea8c885743575b0e93a98846b3663e9903aa66 (patch)
tree9b1903bcb03789d15ed255b76be5d683f3b1e547 /spec
parent11eefba891f214eefc1efa334adbcc9e979c0ce3 (diff)
Support slash commands in noteable description and notes
Some important things to note: - commands are removed from noteable.description / note.note - commands are translated to params so that they are treated as normal params in noteable Creation services - the logic is not in the models but in the Creation services, which is the right place for advanced logic that has nothing to do with what models should be responsible of! - UI/JS needs to be updated to handle notes which consist of commands only - the `/merge` command is not handled yet Other improvements: - Don't process commands in commit notes and display a flash is note is only commands - Add autocomplete for slash commands - Add description and params to slash command DSL methods - Ensure replying by email with a commands-only note works - Use :subscription_event instead of calling noteable.subscribe - Support :todo_event in IssuableBaseService Signed-off-by: Rémy Coutable <remy@rymai.me>
Diffstat (limited to 'spec')
-rw-r--r--spec/features/issues/user_uses_slash_commands_spec.rb59
-rw-r--r--spec/features/merge_requests/user_uses_slash_commands_spec.rb59
-rw-r--r--spec/fixtures/emails/commands_only_reply.eml40
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb9
-rw-r--r--spec/lib/gitlab/slash_commands/dsl_spec.rb110
-rw-r--r--spec/lib/gitlab/slash_commands/extractor_spec.rb177
-rw-r--r--spec/services/issues/create_service_spec.rb2
-rw-r--r--spec/services/merge_requests/create_service_spec.rb11
-rw-r--r--spec/services/notes/create_service_spec.rb23
-rw-r--r--spec/services/slash_commands/interpret_service_spec.rb217
-rw-r--r--spec/support/issuable_create_service_slash_commands_shared_examples.rb83
-rw-r--r--spec/support/issuable_slash_commands_shared_examples.rb170
-rw-r--r--spec/support/note_create_service_slash_commands_shared_examples.rb116
13 files changed, 1068 insertions, 8 deletions
diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb
new file mode 100644
index 00000000000..47c4ce306e9
--- /dev/null
+++ b/spec/features/issues/user_uses_slash_commands_spec.rb
@@ -0,0 +1,59 @@
+require 'rails_helper'
+
+feature 'Issues > User uses slash commands', feature: true, js: true do
+ include WaitForAjax
+
+ it_behaves_like 'issuable record that supports slash commands in its description and notes', :issue do
+ let(:issuable) { create(:issue, project: project) }
+ end
+
+ describe 'issue-only commands' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public) }
+
+ before do
+ project.team << [user, :master]
+ login_with(user)
+ visit namespace_project_issue_path(project.namespace, project, issue)
+ end
+
+ describe 'adding a due date from note' do
+ let(:issue) { create(:issue, project: project) }
+
+ it 'does not create a note, and sets the due date accordingly' do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "/due_date 2016-08-28"
+ click_button 'Comment'
+ end
+
+ expect(page).not_to have_content '/due_date 2016-08-28'
+ expect(page).to have_content 'Your commands are being executed.'
+
+ issue.reload
+
+ expect(issue.due_date).to eq Date.new(2016, 8, 28)
+ end
+ end
+
+ describe 'removing a due date from note' do
+ let(:issue) { create(:issue, project: project, due_date: Date.new(2016, 8, 28)) }
+
+ it 'does not create a note, and removes the due date accordingly' do
+ expect(issue.due_date).to eq Date.new(2016, 8, 28)
+
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "/clear_due_date"
+ click_button 'Comment'
+ end
+
+ expect(page).not_to have_content '/clear_due_date'
+ expect(page).to have_content 'Your commands are being executed.'
+
+ issue.reload
+
+ expect(issue.due_date).to be_nil
+ end
+ end
+ end
+
+end
diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
new file mode 100644
index 00000000000..890648f3860
--- /dev/null
+++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
@@ -0,0 +1,59 @@
+require 'rails_helper'
+
+feature 'Merge Requests > User uses slash commands', feature: true, js: true do
+ include WaitForAjax
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
+
+ it_behaves_like 'issuable record that supports slash commands in its description and notes', :merge_request do
+ let(:issuable) { create(:merge_request, source_project: project) }
+ let(:new_url_opts) { { merge_request: { source_branch: 'feature' } } }
+ end
+
+ describe 'adding a due date from note' do
+ before do
+ project.team << [user, :master]
+ login_with(user)
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ it 'does not recognize the command nor create a note' do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "/due_date 2016-08-28"
+ click_button 'Comment'
+ end
+
+ expect(page).not_to have_content '/due_date 2016-08-28'
+ end
+ end
+
+ # Postponed because of high complexity
+ xdescribe 'merging from note' do
+ before do
+ project.team << [user, :master]
+ login_with(user)
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ it 'creates a note without the commands and interpret the commands accordingly' do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "Let's merge this!\n/merge\n/milestone %ASAP"
+ click_button 'Comment'
+ end
+
+ expect(page).to have_content("Let's merge this!")
+ expect(page).not_to have_content('/merge')
+ expect(page).not_to have_content('/milestone %ASAP')
+
+ merge_request.reload
+ note = merge_request.notes.user.first
+
+ expect(note.note).to eq "Let's merge this!\r\n"
+ expect(merge_request).to be_merged
+ expect(merge_request.milestone).to eq milestone
+ end
+ end
+end
diff --git a/spec/fixtures/emails/commands_only_reply.eml b/spec/fixtures/emails/commands_only_reply.eml
new file mode 100644
index 00000000000..b64d851a79c
--- /dev/null
+++ b/spec/fixtures/emails/commands_only_reply.eml
@@ -0,0 +1,40 @@
+Return-Path: <jake@adventuretime.ooo>
+Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
+Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
+Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
+Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
+Date: Thu, 13 Jun 2013 17:03:48 -0400
+From: Jake the Dog <jake@adventuretime.ooo>
+To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo
+Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
+In-Reply-To: <issue_1@localhost>
+References: <issue_1@localhost> <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>
+Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'
+Mime-Version: 1.0
+Content-Type: text/plain;
+ charset=ISO-8859-1
+Content-Transfer-Encoding: 7bit
+X-Sieve: CMU Sieve 2.2
+X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
+ 13 Jun 2013 14:03:48 -0700 (PDT)
+X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
+
+/close
+/unsubscribe
+
+
+On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta
+<reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo> wrote:
+>
+>
+>
+> eviltrout posted in 'Adventure Time Sux' on Discourse Meta:
+>
+> ---
+> hey guys everyone knows adventure time sucks!
+>
+> ---
+> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3
+>
+> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences).
+>
diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
index a2119b0dadf..e2339c5e103 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -60,6 +60,15 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do
it "raises an InvalidNoteError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
+
+ context 'because the note was commands only' do
+ let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") }
+
+ it 'raises a CommandsOnlyNoteError' do
+ expect { receiver.execute }.not_to raise_error
+ end
+
+ end
end
context "when the reply is blank" do
diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb
new file mode 100644
index 00000000000..f8abb35674d
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Dsl do
+ before :all do
+ DummyClass = Class.new do
+ include Gitlab::SlashCommands::Dsl
+
+ desc 'A command with no args'
+ command :no_args, :none do
+ "Hello World!"
+ end
+
+ desc 'A command returning a value'
+ command :returning do
+ return 42
+ end
+
+ params 'The first argument'
+ command :one_arg, :once, :first do |arg1|
+ arg1
+ end
+
+ desc 'A command with two args'
+ params 'The first argument', 'The second argument'
+ command :two_args do |arg1, arg2|
+ [arg1, arg2]
+ end
+
+ command :wildcard do |*args|
+ args
+ end
+ end
+ end
+ let(:dummy) { DummyClass.new }
+
+ describe '.command_definitions' do
+ it 'returns an array with commands definitions' do
+ expected = [
+ { name: :no_args, aliases: [:none], description: 'A command with no args', params: [] },
+ { name: :returning, aliases: [], description: 'A command returning a value', params: [] },
+ { name: :one_arg, aliases: [:once, :first], description: '', params: ['The first argument'] },
+ { name: :two_args, aliases: [], description: 'A command with two args', params: ['The first argument', 'The second argument'] },
+ { name: :wildcard, aliases: [], description: '', params: [] }
+ ]
+
+ expect(DummyClass.command_definitions).to eq expected
+ end
+ end
+
+ describe '.command_names' do
+ it 'returns an array with commands definitions' do
+ expect(DummyClass.command_names).to eq [
+ :no_args, :none, :returning, :one_arg,
+ :once, :first, :two_args, :wildcard
+ ]
+ end
+ end
+
+ describe 'command with no args' do
+ context 'called with no args' do
+ it 'succeeds' do
+ expect(dummy.__send__(:no_args)).to eq 'Hello World!'
+ end
+ end
+ end
+
+ describe 'command with an explicit return' do
+ context 'called with no args' do
+ it 'succeeds' do
+ expect(dummy.__send__(:returning)).to eq 42
+ end
+ end
+ end
+
+ describe 'command with one arg' do
+ context 'called with one arg' do
+ it 'succeeds' do
+ expect(dummy.__send__(:one_arg, 42)).to eq 42
+ end
+ end
+ end
+
+ describe 'command with two args' do
+ context 'called with two args' do
+ it 'succeeds' do
+ expect(dummy.__send__(:two_args, 42, 'foo')).to eq [42, 'foo']
+ end
+ end
+ end
+
+ describe 'command with wildcard' do
+ context 'called with no args' do
+ it 'succeeds' do
+ expect(dummy.__send__(:wildcard)).to eq []
+ end
+ end
+
+ context 'called with one arg' do
+ it 'succeeds' do
+ expect(dummy.__send__(:wildcard, 42)).to eq [42]
+ end
+ end
+
+ context 'called with two args' do
+ it 'succeeds' do
+ expect(dummy.__send__(:wildcard, 42, 'foo')).to eq [42, 'foo']
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb
new file mode 100644
index 00000000000..fd1b30052ed
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb
@@ -0,0 +1,177 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Extractor do
+ let(:extractor) { described_class.new([:open, :assign, :labels, :power]) }
+
+ shared_examples 'command with no argument' do
+ it 'extracts command' do
+ commands = extractor.extract_commands!(original_msg)
+
+ expect(commands).to eq [['open']]
+ expect(original_msg).to eq final_msg
+ end
+ end
+
+ shared_examples 'command with a single argument' do
+ it 'extracts command' do
+ commands = extractor.extract_commands!(original_msg)
+
+ expect(commands).to eq [['assign', '@joe']]
+ expect(original_msg).to eq final_msg
+ end
+ end
+
+ shared_examples 'command with multiple arguments' do
+ it 'extracts command' do
+ commands = extractor.extract_commands!(original_msg)
+
+ expect(commands).to eq [['labels', '~foo ~"bar baz" label']]
+ expect(original_msg).to eq final_msg
+ end
+ end
+
+ describe '#extract_commands!' do
+ describe 'command with no argument' do
+ context 'at the start of content' do
+ it_behaves_like 'command with no argument' do
+ let(:original_msg) { "/open\nworld" }
+ let(:final_msg) { "world" }
+ end
+ end
+
+ context 'in the middle of content' do
+ it_behaves_like 'command with no argument' do
+ let(:original_msg) { "hello\n/open\nworld" }
+ let(:final_msg) { "hello\nworld" }
+ end
+ end
+
+ context 'in the middle of a line' do
+ it 'does not extract command' do
+ msg = "hello\nworld /open"
+ commands = extractor.extract_commands!(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq "hello\nworld /open"
+ end
+ end
+
+ context 'at the end of content' do
+ it_behaves_like 'command with no argument' do
+ let(:original_msg) { "hello\n/open" }
+ let(:final_msg) { "hello\n" }
+ end
+ end
+ end
+
+ describe 'command with a single argument' do
+ context 'at the start of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "/assign @joe\nworld" }
+ let(:final_msg) { "world" }
+ end
+ end
+
+ context 'in the middle of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "hello\n/assign @joe\nworld" }
+ let(:final_msg) { "hello\nworld" }
+ end
+ end
+
+ context 'in the middle of a line' do
+ it 'does not extract command' do
+ msg = "hello\nworld /assign @joe"
+ commands = extractor.extract_commands!(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq "hello\nworld /assign @joe"
+ end
+ end
+
+ context 'at the end of content' do
+ it_behaves_like 'command with a single argument' do
+ let(:original_msg) { "hello\n/assign @joe" }
+ let(:final_msg) { "hello\n" }
+ end
+ end
+
+ context 'when argument is not separated with a space' do
+ it 'does not extract command' do
+ msg = "hello\n/assign@joe\nworld"
+ commands = extractor.extract_commands!(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq "hello\n/assign@joe\nworld"
+ end
+ end
+ end
+
+ describe 'command with multiple arguments' do
+ context 'at the start of content' do
+ it_behaves_like 'command with multiple arguments' do
+ let(:original_msg) { %(/labels ~foo ~"bar baz" label\nworld) }
+ let(:final_msg) { "world" }
+ end
+ end
+
+ context 'in the middle of content' do
+ it_behaves_like 'command with multiple arguments' do
+ let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label\nworld) }
+ let(:final_msg) { "hello\nworld" }
+ end
+ end
+
+ context 'in the middle of a line' do
+ it 'does not extract command' do
+ msg = %(hello\nworld /labels ~foo ~"bar baz" label)
+ commands = extractor.extract_commands!(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label)
+ end
+ end
+
+ context 'at the end of content' do
+ it_behaves_like 'command with multiple arguments' do
+ let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label) }
+ let(:final_msg) { "hello\n" }
+ end
+ end
+
+ context 'when argument is not separated with a space' do
+ it 'does not extract command' do
+ msg = %(hello\n/labels~foo ~"bar baz" label\nworld)
+ commands = extractor.extract_commands!(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld)
+ end
+ end
+ end
+
+ it 'extracts command with multiple arguments and various prefixes' do
+ msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld)
+ commands = extractor.extract_commands!(msg)
+
+ expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']]
+ expect(msg).to eq "hello\nworld"
+ end
+
+ it 'extracts multiple commands' do
+ msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/open)
+ commands = extractor.extract_commands!(msg)
+
+ expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['open']]
+ expect(msg).to eq "hello\nworld\n"
+ end
+
+ it 'does not alter original content if no command is found' do
+ msg = 'Fixes #123'
+ commands = extractor.extract_commands!(msg)
+
+ expect(commands).to be_empty
+ expect(msg).to eq 'Fixes #123'
+ end
+ end
+end
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 1ee9f3aae4d..fcc3c0a00bd 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -73,5 +73,7 @@ describe Issues::CreateService, services: true do
end
end
end
+
+ it_behaves_like 'new issuable record that supports slash commands'
end
end
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index b84a580967a..c1e4f8bd96b 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -17,7 +17,7 @@ describe MergeRequests::CreateService, services: true do
}
end
- let(:service) { MergeRequests::CreateService.new(project, user, opts) }
+ let(:service) { described_class.new(project, user, opts) }
before do
project.team << [user, :master]
@@ -74,5 +74,14 @@ describe MergeRequests::CreateService, services: true do
end
end
end
+
+ it_behaves_like 'new issuable record that supports slash commands' do
+ let(:default_params) do
+ {
+ source_branch: 'feature',
+ target_branch: 'master'
+ }
+ end
+ end
end
end
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 32753e84b31..36ca7d2bce8 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -4,22 +4,31 @@ describe Notes::CreateService, services: true do
let(:project) { create(:empty_project) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
+ let(:opts) do
+ { note: 'Awesome comment', noteable_type: 'Issue', noteable_id: issue.id }
+ end
describe '#execute' do
context "valid params" do
before do
project.team << [user, :master]
- opts = {
- note: 'Awesome comment',
- noteable_type: 'Issue',
- noteable_id: issue.id
- }
-
@note = Notes::CreateService.new(project, user, opts).execute
end
it { expect(@note).to be_valid }
- it { expect(@note.note).to eq('Awesome comment') }
+ it { expect(@note.note).to eq(opts[:note]) }
+
+ it_behaves_like 'note on noteable that supports slash commands' do
+ let(:noteable) { create(:issue, project: project) }
+ end
+
+ it_behaves_like 'note on noteable that supports slash commands' do
+ let(:noteable) { create(:merge_request, source_project: project) }
+ end
+
+ it_behaves_like 'note on noteable that does not support slash commands' do
+ let(:noteable) { create(:commit, project: project) }
+ end
end
end
diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb
new file mode 100644
index 00000000000..fa0f65495ce
--- /dev/null
+++ b/spec/services/slash_commands/interpret_service_spec.rb
@@ -0,0 +1,217 @@
+require 'spec_helper'
+
+describe SlashCommands::InterpretService, services: true do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:issue) { create(:issue, project: project) }
+ let(:milestone) { create(:milestone, project: project, title: '9.10') }
+ let(:inprogress) { create(:label, project: project, title: 'In Progress') }
+ let(:bug) { create(:label, project: project, title: 'Bug') }
+
+ describe '#command_names' do
+ subject { described_class.command_names }
+
+ it 'returns the known commands' do
+ is_expected.to match_array([
+ :open, :reopen,
+ :close,
+ :assign, :reassign,
+ :unassign, :remove_assignee,
+ :milestone,
+ :remove_milestone,
+ :clear_milestone,
+ :labels, :label,
+ :unlabel, :remove_labels, :remove_label,
+ :clear_labels, :clear_label,
+ :todo,
+ :done,
+ :subscribe,
+ :unsubscribe,
+ :due_date,
+ :clear_due_date
+ ])
+ end
+ end
+
+ describe '#execute' do
+ let(:service) { described_class.new(project, user) }
+
+ shared_examples 'open command' do
+ it 'returns state_event: "open" if content contains /open' do
+ changes = service.execute(content)
+
+ expect(changes).to eq(state_event: 'reopen')
+ end
+ end
+
+ shared_examples 'close command' do
+ it 'returns state_event: "close" if content contains /open' do
+ changes = service.execute(content)
+
+ expect(changes).to eq(state_event: 'close')
+ end
+ end
+
+ shared_examples 'assign command' do
+ it 'fetches assignee and populates assignee_id if content contains /assign' do
+ changes = service.execute(content)
+
+ expect(changes).to eq(assignee_id: user.id)
+ end
+ end
+
+ shared_examples 'milestone command' do
+ it 'fetches milestone and populates milestone_id if content contains /milestone' do
+ changes = service.execute(content)
+
+ expect(changes).to eq(milestone_id: milestone.id)
+ end
+ end
+
+ shared_examples 'label command' do
+ it 'fetches label ids and populates add_label_ids if content contains /label' do
+ changes = service.execute(content)
+
+ expect(changes).to eq(add_label_ids: [bug.id, inprogress.id])
+ end
+ end
+
+ shared_examples 'remove_labels command' do
+ it 'fetches label ids and populates remove_label_ids if content contains /label' do
+ changes = service.execute(content)
+
+ expect(changes).to eq(remove_label_ids: [inprogress.id])
+ end
+ end
+
+ shared_examples 'clear_labels command' do
+ it 'populates label_ids: [] if content contains /clear_labels' do
+ changes = service.execute(content)
+
+ expect(changes).to eq(label_ids: [])
+ end
+ end
+
+ shared_examples 'command returning no changes' do
+ it 'returns an empty hash if content contains /open' do
+ changes = service.execute(content)
+
+ expect(changes).to be_empty
+ end
+ end
+
+ it_behaves_like 'open command' do
+ let(:content) { '/open' }
+ end
+
+ it_behaves_like 'open command' do
+ let(:content) { '/reopen' }
+ end
+
+ it_behaves_like 'close command' do
+ let(:content) { '/close' }
+ end
+
+ it_behaves_like 'assign command' do
+ let(:content) { "/assign @#{user.username}" }
+ end
+
+ it 'does not populate assignee_id if content contains /assign with an unknown user' do
+ changes = service.execute('/assign joe')
+
+ expect(changes).to be_empty
+ end
+
+ it 'does not populate assignee_id if content contains /assign without user' do
+ changes = service.execute('/assign')
+
+ expect(changes).to be_empty
+ end
+
+ it 'populates assignee_id: nil if content contains /unassign' do
+ changes = service.execute('/unassign')
+
+ expect(changes).to eq(assignee_id: nil)
+ end
+
+ it_behaves_like 'milestone command' do
+ let(:content) { "/milestone %#{milestone.title}" }
+ end
+
+ it 'populates milestone_id: nil if content contains /clear_milestone' do
+ changes = service.execute('/clear_milestone')
+
+ expect(changes).to eq(milestone_id: nil)
+ end
+
+ it_behaves_like 'label command' do
+ let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
+ end
+
+ it_behaves_like 'label command' do
+ let(:content) { %(/labels ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
+ end
+
+ it_behaves_like 'remove_labels command' do
+ let(:content) { %(/unlabel ~"#{inprogress.title}") }
+ end
+
+ it_behaves_like 'remove_labels command' do
+ let(:content) { %(/remove_labels ~"#{inprogress.title}") }
+ end
+
+ it_behaves_like 'remove_labels command' do
+ let(:content) { %(/remove_label ~"#{inprogress.title}") }
+ end
+
+ it_behaves_like 'clear_labels command' do
+ let(:content) { '/clear_labels' }
+ end
+
+ it_behaves_like 'clear_labels command' do
+ let(:content) { '/clear_label' }
+ end
+
+ it 'populates todo: :mark if content contains /todo' do
+ changes = service.execute('/todo')
+
+ expect(changes).to eq(todo_event: 'mark')
+ end
+
+ it 'populates todo: :done if content contains /done' do
+ changes = service.execute('/done')
+
+ expect(changes).to eq(todo_event: 'done')
+ end
+
+ it 'populates subscription: :subscribe if content contains /subscribe' do
+ changes = service.execute('/subscribe')
+
+ expect(changes).to eq(subscription_event: 'subscribe')
+ end
+
+ it 'populates subscription: :unsubscribe if content contains /unsubscribe' do
+ changes = service.execute('/unsubscribe')
+
+ expect(changes).to eq(subscription_event: 'unsubscribe')
+ end
+
+ it 'populates due_date: Time.now.tomorrow if content contains /due_date 2016-08-28' do
+ changes = service.execute('/due_date 2016-08-28')
+
+ expect(changes).to eq(due_date: Date.new(2016, 8, 28))
+ end
+
+ it 'populates due_date: Time.now.tomorrow if content contains /due_date foo' do
+ changes = service.execute('/due_date foo')
+
+ expect(changes).to be_empty
+ end
+
+ it 'populates due_date: nil if content contains /clear_due_date' do
+ changes = service.execute('/clear_due_date')
+
+ expect(changes).to eq(due_date: nil)
+ end
+ end
+end
diff --git a/spec/support/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/issuable_create_service_slash_commands_shared_examples.rb
new file mode 100644
index 00000000000..bd0201c866f
--- /dev/null
+++ b/spec/support/issuable_create_service_slash_commands_shared_examples.rb
@@ -0,0 +1,83 @@
+# Specifications for behavior common to all objects with executable attributes.
+# It can take a `default_params`.
+
+shared_examples 'new issuable record that supports slash commands' do
+ let!(:project) { create(:project) }
+ let(:user) { create(:user).tap { |u| project.team << [u, :master] } }
+ let(:assignee) { create(:user) }
+ let!(:milestone) { create(:milestone, project: project) }
+ let!(:labels) { create_list(:label, 3, project: project) }
+ let(:base_params) { { title: FFaker::Lorem.sentence(3) } }
+ let(:params) { base_params.merge(defined?(default_params) ? default_params : {}).merge(example_params) }
+ let(:issuable) { described_class.new(project, user, params).execute }
+
+ context 'with labels in command only' do
+ let(:example_params) do
+ {
+ description: "/label ~#{labels.first.name} ~#{labels.second.name}\n/remove_label ~#{labels.third.name}"
+ }
+ end
+
+ it 'attaches labels to issuable' do
+ expect(issuable).to be_persisted
+ expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id])
+ end
+ end
+
+ context 'with labels in params and command' do
+ let(:example_params) do
+ {
+ label_ids: [labels.second.id],
+ description: "/label ~#{labels.first.name}\n/remove_label ~#{labels.third.name}"
+ }
+ end
+
+ it 'attaches all labels to issuable' do
+ expect(issuable).to be_persisted
+ expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id])
+ end
+ end
+
+ context 'with assignee and milestone in command only' do
+ let(:example_params) do
+ {
+ description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
+ }
+ end
+
+ it 'assigns and sets milestone to issuable' do
+ expect(issuable).to be_persisted
+ expect(issuable.assignee).to eq(assignee)
+ expect(issuable.milestone).to eq(milestone)
+ end
+ end
+
+ context 'with assignee and milestone in params and command' do
+ let(:example_params) do
+ {
+ assignee: build_stubbed(:user),
+ milestone_id: double(:milestone),
+ description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
+ }
+ end
+
+ it 'assigns and sets milestone to issuable from command' do
+ expect(issuable).to be_persisted
+ expect(issuable.assignee).to eq(assignee)
+ expect(issuable.milestone).to eq(milestone)
+ end
+ end
+
+ describe '/close' do
+ let(:example_params) do
+ {
+ description: '/close'
+ }
+ end
+
+ it 'returns an open issue' do
+ expect(issuable).to be_persisted
+ expect(issuable).to be_open
+ end
+ end
+end
diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb
new file mode 100644
index 00000000000..0c8bd69add6
--- /dev/null
+++ b/spec/support/issuable_slash_commands_shared_examples.rb
@@ -0,0 +1,170 @@
+# Specifications for behavior common to all objects with executable attributes.
+# It takes a `issuable_type`, and expect an `issuable`.
+
+shared_examples 'issuable record that supports slash commands in its description and notes' do |issuable_type|
+ let(:user) { create(:user) }
+ let(:assignee) { create(:user, username: 'bob') }
+ let(:project) { create(:project, :public) }
+ let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
+ let!(:label_bug) { create(:label, project: project, title: 'bug') }
+ let!(:label_feature) { create(:label, project: project, title: 'feature') }
+ let(:new_url_opts) { {} }
+
+ before do
+ project.team << [user, :master]
+ project.team << [assignee, :developer]
+ login_with(user)
+ end
+
+ describe "new #{issuable_type}" do
+ context 'with commands in the description' do
+ it "creates the #{issuable_type} and interpret commands accordingly" do
+ visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts)
+ fill_in "#{issuable_type}_title", with: 'bug 345'
+ fill_in "#{issuable_type}_description", with: "bug description\n/label ~bug\n/milestone %\"ASAP\""
+ click_button "Submit #{issuable_type}".humanize
+
+ issuable = project.public_send(issuable_type.to_s.pluralize).first
+
+ expect(issuable.description).to eq "bug description\r\n"
+ expect(issuable.labels).to eq [label_bug]
+ expect(issuable.milestone).to eq milestone
+ expect(page).to have_content 'bug 345'
+ expect(page).to have_content 'bug description'
+ end
+ end
+ end
+
+ describe "note on #{issuable_type}" do
+ before do
+ visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
+ end
+
+ context 'with a note containing commands' do
+ it 'creates a note without the commands and interpret the commands accordingly' do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "Awesome!\n/assign @bob\n/label ~bug\n/milestone %\"ASAP\""
+ click_button 'Comment'
+ end
+
+ expect(page).to have_content 'Awesome!'
+ expect(page).not_to have_content '/assign @bob'
+ expect(page).not_to have_content '/label ~bug'
+ expect(page).not_to have_content '/milestone %"ASAP"'
+
+ issuable.reload
+ note = issuable.notes.user.first
+
+ expect(note.note).to eq "Awesome!\r\n"
+ expect(issuable.assignee).to eq assignee
+ expect(issuable.labels).to eq [label_bug]
+ expect(issuable.milestone).to eq milestone
+ end
+ end
+
+ context 'with a note containing only commands' do
+ it 'does not create a note but interpret the commands accordingly' do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "/assign @bob\n/label ~bug\n/milestone %\"ASAP\""
+ click_button 'Comment'
+ end
+
+ expect(page).not_to have_content '/assign @bob'
+ expect(page).not_to have_content '/label ~bug'
+ expect(page).not_to have_content '/milestone %"ASAP"'
+ expect(page).to have_content 'Your commands are being executed.'
+
+ issuable.reload
+
+ expect(issuable.notes.user).to be_empty
+ expect(issuable.assignee).to eq assignee
+ expect(issuable.labels).to eq [label_bug]
+ expect(issuable.milestone).to eq milestone
+ end
+ end
+
+ context "with a note marking the #{issuable_type} as todo" do
+ it "creates a new todo for the #{issuable_type}" do
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "/todo"
+ click_button 'Comment'
+ end
+
+ expect(page).not_to have_content '/todo'
+ expect(page).to have_content 'Your commands are being executed.'
+
+ todos = TodosFinder.new(user).execute
+ todo = todos.first
+
+ expect(todos.size).to eq 1
+ expect(todo).to be_pending
+ expect(todo.target).to eq issuable
+ expect(todo.author).to eq user
+ expect(todo.user).to eq user
+ end
+ end
+
+ context "with a note marking the #{issuable_type} as done" do
+ before do
+ TodoService.new.mark_todo(issuable, user)
+ end
+
+ it "creates a new todo for the #{issuable_type}" do
+ todos = TodosFinder.new(user).execute
+ todo = todos.first
+
+ expect(todos.size).to eq 1
+ expect(todos.first).to be_pending
+ expect(todo.target).to eq issuable
+ expect(todo.author).to eq user
+ expect(todo.user).to eq user
+
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "/done"
+ click_button 'Comment'
+ end
+
+ expect(page).not_to have_content '/done'
+ expect(page).to have_content 'Your commands are being executed.'
+
+ expect(todo.reload).to be_done
+ end
+ end
+
+ context "with a note subscribing to the #{issuable_type}" do
+ it "creates a new todo for the #{issuable_type}" do
+ expect(issuable.subscribed?(user)).to be_falsy
+
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "/subscribe"
+ click_button 'Comment'
+ end
+
+ expect(page).not_to have_content '/subscribe'
+ expect(page).to have_content 'Your commands are being executed.'
+
+ expect(issuable.subscribed?(user)).to be_truthy
+ end
+ end
+
+ context "with a note unsubscribing to the #{issuable_type} as done" do
+ before do
+ issuable.subscribe(user)
+ end
+
+ it "creates a new todo for the #{issuable_type}" do
+ expect(issuable.subscribed?(user)).to be_truthy
+
+ page.within('.js-main-target-form') do
+ fill_in 'note[note]', with: "/unsubscribe"
+ click_button 'Comment'
+ end
+
+ expect(page).not_to have_content '/unsubscribe'
+ expect(page).to have_content 'Your commands are being executed.'
+
+ expect(issuable.subscribed?(user)).to be_falsy
+ end
+ end
+ end
+end
diff --git a/spec/support/note_create_service_slash_commands_shared_examples.rb b/spec/support/note_create_service_slash_commands_shared_examples.rb
new file mode 100644
index 00000000000..3f7ad8b2f91
--- /dev/null
+++ b/spec/support/note_create_service_slash_commands_shared_examples.rb
@@ -0,0 +1,116 @@
+# Specifications for behavior common to all note objects with executable attributes.
+# It expects a `noteable` object for which the note is posted.
+
+shared_context 'note on noteable' do
+ let!(:project) { create(:project) }
+ let(:user) { create(:user).tap { |u| project.team << [u, :master] } }
+ let(:assignee) { create(:user) }
+ let(:base_params) { { noteable: noteable } }
+ let(:params) { base_params.merge(example_params) }
+ let(:note) { described_class.new(project, user, params).execute }
+end
+
+shared_examples 'note on noteable that does not support slash commands' do
+ include_context 'note on noteable'
+
+ let(:params) { { commit_id: noteable.id, noteable_type: 'Commit' }.merge(example_params) }
+
+ describe 'note with only command' do
+ describe '/close, /label, /assign & /milestone' do
+ let(:note_text) { %(/close\n/assign @#{assignee.username}") }
+ let(:example_params) { { note: note_text } }
+
+ it 'saves the note and does not alter the note text' do
+ expect(note).to be_persisted
+ expect(note.note).to eq note_text
+ end
+ end
+ end
+
+ describe 'note with command & text' do
+ describe '/close, /label, /assign & /milestone' do
+ let(:note_text) { %(HELLO\n/close\n/assign @#{assignee.username}\nWORLD) }
+ let(:example_params) { { note: note_text } }
+
+ it 'saves the note and does not alter the note text' do
+ expect(note).to be_persisted
+ expect(note.note).to eq note_text
+ end
+ end
+ end
+end
+
+shared_examples 'note on noteable that supports slash commands' do
+ include_context 'note on noteable'
+
+ let!(:milestone) { create(:milestone, project: project) }
+ let!(:labels) { create_pair(:label, project: project) }
+
+ describe 'note with only command' do
+ describe '/close, /label, /assign & /milestone' do
+ let(:example_params) do
+ {
+ note: %(/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
+ }
+ end
+
+ it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do
+ expect(note).not_to be_persisted
+ expect(note.note).to eq ''
+ expect(noteable).to be_closed
+ expect(noteable.labels).to match_array(labels)
+ expect(noteable.assignee).to eq(assignee)
+ expect(noteable.milestone).to eq(milestone)
+ end
+ end
+
+ describe '/open' do
+ let(:noteable) { create(:issue, project: project, state: :closed) }
+ let(:example_params) do
+ {
+ note: '/open'
+ }
+ end
+
+ it 'opens the noteable, and leave no note' do
+ expect(note).not_to be_persisted
+ expect(note.note).to eq ''
+ expect(noteable).to be_open
+ end
+ end
+ end
+
+ describe 'note with command & text' do
+ describe '/close, /label, /assign & /milestone' do
+ let(:example_params) do
+ {
+ note: %(HELLO\n/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}"\nWORLD)
+ }
+ end
+
+ it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do
+ expect(note).to be_persisted
+ expect(note.note).to eq "HELLO\nWORLD"
+ expect(noteable).to be_closed
+ expect(noteable.labels).to match_array(labels)
+ expect(noteable.assignee).to eq(assignee)
+ expect(noteable.milestone).to eq(milestone)
+ end
+ end
+
+ describe '/open' do
+ let(:noteable) { create(:issue, project: project, state: :closed) }
+ let(:example_params) do
+ {
+ note: "HELLO\n/open\nWORLD"
+ }
+ end
+
+ it 'opens the noteable' do
+ expect(note).to be_persisted
+ expect(note.note).to eq "HELLO\nWORLD"
+ expect(noteable).to be_open
+ end
+ end
+ end
+end