diff options
author | Rémy Coutable <remy@rymai.me> | 2016-06-30 18:34:19 +0300 |
---|---|---|
committer | Rémy Coutable <remy@rymai.me> | 2016-08-13 01:05:57 +0300 |
commit | 0eea8c885743575b0e93a98846b3663e9903aa66 (patch) | |
tree | 9b1903bcb03789d15ed255b76be5d683f3b1e547 /spec | |
parent | 11eefba891f214eefc1efa334adbcc9e979c0ce3 (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')
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 |