From 0eea8c885743575b0e93a98846b3663e9903aa66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 30 Jun 2016 17:34:19 +0200 Subject: [PATCH 01/25] Support slash commands in noteable description and notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG | 1 + ...o_complete.js => gfm_auto_complete.js.es6} | 38 ++- app/assets/javascripts/notes.js | 7 +- .../stylesheets/framework/markdown_area.scss | 5 + app/controllers/projects/notes_controller.rb | 2 +- app/controllers/projects_controller.rb | 3 +- app/finders/todos_finder.rb | 2 +- app/services/issuable_base_service.rb | 81 +++++-- app/services/issues/create_service.rb | 20 +- app/services/merge_requests/create_service.rb | 25 +- app/services/notes/create_service.rb | 50 +++- app/services/projects/autocomplete_service.rb | 4 + .../slash_commands/interpret_service.rb | 133 +++++++++++ doc/workflow/README.md | 1 + doc/workflow/slash_commands.md | 28 +++ lib/gitlab/email/handler/base_handler.rb | 1 + lib/gitlab/slash_commands/dsl.rb | 76 ++++++ lib/gitlab/slash_commands/extractor.rb | 59 +++++ .../issues/user_uses_slash_commands_spec.rb | 59 +++++ .../user_uses_slash_commands_spec.rb | 59 +++++ spec/fixtures/emails/commands_only_reply.eml | 40 ++++ .../email/handler/create_note_handler_spec.rb | 9 + spec/lib/gitlab/slash_commands/dsl_spec.rb | 110 +++++++++ .../gitlab/slash_commands/extractor_spec.rb | 177 ++++++++++++++ spec/services/issues/create_service_spec.rb | 2 + .../merge_requests/create_service_spec.rb | 11 +- spec/services/notes/create_service_spec.rb | 23 +- .../slash_commands/interpret_service_spec.rb | 217 ++++++++++++++++++ ..._service_slash_commands_shared_examples.rb | 83 +++++++ ...issuable_slash_commands_shared_examples.rb | 170 ++++++++++++++ ..._service_slash_commands_shared_examples.rb | 116 ++++++++++ 31 files changed, 1553 insertions(+), 59 deletions(-) rename app/assets/javascripts/{gfm_auto_complete.js => gfm_auto_complete.js.es6} (86%) create mode 100644 app/services/slash_commands/interpret_service.rb create mode 100644 doc/workflow/slash_commands.md create mode 100644 lib/gitlab/slash_commands/dsl.rb create mode 100644 lib/gitlab/slash_commands/extractor.rb create mode 100644 spec/features/issues/user_uses_slash_commands_spec.rb create mode 100644 spec/features/merge_requests/user_uses_slash_commands_spec.rb create mode 100644 spec/fixtures/emails/commands_only_reply.eml create mode 100644 spec/lib/gitlab/slash_commands/dsl_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/extractor_spec.rb create mode 100644 spec/services/slash_commands/interpret_service_spec.rb create mode 100644 spec/support/issuable_create_service_slash_commands_shared_examples.rb create mode 100644 spec/support/issuable_slash_commands_shared_examples.rb create mode 100644 spec/support/note_create_service_slash_commands_shared_examples.rb diff --git a/CHANGELOG b/CHANGELOG index 96965a20f69..63ee7b91dcb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -53,6 +53,7 @@ v 8.11.0 (unreleased) - Update version_sorter and use new interface for faster tag sorting - Optimize checking if a user has read access to a list of issues !5370 - Store all DB secrets in secrets.yml, under descriptive names !5274 + - Support slash commands in issue and merge request descriptions as well as comments. !5021 - Nokogiri's various parsing methods are now instrumented - Add simple identifier to public SSH keys (muteor) - Admin page now references docs instead of a specific file !5600 (AnAverageHuman) diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js.es6 similarity index 86% rename from app/assets/javascripts/gfm_auto_complete.js rename to app/assets/javascripts/gfm_auto_complete.js.es6 index 2e5b15f4b77..21639c7c084 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -223,7 +223,7 @@ } } }); - return this.input.atwho({ + this.input.atwho({ at: '~', alias: 'labels', searchKey: 'search', @@ -249,6 +249,41 @@ } } }); + return this.input.atwho({ + at: '/', + alias: 'commands', + displayTpl: function(value) { + var tpl = '
  • /${name}'; + if (value.aliases.length > 0) { + tpl += ' (or /<%- aliases.join(", /") %>)'; + } + if (value.params.length > 0) { + tpl += ' <%- params.join(" ") %>'; + } + if (value.description !== '') { + tpl += '<%- description %>'; + } + tpl += '
  • '; + return _.template(tpl)(value); + }, + insertTpl: function(value) { + var tpl = "\n/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; + } + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert + } + }); }, destroyAtWho: function() { return this.input.atwho('destroy'); @@ -265,6 +300,7 @@ this.input.atwho('load', 'mergerequests', data.mergerequests); this.input.atwho('load', ':', data.emojis); this.input.atwho('load', '~', data.labels); + this.input.atwho('load', '/', data.commands); return $(':focus').trigger('keyup'); } }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 9ece474d994..99bc1a640a8 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -231,7 +231,12 @@ var $notesList, votesBlock; if (!note.valid) { if (note.award) { - new Flash('You have already awarded this emoji!', 'alert'); + new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline); + } + else { + if (note.errors.commands_only) { + new Flash(note.errors.commands_only, 'notice', this.parentTimeline); + } } return; } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 96565da1bc9..edea4ad00eb 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -147,3 +147,8 @@ color: $gl-link-color; } } + +.atwho-view small.description { + float: right; + padding: 3px 5px; +} diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 766b7e9cf22..f2422729364 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -125,7 +125,7 @@ class Projects::NotesController < Projects::ApplicationController id: note.id, name: note.name } - elsif note.valid? + elsif note.persisted? Banzai::NoteRenderer.render([note], @project, current_user) attrs = { diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 47efbd4a939..64d31e4a3a0 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -145,7 +145,8 @@ class ProjectsController < Projects::ApplicationController milestones: autocomplete.milestones, mergerequests: autocomplete.merge_requests, labels: autocomplete.labels, - members: participants + members: participants, + commands: autocomplete.commands } respond_to do |format| diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index ff866c2faa5..fd859e134e5 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -17,7 +17,7 @@ class TodosFinder attr_accessor :current_user, :params - def initialize(current_user, params) + def initialize(current_user, params = {}) @current_user = current_user @params = params end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 2d96efe1042..b365e19c4a8 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -69,14 +69,9 @@ class IssuableBaseService < BaseService end def filter_labels - if params[:add_label_ids].present? || params[:remove_label_ids].present? - params.delete(:label_ids) - - filter_labels_in_param(:add_label_ids) - filter_labels_in_param(:remove_label_ids) - else - filter_labels_in_param(:label_ids) - end + filter_labels_in_param(:add_label_ids) + filter_labels_in_param(:remove_label_ids) + filter_labels_in_param(:label_ids) end def filter_labels_in_param(key) @@ -85,23 +80,65 @@ class IssuableBaseService < BaseService params[key] = project.labels.where(id: params[key]).pluck(:id) end + def process_label_ids(attributes, base_label_ids: [], merge_all: false) + label_ids = attributes.delete(:label_ids) { [] } + add_label_ids = attributes.delete(:add_label_ids) { [] } + remove_label_ids = attributes.delete(:remove_label_ids) { [] } + + new_label_ids = base_label_ids + new_label_ids |= label_ids if merge_all || (add_label_ids.empty? && remove_label_ids.empty?) + new_label_ids |= add_label_ids + new_label_ids -= remove_label_ids + + new_label_ids + end + + def merge_slash_commands_into_params! + command_params = SlashCommands::InterpretService.new(project, current_user). + execute(params[:description]) + + params.merge!(command_params) + end + + def create_issuable(issuable, attributes) + issuable.with_transaction_returning_status do + attributes.delete(:state_event) + params[:author] ||= current_user + label_ids = process_label_ids(attributes, merge_all: true) + + issuable.assign_attributes(attributes) + + if issuable.save + issuable.update_attributes(label_ids: label_ids) + end + end + end + + def create(issuable) + merge_slash_commands_into_params! + filter_params + + if params.present? && create_issuable(issuable, params) + handle_creation(issuable) + issuable.create_cross_references!(current_user) + execute_hooks(issuable) + end + + issuable + end + def update_issuable(issuable, attributes) issuable.with_transaction_returning_status do - add_label_ids = attributes.delete(:add_label_ids) - remove_label_ids = attributes.delete(:remove_label_ids) + attributes[:label_ids] = process_label_ids(attributes, base_label_ids: issuable.label_ids) - issuable.label_ids |= add_label_ids if add_label_ids - issuable.label_ids -= remove_label_ids if remove_label_ids - - issuable.assign_attributes(attributes.merge(updated_by: current_user)) - - issuable.save + issuable.update(attributes.merge(updated_by: current_user)) end end def update(issuable) change_state(issuable) change_subscription(issuable) + change_todo(issuable) filter_params old_labels = issuable.labels.to_a @@ -134,6 +171,18 @@ class IssuableBaseService < BaseService end end + def change_todo(issuable) + case params.delete(:todo_event) + when 'mark' + todo_service.mark_todo(issuable, current_user) + when 'done' + todo = TodosFinder.new(current_user).execute.find_by(target: issuable) + if todo + todo_service.mark_todos_as_done([todo], current_user) + end + end + end + def has_changes?(issuable, old_labels: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 5e2de2ccf64..1b03d7f4c05 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -1,25 +1,19 @@ module Issues class CreateService < Issues::BaseService def execute - filter_params - label_params = params.delete(:label_ids) + issue = project.issues.new request = params.delete(:request) api = params.delete(:api) - issue = project.issues.new(params) - issue.author = params[:author] || current_user issue.spam = spam_check_service.execute(request, api) - if issue.save - issue.update_attributes(label_ids: label_params) - notification_service.new_issue(issue, current_user) - todo_service.new_issue(issue, current_user) - event_service.open_issue(issue, current_user) - issue.create_cross_references!(current_user) - execute_hooks(issue, 'open') - end + create(issue) + end - issue + def handle_creation(issuable) + event_service.open_issue(issuable, current_user) + notification_service.new_issue(issuable, current_user) + todo_service.new_issue(issuable, current_user) end private diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 96a25330af1..0b592cd5620 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -7,26 +7,19 @@ module MergeRequests source_project = @project @project = Project.find(params[:target_project_id]) if params[:target_project_id] - filter_params - label_params = params.delete(:label_ids) - force_remove_source_branch = params.delete(:force_remove_source_branch) + params[:target_project_id] ||= source_project.id - merge_request = MergeRequest.new(params) + merge_request = MergeRequest.new merge_request.source_project = source_project - merge_request.target_project ||= source_project - merge_request.author = current_user - merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch + merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch) - if merge_request.save - merge_request.update_attributes(label_ids: label_params) - event_service.open_mr(merge_request, current_user) - notification_service.new_merge_request(merge_request, current_user) - todo_service.new_merge_request(merge_request, current_user) - merge_request.create_cross_references!(current_user) - execute_hooks(merge_request) - end + create(merge_request) + end - merge_request + def handle_creation(issuable) + event_service.open_mr(issuable, current_user) + notification_service.new_merge_request(issuable, current_user) + todo_service.new_merge_request(issuable, current_user) end end end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 18971bd0be3..d7531a5d63b 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -11,13 +11,61 @@ module Notes return noteable.create_award_emoji(note.award_emoji_name, current_user) end + # We execute commands (extracted from `params[:note]`) on the noteable + # **before** we save the note because if the note consists of commands + # only, there is no need be create a note! + commands_executed = execute_slash_commands!(note) + if note.save # Finish the harder work in the background NewNoteWorker.perform_in(2.seconds, note.id, params) - TodoService.new.new_note(note, current_user) + todo_service.new_note(note, current_user) + end + + if commands_executed && note.note.blank? + note.errors.add(:commands_only, 'Your commands are being executed.') end note end + + private + + def execute_slash_commands!(note) + noteable_update_service = noteable_update_service(note.noteable_type) + return unless noteable_update_service + + command_params = SlashCommands::InterpretService.new(project, current_user). + execute(note.note) + + commands = execute_or_filter_commands(command_params, note) + + if commands.any? + noteable_update_service.new(project, current_user, commands).execute(note.noteable) + end + end + + def execute_or_filter_commands(commands, note) + final_commands = commands.reduce({}) do |memo, (command_key, command_value)| + if command_key != :due_date || note.noteable.respond_to?(:due_date) + memo[command_key] = command_value + end + + memo + end + + final_commands + end + + def noteable_update_service(noteable_type) + case noteable_type + when 'Issue' + Issues::UpdateService + when 'MergeRequest' + MergeRequests::UpdateService + else + nil + end + end end end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 23b6668e0d1..e943d2ffbcb 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -15,5 +15,9 @@ module Projects def labels @project.labels.select([:title, :color]) end + + def commands + SlashCommands::InterpretService.command_definitions + end end end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb new file mode 100644 index 00000000000..2c92a4f7de5 --- /dev/null +++ b/app/services/slash_commands/interpret_service.rb @@ -0,0 +1,133 @@ +module SlashCommands + class InterpretService < BaseService + include Gitlab::SlashCommands::Dsl + + # Takes a text and interpret the commands that are extracted from it. + # Returns a hash of changes to be applied to a record. + def execute(content) + @updates = {} + + commands = extractor.extract_commands!(content) + commands.each do |command| + __send__(*command) + end + + @updates + end + + private + + def extractor + @extractor ||= Gitlab::SlashCommands::Extractor.new(self.class.command_names) + end + + desc 'Close this issue or merge request' + command :close do + @updates[:state_event] = 'close' + end + + desc 'Reopen this issue or merge request' + command :open, :reopen do + @updates[:state_event] = 'reopen' + end + + desc 'Reassign' + params '@user' + command :assign, :reassign do |assignee_param| + user = extract_references(assignee_param, :user).first + return unless user + + @updates[:assignee_id] = user.id + end + + desc 'Remove assignee' + command :unassign, :remove_assignee do + @updates[:assignee_id] = nil + end + + desc 'Change milestone' + params '%"milestone"' + command :milestone do |milestone_param| + milestone = extract_references(milestone_param, :milestone).first + return unless milestone + + @updates[:milestone_id] = milestone.id + end + + desc 'Remove milestone' + command :clear_milestone, :remove_milestone do + @updates[:milestone_id] = nil + end + + desc 'Add label(s)' + params '~label1 ~"label 2"' + command :label, :labels do |labels_param| + label_ids = find_label_ids(labels_param) + return if label_ids.empty? + + @updates[:add_label_ids] = label_ids + end + + desc 'Remove label(s)' + params '~label1 ~"label 2"' + command :unlabel, :remove_label, :remove_labels do |labels_param| + label_ids = find_label_ids(labels_param) + return if label_ids.empty? + + @updates[:remove_label_ids] = label_ids + end + + desc 'Remove all labels' + command :clear_labels, :clear_label do + @updates[:label_ids] = [] + end + + desc 'Add a todo' + command :todo do + @updates[:todo_event] = 'mark' + end + + desc 'Mark todo as done' + command :done do + @updates[:todo_event] = 'done' + end + + desc 'Subscribe' + command :subscribe do + @updates[:subscription_event] = 'subscribe' + end + + desc 'Unsubscribe' + command :unsubscribe do + @updates[:subscription_event] = 'unsubscribe' + end + + desc 'Set a due date' + params ' | ' + command :due_date do |due_date_param| + due_date = begin + Time.now + ChronicDuration.parse(due_date_param) + rescue ChronicDuration::DurationParseError + Date.parse(due_date_param) rescue nil + end + + @updates[:due_date] = due_date if due_date + end + + desc 'Remove due date' + command :clear_due_date do + @updates[:due_date] = nil + end + + def find_label_ids(labels_param) + extract_references(labels_param, :label).map(&:id) + end + + def extract_references(cmd_arg, type) + ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(cmd_arg, author: current_user) + + ext.references(type) + end + end +end diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 49dec613716..17c04377b4c 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -6,6 +6,7 @@ - [GitLab Flow](gitlab_flow.md) - [Groups](groups.md) - [Keyboard shortcuts](shortcuts.md) +- [Slash commands](slash_commands.md) - [File finder](file_finder.md) - [Labels](../user/project/labels.md) - [Notification emails](notifications.md) diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md new file mode 100644 index 00000000000..3bfc66309ba --- /dev/null +++ b/doc/workflow/slash_commands.md @@ -0,0 +1,28 @@ +# GitLab slash commands + +Slash commands are textual shortcuts for common actions on issues or merge +requests that are usually done by clicking buttons or dropdowns in GitLab's UI. +You can enter these commands while creating a new issue or merge request, and +in comments. Each command should be on a separate line in order to be properly +detected and executed. + +Here is a list of all of the available commands and descriptions about what they +do. + +| Command | Aliases | Action | +|:---------------------------|:--------------------|:-------------| +| `/close` | None | Close the issue or merge request | +| `/open` | `/reopen` | Reopen the issue or merge request | +| `/assign @username` | `/reassign` | Reassign | +| `/unassign` | `/remove_assignee` | Remove assignee | +| `/milestone %milestone` | None | Change milestone | +| `/clear_milestone` | `/remove_milestone` | Remove milestone | +| `/label ~foo ~"bar baz"` | `/labels` | Add label(s) | +| `/unlabel ~foo ~"bar baz"` | `/remove_label`, `remove_labels` | Remove label(s) | +| `/clear_labels` | `/clear_label` | Clear all labels | +| `/todo` | None | Add a todo | +| `/done` | None | Mark todo as done | +| `/subscribe` | None | Subscribe | +| `/unsubscribe` | None | Unsubscribe | +| `/due_date` | None | Set a due date | +| `/clear_due_date` | None | Remove due date | diff --git a/lib/gitlab/email/handler/base_handler.rb b/lib/gitlab/email/handler/base_handler.rb index b7ed11cb638..7cccf465334 100644 --- a/lib/gitlab/email/handler/base_handler.rb +++ b/lib/gitlab/email/handler/base_handler.rb @@ -45,6 +45,7 @@ module Gitlab def verify_record!(record:, invalid_exception:, record_name:) return if record.persisted? + return if record.errors.key?(:commands_only) error_title = "The #{record_name} could not be created for the following reasons:" diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb new file mode 100644 index 00000000000..3ded4109f2e --- /dev/null +++ b/lib/gitlab/slash_commands/dsl.rb @@ -0,0 +1,76 @@ +module Gitlab + module SlashCommands + module Dsl + extend ActiveSupport::Concern + + included do + @command_definitions = [] + end + + module ClassMethods + def command_definitions + @command_definitions + end + + def command_names + command_definitions.flat_map do |command_definition| + [command_definition[:name], command_definition[:aliases]].flatten + end + end + + # Allows to give a description to the next slash command + def desc(text) + @description = text + end + + # Allows to define params for the next slash command + def params(*params) + @params = params + end + + # Registers a new command which is recognizeable + # from body of email or comment. + # Example: + # + # command :command_key do |arguments| + # # Awesome code block + # end + # + def command(*command_names, &block) + command_name, *aliases = command_names + proxy_method_name = "__#{command_name}__" + + # This proxy method is needed because calling `return` from inside a + # block/proc, causes a `return` from the enclosing method or lambda, + # otherwise a LocalJumpError error is raised. + define_method(proxy_method_name, &block) + + define_method(command_name) do |*args| + proxy_method = method(proxy_method_name) + + if proxy_method.arity == -1 || proxy_method.arity == args.size + instance_exec(*args, &proxy_method) + end + end + + private command_name + aliases.each do |alias_command| + alias_method alias_command, command_name + private alias_command + end + + command_definition = { + name: command_name, + aliases: aliases, + description: @description || '', + params: @params || [] + } + @command_definitions << command_definition + + @description = nil + @params = nil + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb new file mode 100644 index 00000000000..1a854b81aca --- /dev/null +++ b/lib/gitlab/slash_commands/extractor.rb @@ -0,0 +1,59 @@ +module Gitlab + module SlashCommands + # This class takes an array of commands that should be extracted from a + # given text. + # + # ``` + # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) + # ``` + class Extractor + attr_reader :command_names + + def initialize(command_names) + @command_names = command_names + end + + # Extracts commands from content and return an array of commands. + # The array looks like the following: + # [ + # ['command1'], + # ['command3', 'arg1 arg2'], + # ] + # The command and the arguments are stripped. + # The original command text is removed from the given `content`. + # + # Usage: + # ``` + # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) + # msg = %(hello\n/labels ~foo ~"bar baz"\nworld) + # commands = extractor.extract_commands! #=> [['labels', '~foo ~"bar baz"']] + # msg #=> "hello\nworld" + # ``` + def extract_commands!(content) + return [] unless content + + commands = [] + + content.gsub!(commands_regex) do + commands << [$1, $2].flatten.reject(&:blank?) + '' + end + + commands + end + + private + + # Builds a regular expression to match known commands. + # First match group captures the command name and + # second match group captures its arguments. + # + # It looks something like: + # + # /^\/(?close|reopen|...)(?:( |$))(?[^\/\n]*)(?:\n|$)/ + def commands_regex + /^\/(?#{command_names.join('|')})(?:( |$))(?[^\/\n]*)(?:\n|$)/ + end + end + end +end 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: +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 ; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for ; 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 +To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo +Message-ID: +In-Reply-To: +References: +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 + 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 From a54fdc384fee9daeab1b9fb638dae5dce4e4be15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 9 Aug 2016 17:51:40 +0200 Subject: [PATCH 02/25] Enforce permissions in `{Issues,MergeRequests}::{Close,Reopen}Service` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- app/services/issues/close_service.rb | 2 + app/services/issues/reopen_service.rb | 2 + app/services/merge_requests/close_service.rb | 2 + app/services/merge_requests/reopen_service.rb | 2 + spec/services/issues/close_service_spec.rb | 18 ++- spec/services/issues/reopen_service_spec.rb | 25 ++++ .../merge_requests/close_service_spec.rb | 16 ++- .../merge_requests/reopen_service_spec.rb | 19 ++- ...issuable_slash_commands_shared_examples.rb | 113 +++++++++++++++--- 9 files changed, 178 insertions(+), 21 deletions(-) create mode 100644 spec/services/issues/reopen_service_spec.rb diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 859c934ea3b..45cca216ccc 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -1,6 +1,8 @@ module Issues class CloseService < Issues::BaseService def execute(issue, commit: nil, notifications: true, system_note: true) + return issue unless can?(current_user, :update_issue, issue) + if project.jira_tracker? && project.jira_service.active project.jira_service.execute(commit, issue) todo_service.close_issue(issue, current_user) diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index e48ca359f4f..40fbe354492 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -1,6 +1,8 @@ module Issues class ReopenService < Issues::BaseService def execute(issue) + return issue unless can?(current_user, :update_issue, issue) + if issue.reopen event_service.reopen_issue(issue, current_user) create_note(issue) diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index 27ee81fe3e7..f2053bda83a 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -1,6 +1,8 @@ module MergeRequests class CloseService < MergeRequests::BaseService def execute(merge_request, commit = nil) + return merge_request unless can?(current_user, :update_merge_request, merge_request) + # If we close MergeRequest we want to ignore validation # so we can close broken one (Ex. fork project removed) merge_request.allow_broken = true diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index eb88ae9d11c..fadcce5d9b6 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -1,6 +1,8 @@ module MergeRequests class ReopenService < MergeRequests::BaseService def execute(merge_request) + return merge_request unless can?(current_user, :update_merge_request, merge_request) + if merge_request.reopen event_service.reopen_mr(merge_request, current_user) create_note(merge_request) diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 1318607a388..aff022a573e 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe Issues::CloseService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } + let(:guest) { create(:user) } let(:issue) { create(:issue, assignee: user2) } let(:project) { issue.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } @@ -10,13 +11,14 @@ describe Issues::CloseService, services: true do before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context "valid params" do before do perform_enqueued_jobs do - @issue = Issues::CloseService.new(project, user, {}).execute(issue) + @issue = described_class.new(project, user, {}).execute(issue) end end @@ -39,10 +41,22 @@ describe Issues::CloseService, services: true do end end + context 'current user is not authorized to close issue' do + before do + perform_enqueued_jobs do + @issue = described_class.new(project, guest).execute(issue) + end + end + + it 'does not close the issue' do + expect(@issue).to be_open + end + end + context "external issue tracker" do before do allow(project).to receive(:default_issues_tracker?).and_return(false) - @issue = Issues::CloseService.new(project, user, {}).execute(issue) + @issue = described_class.new(project, user, {}).execute(issue) end it { expect(@issue).to be_valid } diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb new file mode 100644 index 00000000000..34a89fcd4e1 --- /dev/null +++ b/spec/services/issues/reopen_service_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Issues::ReopenService, services: true do + let(:guest) { create(:user) } + let(:issue) { create(:issue, :closed) } + let(:project) { issue.project } + + before do + project.team << [guest, :guest] + end + + describe '#execute' do + context 'current user is not authorized to reopen issue' do + before do + perform_enqueued_jobs do + @issue = described_class.new(project, guest).execute(issue) + end + end + + it 'does not reopen the issue' do + expect(@issue).to be_closed + end + end + end +end diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index 403533be5d9..24c25e4350f 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe MergeRequests::CloseService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } + let(:guest) { create(:user) } let(:merge_request) { create(:merge_request, assignee: user2) } let(:project) { merge_request.project } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: merge_request, author: user2) } @@ -10,11 +11,12 @@ describe MergeRequests::CloseService, services: true do before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context 'valid params' do - let(:service) { MergeRequests::CloseService.new(project, user, {}) } + let(:service) { described_class.new(project, user, {}) } before do allow(service).to receive(:execute_hooks) @@ -47,5 +49,17 @@ describe MergeRequests::CloseService, services: true do expect(todo.reload).to be_done end end + + context 'current user is not authorized to close merge request' do + before do + perform_enqueued_jobs do + @merge_request = described_class.new(project, guest).execute(merge_request) + end + end + + it 'does not close the merge request' do + expect(@merge_request).to be_open + end + end end end diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb index 3419b8bf5e6..af7424a76a9 100644 --- a/spec/services/merge_requests/reopen_service_spec.rb +++ b/spec/services/merge_requests/reopen_service_spec.rb @@ -3,22 +3,23 @@ require 'spec_helper' describe MergeRequests::ReopenService, services: true do let(:user) { create(:user) } let(:user2) { create(:user) } - let(:merge_request) { create(:merge_request, assignee: user2) } + let(:guest) { create(:user) } + let(:merge_request) { create(:merge_request, :closed, assignee: user2) } let(:project) { merge_request.project } before do project.team << [user, :master] project.team << [user2, :developer] + project.team << [guest, :guest] end describe '#execute' do context 'valid params' do - let(:service) { MergeRequests::ReopenService.new(project, user, {}) } + let(:service) { described_class.new(project, user, {}) } before do allow(service).to receive(:execute_hooks) - merge_request.state = :closed perform_enqueued_jobs do service.execute(merge_request) end @@ -43,5 +44,17 @@ describe MergeRequests::ReopenService, services: true do expect(note.note).to include 'Status changed to reopened' end end + + context 'current user is not authorized to reopen merge request' do + before do + perform_enqueued_jobs do + @merge_request = described_class.new(project, guest).execute(merge_request) + end + end + + it 'does not reopen the merge request' do + expect(@merge_request).to be_closed + end + end end end diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb index 0c8bd69add6..3b8783cb053 100644 --- a/spec/support/issuable_slash_commands_shared_examples.rb +++ b/spec/support/issuable_slash_commands_shared_examples.rb @@ -2,8 +2,9 @@ # 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(:master) { create(:user) } let(:assignee) { create(:user, username: 'bob') } + let(:guest) { create(:user) } let(:project) { create(:project, :public) } let!(:milestone) { create(:milestone, project: project, title: 'ASAP') } let!(:label_bug) { create(:label, project: project, title: 'bug') } @@ -11,9 +12,10 @@ shared_examples 'issuable record that supports slash commands in its description let(:new_url_opts) { {} } before do - project.team << [user, :master] + project.team << [master, :master] project.team << [assignee, :developer] - login_with(user) + project.team << [guest, :guest] + login_with(master) end describe "new #{issuable_type}" do @@ -83,6 +85,87 @@ shared_examples 'issuable record that supports slash commands in its description end end + context "with a note closing the #{issuable_type}" do + before do + expect(issuable).to be_open + end + + context "when current user can close #{issuable_type}" do + it "closes the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/close" + click_button 'Comment' + end + + expect(page).not_to have_content '/close' + expect(page).to have_content 'Your commands are being executed.' + + expect(issuable.reload).to be_closed + end + end + + context "when current user cannot close #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not close the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/close" + click_button 'Comment' + end + + expect(page).not_to have_content '/close' + expect(page).to have_content 'Your commands are being executed.' + + expect(issuable).to be_open + end + end + end + + context "with a note reopening the #{issuable_type}" do + before do + issuable.close + expect(issuable).to be_closed + end + + context "when current user can reopen #{issuable_type}" do + it "reopens the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/reopen" + click_button 'Comment' + end + + expect(page).not_to have_content '/reopen' + expect(page).to have_content 'Your commands are being executed.' + + expect(issuable.reload).to be_open + end + end + + context "when current user cannot reopen #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not reopen the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/reopen" + click_button 'Comment' + end + + expect(page).not_to have_content '/reopen' + expect(page).to have_content 'Your commands are being executed.' + + expect(issuable).to be_closed + end + 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 @@ -93,31 +176,31 @@ shared_examples 'issuable record that supports slash commands in its description expect(page).not_to have_content '/todo' expect(page).to have_content 'Your commands are being executed.' - todos = TodosFinder.new(user).execute + todos = TodosFinder.new(master).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 + expect(todo.author).to eq master + expect(todo.user).to eq master end end context "with a note marking the #{issuable_type} as done" do before do - TodoService.new.mark_todo(issuable, user) + TodoService.new.mark_todo(issuable, master) end it "creates a new todo for the #{issuable_type}" do - todos = TodosFinder.new(user).execute + todos = TodosFinder.new(master).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 + expect(todo.author).to eq master + expect(todo.user).to eq master page.within('.js-main-target-form') do fill_in 'note[note]', with: "/done" @@ -133,7 +216,7 @@ shared_examples 'issuable record that supports slash commands in its description 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 + expect(issuable.subscribed?(master)).to be_falsy page.within('.js-main-target-form') do fill_in 'note[note]', with: "/subscribe" @@ -143,17 +226,17 @@ shared_examples 'issuable record that supports slash commands in its description expect(page).not_to have_content '/subscribe' expect(page).to have_content 'Your commands are being executed.' - expect(issuable.subscribed?(user)).to be_truthy + expect(issuable.subscribed?(master)).to be_truthy end end context "with a note unsubscribing to the #{issuable_type} as done" do before do - issuable.subscribe(user) + issuable.subscribe(master) end it "creates a new todo for the #{issuable_type}" do - expect(issuable.subscribed?(user)).to be_truthy + expect(issuable.subscribed?(master)).to be_truthy page.within('.js-main-target-form') do fill_in 'note[note]', with: "/unsubscribe" @@ -163,7 +246,7 @@ shared_examples 'issuable record that supports slash commands in its description expect(page).not_to have_content '/unsubscribe' expect(page).to have_content 'Your commands are being executed.' - expect(issuable.subscribed?(user)).to be_falsy + expect(issuable.subscribed?(master)).to be_falsy end end end From 7cc4ab14b8a2f1d7d374a320b79374764527659f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 9 Aug 2016 19:26:45 +0200 Subject: [PATCH 03/25] New Notes::SlashCommandsService service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check for update_issuable permission in Notes::SlashCommandsService Signed-off-by: Rémy Coutable --- app/services/issuable_base_service.rb | 18 +- app/services/notes/create_service.rb | 43 +-- app/services/notes/slash_commands_service.rb | 22 ++ .../slash_commands/interpret_service.rb | 9 +- .../user_uses_slash_commands_spec.rb | 27 -- spec/services/notes/create_service_spec.rb | 23 +- .../notes/slash_commands_service_spec.rb | 129 +++++++++ .../slash_commands/interpret_service_spec.rb | 257 ++++++++++++++---- ...issuable_slash_commands_shared_examples.rb | 4 +- ..._service_slash_commands_shared_examples.rb | 116 -------- 10 files changed, 392 insertions(+), 256 deletions(-) create mode 100644 app/services/notes/slash_commands_service.rb create mode 100644 spec/services/notes/slash_commands_service_spec.rb delete mode 100644 spec/support/note_create_service_slash_commands_shared_examples.rb diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index b365e19c4a8..3512e2b735e 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -81,21 +81,21 @@ class IssuableBaseService < BaseService end def process_label_ids(attributes, base_label_ids: [], merge_all: false) - label_ids = attributes.delete(:label_ids) { [] } - add_label_ids = attributes.delete(:add_label_ids) { [] } - remove_label_ids = attributes.delete(:remove_label_ids) { [] } + label_ids = attributes.delete(:label_ids) + add_label_ids = attributes.delete(:add_label_ids) + remove_label_ids = attributes.delete(:remove_label_ids) new_label_ids = base_label_ids - new_label_ids |= label_ids if merge_all || (add_label_ids.empty? && remove_label_ids.empty?) - new_label_ids |= add_label_ids - new_label_ids -= remove_label_ids + new_label_ids = label_ids if label_ids && (merge_all || (add_label_ids.empty? && remove_label_ids.empty?)) + new_label_ids |= add_label_ids if add_label_ids + new_label_ids -= remove_label_ids if remove_label_ids new_label_ids end - def merge_slash_commands_into_params! + def merge_slash_commands_into_params!(issuable) command_params = SlashCommands::InterpretService.new(project, current_user). - execute(params[:description]) + execute(params[:description], issuable) params.merge!(command_params) end @@ -115,7 +115,7 @@ class IssuableBaseService < BaseService end def create(issuable) - merge_slash_commands_into_params! + merge_slash_commands_into_params!(issuable) filter_params if params.present? && create_issuable(issuable, params) diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index d7531a5d63b..0c2513409a1 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -14,7 +14,7 @@ module Notes # We execute commands (extracted from `params[:note]`) on the noteable # **before** we save the note because if the note consists of commands # only, there is no need be create a note! - commands_executed = execute_slash_commands!(note) + commands_executed = SlashCommandsService.new(project, current_user).execute(note) if note.save # Finish the harder work in the background @@ -22,50 +22,13 @@ module Notes todo_service.new_note(note, current_user) end + # We must add the error after we call #save because errors are reset + # when #save is called if commands_executed && note.note.blank? note.errors.add(:commands_only, 'Your commands are being executed.') end note end - - private - - def execute_slash_commands!(note) - noteable_update_service = noteable_update_service(note.noteable_type) - return unless noteable_update_service - - command_params = SlashCommands::InterpretService.new(project, current_user). - execute(note.note) - - commands = execute_or_filter_commands(command_params, note) - - if commands.any? - noteable_update_service.new(project, current_user, commands).execute(note.noteable) - end - end - - def execute_or_filter_commands(commands, note) - final_commands = commands.reduce({}) do |memo, (command_key, command_value)| - if command_key != :due_date || note.noteable.respond_to?(:due_date) - memo[command_key] = command_value - end - - memo - end - - final_commands - end - - def noteable_update_service(noteable_type) - case noteable_type - when 'Issue' - Issues::UpdateService - when 'MergeRequest' - MergeRequests::UpdateService - else - nil - end - end end end diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb new file mode 100644 index 00000000000..54d43d06466 --- /dev/null +++ b/app/services/notes/slash_commands_service.rb @@ -0,0 +1,22 @@ +module Notes + class SlashCommandsService < BaseService + + UPDATE_SERVICES = { + 'Issue' => Issues::UpdateService, + 'MergeRequest' => MergeRequests::UpdateService + } + + def execute(note) + noteable_update_service = UPDATE_SERVICES[note.noteable_type] + return false unless noteable_update_service + return false unless can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable) + + commands = SlashCommands::InterpretService.new(project, current_user). + execute(note.note, note.noteable) + + if commands.any? + noteable_update_service.new(project, current_user, commands).execute(note.noteable) + end + end + end +end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 2c92a4f7de5..bff61683976 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -2,9 +2,12 @@ module SlashCommands class InterpretService < BaseService include Gitlab::SlashCommands::Dsl + attr_reader :noteable + # Takes a text and interpret the commands that are extracted from it. # Returns a hash of changes to be applied to a record. - def execute(content) + def execute(content, noteable) + @noteable = noteable @updates = {} commands = extractor.extract_commands!(content) @@ -105,6 +108,8 @@ module SlashCommands desc 'Set a due date' params ' | ' command :due_date do |due_date_param| + return unless noteable.respond_to?(:due_date) + due_date = begin Time.now + ChronicDuration.parse(due_date_param) rescue ChronicDuration::DurationParseError @@ -116,6 +121,8 @@ module SlashCommands desc 'Remove due date' command :clear_due_date do + return unless noteable.respond_to?(:due_date) + @updates[:due_date] = nil 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 index 890648f3860..08c452c6e59 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -29,31 +29,4 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do 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/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 36ca7d2bce8..92dbccf0729 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -9,25 +9,30 @@ describe Notes::CreateService, services: true do end describe '#execute' do + before do + project.team << [user, :master] + end + context "valid params" do before do - project.team << [user, :master] @note = Notes::CreateService.new(project, user, opts).execute end it { expect(@note).to be_valid } it { expect(@note.note).to eq(opts[:note]) } + end - it_behaves_like 'note on noteable that supports slash commands' do - let(:noteable) { create(:issue, project: project) } - end + describe 'note with commands' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) } - it_behaves_like 'note on noteable that supports slash commands' do - let(:noteable) { create(:merge_request, source_project: project) } - end + it 'saves the note and does not alter the note text' do + expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original - it_behaves_like 'note on noteable that does not support slash commands' do - let(:noteable) { create(:commit, project: project) } + note = described_class.new(project, user, opts.merge(note: note_text)).execute + + expect(note.note).to eq "HELLO\nWORLD" + end end end end diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb new file mode 100644 index 00000000000..5632ec09834 --- /dev/null +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -0,0 +1,129 @@ +require 'spec_helper' + +describe Notes::SlashCommandsService, services: true do + shared_context 'note on noteable' do + let(:project) { create(:empty_project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + end + + shared_examples 'note on noteable that does not support slash commands' do + include_context 'note on noteable' + + before do + note.note = note_text + described_class.new(project, master).execute(note) + end + + describe 'note with only command' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) { %(/close\n/assign @#{assignee.username}") } + + it 'saves the note and does not alter the note text' do + 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) } + + it 'saves the note and does not alter the note text' do + 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' + + before do + note.note = note_text + end + + 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(:note_text) do + %(/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 + described_class.new(project, master).execute(note) + + expect(note.note).to eq '' + expect(note.noteable).to be_closed + expect(note.noteable.labels).to match_array(labels) + expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.milestone).to eq(milestone) + end + end + + describe '/open' do + before do + note.noteable.close! + expect(note.noteable).to be_closed + end + let(:note_text) { '/open' } + + it 'opens the noteable, and leave no note' do + described_class.new(project, master).execute(note) + + expect(note.note).to eq '' + expect(note.noteable).to be_open + end + end + end + + describe 'note with command & text' do + describe '/close, /label, /assign & /milestone' do + let(:note_text) do + %(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 + described_class.new(project, master).execute(note) + + expect(note.note).to eq "HELLO\nWORLD" + expect(note.noteable).to be_closed + expect(note.noteable.labels).to match_array(labels) + expect(note.noteable.assignee).to eq(assignee) + expect(note.noteable.milestone).to eq(milestone) + end + end + + describe '/open' do + before do + note.noteable.close + expect(note.noteable).to be_closed + end + let(:note_text) { "HELLO\n/open\nWORLD" } + + it 'opens the noteable' do + described_class.new(project, master).execute(note) + + expect(note.note).to eq "HELLO\nWORLD" + expect(note.noteable).to be_open + end + end + end + end + + describe '#execute' do + it_behaves_like 'note on noteable that supports slash commands' do + let(:note) { build(:note_on_issue, project: project) } + end + + it_behaves_like 'note on noteable that supports slash commands' do + let(:note) { build(:note_on_merge_request, project: project) } + end + + it_behaves_like 'note on noteable that does not support slash commands' do + let(:note) { build(:note_on_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 index fa0f65495ce..d03c84f59b3 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -18,8 +18,7 @@ describe SlashCommands::InterpretService, services: true do :assign, :reassign, :unassign, :remove_assignee, :milestone, - :remove_milestone, - :clear_milestone, + :clear_milestone, :remove_milestone, :labels, :label, :unlabel, :remove_labels, :remove_label, :clear_labels, :clear_label, @@ -35,10 +34,12 @@ describe SlashCommands::InterpretService, services: true do describe '#execute' do let(:service) { described_class.new(project, user) } + let(:issue) { create(:issue) } + let(:merge_request) { create(:merge_request) } shared_examples 'open command' do it 'returns state_event: "open" if content contains /open' do - changes = service.execute(content) + changes = service.execute(content, issuable) expect(changes).to eq(state_event: 'reopen') end @@ -46,7 +47,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'close command' do it 'returns state_event: "close" if content contains /open' do - changes = service.execute(content) + changes = service.execute(content, issuable) expect(changes).to eq(state_event: 'close') end @@ -54,31 +55,47 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'assign command' do it 'fetches assignee and populates assignee_id if content contains /assign' do - changes = service.execute(content) + changes = service.execute(content, issuable) expect(changes).to eq(assignee_id: user.id) end end + shared_examples 'unassign command' do + it 'populates assignee_id: nil if content contains /unassign' do + changes = service.execute(content, issuable) + + expect(changes).to eq(assignee_id: nil) + end + end + shared_examples 'milestone command' do it 'fetches milestone and populates milestone_id if content contains /milestone' do - changes = service.execute(content) + changes = service.execute(content, issuable) expect(changes).to eq(milestone_id: milestone.id) end end + shared_examples 'clear_milestone command' do + it 'populates milestone_id: nil if content contains /clear_milestone' do + changes = service.execute(content, issuable) + + expect(changes).to eq(milestone_id: nil) + 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) + changes = service.execute(content, issuable) 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) + shared_examples 'unlabel command' do + it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do + changes = service.execute(content, issuable) expect(changes).to eq(remove_label_ids: [inprogress.id]) end @@ -86,15 +103,63 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'clear_labels command' do it 'populates label_ids: [] if content contains /clear_labels' do - changes = service.execute(content) + changes = service.execute(content, issuable) 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) + shared_examples 'todo command' do + it 'populates todo_event: "mark" if content contains /todo' do + changes = service.execute(content, issuable) + + expect(changes).to eq(todo_event: 'mark') + end + end + + shared_examples 'done command' do + it 'populates todo_event: "done" if content contains /done' do + changes = service.execute(content, issuable) + + expect(changes).to eq(todo_event: 'done') + end + end + + shared_examples 'subscribe command' do + it 'populates subscription_event: "subscribe" if content contains /subscribe' do + changes = service.execute(content, issuable) + + expect(changes).to eq(subscription_event: 'subscribe') + end + end + + shared_examples 'unsubscribe command' do + it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do + changes = service.execute(content, issuable) + + expect(changes).to eq(subscription_event: 'unsubscribe') + end + end + + shared_examples 'due_date command' do + it 'populates due_date: Date.new(2016, 8, 28) if content contains /due_date 2016-08-28' do + changes = service.execute(content, issuable) + + expect(changes).to eq(due_date: Date.new(2016, 8, 28)) + end + end + + shared_examples 'clear_due_date command' do + it 'populates due_date: nil if content contains /clear_due_date' do + changes = service.execute(content, issuable) + + expect(changes).to eq(due_date: nil) + end + end + + shared_examples 'empty command' do + it 'populates {} if content contains an unsupported command' do + changes = service.execute(content, issuable) expect(changes).to be_empty end @@ -102,116 +167,204 @@ describe SlashCommands::InterpretService, services: true do it_behaves_like 'open command' do let(:content) { '/open' } + let(:issuable) { issue } + end + + it_behaves_like 'open command' do + let(:content) { '/open' } + let(:issuable) { merge_request } end it_behaves_like 'open command' do let(:content) { '/reopen' } + let(:issuable) { issue } end it_behaves_like 'close command' do let(:content) { '/close' } + let(:issuable) { issue } + end + + it_behaves_like 'close command' do + let(:content) { '/close' } + let(:issuable) { merge_request } end it_behaves_like 'assign command' do let(:content) { "/assign @#{user.username}" } + let(:issuable) { issue } + end + + it_behaves_like 'assign command' do + let(:content) { "/assign @#{user.username}" } + let(:issuable) { merge_request } end it 'does not populate assignee_id if content contains /assign with an unknown user' do - changes = service.execute('/assign joe') + changes = service.execute('/assign joe', issue) expect(changes).to be_empty end it 'does not populate assignee_id if content contains /assign without user' do - changes = service.execute('/assign') + changes = service.execute('/assign', issue) expect(changes).to be_empty end - it 'populates assignee_id: nil if content contains /unassign' do - changes = service.execute('/unassign') + it_behaves_like 'unassign command' do + let(:content) { '/unassign' } + let(:issuable) { issue } + end - expect(changes).to eq(assignee_id: nil) + it_behaves_like 'unassign command' do + let(:content) { '/unassign' } + let(:issuable) { merge_request } + end + + it_behaves_like 'unassign command' do + let(:content) { '/remove_assignee' } + let(:issuable) { issue } end it_behaves_like 'milestone command' do let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { issue } end - it 'populates milestone_id: nil if content contains /clear_milestone' do - changes = service.execute('/clear_milestone') + it_behaves_like 'milestone command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { merge_request } + end - expect(changes).to eq(milestone_id: nil) + it_behaves_like 'clear_milestone command' do + let(:content) { '/clear_milestone' } + let(:issuable) { issue } + end + + it_behaves_like 'clear_milestone command' do + let(:content) { '/clear_milestone' } + let(:issuable) { merge_request } + end + + it_behaves_like 'clear_milestone command' do + let(:content) { '/remove_milestone' } + let(:issuable) { issue } end it_behaves_like 'label command' do let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { issue } + end + + it_behaves_like 'label command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { merge_request } end it_behaves_like 'label command' do let(:content) { %(/labels ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { issue } end - it_behaves_like 'remove_labels command' do + it_behaves_like 'unlabel command' do let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { issue } end - it_behaves_like 'remove_labels command' do + it_behaves_like 'unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { merge_request } + end + + it_behaves_like 'unlabel command' do let(:content) { %(/remove_labels ~"#{inprogress.title}") } + let(:issuable) { issue } end - it_behaves_like 'remove_labels command' do + it_behaves_like 'unlabel command' do let(:content) { %(/remove_label ~"#{inprogress.title}") } + let(:issuable) { issue } end it_behaves_like 'clear_labels command' do let(:content) { '/clear_labels' } + let(:issuable) { issue } + end + + it_behaves_like 'clear_labels command' do + let(:content) { '/clear_labels' } + let(:issuable) { merge_request } end it_behaves_like 'clear_labels command' do let(:content) { '/clear_label' } + let(:issuable) { issue } end - it 'populates todo: :mark if content contains /todo' do - changes = service.execute('/todo') - - expect(changes).to eq(todo_event: 'mark') + it_behaves_like 'todo command' do + let(:content) { '/todo' } + let(:issuable) { issue } end - it 'populates todo: :done if content contains /done' do - changes = service.execute('/done') - - expect(changes).to eq(todo_event: 'done') + it_behaves_like 'todo command' do + let(:content) { '/todo' } + let(:issuable) { merge_request } end - it 'populates subscription: :subscribe if content contains /subscribe' do - changes = service.execute('/subscribe') - - expect(changes).to eq(subscription_event: 'subscribe') + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { issue } end - it 'populates subscription: :unsubscribe if content contains /unsubscribe' do - changes = service.execute('/unsubscribe') - - expect(changes).to eq(subscription_event: 'unsubscribe') + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { merge_request } 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)) + it_behaves_like 'subscribe command' do + let(:content) { '/subscribe' } + let(:issuable) { issue } 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 + it_behaves_like 'subscribe command' do + let(:content) { '/subscribe' } + let(:issuable) { merge_request } end - it 'populates due_date: nil if content contains /clear_due_date' do - changes = service.execute('/clear_due_date') + it_behaves_like 'unsubscribe command' do + let(:content) { '/unsubscribe' } + let(:issuable) { issue } + end - expect(changes).to eq(due_date: nil) + it_behaves_like 'unsubscribe command' do + let(:content) { '/unsubscribe' } + let(:issuable) { merge_request } + end + + it_behaves_like 'due_date command' do + let(:content) { '/due_date 2016-08-28' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/due_date foo bar' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/due_date 2016-08-28' } + let(:issuable) { merge_request } + end + + it_behaves_like 'clear_due_date command' do + let(:content) { '/clear_due_date' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/clear_due_date' } + let(:issuable) { merge_request } end end end diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb index 3b8783cb053..824ccb0ddfb 100644 --- a/spec/support/issuable_slash_commands_shared_examples.rb +++ b/spec/support/issuable_slash_commands_shared_examples.rb @@ -118,7 +118,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/close' - expect(page).to have_content 'Your commands are being executed.' + expect(page).not_to have_content 'Your commands are being executed.' expect(issuable).to be_open end @@ -159,7 +159,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/reopen' - expect(page).to have_content 'Your commands are being executed.' + expect(page).not_to have_content 'Your commands are being executed.' expect(issuable).to be_closed 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 deleted file mode 100644 index 3f7ad8b2f91..00000000000 --- a/spec/support/note_create_service_slash_commands_shared_examples.rb +++ /dev/null @@ -1,116 +0,0 @@ -# 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 From 39f7f63fe951ff861ad151125188e6cdd598b6ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 9 Aug 2016 20:54:18 +0200 Subject: [PATCH 04/25] Add the /title slash command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- app/services/issuable_base_service.rb | 2 +- .../slash_commands/interpret_service.rb | 6 +++ doc/workflow/slash_commands.md | 3 +- .../email/handler/create_note_handler_spec.rb | 15 +++++++- .../slash_commands/interpret_service_spec.rb | 38 +++++++++++++++---- ...issuable_slash_commands_shared_examples.rb | 37 ++++++++++++++++++ 6 files changed, 89 insertions(+), 12 deletions(-) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 3512e2b735e..1ef7a2433dc 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -86,7 +86,7 @@ class IssuableBaseService < BaseService remove_label_ids = attributes.delete(:remove_label_ids) new_label_ids = base_label_ids - new_label_ids = label_ids if label_ids && (merge_all || (add_label_ids.empty? && remove_label_ids.empty?)) + new_label_ids = label_ids if label_ids && (merge_all || (add_label_ids.blank? && remove_label_ids.blank?)) new_label_ids |= add_label_ids if add_label_ids new_label_ids -= remove_label_ids if remove_label_ids diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index bff61683976..74825f30868 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -34,6 +34,12 @@ module SlashCommands @updates[:state_event] = 'reopen' end + desc 'Change title' + params '' + command :title do |title_param| + @updates[:title] = title_param + end + desc 'Reassign' params '@user' command :assign, :reassign do |assignee_param| diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md index 3bfc66309ba..bf5b8ebe1c8 100644 --- a/doc/workflow/slash_commands.md +++ b/doc/workflow/slash_commands.md @@ -13,6 +13,7 @@ do. |:---------------------------|:--------------------|:-------------| | `/close` | None | Close the issue or merge request | | `/open` | `/reopen` | Reopen the issue or merge request | +| `/title ` | None | Change title | | `/assign @username` | `/reassign` | Reassign | | `/unassign` | `/remove_assignee` | Remove assignee | | `/milestone %milestone` | None | Change milestone | @@ -24,5 +25,5 @@ do. | `/done` | None | Mark todo as done | | `/subscribe` | None | Subscribe | | `/unsubscribe` | None | Unsubscribe | -| `/due_date` | None | Set a due date | +| `/due_date | ` | None | Set a due date | | `/clear_due_date` | None | Remove due date | 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 e2339c5e103..afb072105cf 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -64,10 +64,21 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do 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 + context 'and current user cannot update noteable' do + it 'raises a CommandsOnlyNoteError' do + expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError) + end end + context 'and current user can update noteable' do + before do + project.team << [user, :developer] + end + + it 'raises a CommandsOnlyNoteError' do + expect { receiver.execute }.not_to raise_error + end + end end end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index d03c84f59b3..66ebe091893 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -15,6 +15,7 @@ describe SlashCommands::InterpretService, services: true do is_expected.to match_array([ :open, :reopen, :close, + :title, :assign, :reassign, :unassign, :remove_assignee, :milestone, @@ -53,6 +54,14 @@ describe SlashCommands::InterpretService, services: true do end end + shared_examples 'title command' do + it 'populates title: "A brand new title" if content contains /title A brand new title' do + changes = service.execute(content, issuable) + + expect(changes).to eq(title: 'A brand new title') + end + end + shared_examples 'assign command' do it 'fetches assignee and populates assignee_id if content contains /assign' do changes = service.execute(content, issuable) @@ -190,6 +199,21 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { merge_request } end + it_behaves_like 'title command' do + let(:content) { '/title A brand new title' } + let(:issuable) { issue } + end + + it_behaves_like 'title command' do + let(:content) { '/title A brand new title' } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/title' } + let(:issuable) { issue } + end + it_behaves_like 'assign command' do let(:content) { "/assign @#{user.username}" } let(:issuable) { issue } @@ -200,16 +224,14 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { merge_request } end - it 'does not populate assignee_id if content contains /assign with an unknown user' do - changes = service.execute('/assign joe', issue) - - expect(changes).to be_empty + it_behaves_like 'empty command' do + let(:content) { '/assign @abcd1234' } + let(:issuable) { issue } end - it 'does not populate assignee_id if content contains /assign without user' do - changes = service.execute('/assign', issue) - - expect(changes).to be_empty + it_behaves_like 'empty command' do + let(:content) { '/assign' } + let(:issuable) { issue } end it_behaves_like 'unassign command' do diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb index 824ccb0ddfb..4f2e7c3bee8 100644 --- a/spec/support/issuable_slash_commands_shared_examples.rb +++ b/spec/support/issuable_slash_commands_shared_examples.rb @@ -166,6 +166,43 @@ shared_examples 'issuable record that supports slash commands in its description end end + context "with a note changing the #{issuable_type}'s title" do + context "when current user can change title of #{issuable_type}" do + it "reopens the #{issuable_type}" do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/title Awesome new title" + click_button 'Comment' + end + + expect(page).not_to have_content '/title' + expect(page).to have_content 'Your commands are being executed.' + + expect(issuable.reload.title).to eq 'Awesome new title' + end + end + + context "when current user cannot change title of #{issuable_type}" do + before do + logout + login_with(guest) + visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) + end + + it "does not reopen the #{issuable_type}" do + current_title = issuable.title + page.within('.js-main-target-form') do + fill_in 'note[note]', with: "/title Awesome new title" + click_button 'Comment' + end + + expect(page).not_to have_content '/title' + expect(page).not_to have_content 'Your commands are being executed.' + + expect(issuable.reload.title).not_to eq 'Awesome new title' + end + 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 From e021604454f1093b7d762b28eae36e30083f0053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 9 Aug 2016 22:47:29 +0200 Subject: [PATCH 05/25] Don't extract slash commands inside blockcode, blockquote or HTML tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve slash command descriptions, support /due tomorrow Signed-off-by: Rémy Coutable --- app/services/issuable_base_service.rb | 2 +- .../slash_commands/interpret_service.rb | 14 +++-- doc/workflow/slash_commands.md | 6 +-- lib/gitlab/slash_commands/extractor.rb | 51 +++++++++++++++++-- .../gitlab/slash_commands/extractor_spec.rb | 27 ++++++++++ .../slash_commands/interpret_service_spec.rb | 14 +++-- 6 files changed, 98 insertions(+), 16 deletions(-) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 1ef7a2433dc..c14bda811c2 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -173,7 +173,7 @@ class IssuableBaseService < BaseService def change_todo(issuable) case params.delete(:todo_event) - when 'mark' + when 'add' todo_service.mark_todo(issuable, current_user) when 'done' todo = TodosFinder.new(current_user).execute.find_by(target: issuable) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 74825f30868..3030af05999 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -40,7 +40,7 @@ module SlashCommands @updates[:title] = title_param end - desc 'Reassign' + desc 'Assign' params '@user' command :assign, :reassign do |assignee_param| user = extract_references(assignee_param, :user).first @@ -54,7 +54,7 @@ module SlashCommands @updates[:assignee_id] = nil end - desc 'Change milestone' + desc 'Set milestone' params '%"milestone"' command :milestone do |milestone_param| milestone = extract_references(milestone_param, :milestone).first @@ -93,7 +93,7 @@ module SlashCommands desc 'Add a todo' command :todo do - @updates[:todo_event] = 'mark' + @updates[:todo_event] = 'add' end desc 'Mark todo as done' @@ -113,11 +113,15 @@ module SlashCommands desc 'Set a due date' params ' | ' - command :due_date do |due_date_param| + command :due_date, :due do |due_date_param| return unless noteable.respond_to?(:due_date) due_date = begin - Time.now + ChronicDuration.parse(due_date_param) + if due_date_param.downcase == 'tomorrow' + Date.tomorrow + else + Time.now + ChronicDuration.parse(due_date_param) + end rescue ChronicDuration::DurationParseError Date.parse(due_date_param) rescue nil end diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md index bf5b8ebe1c8..46f291561d7 100644 --- a/doc/workflow/slash_commands.md +++ b/doc/workflow/slash_commands.md @@ -14,9 +14,9 @@ do. | `/close` | None | Close the issue or merge request | | `/open` | `/reopen` | Reopen the issue or merge request | | `/title ` | None | Change title | -| `/assign @username` | `/reassign` | Reassign | +| `/assign @username` | `/reassign` | Assign | | `/unassign` | `/remove_assignee` | Remove assignee | -| `/milestone %milestone` | None | Change milestone | +| `/milestone %milestone` | None | Set milestone | | `/clear_milestone` | `/remove_milestone` | Remove milestone | | `/label ~foo ~"bar baz"` | `/labels` | Add label(s) | | `/unlabel ~foo ~"bar baz"` | `/remove_label`, `remove_labels` | Remove label(s) | @@ -25,5 +25,5 @@ do. | `/done` | None | Mark todo as done | | `/subscribe` | None | Subscribe | | `/unsubscribe` | None | Unsubscribe | -| `/due_date | ` | None | Set a due date | +| `/due_date | ` | `/due` | Set a due date | | `/clear_due_date` | None | Remove due date | diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb index 1a854b81aca..ce0a2eba535 100644 --- a/lib/gitlab/slash_commands/extractor.rb +++ b/lib/gitlab/slash_commands/extractor.rb @@ -34,9 +34,14 @@ module Gitlab commands = [] + content.delete!("\r") content.gsub!(commands_regex) do - commands << [$1, $2].flatten.reject(&:blank?) - '' + if $~[:cmd] + commands << [$~[:cmd], $~[:args]].reject(&:blank?) + '' + else + $~[0] + end end commands @@ -52,7 +57,47 @@ module Gitlab # # /^\/(?close|reopen|...)(?:( |$))(?[^\/\n]*)(?:\n|$)/ def commands_regex - /^\/(?#{command_names.join('|')})(?:( |$))(?[^\/\n]*)(?:\n|$)/ + @commands_regex ||= %r{ + (? + # Code blocks: + # ``` + # Anything, including `/cmd args` which are ignored by this filter + # ``` + + ^``` + .+? + \n```$ + ) + | + (? + # HTML block: + # + # Anything, including `/cmd args` which are ignored by this filter + # + + ^<[^>]+?>\n + .+? + \n<\/[^>]+?>$ + ) + | + (? + # Quote block: + # >>> + # Anything, including `/cmd args` which are ignored by this filter + # >>> + + ^>>> + .+? + \n>>>$ + ) + | + (?: + # Command not in a blockquote, blockcode, or HTML tag: + # /close + + ^\/(?#{command_names.join('|')})(?:(\ |$))(?[^\/\n]*)(?:\n|$) + ) + }mx end end end diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb index fd1b30052ed..11836b10204 100644 --- a/spec/lib/gitlab/slash_commands/extractor_spec.rb +++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb @@ -173,5 +173,32 @@ describe Gitlab::SlashCommands::Extractor do expect(commands).to be_empty expect(msg).to eq 'Fixes #123' end + + it 'does not extract commands inside a blockcode' do + msg = msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld" + expected = msg.delete("\r") + commands = extractor.extract_commands!(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end + + it 'does not extract commands inside a blockquote' do + msg = "Hello\r\n>>>\r\nThis is some text\r\n/close\r\n/assign @user\r\n>>>\r\n\r\nWorld" + expected = msg.delete("\r") + commands = extractor.extract_commands!(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end + + it 'does not extract commands inside a HTML tag' do + msg = msg = "Hello\r\n
    \r\nThis is some text\r\n/close\r\n/assign @user\r\n
    \r\n\r\nWorld" + expected = msg.delete("\r") + commands = extractor.extract_commands!(msg) + + expect(commands).to be_empty + expect(msg).to eq expected + end end end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 66ebe091893..620687e3212 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -27,7 +27,7 @@ describe SlashCommands::InterpretService, services: true do :done, :subscribe, :unsubscribe, - :due_date, + :due_date, :due, :clear_due_date ]) end @@ -119,10 +119,10 @@ describe SlashCommands::InterpretService, services: true do end shared_examples 'todo command' do - it 'populates todo_event: "mark" if content contains /todo' do + it 'populates todo_event: "add" if content contains /todo' do changes = service.execute(content, issuable) - expect(changes).to eq(todo_event: 'mark') + expect(changes).to eq(todo_event: 'add') end end @@ -154,7 +154,7 @@ describe SlashCommands::InterpretService, services: true do it 'populates due_date: Date.new(2016, 8, 28) if content contains /due_date 2016-08-28' do changes = service.execute(content, issuable) - expect(changes).to eq(due_date: Date.new(2016, 8, 28)) + expect(changes).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28)) end end @@ -369,6 +369,12 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { issue } end + it_behaves_like 'due_date command' do + let(:content) { '/due tomorrow' } + let(:issuable) { issue } + let(:expected_date) { Date.tomorrow } + end + it_behaves_like 'empty command' do let(:content) { '/due_date foo bar' } let(:issuable) { issue } From 23db6449542498636c145e83c71a4a466eb62746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Wed, 10 Aug 2016 14:12:09 +0200 Subject: [PATCH 06/25] Add support for no-op slash commands that appear in autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first one is /cc Signed-off-by: Rémy Coutable --- app/services/slash_commands/interpret_service.rb | 10 +++++++++- lib/gitlab/slash_commands/dsl.rb | 14 +++++++++++--- spec/lib/gitlab/slash_commands/dsl_spec.rb | 16 +++++++++++----- spec/lib/gitlab/slash_commands/extractor_spec.rb | 4 ++-- .../issuable_slash_commands_shared_examples.rb | 5 ++--- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 3030af05999..55b14f118d0 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -117,7 +117,7 @@ module SlashCommands return unless noteable.respond_to?(:due_date) due_date = begin - if due_date_param.downcase == 'tomorrow' + if due_date_param.casecmp('tomorrow').zero? Date.tomorrow else Time.now + ChronicDuration.parse(due_date_param) @@ -136,6 +136,14 @@ module SlashCommands @updates[:due_date] = nil end + # This is a dummy command, so that it appears in the autocomplete commands + desc 'CC' + params '@user' + noop true + command :cc do + return + end + def find_label_ids(labels_param) extract_references(labels_param, :label).map(&:id) end diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index 3ded4109f2e..edfe8405876 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -14,8 +14,10 @@ module Gitlab def command_names command_definitions.flat_map do |command_definition| - [command_definition[:name], command_definition[:aliases]].flatten - end + unless command_definition[:noop] + [command_definition[:name], command_definition[:aliases]].flatten + end + end.compact end # Allows to give a description to the next slash command @@ -28,6 +30,11 @@ module Gitlab @params = params end + # Allows to define if a command is a no-op, but should appear in autocomplete + def noop(noop) + @noop = noop + end + # Registers a new command which is recognizeable # from body of email or comment. # Example: @@ -63,7 +70,8 @@ module Gitlab name: command_name, aliases: aliases, description: @description || '', - params: @params || [] + params: @params || [], + noop: @noop || false } @command_definitions << command_definition diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index f8abb35674d..39e1996c891 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -29,6 +29,11 @@ describe Gitlab::SlashCommands::Dsl do command :wildcard do |*args| args end + + noop true + command :cc do |*args| + args + end end end let(:dummy) { DummyClass.new } @@ -36,11 +41,12 @@ describe Gitlab::SlashCommands::Dsl do 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: [] } + { name: :no_args, aliases: [:none], description: 'A command with no args', params: [], noop: false }, + { name: :returning, aliases: [], description: 'A command returning a value', params: [], noop: false }, + { name: :one_arg, aliases: [:once, :first], description: '', params: ['The first argument'], noop: false }, + { name: :two_args, aliases: [], description: 'A command with two args', params: ['The first argument', 'The second argument'], noop: false }, + { name: :wildcard, aliases: [], description: '', params: [], noop: false }, + { name: :cc, aliases: [], description: '', params: [], noop: true } ] expect(DummyClass.command_definitions).to eq expected diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb index 11836b10204..ac7296bdba1 100644 --- a/spec/lib/gitlab/slash_commands/extractor_spec.rb +++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb @@ -175,7 +175,7 @@ describe Gitlab::SlashCommands::Extractor do end it 'does not extract commands inside a blockcode' do - msg = msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld" + msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld" expected = msg.delete("\r") commands = extractor.extract_commands!(msg) @@ -193,7 +193,7 @@ describe Gitlab::SlashCommands::Extractor do end it 'does not extract commands inside a HTML tag' do - msg = msg = "Hello\r\n
    \r\nThis is some text\r\n/close\r\n/assign @user\r\n
    \r\n\r\nWorld" + msg = "Hello\r\n
    \r\nThis is some text\r\n/close\r\n/assign @user\r\n
    \r\n\r\nWorld" expected = msg.delete("\r") commands = extractor.extract_commands!(msg) diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb index 4f2e7c3bee8..e1255bd248d 100644 --- a/spec/support/issuable_slash_commands_shared_examples.rb +++ b/spec/support/issuable_slash_commands_shared_examples.rb @@ -28,7 +28,7 @@ shared_examples 'issuable record that supports slash commands in its description issuable = project.public_send(issuable_type.to_s.pluralize).first - expect(issuable.description).to eq "bug description\r\n" + expect(issuable.description).to eq "bug description\n" expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone expect(page).to have_content 'bug 345' @@ -57,7 +57,7 @@ shared_examples 'issuable record that supports slash commands in its description issuable.reload note = issuable.notes.user.first - expect(note.note).to eq "Awesome!\r\n" + expect(note.note).to eq "Awesome!\n" expect(issuable.assignee).to eq assignee expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone @@ -189,7 +189,6 @@ shared_examples 'issuable record that supports slash commands in its description end it "does not reopen the #{issuable_type}" do - current_title = issuable.title page.within('.js-main-target-form') do fill_in 'note[note]', with: "/title Awesome new title" click_button 'Comment' From 65349c22129fcdf2ae0c7103094bbf50ae73db61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Wed, 10 Aug 2016 17:51:01 +0200 Subject: [PATCH 07/25] Make slash commands contextual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return only slash commands that make sense for the current noteable - Allow slash commands decription to be dynamic Other improvements: - Add permission checks in slash commands definition - Use IssuesFinder and MergeRequestsFinder - Use next if instead of a unless block, and use splat operator instead of flatten Signed-off-by: Rémy Coutable --- Gemfile | 3 +- Gemfile.lock | 2 + app/controllers/projects_controller.rb | 2 +- app/services/projects/autocomplete_service.rb | 23 ++- .../slash_commands/interpret_service.rb | 137 +++++++++++--- doc/workflow/slash_commands.md | 2 +- lib/gitlab/slash_commands/dsl.rb | 40 ++++- spec/fixtures/emails/commands_only_reply.eml | 2 +- spec/lib/gitlab/slash_commands/dsl_spec.rb | 82 +++++++-- .../slash_commands/interpret_service_spec.rb | 168 ++++++++++++++++-- 10 files changed, 395 insertions(+), 66 deletions(-) diff --git a/Gemfile b/Gemfile index 8b44b54e22c..7c7889fb946 100644 --- a/Gemfile +++ b/Gemfile @@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6' # Detect and convert string character encoding gem 'charlock_holmes', '~> 0.7.3' -# Parse duration +# Parse time & duration +gem 'chronic', '~> 0.10.2' gem 'chronic_duration', '~> 0.10.6' gem 'sass-rails', '~> 5.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index 3ba6048143c..ecce224adeb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -128,6 +128,7 @@ GEM mime-types (>= 1.16) cause (0.1) charlock_holmes (0.7.3) + chronic (0.10.2) chronic_duration (0.10.6) numerizer (~> 0.1.1) chunky_png (1.3.5) @@ -822,6 +823,7 @@ DEPENDENCIES capybara-screenshot (~> 1.0.0) carrierwave (~> 0.10.0) charlock_holmes (~> 0.7.3) + chronic (~> 0.10.2) chronic_duration (~> 0.10.6) coffee-rails (~> 4.1.0) connection_pool (~> 2.0) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 64d31e4a3a0..af20984cbe7 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -146,7 +146,7 @@ class ProjectsController < Projects::ApplicationController mergerequests: autocomplete.merge_requests, labels: autocomplete.labels, members: participants, - commands: autocomplete.commands + commands: autocomplete.commands(note_type, note_id) } respond_to do |format| diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index e943d2ffbcb..779f64f584e 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -16,8 +16,27 @@ module Projects @project.labels.select([:title, :color]) end - def commands - SlashCommands::InterpretService.command_definitions + def commands(noteable_type, noteable_id) + SlashCommands::InterpretService.command_definitions( + project: @project, + noteable: command_target(noteable_type, noteable_id), + current_user: current_user + ) + end + + private + + def command_target(noteable_type, noteable_id) + case noteable_type + when 'Issue' + IssuesFinder.new(current_user, project_id: @project.id, state: 'all'). + execute.find_or_initialize_by(iid: noteable_id) + when 'MergeRequest' + MergeRequestsFinder.new(current_user, project_id: @project.id, state: 'all'). + execute.find_or_initialize_by(iid: noteable_id) + else + nil + end end end end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 55b14f118d0..e94ee83df85 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -10,7 +10,7 @@ module SlashCommands @noteable = noteable @updates = {} - commands = extractor.extract_commands!(content) + commands = extractor(noteable: noteable).extract_commands!(content) commands.each do |command| __send__(*command) end @@ -20,28 +20,57 @@ module SlashCommands private - def extractor - @extractor ||= Gitlab::SlashCommands::Extractor.new(self.class.command_names) + def extractor(opts = {}) + opts.merge!(current_user: current_user, project: project) + + Gitlab::SlashCommands::Extractor.new(self.class.command_names(opts)) end - desc 'Close this issue or merge request' + desc ->(opts) { "Close this #{opts[:noteable].to_ability_name.humanize(capitalize: false)}" } + condition ->(opts) do + opts[:noteable] && + opts[:noteable].open? && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :close do @updates[:state_event] = 'close' end - desc 'Reopen this issue or merge request' + desc ->(opts) { "Reopen this #{opts[:noteable].to_ability_name.humanize(capitalize: false)}" } + condition ->(opts) do + opts[:noteable] && + opts[:noteable].closed? && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :open, :reopen do @updates[:state_event] = 'reopen' end desc 'Change title' params '' + condition ->(opts) do + opts[:noteable] && + opts[:noteable].persisted? && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :title do |title_param| @updates[:title] = title_param end desc 'Assign' params '@user' + condition ->(opts) do + opts[:noteable] && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :assign, :reassign do |assignee_param| user = extract_references(assignee_param, :user).first return unless user @@ -50,12 +79,26 @@ module SlashCommands end desc 'Remove assignee' + condition ->(opts) do + opts[:noteable] && + opts[:noteable].assignee_id? && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :unassign, :remove_assignee do @updates[:assignee_id] = nil end desc 'Set milestone' params '%"milestone"' + condition ->(opts) do + opts[:noteable] && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) && + opts[:project].milestones.active.any? + end command :milestone do |milestone_param| milestone = extract_references(milestone_param, :milestone).first return unless milestone @@ -64,12 +107,26 @@ module SlashCommands end desc 'Remove milestone' + condition ->(opts) do + opts[:noteable] && + opts[:noteable].milestone_id? && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :clear_milestone, :remove_milestone do @updates[:milestone_id] = nil end desc 'Add label(s)' params '~label1 ~"label 2"' + condition ->(opts) do + opts[:noteable] && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) && + opts[:project].labels.any? + end command :label, :labels do |labels_param| label_ids = find_label_ids(labels_param) return if label_ids.empty? @@ -79,6 +136,13 @@ module SlashCommands desc 'Remove label(s)' params '~label1 ~"label 2"' + condition ->(opts) do + opts[:noteable] && + opts[:noteable].labels.any? && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :unlabel, :remove_label, :remove_labels do |labels_param| label_ids = find_label_ids(labels_param) return if label_ids.empty? @@ -87,52 +151,85 @@ module SlashCommands end desc 'Remove all labels' + condition ->(opts) do + opts[:noteable] && + opts[:noteable].labels.any? && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :clear_labels, :clear_label do @updates[:label_ids] = [] end desc 'Add a todo' + condition ->(opts) do + opts[:noteable] && + opts[:noteable].persisted? && + opts[:current_user] && + !TodosFinder.new(opts[:current_user]).execute.exists?(target: opts[:noteable]) + end command :todo do @updates[:todo_event] = 'add' end desc 'Mark todo as done' + condition ->(opts) do + opts[:noteable] && + opts[:current_user] && + TodosFinder.new(opts[:current_user]).execute.exists?(target: opts[:noteable]) + end command :done do @updates[:todo_event] = 'done' end desc 'Subscribe' + condition ->(opts) do + opts[:noteable] && + opts[:current_user] && + opts[:noteable].persisted? && + !opts[:noteable].subscribed?(opts[:current_user]) + end command :subscribe do @updates[:subscription_event] = 'subscribe' end desc 'Unsubscribe' + condition ->(opts) do + opts[:noteable] && + opts[:current_user] && + opts[:noteable].persisted? && + opts[:noteable].subscribed?(opts[:current_user]) + end command :unsubscribe do @updates[:subscription_event] = 'unsubscribe' end - desc 'Set a due date' - params ' | ' + desc 'Set due date' + params 'a date in natural language' + condition ->(opts) do + opts[:noteable] && + opts[:noteable].respond_to?(:due_date) && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :due_date, :due do |due_date_param| - return unless noteable.respond_to?(:due_date) - - due_date = begin - if due_date_param.casecmp('tomorrow').zero? - Date.tomorrow - else - Time.now + ChronicDuration.parse(due_date_param) - end - rescue ChronicDuration::DurationParseError - Date.parse(due_date_param) rescue nil - end + due_date = Chronic.parse(due_date_param).try(:to_date) @updates[:due_date] = due_date if due_date end desc 'Remove due date' + condition ->(opts) do + opts[:noteable] && + opts[:noteable].respond_to?(:due_date) && + opts[:noteable].due_date? && + opts[:current_user] && + opts[:project] && + opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + end command :clear_due_date do - return unless noteable.respond_to?(:due_date) - @updates[:due_date] = nil end diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md index 46f291561d7..c4edbeddd40 100644 --- a/doc/workflow/slash_commands.md +++ b/doc/workflow/slash_commands.md @@ -25,5 +25,5 @@ do. | `/done` | None | Mark todo as done | | `/subscribe` | None | Subscribe | | `/unsubscribe` | None | Unsubscribe | -| `/due_date | ` | `/due` | Set a due date | +| `/due_date a date in natural language` | `/due` | Set due date | | `/clear_due_date` | None | Remove due date | diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index edfe8405876..20e1d071d06 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -8,15 +8,25 @@ module Gitlab end module ClassMethods - def command_definitions - @command_definitions + def command_definitions(opts = {}) + @command_definitions.map do |cmd_def| + next if cmd_def[:cond_lambda] && !cmd_def[:cond_lambda].call(opts) + + cmd_def = cmd_def.dup + + if cmd_def[:description].present? && cmd_def[:description].respond_to?(:call) + cmd_def[:description] = cmd_def[:description].call(opts) rescue '' + end + + cmd_def + end.compact end - def command_names - command_definitions.flat_map do |command_definition| - unless command_definition[:noop] - [command_definition[:name], command_definition[:aliases]].flatten - end + def command_names(opts = {}) + command_definitions(opts).flat_map do |command_definition| + next if command_definition[:noop] + + [command_definition[:name], *command_definition[:aliases]] end.compact end @@ -35,6 +45,11 @@ module Gitlab @noop = noop end + # Allows to define if a lambda to conditionally return an action + def condition(cond_lambda) + @cond_lambda = cond_lambda + end + # Registers a new command which is recognizeable # from body of email or comment. # Example: @@ -53,6 +68,10 @@ module Gitlab define_method(proxy_method_name, &block) define_method(command_name) do |*args| + unless @cond_lambda.nil? || @cond_lambda.call(project: project, current_user: current_user, noteable: noteable) + return + end + proxy_method = method(proxy_method_name) if proxy_method.arity == -1 || proxy_method.arity == args.size @@ -70,13 +89,16 @@ module Gitlab name: command_name, aliases: aliases, description: @description || '', - params: @params || [], - noop: @noop || false + params: @params || [] } + command_definition[:noop] = @noop unless @noop.nil? + command_definition[:cond_lambda] = @cond_lambda unless @cond_lambda.nil? @command_definitions << command_definition @description = nil @params = nil + @noop = nil + @cond_lambda = nil end end end diff --git a/spec/fixtures/emails/commands_only_reply.eml b/spec/fixtures/emails/commands_only_reply.eml index b64d851a79c..ccd92e406c4 100644 --- a/spec/fixtures/emails/commands_only_reply.eml +++ b/spec/fixtures/emails/commands_only_reply.eml @@ -20,7 +20,7 @@ X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 /close -/unsubscribe +/todo On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 39e1996c891..893a7692f11 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe Gitlab::SlashCommands::Dsl do + COND_LAMBDA = ->(opts) { opts[:project] == 'foo' } before :all do DummyClass = Class.new do include Gitlab::SlashCommands::Dsl @@ -20,18 +21,23 @@ describe Gitlab::SlashCommands::Dsl do arg1 end - desc 'A command with two args' + desc ->(opts) { "A dynamic description for #{opts.fetch(:noteable)}" } params 'The first argument', 'The second argument' command :two_args do |arg1, arg2| [arg1, arg2] end - command :wildcard do |*args| + noop true + command :cc do |*args| args end - noop true - command :cc do |*args| + condition COND_LAMBDA + command :cond_action do |*args| + args + end + + command :wildcard do |*args| args end end @@ -39,27 +45,73 @@ describe Gitlab::SlashCommands::Dsl do 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: [], noop: false }, - { name: :returning, aliases: [], description: 'A command returning a value', params: [], noop: false }, - { name: :one_arg, aliases: [:once, :first], description: '', params: ['The first argument'], noop: false }, - { name: :two_args, aliases: [], description: 'A command with two args', params: ['The first argument', 'The second argument'], noop: false }, - { name: :wildcard, aliases: [], description: '', params: [], noop: false }, - { name: :cc, aliases: [], description: '', params: [], noop: true } + let(:base_expected) do + [ + { 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: '', params: ['The first argument', 'The second argument'] }, + { name: :cc, aliases: [], description: '', params: [], noop: true }, + { name: :wildcard, aliases: [], description: '', params: [] } ] + end - expect(DummyClass.command_definitions).to eq expected + it 'returns an array with commands definitions' do + expect(DummyClass.command_definitions).to match_array base_expected + end + + context 'with options passed' do + context 'when condition is met' do + let(:expected) { base_expected << { name: :cond_action, aliases: [], description: '', params: [], cond_lambda: COND_LAMBDA } } + + it 'returns an array with commands definitions' do + expect(DummyClass.command_definitions(project: 'foo')).to match_array expected + end + end + + context 'when condition is not met' do + it 'returns an array with commands definitions without actions that did not met conditions' do + expect(DummyClass.command_definitions(project: 'bar')).to match_array base_expected + end + end + + context 'when description can be generated dynamically' do + it 'returns an array with commands definitions with dynamic descriptions' do + base_expected[3][:description] = 'A dynamic description for merge request' + + expect(DummyClass.command_definitions(noteable: 'merge request')).to match_array base_expected + end + end end end describe '.command_names' do - it 'returns an array with commands definitions' do - expect(DummyClass.command_names).to eq [ + let(:base_expected) do + [ :no_args, :none, :returning, :one_arg, :once, :first, :two_args, :wildcard ] end + + it 'returns an array with commands definitions' do + expect(DummyClass.command_names).to eq base_expected + end + + context 'with options passed' do + context 'when condition is met' do + let(:expected) { base_expected << :cond_action } + + it 'returns an array with commands definitions' do + expect(DummyClass.command_names(project: 'foo')).to match_array expected + end + end + + context 'when condition is not met' do + it 'returns an array with commands definitions without action that did not met conditions' do + expect(DummyClass.command_names(project: 'bar')).to match_array base_expected + end + end + end end describe 'command with no args' do diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 620687e3212..0cf77e53435 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -8,38 +8,152 @@ describe SlashCommands::InterpretService, services: true do 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 } + before do + project.team << [user, :developer] + end - it 'returns the known commands' do + describe '#command_names' do + subject do + described_class.command_names( + project: project, + noteable: issue, + current_user: user + ) + end + + it 'returns the basic known commands' do is_expected.to match_array([ - :open, :reopen, :close, :title, :assign, :reassign, - :unassign, :remove_assignee, - :milestone, - :clear_milestone, :remove_milestone, - :labels, :label, - :unlabel, :remove_labels, :remove_label, - :clear_labels, :clear_label, :todo, - :done, :subscribe, - :unsubscribe, - :due_date, :due, - :clear_due_date + :due_date, :due ]) end + + context 'when noteable is open' do + it 'includes the :close command' do + is_expected.to include(*[:close]) + end + end + + context 'when noteable is closed' do + before do + issue.close! + end + + it 'includes the :open, :reopen commands' do + is_expected.to include(*[:open, :reopen]) + end + end + + context 'when noteable has an assignee' do + before do + issue.update(assignee_id: user.id) + end + + it 'includes the :unassign, :remove_assignee commands' do + is_expected.to include(*[:unassign, :remove_assignee]) + end + end + + context 'when noteable has a milestone' do + before do + issue.update(milestone: milestone) + end + + it 'includes the :clear_milestone, :remove_milestone commands' do + is_expected.to include(*[:milestone, :clear_milestone, :remove_milestone]) + end + end + + context 'when project has a milestone' do + before do + milestone + end + + it 'includes the :milestone command' do + is_expected.to include(*[:milestone]) + end + end + + context 'when noteable has a label' do + before do + issue.update(label_ids: [bug.id]) + end + + it 'includes the :unlabel, :remove_labels, :remove_label, :clear_labels, :clear_label commands' do + is_expected.to include(*[:unlabel, :remove_labels, :remove_label, :clear_labels, :clear_label]) + end + end + + context 'when project has a label' do + before do + inprogress + end + + it 'includes the :labels, :label commands' do + is_expected.to include(*[:labels, :label]) + end + end + + context 'when user has no todo' do + it 'includes the :todo command' do + is_expected.to include(*[:todo]) + end + end + + context 'when user has a todo' do + before do + TodoService.new.mark_todo(issue, user) + end + + it 'includes the :done command' do + is_expected.to include(*[:done]) + end + end + + context 'when user is not subscribed' do + it 'includes the :subscribe command' do + is_expected.to include(*[:subscribe]) + end + end + + context 'when user is subscribed' do + before do + issue.subscribe(user) + end + + it 'includes the :unsubscribe command' do + is_expected.to include(*[:unsubscribe]) + end + end + + context 'when noteable has a no due date' do + it 'includes the :due_date, :due commands' do + is_expected.to include(*[:due_date, :due]) + end + end + + context 'when noteable has a due date' do + before do + issue.update(due_date: Date.today) + end + + it 'includes the :clear_due_date command' do + is_expected.to include(*[:due_date, :due, :clear_due_date]) + end + end end describe '#execute' do let(:service) { described_class.new(project, user) } - let(:issue) { create(:issue) } - let(:merge_request) { create(:merge_request) } + let(:merge_request) { create(:merge_request, source_project: project) } shared_examples 'open command' do it 'returns state_event: "open" if content contains /open' do + issuable.close! changes = service.execute(content, issuable) expect(changes).to eq(state_event: 'reopen') @@ -72,6 +186,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'unassign command' do it 'populates assignee_id: nil if content contains /unassign' do + issuable.update(assignee_id: user.id) changes = service.execute(content, issuable) expect(changes).to eq(assignee_id: nil) @@ -80,6 +195,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'milestone command' do it 'fetches milestone and populates milestone_id if content contains /milestone' do + milestone # populate the milestone changes = service.execute(content, issuable) expect(changes).to eq(milestone_id: milestone.id) @@ -88,6 +204,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'clear_milestone command' do it 'populates milestone_id: nil if content contains /clear_milestone' do + issuable.update(milestone_id: milestone.id) changes = service.execute(content, issuable) expect(changes).to eq(milestone_id: nil) @@ -96,6 +213,8 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'label command' do it 'fetches label ids and populates add_label_ids if content contains /label' do + bug # populate the label + inprogress # populate the label changes = service.execute(content, issuable) expect(changes).to eq(add_label_ids: [bug.id, inprogress.id]) @@ -104,6 +223,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'unlabel command' do it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do + issuable.update(label_ids: [inprogress.id]) # populate the label changes = service.execute(content, issuable) expect(changes).to eq(remove_label_ids: [inprogress.id]) @@ -112,6 +232,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'clear_labels command' do it 'populates label_ids: [] if content contains /clear_labels' do + issuable.update(label_ids: [inprogress.id]) # populate the label changes = service.execute(content, issuable) expect(changes).to eq(label_ids: []) @@ -128,6 +249,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'done command' do it 'populates todo_event: "done" if content contains /done' do + TodoService.new.mark_todo(issuable, user) changes = service.execute(content, issuable) expect(changes).to eq(todo_event: 'done') @@ -144,6 +266,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'unsubscribe command' do it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do + issuable.subscribe(user) changes = service.execute(content, issuable) expect(changes).to eq(subscription_event: 'unsubscribe') @@ -160,6 +283,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'clear_due_date command' do it 'populates due_date: nil if content contains /clear_due_date' do + issuable.update(due_date: Date.today) changes = service.execute(content, issuable) expect(changes).to eq(due_date: nil) @@ -375,6 +499,18 @@ describe SlashCommands::InterpretService, services: true do let(:expected_date) { Date.tomorrow } end + it_behaves_like 'due_date command' do + let(:content) { '/due 5 days from now' } + let(:issuable) { issue } + let(:expected_date) { 5.days.from_now.to_date } + end + + it_behaves_like 'due_date command' do + let(:content) { '/due in 2 days' } + let(:issuable) { issue } + let(:expected_date) { 2.days.from_now.to_date } + end + it_behaves_like 'empty command' do let(:content) { '/due_date foo bar' } let(:issuable) { issue } From 42e30a5012bb3384ee6f275ff058d4c0841776cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 11 Aug 2016 18:51:37 +0200 Subject: [PATCH 08/25] Accept blocks for `.desc` and `.condition` slash commands DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also, pass options as instance variables, making the DSL more user-friendly / natural. Signed-off-by: Rémy Coutable --- .../slash_commands/interpret_service.rb | 153 +++++++----------- lib/gitlab/slash_commands/dsl.rb | 113 ++++++++----- spec/lib/gitlab/slash_commands/dsl_spec.rb | 61 ++++--- 3 files changed, 172 insertions(+), 155 deletions(-) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index e94ee83df85..ae22ed6b845 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -26,25 +26,23 @@ module SlashCommands Gitlab::SlashCommands::Extractor.new(self.class.command_names(opts)) end - desc ->(opts) { "Close this #{opts[:noteable].to_ability_name.humanize(capitalize: false)}" } - condition ->(opts) do - opts[:noteable] && - opts[:noteable].open? && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + desc do + "Close this #{noteable.to_ability_name.humanize(capitalize: false)}" + end + condition do + noteable.open? && + current_user.can?(:"update_#{noteable.to_ability_name}", project) end command :close do @updates[:state_event] = 'close' end - desc ->(opts) { "Reopen this #{opts[:noteable].to_ability_name.humanize(capitalize: false)}" } - condition ->(opts) do - opts[:noteable] && - opts[:noteable].closed? && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + desc do + "Reopen this #{noteable.to_ability_name.humanize(capitalize: false)}" + end + condition do + noteable.closed? && + current_user.can?(:"update_#{noteable.to_ability_name}", project) end command :open, :reopen do @updates[:state_event] = 'reopen' @@ -52,12 +50,9 @@ module SlashCommands desc 'Change title' params '' - condition ->(opts) do - opts[:noteable] && - opts[:noteable].persisted? && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + condition do + noteable.persisted? && + current_user.can?(:"update_#{noteable.to_ability_name}", project) end command :title do |title_param| @updates[:title] = title_param @@ -65,11 +60,8 @@ module SlashCommands desc 'Assign' params '@user' - condition ->(opts) do - opts[:noteable] && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + condition do + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :assign, :reassign do |assignee_param| user = extract_references(assignee_param, :user).first @@ -79,12 +71,9 @@ module SlashCommands end desc 'Remove assignee' - condition ->(opts) do - opts[:noteable] && - opts[:noteable].assignee_id? && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + condition do + noteable.assignee_id? && + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :unassign, :remove_assignee do @updates[:assignee_id] = nil @@ -92,12 +81,9 @@ module SlashCommands desc 'Set milestone' params '%"milestone"' - condition ->(opts) do - opts[:noteable] && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) && - opts[:project].milestones.active.any? + condition do + current_user.can?(:"admin_#{noteable.to_ability_name}", project) && + project.milestones.active.any? end command :milestone do |milestone_param| milestone = extract_references(milestone_param, :milestone).first @@ -107,12 +93,9 @@ module SlashCommands end desc 'Remove milestone' - condition ->(opts) do - opts[:noteable] && - opts[:noteable].milestone_id? && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + condition do + noteable.milestone_id? && + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :clear_milestone, :remove_milestone do @updates[:milestone_id] = nil @@ -120,12 +103,9 @@ module SlashCommands desc 'Add label(s)' params '~label1 ~"label 2"' - condition ->(opts) do - opts[:noteable] && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) && - opts[:project].labels.any? + condition do + current_user.can?(:"admin_#{noteable.to_ability_name}", project) && + project.labels.any? end command :label, :labels do |labels_param| label_ids = find_label_ids(labels_param) @@ -136,12 +116,9 @@ module SlashCommands desc 'Remove label(s)' params '~label1 ~"label 2"' - condition ->(opts) do - opts[:noteable] && - opts[:noteable].labels.any? && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + condition do + noteable.labels.any? && + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :unlabel, :remove_label, :remove_labels do |labels_param| label_ids = find_label_ids(labels_param) @@ -151,55 +128,46 @@ module SlashCommands end desc 'Remove all labels' - condition ->(opts) do - opts[:noteable] && - opts[:noteable].labels.any? && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) + condition do + noteable.labels.any? && + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :clear_labels, :clear_label do @updates[:label_ids] = [] end desc 'Add a todo' - condition ->(opts) do - opts[:noteable] && - opts[:noteable].persisted? && - opts[:current_user] && - !TodosFinder.new(opts[:current_user]).execute.exists?(target: opts[:noteable]) + condition do + noteable.persisted? && + current_user && + !TodosFinder.new(current_user).execute.exists?(target: noteable) end command :todo do @updates[:todo_event] = 'add' end desc 'Mark todo as done' - condition ->(opts) do - opts[:noteable] && - opts[:current_user] && - TodosFinder.new(opts[:current_user]).execute.exists?(target: opts[:noteable]) + condition do + current_user && + TodosFinder.new(current_user).execute.exists?(target: noteable) end command :done do @updates[:todo_event] = 'done' end desc 'Subscribe' - condition ->(opts) do - opts[:noteable] && - opts[:current_user] && - opts[:noteable].persisted? && - !opts[:noteable].subscribed?(opts[:current_user]) + condition do + noteable.persisted? && + !noteable.subscribed?(current_user) end command :subscribe do @updates[:subscription_event] = 'subscribe' end desc 'Unsubscribe' - condition ->(opts) do - opts[:noteable] && - opts[:current_user] && - opts[:noteable].persisted? && - opts[:noteable].subscribed?(opts[:current_user]) + condition do + noteable.persisted? && + noteable.subscribed?(current_user) end command :unsubscribe do @updates[:subscription_event] = 'unsubscribe' @@ -207,12 +175,9 @@ module SlashCommands desc 'Set due date' params 'a date in natural language' - condition ->(opts) do - opts[:noteable] && - opts[:noteable].respond_to?(:due_date) && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + condition do + noteable.respond_to?(:due_date) && + current_user.can?(:"update_#{noteable.to_ability_name}", project) end command :due_date, :due do |due_date_param| due_date = Chronic.parse(due_date_param).try(:to_date) @@ -221,13 +186,10 @@ module SlashCommands end desc 'Remove due date' - condition ->(opts) do - opts[:noteable] && - opts[:noteable].respond_to?(:due_date) && - opts[:noteable].due_date? && - opts[:current_user] && - opts[:project] && - opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project]) + condition do + noteable.respond_to?(:due_date) && + noteable.due_date? && + current_user.can?(:"update_#{noteable.to_ability_name}", project) end command :clear_due_date do @updates[:due_date] = nil @@ -236,10 +198,7 @@ module SlashCommands # This is a dummy command, so that it appears in the autocomplete commands desc 'CC' params '@user' - noop true - command :cc do - return - end + command :cc, noop: true def find_label_ids(labels_param) extract_references(labels_param, :label).map(&:id) diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index 20e1d071d06..3affd6253e9 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -8,20 +8,27 @@ module Gitlab end module ClassMethods + # This method is used to generate the autocompletion menu + # It returns no-op slash commands (such as `/cc`) def command_definitions(opts = {}) @command_definitions.map do |cmd_def| - next if cmd_def[:cond_lambda] && !cmd_def[:cond_lambda].call(opts) + context = OpenStruct.new(opts) + next if cmd_def[:cond_block] && !context.instance_exec(&cmd_def[:cond_block]) cmd_def = cmd_def.dup if cmd_def[:description].present? && cmd_def[:description].respond_to?(:call) - cmd_def[:description] = cmd_def[:description].call(opts) rescue '' + cmd_def[:description] = context.instance_exec(&cmd_def[:description]) rescue '' end cmd_def end.compact end + # This method is used to generate a list of valid commands in the current + # context of `opts`. + # It excludes no-op slash commands (such as `/cc`). + # This list can then be given to `Gitlab::SlashCommands::Extractor`. def command_names(opts = {}) command_definitions(opts).flat_map do |command_definition| next if command_definition[:noop] @@ -30,75 +37,103 @@ module Gitlab end.compact end - # Allows to give a description to the next slash command - def desc(text) - @description = text + # Allows to give a description to the next slash command. + # This description is shown in the autocomplete menu. + # It accepts a block that will be evaluated with the context given to + # `.command_definitions` or `.command_names`. + # + # Example: + # + # desc do + # "This is a dynamic description for #{noteable.to_ability_name}" + # end + # command :command_key do |arguments| + # # Awesome code block + # end + def desc(text = '', &block) + @description = block_given? ? block : text end - # Allows to define params for the next slash command + # Allows to define params for the next slash command. + # These params are shown in the autocomplete menu. + # + # Example: + # + # params "~label ~label2" + # command :command_key do |arguments| + # # Awesome code block + # end def params(*params) @params = params end - # Allows to define if a command is a no-op, but should appear in autocomplete - def noop(noop) - @noop = noop - end - - # Allows to define if a lambda to conditionally return an action - def condition(cond_lambda) - @cond_lambda = cond_lambda - end - - # Registers a new command which is recognizeable - # from body of email or comment. + # Allows to define conditions that must be met in order for the command + # to be returned by `.command_names` & `.command_definitions`. + # It accepts a block that will be evaluated with the context given to + # `.command_definitions`, `.command_names`, and the actual command method. + # # Example: # + # condition do + # project.public? + # end # command :command_key do |arguments| # # Awesome code block # end + def condition(&block) + @cond_block = block + end + + # Registers a new command which is recognizeable from body of email or + # comment. + # It accepts aliases and takes a block. # + # Example: + # + # command :my_command, :alias_for_my_command do |arguments| + # # Awesome code block + # end def command(*command_names, &block) + opts = command_names.extract_options! command_name, *aliases = command_names proxy_method_name = "__#{command_name}__" - # This proxy method is needed because calling `return` from inside a - # block/proc, causes a `return` from the enclosing method or lambda, - # otherwise a LocalJumpError error is raised. - define_method(proxy_method_name, &block) + if block_given? + # This proxy method is needed because calling `return` from inside a + # block/proc, causes a `return` from the enclosing method or lambda, + # otherwise a LocalJumpError error is raised. + define_method(proxy_method_name, &block) - define_method(command_name) do |*args| - unless @cond_lambda.nil? || @cond_lambda.call(project: project, current_user: current_user, noteable: noteable) - return + define_method(command_name) do |*args| + return if @cond_block && !instance_exec(&@cond_block) + + proxy_method = method(proxy_method_name) + + if proxy_method.arity == -1 || proxy_method.arity == args.size + instance_exec(*args, &proxy_method) + end end - proxy_method = method(proxy_method_name) - - if proxy_method.arity == -1 || proxy_method.arity == args.size - instance_exec(*args, &proxy_method) + private command_name + aliases.each do |alias_command| + alias_method alias_command, command_name + private alias_command end end - private command_name - aliases.each do |alias_command| - alias_method alias_command, command_name - private alias_command - end - command_definition = { name: command_name, aliases: aliases, description: @description || '', params: @params || [] } - command_definition[:noop] = @noop unless @noop.nil? - command_definition[:cond_lambda] = @cond_lambda unless @cond_lambda.nil? + command_definition[:noop] = opts[:noop] || false + command_definition[:cond_block] = @cond_block @command_definitions << command_definition @description = nil @params = nil - @noop = nil - @cond_lambda = nil + @cond_block = nil end end end diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 893a7692f11..7c946313ae1 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -1,9 +1,8 @@ require 'spec_helper' describe Gitlab::SlashCommands::Dsl do - COND_LAMBDA = ->(opts) { opts[:project] == 'foo' } before :all do - DummyClass = Class.new do + DummyClass = Struct.new(:project) do include Gitlab::SlashCommands::Dsl desc 'A command with no args' @@ -21,20 +20,21 @@ describe Gitlab::SlashCommands::Dsl do arg1 end - desc ->(opts) { "A dynamic description for #{opts.fetch(:noteable)}" } + desc do + "A dynamic description for #{noteable.upcase}" + end params 'The first argument', 'The second argument' command :two_args do |arg1, arg2| [arg1, arg2] end - noop true - command :cc do |*args| - args - end + command :cc, noop: true - condition COND_LAMBDA - command :cond_action do |*args| - args + condition do + project == 'foo' + end + command :cond_action do |arg| + arg end command :wildcard do |*args| @@ -42,17 +42,16 @@ describe Gitlab::SlashCommands::Dsl do end end end - let(:dummy) { DummyClass.new } describe '.command_definitions' do let(:base_expected) do [ - { 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: '', params: ['The first argument', 'The second argument'] }, - { name: :cc, aliases: [], description: '', params: [], noop: true }, - { name: :wildcard, aliases: [], description: '', params: [] } + { name: :no_args, aliases: [:none], description: 'A command with no args', params: [], noop: false, cond_block: nil }, + { name: :returning, aliases: [], description: 'A command returning a value', params: [], noop: false, cond_block: nil }, + { name: :one_arg, aliases: [:once, :first], description: '', params: ['The first argument'], noop: false, cond_block: nil }, + { name: :two_args, aliases: [], description: '', params: ['The first argument', 'The second argument'], noop: false, cond_block: nil }, + { name: :cc, aliases: [], description: '', params: [], noop: true, cond_block: nil }, + { name: :wildcard, aliases: [], description: '', params: [], noop: false, cond_block: nil} ] end @@ -62,7 +61,7 @@ describe Gitlab::SlashCommands::Dsl do context 'with options passed' do context 'when condition is met' do - let(:expected) { base_expected << { name: :cond_action, aliases: [], description: '', params: [], cond_lambda: COND_LAMBDA } } + let(:expected) { base_expected << { name: :cond_action, aliases: [], description: '', params: [], noop: false, cond_block: a_kind_of(Proc) } } it 'returns an array with commands definitions' do expect(DummyClass.command_definitions(project: 'foo')).to match_array expected @@ -77,7 +76,7 @@ describe Gitlab::SlashCommands::Dsl do context 'when description can be generated dynamically' do it 'returns an array with commands definitions with dynamic descriptions' do - base_expected[3][:description] = 'A dynamic description for merge request' + base_expected[3][:description] = 'A dynamic description for MERGE REQUEST' expect(DummyClass.command_definitions(noteable: 'merge request')).to match_array base_expected end @@ -114,6 +113,8 @@ describe Gitlab::SlashCommands::Dsl do end end + let(:dummy) { DummyClass.new(nil) } + describe 'command with no args' do context 'called with no args' do it 'succeeds' do @@ -146,6 +147,28 @@ describe Gitlab::SlashCommands::Dsl do end end + describe 'noop command' do + it 'is not meant to be called directly' do + expect { dummy.__send__(:cc) }.to raise_error(NoMethodError) + end + end + + describe 'command with condition' do + context 'when condition is not met' do + it 'returns nil' do + expect(dummy.__send__(:cond_action)).to be_nil + end + end + + context 'when condition is met' do + let(:dummy) { DummyClass.new('foo') } + + it 'succeeds' do + expect(dummy.__send__(:cond_action, 42)).to eq 42 + end + end + end + describe 'command with wildcard' do context 'called with no args' do it 'succeeds' do From aadc5062ebe755aaf3fbb27fdd0af093770c9ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 11 Aug 2016 19:00:06 +0200 Subject: [PATCH 09/25] New TodoService#todo_exists? method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- app/services/slash_commands/interpret_service.rb | 4 ++-- app/services/todo_service.rb | 4 ++++ spec/lib/gitlab/slash_commands/dsl_spec.rb | 2 +- spec/services/todo_service_spec.rb | 12 ++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index ae22ed6b845..f8aeefbfbce 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -140,7 +140,7 @@ module SlashCommands condition do noteable.persisted? && current_user && - !TodosFinder.new(current_user).execute.exists?(target: noteable) + !TodoService.new.todo_exist?(noteable, current_user) end command :todo do @updates[:todo_event] = 'add' @@ -149,7 +149,7 @@ module SlashCommands desc 'Mark todo as done' condition do current_user && - TodosFinder.new(current_user).execute.exists?(target: noteable) + TodoService.new.todo_exist?(noteable, current_user) end command :done do @updates[:todo_event] = 'done' diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 6b48d68cccb..db1578d1dc4 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -154,6 +154,10 @@ class TodoService create_todos(current_user, attributes) end + def todo_exist?(issuable, current_user) + TodosFinder.new(current_user).execute.exists?(target: issuable) + end + private def create_todos(users, attributes) diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 7c946313ae1..385f534ad6f 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -51,7 +51,7 @@ describe Gitlab::SlashCommands::Dsl do { name: :one_arg, aliases: [:once, :first], description: '', params: ['The first argument'], noop: false, cond_block: nil }, { name: :two_args, aliases: [], description: '', params: ['The first argument', 'The second argument'], noop: false, cond_block: nil }, { name: :cc, aliases: [], description: '', params: [], noop: true, cond_block: nil }, - { name: :wildcard, aliases: [], description: '', params: [], noop: false, cond_block: nil} + { name: :wildcard, aliases: [], description: '', params: [], noop: false, cond_block: nil } ] end diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 34d8ea9090e..4c41df0d4f5 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -290,6 +290,18 @@ describe TodoService, services: true do should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED) end end + + describe '#todo_exists?' do + it 'returns false when no todo exist for the given issuable' do + expect(service.todo_exist?(unassigned_issue, author)).to be_falsy + end + + it 'returns true when a todo exist for the given issuable' do + service.mark_todo(unassigned_issue, author) + + expect(service.todo_exist?(unassigned_issue, author)).to be_truthy + end + end end describe 'Merge Requests' do From f393f2dde016edf63b5168eb63405f15d65803eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Fri, 12 Aug 2016 11:19:29 +0200 Subject: [PATCH 10/25] Simplify the slash commands DSL to store action blocks instead of creating methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Other improvements: - Ensure slash commands autocomplete doesn't break when noteable_type is not given - Slash commands: improve autocomplete behavior and /due command - We don't display slash commands for note edit forms. - Add tests for reply by email with slash commands - Be sure to execute slash commands after the note creation in Notes::CreateService Signed-off-by: Rémy Coutable --- .../javascripts/gfm_auto_complete.js.es6 | 4 +- app/controllers/projects_controller.rb | 8 +- app/services/issuable_base_service.rb | 4 +- app/services/notes/create_service.rb | 5 +- app/services/notes/slash_commands_service.rb | 15 +- app/services/projects/autocomplete_service.rb | 34 ++-- app/services/projects/participants_service.rb | 25 +-- .../slash_commands/interpret_service.rb | 33 ++-- .../layouts/_init_auto_complete.html.haml | 2 +- doc/workflow/slash_commands.md | 2 +- lib/gitlab/slash_commands/dsl.rb | 88 ++++++----- .../issues/user_uses_slash_commands_spec.rb | 1 - spec/fixtures/emails/commands_in_reply.eml | 43 +++++ spec/fixtures/emails/commands_only_reply.eml | 1 + .../email/handler/create_note_handler_spec.rb | 45 +++++- spec/lib/gitlab/slash_commands/dsl_spec.rb | 147 +++++++++++------- 16 files changed, 297 insertions(+), 160 deletions(-) create mode 100644 spec/fixtures/emails/commands_in_reply.eml diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6 index 21639c7c084..9be32ed5937 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.es6 +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -249,7 +249,8 @@ } } }); - return this.input.atwho({ + // We don't instantiate the slash commands autocomplete for note edit forms + $("form:not(.edit-note) .js-gfm-input").atwho({ at: '/', alias: 'commands', displayTpl: function(value) { @@ -284,6 +285,7 @@ beforeInsert: this.DefaultOptions.beforeInsert } }); + return; }, destroyAtWho: function() { return this.input.atwho('destroy'); diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index af20984cbe7..9c387fd3daa 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -134,10 +134,8 @@ class ProjectsController < Projects::ApplicationController end def autocomplete_sources - note_type = params['type'] - note_id = params['type_id'] - autocomplete = ::Projects::AutocompleteService.new(@project, current_user) - participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) + autocomplete = ::Projects::AutocompleteService.new(@project, current_user, params) + participants = ::Projects::ParticipantsService.new(@project, current_user, params).execute @suggestions = { emojis: Gitlab::AwardEmoji.urls, @@ -146,7 +144,7 @@ class ProjectsController < Projects::ApplicationController mergerequests: autocomplete.merge_requests, labels: autocomplete.labels, members: participants, - commands: autocomplete.commands(note_type, note_id) + commands: autocomplete.commands } respond_to do |format| diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index c14bda811c2..1a01b333366 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -94,10 +94,10 @@ class IssuableBaseService < BaseService end def merge_slash_commands_into_params!(issuable) - command_params = SlashCommands::InterpretService.new(project, current_user). + commands = SlashCommands::InterpretService.new(project, current_user). execute(params[:description], issuable) - params.merge!(command_params) + params.merge!(commands) end def create_issuable(issuable, attributes) diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 0c2513409a1..1b2d63034b8 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -14,7 +14,8 @@ module Notes # We execute commands (extracted from `params[:note]`) on the noteable # **before** we save the note because if the note consists of commands # only, there is no need be create a note! - commands_executed = SlashCommandsService.new(project, current_user).execute(note) + slash_commands_service = SlashCommandsService.new(project, current_user) + commands = slash_commands_service.extract_commands(note) if note.save # Finish the harder work in the background @@ -24,7 +25,7 @@ module Notes # We must add the error after we call #save because errors are reset # when #save is called - if commands_executed && note.note.blank? + if slash_commands_service.execute(commands, note) && note.note.blank? note.errors.add(:commands_only, 'Your commands are being executed.') end diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb index 54d43d06466..ebced9577d8 100644 --- a/app/services/notes/slash_commands_service.rb +++ b/app/services/notes/slash_commands_service.rb @@ -6,16 +6,19 @@ module Notes 'MergeRequest' => MergeRequests::UpdateService } - def execute(note) - noteable_update_service = UPDATE_SERVICES[note.noteable_type] - return false unless noteable_update_service - return false unless can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable) + def extract_commands(note) + @noteable_update_service = UPDATE_SERVICES[note.noteable_type] + return [] unless @noteable_update_service + return [] unless can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable) - commands = SlashCommands::InterpretService.new(project, current_user). + SlashCommands::InterpretService.new(project, current_user). execute(note.note, note.noteable) + end + def execute(commands, note) if commands.any? - noteable_update_service.new(project, current_user, commands).execute(note.noteable) + @noteable_update_service.new(project, current_user, commands). + execute(note.noteable) end end end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 779f64f584e..477c999eff4 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -16,26 +16,34 @@ module Projects @project.labels.select([:title, :color]) end - def commands(noteable_type, noteable_id) + def commands + # We don't return commands when editing an issue or merge request + # This should be improved by not enabling autocomplete at the JS-level + # following this suggestion: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5021#note_13837384 + return [] if !target || %w[edit update].include?(params[:action_name]) + SlashCommands::InterpretService.command_definitions( - project: @project, - noteable: command_target(noteable_type, noteable_id), + project: project, + noteable: target, current_user: current_user ) end private - def command_target(noteable_type, noteable_id) - case noteable_type - when 'Issue' - IssuesFinder.new(current_user, project_id: @project.id, state: 'all'). - execute.find_or_initialize_by(iid: noteable_id) - when 'MergeRequest' - MergeRequestsFinder.new(current_user, project_id: @project.id, state: 'all'). - execute.find_or_initialize_by(iid: noteable_id) - else - nil + def target + @target ||= begin + noteable_id = params[:type_id] + case params[:type] + when 'Issue' + IssuesFinder.new(current_user, project_id: project.id, state: 'all'). + execute.find_or_initialize_by(iid: noteable_id) + when 'MergeRequest' + MergeRequestsFinder.new(current_user, project_id: project.id, state: 'all'). + execute.find_or_initialize_by(iid: noteable_id) + else + nil + end end end end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 02c4eee3d02..1c8f2913e8b 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -1,8 +1,11 @@ module Projects class ParticipantsService < BaseService - def execute(noteable_type, noteable_id) - @noteable_type = noteable_type - @noteable_id = noteable_id + attr_reader :noteable_type, :noteable_id + + def execute + @noteable_type = params[:type] + @noteable_id = params[:type_id] + project_members = sorted(project.team.members) participants = target_owner + participants_in_target + all_members + groups + project_members participants.uniq @@ -10,13 +13,15 @@ module Projects def target @target ||= - case @noteable_type - when "Issue" - project.issues.find_by_iid(@noteable_id) - when "MergeRequest" - project.merge_requests.find_by_iid(@noteable_id) - when "Commit" - project.commit(@noteable_id) + case noteable_type + when 'Issue' + IssuesFinder.new(current_user, project_id: project.id, state: 'all'). + execute.find_by(iid: noteable_id) + when 'MergeRequest' + MergeRequestsFinder.new(current_user, project_id: project.id, state: 'all'). + execute.find_by(iid: noteable_id) + when 'Commit' + project.commit(noteable_id) else nil end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index f8aeefbfbce..112bebe423a 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -11,8 +11,8 @@ module SlashCommands @updates = {} commands = extractor(noteable: noteable).extract_commands!(content) - commands.each do |command| - __send__(*command) + commands.each do |command, *args| + execute_command(command, *args) end @updates @@ -30,8 +30,9 @@ module SlashCommands "Close this #{noteable.to_ability_name.humanize(capitalize: false)}" end condition do + noteable.persisted? && noteable.open? && - current_user.can?(:"update_#{noteable.to_ability_name}", project) + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end command :close do @updates[:state_event] = 'close' @@ -42,7 +43,7 @@ module SlashCommands end condition do noteable.closed? && - current_user.can?(:"update_#{noteable.to_ability_name}", project) + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end command :open, :reopen do @updates[:state_event] = 'reopen' @@ -52,7 +53,7 @@ module SlashCommands params '' condition do noteable.persisted? && - current_user.can?(:"update_#{noteable.to_ability_name}", project) + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end command :title do |title_param| @updates[:title] = title_param @@ -65,9 +66,8 @@ module SlashCommands end command :assign, :reassign do |assignee_param| user = extract_references(assignee_param, :user).first - return unless user - @updates[:assignee_id] = user.id + @updates[:assignee_id] = user.id if user end desc 'Remove assignee' @@ -87,9 +87,8 @@ module SlashCommands end command :milestone do |milestone_param| milestone = extract_references(milestone_param, :milestone).first - return unless milestone - @updates[:milestone_id] = milestone.id + @updates[:milestone_id] = milestone.id if milestone end desc 'Remove milestone' @@ -109,9 +108,8 @@ module SlashCommands end command :label, :labels do |labels_param| label_ids = find_label_ids(labels_param) - return if label_ids.empty? - @updates[:add_label_ids] = label_ids + @updates[:add_label_ids] = label_ids unless label_ids.empty? end desc 'Remove label(s)' @@ -122,9 +120,8 @@ module SlashCommands end command :unlabel, :remove_label, :remove_labels do |labels_param| label_ids = find_label_ids(labels_param) - return if label_ids.empty? - @updates[:remove_label_ids] = label_ids + @updates[:remove_label_ids] = label_ids unless label_ids.empty? end desc 'Remove all labels' @@ -139,7 +136,6 @@ module SlashCommands desc 'Add a todo' condition do noteable.persisted? && - current_user && !TodoService.new.todo_exist?(noteable, current_user) end command :todo do @@ -148,7 +144,6 @@ module SlashCommands desc 'Mark todo as done' condition do - current_user && TodoService.new.todo_exist?(noteable, current_user) end command :done do @@ -174,12 +169,12 @@ module SlashCommands end desc 'Set due date' - params 'a date in natural language' + params '' condition do noteable.respond_to?(:due_date) && - current_user.can?(:"update_#{noteable.to_ability_name}", project) + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end - command :due_date, :due do |due_date_param| + command :due, :due_date do |due_date_param| due_date = Chronic.parse(due_date_param).try(:to_date) @updates[:due_date] = due_date if due_date @@ -189,7 +184,7 @@ module SlashCommands condition do noteable.respond_to?(:due_date) && noteable.due_date? && - current_user.can?(:"update_#{noteable.to_ability_name}", project) + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end command :clear_due_date do @updates[:due_date] = nil diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index 351100f3523..a51347fde83 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -2,6 +2,6 @@ - noteable_class = @noteable.class if @noteable.present? :javascript - GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id])}" + GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id], action_name: action_name)}" GitLab.GfmAutoComplete.cachedData = undefined; GitLab.GfmAutoComplete.setup(); diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md index c4edbeddd40..2bdc18ad248 100644 --- a/doc/workflow/slash_commands.md +++ b/doc/workflow/slash_commands.md @@ -25,5 +25,5 @@ do. | `/done` | None | Mark todo as done | | `/subscribe` | None | Subscribe | | `/unsubscribe` | None | Unsubscribe | -| `/due_date a date in natural language` | `/due` | Set due date | +| `/due ` | `/due_date` | Set due date | | `/clear_due_date` | None | Remove due date | diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index 3affd6253e9..ce659aff1da 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -4,20 +4,34 @@ module Gitlab extend ActiveSupport::Concern included do - @command_definitions = [] + cattr_accessor :definitions end - module ClassMethods - # This method is used to generate the autocompletion menu - # It returns no-op slash commands (such as `/cc`) + def execute_command(name, *args) + name = name.to_sym + cmd_def = self.class.definitions.find do |cmd_def| + self.class.command_name_and_aliases(cmd_def).include?(name) + end + return unless cmd_def && cmd_def[:action_block] + return if self.class.command_unavailable?(cmd_def, self) + + block_arity = cmd_def[:action_block].arity + if block_arity == -1 || block_arity == args.size + instance_exec(*args, &cmd_def[:action_block]) + end + end + + class_methods do + # This method is used to generate the autocompletion menu. + # It returns no-op slash commands (such as `/cc`). def command_definitions(opts = {}) - @command_definitions.map do |cmd_def| + self.definitions.map do |cmd_def| context = OpenStruct.new(opts) - next if cmd_def[:cond_block] && !context.instance_exec(&cmd_def[:cond_block]) + next if command_unavailable?(cmd_def, context) cmd_def = cmd_def.dup - if cmd_def[:description].present? && cmd_def[:description].respond_to?(:call) + if cmd_def[:description].respond_to?(:call) cmd_def[:description] = context.instance_exec(&cmd_def[:description]) rescue '' end @@ -30,13 +44,24 @@ module Gitlab # It excludes no-op slash commands (such as `/cc`). # This list can then be given to `Gitlab::SlashCommands::Extractor`. def command_names(opts = {}) - command_definitions(opts).flat_map do |command_definition| - next if command_definition[:noop] + self.definitions.flat_map do |cmd_def| + next if cmd_def[:opts].fetch(:noop, false) - [command_definition[:name], *command_definition[:aliases]] + context = OpenStruct.new(opts) + next if command_unavailable?(cmd_def, context) + + command_name_and_aliases(cmd_def) end.compact end + def command_unavailable?(cmd_def, context) + cmd_def[:condition_block] && !context.instance_exec(&cmd_def[:condition_block]) + end + + def command_name_and_aliases(cmd_def) + [cmd_def[:name], *cmd_def[:aliases]] + end + # Allows to give a description to the next slash command. # This description is shown in the autocomplete menu. # It accepts a block that will be evaluated with the context given to @@ -81,7 +106,7 @@ module Gitlab # # Awesome code block # end def condition(&block) - @cond_block = block + @condition_block = block end # Registers a new command which is recognizeable from body of email or @@ -95,45 +120,22 @@ module Gitlab # end def command(*command_names, &block) opts = command_names.extract_options! - command_name, *aliases = command_names - proxy_method_name = "__#{command_name}__" + name, *aliases = command_names - if block_given? - # This proxy method is needed because calling `return` from inside a - # block/proc, causes a `return` from the enclosing method or lambda, - # otherwise a LocalJumpError error is raised. - define_method(proxy_method_name, &block) - - define_method(command_name) do |*args| - return if @cond_block && !instance_exec(&@cond_block) - - proxy_method = method(proxy_method_name) - - if proxy_method.arity == -1 || proxy_method.arity == args.size - instance_exec(*args, &proxy_method) - end - end - - private command_name - aliases.each do |alias_command| - alias_method alias_command, command_name - private alias_command - end - end - - command_definition = { - name: command_name, + self.definitions ||= [] + self.definitions << { + name: name, aliases: aliases, description: @description || '', - params: @params || [] + params: @params || [], + condition_block: @condition_block, + action_block: block, + opts: opts } - command_definition[:noop] = opts[:noop] || false - command_definition[:cond_block] = @cond_block - @command_definitions << command_definition @description = nil @params = nil - @cond_block = nil + @condition_block = nil end end end diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index 47c4ce306e9..fe320070704 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -55,5 +55,4 @@ feature 'Issues > User uses slash commands', feature: true, js: true do end end end - end diff --git a/spec/fixtures/emails/commands_in_reply.eml b/spec/fixtures/emails/commands_in_reply.eml new file mode 100644 index 00000000000..06bf60ab734 --- /dev/null +++ b/spec/fixtures/emails/commands_in_reply.eml @@ -0,0 +1,43 @@ +Return-Path: +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 ; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for ; 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 +To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo +Message-ID: +In-Reply-To: +References: +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 + +Cool! + +/close +/todo +/due tomorrow + + +On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta + 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/fixtures/emails/commands_only_reply.eml b/spec/fixtures/emails/commands_only_reply.eml index ccd92e406c4..aed64224b06 100644 --- a/spec/fixtures/emails/commands_only_reply.eml +++ b/spec/fixtures/emails/commands_only_reply.eml @@ -21,6 +21,7 @@ X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 /close /todo +/due tomorrow On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta 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 afb072105cf..4909fed6b77 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -75,13 +75,54 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do project.team << [user, :developer] end - it 'raises a CommandsOnlyNoteError' do - expect { receiver.execute }.not_to raise_error + it 'does not raise an error' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the 'close' event + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + + expect(noteable.reload).to be_closed + expect(noteable.due_date).to eq(Date.tomorrow) + expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy end end end end + context 'when the note contains slash commands' do + let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") } + + context 'and current user cannot update noteable' do + it 'post a note and does not update the noteable' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the new note + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + + expect(noteable.reload).to be_open + expect(noteable.due_date).to be_nil + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + end + end + + context 'and current user can update noteable' do + before do + project.team << [user, :developer] + end + + it 'post a note and updates the noteable' do + expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy + + # One system note is created for the new note, one for the 'close' event + expect { receiver.execute }.to change { noteable.notes.count }.by(2) + + expect(noteable.reload).to be_closed + expect(noteable.due_date).to eq(Date.tomorrow) + expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy + end + end + end + context "when the reply is blank" do let!(:email_raw) { fixture_file("emails/no_content_reply.eml") } diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 385f534ad6f..500ff3ca1fe 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -46,12 +46,42 @@ describe Gitlab::SlashCommands::Dsl do describe '.command_definitions' do let(:base_expected) do [ - { name: :no_args, aliases: [:none], description: 'A command with no args', params: [], noop: false, cond_block: nil }, - { name: :returning, aliases: [], description: 'A command returning a value', params: [], noop: false, cond_block: nil }, - { name: :one_arg, aliases: [:once, :first], description: '', params: ['The first argument'], noop: false, cond_block: nil }, - { name: :two_args, aliases: [], description: '', params: ['The first argument', 'The second argument'], noop: false, cond_block: nil }, - { name: :cc, aliases: [], description: '', params: [], noop: true, cond_block: nil }, - { name: :wildcard, aliases: [], description: '', params: [], noop: false, cond_block: nil } + { + name: :no_args, aliases: [:none], + description: 'A command with no args', params: [], + condition_block: nil, action_block: a_kind_of(Proc), + opts: {} + }, + { + name: :returning, aliases: [], + description: 'A command returning a value', params: [], + condition_block: nil, action_block: a_kind_of(Proc), + opts: {} + }, + { + name: :one_arg, aliases: [:once, :first], + description: '', params: ['The first argument'], + condition_block: nil, action_block: a_kind_of(Proc), + opts: {} + }, + { + name: :two_args, aliases: [], + description: '', params: ['The first argument', 'The second argument'], + condition_block: nil, action_block: a_kind_of(Proc), + opts: {} + }, + { + name: :cc, aliases: [], + description: '', params: [], + condition_block: nil, action_block: nil, + opts: { noop: true } + }, + { + name: :wildcard, aliases: [], + description: '', params: [], + condition_block: nil, action_block: a_kind_of(Proc), + opts: {} + } ] end @@ -61,7 +91,14 @@ describe Gitlab::SlashCommands::Dsl do context 'with options passed' do context 'when condition is met' do - let(:expected) { base_expected << { name: :cond_action, aliases: [], description: '', params: [], noop: false, cond_block: a_kind_of(Proc) } } + let(:expected) do + base_expected << { + name: :cond_action, aliases: [], + description: '', params: [], + condition_block: a_kind_of(Proc), action_block: a_kind_of(Proc), + opts: {} + } + end it 'returns an array with commands definitions' do expect(DummyClass.command_definitions(project: 'foo')).to match_array expected @@ -115,76 +152,78 @@ describe Gitlab::SlashCommands::Dsl do let(:dummy) { DummyClass.new(nil) } - describe 'command with no args' do - context 'called with no args' do - it 'succeeds' do - expect(dummy.__send__(:no_args)).to eq 'Hello World!' + describe '#execute_command' do + describe 'command with no args' do + context 'called with no args' do + it 'succeeds' do + expect(dummy.execute_command(:no_args)).to eq 'Hello World!' + end 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 + describe 'command with an explicit return' do + context 'called with no args' do + it 'succeeds' do + expect { dummy.execute_command(:returning) }.to raise_error(LocalJumpError) + end 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 + describe 'command with one arg' do + context 'called with one arg' do + it 'succeeds' do + expect(dummy.execute_command(:one_arg, 42)).to eq 42 + end 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'] + describe 'command with two args' do + context 'called with two args' do + it 'succeeds' do + expect(dummy.execute_command(:two_args, 42, 'foo')).to eq [42, 'foo'] + end end end - end - describe 'noop command' do - it 'is not meant to be called directly' do - expect { dummy.__send__(:cc) }.to raise_error(NoMethodError) - end - end - - describe 'command with condition' do - context 'when condition is not met' do + describe 'noop command' do it 'returns nil' do - expect(dummy.__send__(:cond_action)).to be_nil + expect(dummy.execute_command(:cc)).to be_nil end end - context 'when condition is met' do - let(:dummy) { DummyClass.new('foo') } - - it 'succeeds' do - expect(dummy.__send__(:cond_action, 42)).to eq 42 + describe 'command with condition' do + context 'when condition is not met' do + it 'returns nil' do + expect(dummy.execute_command(:cond_action)).to be_nil + end end - end - end - describe 'command with wildcard' do - context 'called with no args' do - it 'succeeds' do - expect(dummy.__send__(:wildcard)).to eq [] + context 'when condition is met' do + let(:dummy) { DummyClass.new('foo') } + + it 'succeeds' do + expect(dummy.execute_command(:cond_action, 42)).to eq 42 + end end end - context 'called with one arg' do - it 'succeeds' do - expect(dummy.__send__(:wildcard, 42)).to eq [42] + describe 'command with wildcard' do + context 'called with no args' do + it 'succeeds' do + expect(dummy.execute_command(:wildcard)).to eq [] + end end - end - context 'called with two args' do - it 'succeeds' do - expect(dummy.__send__(:wildcard, 42, 'foo')).to eq [42, 'foo'] + context 'called with one arg' do + it 'succeeds' do + expect(dummy.execute_command(:wildcard, 42)).to eq [42] + end + end + + context 'called with two args' do + it 'succeeds' do + expect(dummy.execute_command(:wildcard, 42, 'foo')).to eq [42, 'foo'] + end end end end From 5d4993d62357e438b6211247278025040f3ae382 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 18:16:36 -0500 Subject: [PATCH 11/25] Only autocomplete commands at the beginning of a line --- app/assets/javascripts/gfm_auto_complete.js.es6 | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6 index 9be32ed5937..2e1cbb362a3 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.es6 +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -268,7 +268,7 @@ return _.template(tpl)(value); }, insertTpl: function(value) { - var tpl = "\n/${name} "; + var tpl = "/${name} "; var reference_prefix = null; if (value.params.length > 0) { reference_prefix = value.params[0][0]; @@ -282,7 +282,16 @@ callbacks: { sorter: this.DefaultOptions.sorter, filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert + beforeInsert: this.DefaultOptions.beforeInsert, + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; + } + } } }); return; From 5a07b760dff04660d9c7da84852c710b1fc2f786 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 20:17:18 -0500 Subject: [PATCH 12/25] Refactor slash command definition --- app/controllers/projects_controller.rb | 20 ++++- app/services/issuable_base_service.rb | 7 +- app/services/notes/create_service.rb | 6 +- app/services/notes/slash_commands_service.rb | 11 ++- app/services/projects/autocomplete_service.rb | 48 +++++------ app/services/projects/participants_service.rb | 41 +++------- .../slash_commands/interpret_service.rb | 24 ++++-- .../slash_commands/command_definition.rb | 57 +++++++++++++ lib/gitlab/slash_commands/dsl.rb | 82 +++++-------------- lib/gitlab/slash_commands/extractor.rb | 30 +++++-- .../gitlab/slash_commands/extractor_spec.rb | 30 +++---- 11 files changed, 191 insertions(+), 165 deletions(-) create mode 100644 lib/gitlab/slash_commands/command_definition.rb diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 9c387fd3daa..93338dba51e 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -134,8 +134,22 @@ class ProjectsController < Projects::ApplicationController end def autocomplete_sources - autocomplete = ::Projects::AutocompleteService.new(@project, current_user, params) - participants = ::Projects::ParticipantsService.new(@project, current_user, params).execute + noteable = + case params[:type] + when 'Issue' + IssuesFinder.new(current_user, project_id: project.id, state: 'all'). + execute.find_by(iid: params[:type_id]) + when 'MergeRequest' + MergeRequestsFinder.new(current_user, project_id: project.id, state: 'all'). + execute.find_by(iid: params[:type_id]) + when 'Commit' + project.commit(params[:type_id]) + else + nil + end + + autocomplete = ::Projects::AutocompleteService.new(@project, current_user) + participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable) @suggestions = { emojis: Gitlab::AwardEmoji.urls, @@ -144,7 +158,7 @@ class ProjectsController < Projects::ApplicationController mergerequests: autocomplete.merge_requests, labels: autocomplete.labels, members: participants, - commands: autocomplete.commands + commands: autocomplete.commands(noteable, params[:type]) } respond_to do |format| diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 1a01b333366..aa08eef081c 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -94,10 +94,13 @@ class IssuableBaseService < BaseService end def merge_slash_commands_into_params!(issuable) - commands = SlashCommands::InterpretService.new(project, current_user). + description, command_params = + SlashCommands::InterpretService.new(project, current_user). execute(params[:description], issuable) - params.merge!(commands) + params[:description] = description + + params.merge!(command_params) end def create_issuable(issuable, attributes) diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 1b2d63034b8..f7cf4a8edc0 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -15,7 +15,9 @@ module Notes # **before** we save the note because if the note consists of commands # only, there is no need be create a note! slash_commands_service = SlashCommandsService.new(project, current_user) - commands = slash_commands_service.extract_commands(note) + content, command_params = slash_commands_service.extract_commands(note) + + note.note = content if note.save # Finish the harder work in the background @@ -25,7 +27,7 @@ module Notes # We must add the error after we call #save because errors are reset # when #save is called - if slash_commands_service.execute(commands, note) && note.note.blank? + if slash_commands_service.execute(command_params, note) && note.note.blank? note.errors.add(:commands_only, 'Your commands are being executed.') end diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb index ebced9577d8..f2c43775b72 100644 --- a/app/services/notes/slash_commands_service.rb +++ b/app/services/notes/slash_commands_service.rb @@ -1,6 +1,5 @@ module Notes class SlashCommandsService < BaseService - UPDATE_SERVICES = { 'Issue' => Issues::UpdateService, 'MergeRequest' => MergeRequests::UpdateService @@ -15,11 +14,11 @@ module Notes execute(note.note, note.noteable) end - def execute(commands, note) - if commands.any? - @noteable_update_service.new(project, current_user, commands). - execute(note.noteable) - end + def execute(command_params, note) + return if command_params.empty? + + @noteable_update_service.new(project, current_user, command_params). + execute(note.noteable) end end end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 477c999eff4..cb85ee6694d 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -1,7 +1,7 @@ module Projects class AutocompleteService < BaseService def issues - @project.issues.visible_to_user(current_user).opened.select([:iid, :title]) + IssuesFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end def milestones @@ -9,42 +9,34 @@ module Projects end def merge_requests - @project.merge_requests.opened.select([:iid, :title]) + MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end def labels @project.labels.select([:title, :color]) end - def commands - # We don't return commands when editing an issue or merge request - # This should be improved by not enabling autocomplete at the JS-level - # following this suggestion: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5021#note_13837384 - return [] if !target || %w[edit update].include?(params[:action_name]) - - SlashCommands::InterpretService.command_definitions( - project: project, - noteable: target, - current_user: current_user - ) - end - - private - - def target - @target ||= begin - noteable_id = params[:type_id] - case params[:type] + def commands(noteable, type) + noteable ||= + case type when 'Issue' - IssuesFinder.new(current_user, project_id: project.id, state: 'all'). - execute.find_or_initialize_by(iid: noteable_id) + @project.issues.build when 'MergeRequest' - MergeRequestsFinder.new(current_user, project_id: project.id, state: 'all'). - execute.find_or_initialize_by(iid: noteable_id) - else - nil + @project.merge_requests.build end - end + + return [] unless noteable && noteable.is_a?(Issuable) + + opts = { + project: project, + noteable: noteable, + current_user: current_user + } + SlashCommands::InterpretService.command_definitions.map do |definition| + next unless definition.available?(opts) + + definition.to_h(opts) + end.compact end end end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index 1c8f2913e8b..d38328403c1 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -1,45 +1,28 @@ module Projects class ParticipantsService < BaseService - attr_reader :noteable_type, :noteable_id - - def execute - @noteable_type = params[:type] - @noteable_id = params[:type_id] + attr_reader :noteable + + def execute(noteable) + @noteable = noteable project_members = sorted(project.team.members) - participants = target_owner + participants_in_target + all_members + groups + project_members + participants = noteable_owner + participants_in_noteable + all_members + groups + project_members participants.uniq end - def target - @target ||= - case noteable_type - when 'Issue' - IssuesFinder.new(current_user, project_id: project.id, state: 'all'). - execute.find_by(iid: noteable_id) - when 'MergeRequest' - MergeRequestsFinder.new(current_user, project_id: project.id, state: 'all'). - execute.find_by(iid: noteable_id) - when 'Commit' - project.commit(noteable_id) - else - nil - end - end - - def target_owner - return [] unless target && target.author.present? + def noteable_owner + return [] unless noteable && noteable.author.present? [{ - name: target.author.name, - username: target.author.username + name: noteable.author.name, + username: noteable.author.username }] end - def participants_in_target - return [] unless target + def participants_in_noteable + return [] unless noteable - users = target.participants(current_user) + users = noteable.participants(current_user) sorted(users) end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 112bebe423a..a2b92d70f9f 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -10,20 +10,28 @@ module SlashCommands @noteable = noteable @updates = {} - commands = extractor(noteable: noteable).extract_commands!(content) - commands.each do |command, *args| - execute_command(command, *args) + opts = { + noteable: noteable, + current_user: current_user, + project: project + } + + content, commands = extractor.extract_commands(content, opts) + + commands.each do |name, *args| + definition = self.class.command_definitions_by_name[name.to_sym] + next unless definition + + definition.execute(self, opts, *args) end - @updates + [content, @updates] end private - def extractor(opts = {}) - opts.merge!(current_user: current_user, project: project) - - Gitlab::SlashCommands::Extractor.new(self.class.command_names(opts)) + def extractor + Gitlab::SlashCommands::Extractor.new(self.class.command_definitions) end desc do diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb new file mode 100644 index 00000000000..5dec6c91869 --- /dev/null +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -0,0 +1,57 @@ +module Gitlab + module SlashCommands + class CommandDefinition + attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block + + def valid? + name.present? + end + + def all_names + [name, *aliases] + end + + def noop? + action_block.nil? + end + + def available?(opts) + return true unless condition_block + + context = OpenStruct.new(opts) + context.instance_exec(&condition_block) + end + + def to_description(opts) + return description unless description.respond_to?(:call) + + context = OpenStruct.new(opts) + context.instance_exec(&description) rescue '' + end + + def execute(context, opts, *args) + return if noop? || !available?(opts) + + block_arity = action_block.arity + return unless block_arity == -1 || block_arity == args.size + + context.instance_exec(*args, &action_block) + end + + def to_h(opts) + desc = description + if desc.respond_to?(:call) + context = OpenStruct.new(opts) + desc = context.instance_exec(&desc) rescue '' + end + + { + name: name, + aliases: aliases, + description: desc, + params: params + } + end + end + end +end diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index ce659aff1da..58ba7027f84 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -4,64 +4,16 @@ module Gitlab extend ActiveSupport::Concern included do - cattr_accessor :definitions - end - - def execute_command(name, *args) - name = name.to_sym - cmd_def = self.class.definitions.find do |cmd_def| - self.class.command_name_and_aliases(cmd_def).include?(name) + cattr_accessor :command_definitions, instance_accessor: false do + [] end - return unless cmd_def && cmd_def[:action_block] - return if self.class.command_unavailable?(cmd_def, self) - block_arity = cmd_def[:action_block].arity - if block_arity == -1 || block_arity == args.size - instance_exec(*args, &cmd_def[:action_block]) + cattr_accessor :command_definitions_by_name, instance_accessor: false do + {} end end class_methods do - # This method is used to generate the autocompletion menu. - # It returns no-op slash commands (such as `/cc`). - def command_definitions(opts = {}) - self.definitions.map do |cmd_def| - context = OpenStruct.new(opts) - next if command_unavailable?(cmd_def, context) - - cmd_def = cmd_def.dup - - if cmd_def[:description].respond_to?(:call) - cmd_def[:description] = context.instance_exec(&cmd_def[:description]) rescue '' - end - - cmd_def - end.compact - end - - # This method is used to generate a list of valid commands in the current - # context of `opts`. - # It excludes no-op slash commands (such as `/cc`). - # This list can then be given to `Gitlab::SlashCommands::Extractor`. - def command_names(opts = {}) - self.definitions.flat_map do |cmd_def| - next if cmd_def[:opts].fetch(:noop, false) - - context = OpenStruct.new(opts) - next if command_unavailable?(cmd_def, context) - - command_name_and_aliases(cmd_def) - end.compact - end - - def command_unavailable?(cmd_def, context) - cmd_def[:condition_block] && !context.instance_exec(&cmd_def[:condition_block]) - end - - def command_name_and_aliases(cmd_def) - [cmd_def[:name], *cmd_def[:aliases]] - end - # Allows to give a description to the next slash command. # This description is shown in the autocomplete menu. # It accepts a block that will be evaluated with the context given to @@ -119,19 +71,23 @@ module Gitlab # # Awesome code block # end def command(*command_names, &block) - opts = command_names.extract_options! name, *aliases = command_names - self.definitions ||= [] - self.definitions << { - name: name, - aliases: aliases, - description: @description || '', - params: @params || [], - condition_block: @condition_block, - action_block: block, - opts: opts - } + definition = CommandDefinition.new + definition.name = name + definition.aliases = aliases + definition.description = @description || '' + definition.params = @params || [] + definition.condition_block = @condition_block + definition.action_block = block + + return unless definition.valid? + + self.command_definitions << definition + + definition.all_names.each do |name| + self.command_definitions_by_name[name] = definition + end @description = nil @params = nil diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb index ce0a2eba535..a6838cb5e7c 100644 --- a/lib/gitlab/slash_commands/extractor.rb +++ b/lib/gitlab/slash_commands/extractor.rb @@ -7,10 +7,10 @@ module Gitlab # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) # ``` class Extractor - attr_reader :command_names + attr_reader :command_definitions - def initialize(command_names) - @command_names = command_names + def initialize(command_definitions) + @command_definitions = command_definitions end # Extracts commands from content and return an array of commands. @@ -26,16 +26,18 @@ module Gitlab # ``` # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) # msg = %(hello\n/labels ~foo ~"bar baz"\nworld) - # commands = extractor.extract_commands! #=> [['labels', '~foo ~"bar baz"']] + # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']] # msg #=> "hello\nworld" # ``` - def extract_commands!(content) + def extract_commands(content, opts) return [] unless content + content = content.dup + commands = [] content.delete!("\r") - content.gsub!(commands_regex) do + content.gsub!(commands_regex(opts)) do if $~[:cmd] commands << [$~[:cmd], $~[:args]].reject(&:blank?) '' @@ -44,11 +46,19 @@ module Gitlab end end - commands + [content.strip, commands] end private + def command_names(opts) + command_definitions.flat_map do |command| + next if command.noop? + + command.all_names + end.compact + end + # Builds a regular expression to match known commands. # First match group captures the command name and # second match group captures its arguments. @@ -56,7 +66,9 @@ module Gitlab # It looks something like: # # /^\/(?close|reopen|...)(?:( |$))(?[^\/\n]*)(?:\n|$)/ - def commands_regex + def commands_regex(opts) + names = command_names(opts).map(&:to_s) + @commands_regex ||= %r{ (? # Code blocks: @@ -95,7 +107,7 @@ module Gitlab # Command not in a blockquote, blockcode, or HTML tag: # /close - ^\/(?#{command_names.join('|')})(?:(\ |$))(?[^\/\n]*)(?:\n|$) + ^\/(?#{Regexp.union(names)})(?:$|\ (?[^\/\n]*)$) ) }mx end diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb index ac7296bdba1..8a6801205fa 100644 --- a/spec/lib/gitlab/slash_commands/extractor_spec.rb +++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::SlashCommands::Extractor do shared_examples 'command with no argument' do it 'extracts command' do - commands = extractor.extract_commands!(original_msg) + commands = extractor.extract_commands(original_msg) expect(commands).to eq [['open']] expect(original_msg).to eq final_msg @@ -14,7 +14,7 @@ describe Gitlab::SlashCommands::Extractor do shared_examples 'command with a single argument' do it 'extracts command' do - commands = extractor.extract_commands!(original_msg) + commands = extractor.extract_commands(original_msg) expect(commands).to eq [['assign', '@joe']] expect(original_msg).to eq final_msg @@ -23,14 +23,14 @@ describe Gitlab::SlashCommands::Extractor do shared_examples 'command with multiple arguments' do it 'extracts command' do - commands = extractor.extract_commands!(original_msg) + 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 '#extract_commands' do describe 'command with no argument' do context 'at the start of content' do it_behaves_like 'command with no argument' do @@ -49,7 +49,7 @@ describe Gitlab::SlashCommands::Extractor do context 'in the middle of a line' do it 'does not extract command' do msg = "hello\nworld /open" - commands = extractor.extract_commands!(msg) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq "hello\nworld /open" @@ -82,7 +82,7 @@ describe Gitlab::SlashCommands::Extractor do context 'in the middle of a line' do it 'does not extract command' do msg = "hello\nworld /assign @joe" - commands = extractor.extract_commands!(msg) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq "hello\nworld /assign @joe" @@ -99,7 +99,7 @@ describe Gitlab::SlashCommands::Extractor do 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) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq "hello\n/assign@joe\nworld" @@ -125,7 +125,7 @@ describe Gitlab::SlashCommands::Extractor do 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) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label) @@ -142,7 +142,7 @@ describe Gitlab::SlashCommands::Extractor do 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) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld) @@ -152,7 +152,7 @@ describe Gitlab::SlashCommands::Extractor do 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) + commands = extractor.extract_commands(msg) expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']] expect(msg).to eq "hello\nworld" @@ -160,7 +160,7 @@ describe Gitlab::SlashCommands::Extractor do 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) + 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" @@ -168,7 +168,7 @@ describe Gitlab::SlashCommands::Extractor do it 'does not alter original content if no command is found' do msg = 'Fixes #123' - commands = extractor.extract_commands!(msg) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq 'Fixes #123' @@ -177,7 +177,7 @@ describe Gitlab::SlashCommands::Extractor do it 'does not extract commands inside a blockcode' do msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld" expected = msg.delete("\r") - commands = extractor.extract_commands!(msg) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq expected @@ -186,7 +186,7 @@ describe Gitlab::SlashCommands::Extractor do it 'does not extract commands inside a blockquote' do msg = "Hello\r\n>>>\r\nThis is some text\r\n/close\r\n/assign @user\r\n>>>\r\n\r\nWorld" expected = msg.delete("\r") - commands = extractor.extract_commands!(msg) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq expected @@ -195,7 +195,7 @@ describe Gitlab::SlashCommands::Extractor do it 'does not extract commands inside a HTML tag' do msg = "Hello\r\n
    \r\nThis is some text\r\n/close\r\n/assign @user\r\n
    \r\n\r\nWorld" expected = msg.delete("\r") - commands = extractor.extract_commands!(msg) + commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq expected From ccd44546589297bcb36a92f8cbb1a6d13bcf1abf Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 20:17:57 -0500 Subject: [PATCH 13/25] Fix autocomplete on commit and issue/MR edit pages --- app/controllers/projects/commit_controller.rb | 2 +- app/controllers/projects/issues_controller.rb | 11 ++++++----- app/controllers/projects/merge_requests_controller.rb | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index f44e9bb3fd7..02fb3f56890 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController end def commit - @commit ||= @project.commit(params[:id]) + @noteable = @commit ||= @project.commit(params[:id]) end def pipelines diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 660e0eba06f..c8eda6b27f4 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -176,11 +176,12 @@ class Projects::IssuesController < Projects::ApplicationController protected def issue - @issue ||= begin - @project.issues.find_by!(iid: params[:id]) - rescue ActiveRecord::RecordNotFound - redirect_old - end + @noteable = @issue ||= + begin + @project.issues.find_by!(iid: params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_old + end end alias_method :subscribable_resource, :issue alias_method :issuable, :issue diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 139680d2df9..8e82a8c0360 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -324,7 +324,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def merge_request - @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) + @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end alias_method :subscribable_resource, :merge_request alias_method :issuable, :merge_request From f48e898619a104aa379af0a037fb96d09ee8675f Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 20:19:16 -0500 Subject: [PATCH 14/25] Disable slash commands on edit forms --- app/assets/javascripts/gfm_auto_complete.js.es6 | 4 ++-- app/views/layouts/_init_auto_complete.html.haml | 4 ++-- app/views/projects/_zen.html.haml | 3 ++- app/views/projects/notes/_form.html.haml | 6 +++++- app/views/shared/issuable/_form.html.haml | 3 ++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6 index 2e1cbb362a3..3d6f704c9b0 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.es6 +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -249,8 +249,8 @@ } } }); - // We don't instantiate the slash commands autocomplete for note edit forms - $("form:not(.edit-note) .js-gfm-input").atwho({ + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + this.input.filter('[data-supports-slash-commands="true"]').atwho({ at: '/', alias: 'commands', displayTpl: function(value) { diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index a51347fde83..67ff4b272b9 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -1,7 +1,7 @@ - project = @target_project || @project -- noteable_class = @noteable.class if @noteable.present? +- noteable_type = @noteable.class if @noteable.present? :javascript - GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id], action_name: action_name)}" + GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_type, type_id: params[:id])}" GitLab.GfmAutoComplete.cachedData = undefined; GitLab.GfmAutoComplete.setup(); diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index 413477a2d3a..3978fa60d66 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -1,7 +1,8 @@ +- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) .zen-backdrop - classes << ' js-gfm-input js-autosize markdown-area' - if defined?(f) && f - = f.text_area attr, class: classes, placeholder: placeholder + = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands } - else = text_area_tag attr, nil, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index 7c61ba750fe..b2a31607824 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -10,7 +10,11 @@ = f.hidden_field :position = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do - = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here..." + = render 'projects/zen', f: f, + attr: :note, + classes: 'note-textarea js-note-text', + placeholder: "Write a comment or drag your files here...", + supports_slash_commands: true = render 'projects/notes/hints' .error-alert diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index c30bdb0ae91..7e4062c8d4d 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -30,7 +30,8 @@ = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', - placeholder: "Write a comment or drag your files here..." + placeholder: "Write a comment or drag your files here...", + supports_slash_commands: !issuable.persisted? = render 'projects/notes/hints' .clearfix .error-alert From 489e8992cfb4076fb4bf7f1c68bd945d30a26bbf Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 20:19:52 -0500 Subject: [PATCH 15/25] Link to slash commands documentation from form hints --- app/views/projects/notes/_form.html.haml | 2 +- app/views/projects/notes/_hints.html.haml | 11 +++++++++-- app/views/shared/issuable/_form.html.haml | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index b2a31607824..759c72b2477 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -15,7 +15,7 @@ classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here...", supports_slash_commands: true - = render 'projects/notes/hints' + = render 'projects/notes/hints', supports_slash_commands: true .error-alert .note-form-actions.clearfix diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/projects/notes/_hints.html.haml index 25466e7562e..cf6e14648cc 100644 --- a/app/views/projects/notes/_hints.html.haml +++ b/app/views/projects/notes/_hints.html.haml @@ -1,8 +1,15 @@ +- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) .comment-toolbar.clearfix .toolbar-text Styling with = link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1 - is supported + - if supports_slash_commands + and + = link_to 'slash commands', help_page_path('workflow/slash_commands'), target: '_blank', tabindex: -1 + are + - else + is + supported %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } = icon('file-image-o', class: 'toolbar-button-icon') - Attach a file \ No newline at end of file + Attach a file diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 7e4062c8d4d..25b29ccf70d 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -32,7 +32,7 @@ classes: 'note-textarea', placeholder: "Write a comment or drag your files here...", supports_slash_commands: !issuable.persisted? - = render 'projects/notes/hints' + = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted? .clearfix .error-alert From 0afb96015034307f49d6b28da2c9a4143352ac97 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 20:20:07 -0500 Subject: [PATCH 16/25] Autocomplete based on names and aliases --- app/assets/javascripts/gfm_auto_complete.js.es6 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6 index 3d6f704c9b0..3dca06d36b1 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.es6 +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -253,6 +253,7 @@ this.input.filter('[data-supports-slash-commands="true"]').atwho({ at: '/', alias: 'commands', + searchKey: 'search', displayTpl: function(value) { var tpl = '
  • /${name}'; if (value.aliases.length > 0) { @@ -283,6 +284,21 @@ sorter: this.DefaultOptions.sorter, filter: this.DefaultOptions.filter, beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); + }, matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi var match = regexp.exec(subtext); From 463ae8f570f986d9f50ae42067715d6db0dc1541 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 20:20:51 -0500 Subject: [PATCH 17/25] Force notes poll after commands have been executed --- app/assets/javascripts/notes.js | 40 ++++++++++--------- app/services/notes/create_service.rb | 2 +- .../issues/user_uses_slash_commands_spec.rb | 4 +- ...issuable_slash_commands_shared_examples.rb | 22 +++++----- 4 files changed, 35 insertions(+), 33 deletions(-) diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 99bc1a640a8..2484a07f363 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -201,7 +201,7 @@ Increase @pollingInterval up to 120 seconds on every function call, if `shouldReset` has a truthy value, 'null' or 'undefined' the variable will reset to @basePollingInterval. - + Note: this function is used to gradually increase the polling interval if there aren't new notes coming from the server */ @@ -223,7 +223,7 @@ /* Render note in main comments area. - + Note: for rendering inline notes use renderDiscussionNote */ @@ -236,6 +236,7 @@ else { if (note.errors.commands_only) { new Flash(note.errors.commands_only, 'notice', this.parentTimeline); + this.refresh(); } } return; @@ -250,6 +251,7 @@ $notesList.append(note.html).syntaxHighlight(); gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); this.initTaskList(); + this.refresh(); return this.updateNotesCount(1); } }; @@ -270,7 +272,7 @@ /* Render note in discussion area. - + Note: for rendering inline notes use renderDiscussionNote */ @@ -309,7 +311,7 @@ /* Called in response the main target form has been successfully submitted. - + Removes any errors. Resets text and preview. Resets buttons. @@ -334,7 +336,7 @@ /* Shows the main form and does some setup on it. - + Sets some hidden fields in the form. */ @@ -354,7 +356,7 @@ /* General note form setup. - + deactivates the submit button when text is empty hides the preview button when text is empty setup GFM auto complete @@ -371,7 +373,7 @@ /* Called in response to the new note form being submitted - + Adds new note to list. */ @@ -386,7 +388,7 @@ /* Called in response to the new note form being submitted - + Adds new note to list. */ @@ -398,7 +400,7 @@ /* Called in response to the edit note form being submitted - + Updates the current note field. */ @@ -415,7 +417,7 @@ /* Called in response to clicking the edit note link - + Replaces the note text with the note edit form Adds a data attribute to the form with the original content of the note for cancellations */ @@ -455,7 +457,7 @@ /* Called in response to clicking the edit note link - + Hides edit form and restores the original note text to the editor textarea. */ @@ -477,7 +479,7 @@ /* Called in response to deleting a note of any kind. - + Removes the actual note from view. Removes the whole discussion if the last note is being removed. */ @@ -503,7 +505,7 @@ /* Called in response to clicking the delete attachment link - + Removes the attachment wrapper view, including image tag if it exists Resets the note editing form */ @@ -520,7 +522,7 @@ /* Called when clicking on the "reply" button for a diff line. - + Shows the note form below the notes. */ @@ -536,9 +538,9 @@ /* Shows the diff or discussion form and does some setup on it. - + Sets some hidden fields in the form. - + Note: dataHolder must have the "discussionId", "lineCode", "noteableType" and "noteableId" data attributes set. */ @@ -562,7 +564,7 @@ /* Called when clicking on the "add a comment" button on the side of a diff line. - + Inserts a temporary row for the form below the line. Sets up the form and shows it. */ @@ -610,7 +612,7 @@ /* Called in response to "cancel" on a diff note form. - + Shows the reply button again. Removes the form and if necessary it's temporary row. */ @@ -639,7 +641,7 @@ /* Called after an attachment file has been selected. - + Updates the file name for the selected attachment. */ diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index f7cf4a8edc0..e9f37e04993 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -28,7 +28,7 @@ module Notes # We must add the error after we call #save because errors are reset # when #save is called if slash_commands_service.execute(command_params, note) && note.note.blank? - note.errors.add(:commands_only, 'Your commands are being executed.') + note.errors.add(:commands_only, 'Your commands have been executed!') end note diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index fe320070704..510f4254b54 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -27,7 +27,7 @@ feature 'Issues > User uses slash commands', feature: true, js: true do end expect(page).not_to have_content '/due_date 2016-08-28' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' issue.reload @@ -47,7 +47,7 @@ feature 'Issues > User uses slash commands', feature: true, js: true do end expect(page).not_to have_content '/clear_due_date' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' issue.reload diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb index e1255bd248d..ace14c19fd8 100644 --- a/spec/support/issuable_slash_commands_shared_examples.rb +++ b/spec/support/issuable_slash_commands_shared_examples.rb @@ -74,7 +74,7 @@ shared_examples 'issuable record that supports slash commands in its description 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.' + expect(page).to have_content 'Your commands have been executed!' issuable.reload @@ -98,7 +98,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/close' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' expect(issuable.reload).to be_closed end @@ -118,7 +118,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/close' - expect(page).not_to have_content 'Your commands are being executed.' + expect(page).not_to have_content 'Your commands have been executed!' expect(issuable).to be_open end @@ -139,7 +139,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/reopen' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' expect(issuable.reload).to be_open end @@ -159,7 +159,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/reopen' - expect(page).not_to have_content 'Your commands are being executed.' + expect(page).not_to have_content 'Your commands have been executed!' expect(issuable).to be_closed end @@ -175,7 +175,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/title' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' expect(issuable.reload.title).to eq 'Awesome new title' end @@ -195,7 +195,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/title' - expect(page).not_to have_content 'Your commands are being executed.' + expect(page).not_to have_content 'Your commands have been executed!' expect(issuable.reload.title).not_to eq 'Awesome new title' end @@ -210,7 +210,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/todo' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' todos = TodosFinder.new(master).execute todo = todos.first @@ -244,7 +244,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/done' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' expect(todo.reload).to be_done end @@ -260,7 +260,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/subscribe' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' expect(issuable.subscribed?(master)).to be_truthy end @@ -280,7 +280,7 @@ shared_examples 'issuable record that supports slash commands in its description end expect(page).not_to have_content '/unsubscribe' - expect(page).to have_content 'Your commands are being executed.' + expect(page).to have_content 'Your commands have been executed!' expect(issuable.subscribed?(master)).to be_falsy end From d9715266e86e6a48616c24147e46c1219f4e0255 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 20:23:33 -0500 Subject: [PATCH 18/25] Allow setting labels, milestones and assignee based on name --- app/services/slash_commands/interpret_service.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index a2b92d70f9f..50e2a96eefb 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -74,6 +74,8 @@ module SlashCommands end command :assign, :reassign do |assignee_param| user = extract_references(assignee_param, :user).first + user ||= User.find_by(username: assignee_param) + user ||= User.find_by(name: assignee_param) @updates[:assignee_id] = user.id if user end @@ -95,6 +97,7 @@ module SlashCommands end command :milestone do |milestone_param| milestone = extract_references(milestone_param, :milestone).first + milestone ||= project.milestones.find_by(title: milestone_param.strip) @updates[:milestone_id] = milestone.id if milestone end @@ -204,12 +207,15 @@ module SlashCommands command :cc, noop: true def find_label_ids(labels_param) - extract_references(labels_param, :label).map(&:id) + label_ids_by_reference = extract_references(labels_param, :label).map(&:id) + labels_ids_by_name = @project.labels.where(name: labels_param.split).select(:id) + + label_ids_by_reference | labels_ids_by_name end - def extract_references(cmd_arg, type) + def extract_references(arg, type) ext = Gitlab::ReferenceExtractor.new(project, current_user) - ext.analyze(cmd_arg, author: current_user) + ext.analyze(arg, author: current_user) ext.references(type) end From b2b1b4a4226267dbc442d62e19949909d9e58235 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 12 Aug 2016 20:23:49 -0500 Subject: [PATCH 19/25] Prefer `/reopen` over `/open`, remove `/reassign` --- .../slash_commands/interpret_service.rb | 42 +++++++++---------- doc/workflow/slash_commands.md | 6 +-- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 50e2a96eefb..f7b9547bd2b 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -39,8 +39,8 @@ module SlashCommands end condition do noteable.persisted? && - noteable.open? && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + noteable.open? && + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end command :close do @updates[:state_event] = 'close' @@ -51,9 +51,9 @@ module SlashCommands end condition do noteable.closed? && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end - command :open, :reopen do + command :reopen, :open do @updates[:state_event] = 'reopen' end @@ -61,7 +61,7 @@ module SlashCommands params '' condition do noteable.persisted? && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end command :title do |title_param| @updates[:title] = title_param @@ -72,7 +72,7 @@ module SlashCommands condition do current_user.can?(:"admin_#{noteable.to_ability_name}", project) end - command :assign, :reassign do |assignee_param| + command :assign do |assignee_param| user = extract_references(assignee_param, :user).first user ||= User.find_by(username: assignee_param) user ||= User.find_by(name: assignee_param) @@ -83,7 +83,7 @@ module SlashCommands desc 'Remove assignee' condition do noteable.assignee_id? && - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :unassign, :remove_assignee do @updates[:assignee_id] = nil @@ -93,7 +93,7 @@ module SlashCommands params '%"milestone"' condition do current_user.can?(:"admin_#{noteable.to_ability_name}", project) && - project.milestones.active.any? + project.milestones.active.any? end command :milestone do |milestone_param| milestone = extract_references(milestone_param, :milestone).first @@ -105,7 +105,7 @@ module SlashCommands desc 'Remove milestone' condition do noteable.milestone_id? && - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :clear_milestone, :remove_milestone do @updates[:milestone_id] = nil @@ -115,7 +115,7 @@ module SlashCommands params '~label1 ~"label 2"' condition do current_user.can?(:"admin_#{noteable.to_ability_name}", project) && - project.labels.any? + project.labels.any? end command :label, :labels do |labels_param| label_ids = find_label_ids(labels_param) @@ -127,7 +127,7 @@ module SlashCommands params '~label1 ~"label 2"' condition do noteable.labels.any? && - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :unlabel, :remove_label, :remove_labels do |labels_param| label_ids = find_label_ids(labels_param) @@ -138,7 +138,7 @@ module SlashCommands desc 'Remove all labels' condition do noteable.labels.any? && - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + current_user.can?(:"admin_#{noteable.to_ability_name}", project) end command :clear_labels, :clear_label do @updates[:label_ids] = [] @@ -147,7 +147,7 @@ module SlashCommands desc 'Add a todo' condition do noteable.persisted? && - !TodoService.new.todo_exist?(noteable, current_user) + !TodoService.new.todo_exist?(noteable, current_user) end command :todo do @updates[:todo_event] = 'add' @@ -155,7 +155,7 @@ module SlashCommands desc 'Mark todo as done' condition do - TodoService.new.todo_exist?(noteable, current_user) + TodoService.new.todo_exist?(noteable, current_user) end command :done do @updates[:todo_event] = 'done' @@ -164,7 +164,7 @@ module SlashCommands desc 'Subscribe' condition do noteable.persisted? && - !noteable.subscribed?(current_user) + !noteable.subscribed?(current_user) end command :subscribe do @updates[:subscription_event] = 'subscribe' @@ -173,17 +173,17 @@ module SlashCommands desc 'Unsubscribe' condition do noteable.persisted? && - noteable.subscribed?(current_user) + noteable.subscribed?(current_user) end command :unsubscribe do @updates[:subscription_event] = 'unsubscribe' end desc 'Set due date' - params '' + params '' condition do noteable.respond_to?(:due_date) && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end command :due, :due_date do |due_date_param| due_date = Chronic.parse(due_date_param).try(:to_date) @@ -194,8 +194,8 @@ module SlashCommands desc 'Remove due date' condition do noteable.respond_to?(:due_date) && - noteable.due_date? && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + noteable.due_date? && + current_user.can?(:"update_#{noteable.to_ability_name}", noteable) end command :clear_due_date do @updates[:due_date] = nil @@ -204,7 +204,7 @@ module SlashCommands # This is a dummy command, so that it appears in the autocomplete commands desc 'CC' params '@user' - command :cc, noop: true + command :cc def find_label_ids(labels_param) label_ids_by_reference = extract_references(labels_param, :label).map(&:id) diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md index 2bdc18ad248..e787d421fa8 100644 --- a/doc/workflow/slash_commands.md +++ b/doc/workflow/slash_commands.md @@ -12,9 +12,9 @@ do. | Command | Aliases | Action | |:---------------------------|:--------------------|:-------------| | `/close` | None | Close the issue or merge request | -| `/open` | `/reopen` | Reopen the issue or merge request | +| `/reopen` | `/open` | Reopen the issue or merge request | | `/title ` | None | Change title | -| `/assign @username` | `/reassign` | Assign | +| `/assign @username` | None | Assign | | `/unassign` | `/remove_assignee` | Remove assignee | | `/milestone %milestone` | None | Set milestone | | `/clear_milestone` | `/remove_milestone` | Remove milestone | @@ -25,5 +25,5 @@ do. | `/done` | None | Mark todo as done | | `/subscribe` | None | Subscribe | | `/unsubscribe` | None | Unsubscribe | -| `/due ` | `/due_date` | Set due date | +| `/due ` | `/due_date` | Set due date | | `/clear_due_date` | None | Remove due date | From 029b7d2e9266246feff2f165a10b16be1d7fe88e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Sat, 13 Aug 2016 11:58:51 -0500 Subject: [PATCH 20/25] Fixed specs and fixes based on failing specs --- app/controllers/projects/issues_controller.rb | 8 +- app/services/notes/create_service.rb | 23 +- app/services/notes/slash_commands_service.rb | 19 +- .../slash_commands/interpret_service.rb | 79 +++---- .../slash_commands/command_definition.rb | 11 +- lib/gitlab/slash_commands/dsl.rb | 5 +- lib/gitlab/slash_commands/extractor.rb | 12 +- .../slash_commands/command_definition_spec.rb | 143 ++++++++++++ spec/lib/gitlab/slash_commands/dsl_spec.rb | 190 ++++------------ .../gitlab/slash_commands/extractor_spec.rb | 55 +++-- spec/services/notes/create_service_spec.rb | 2 +- .../notes/slash_commands_service_spec.rb | 33 ++- .../slash_commands/interpret_service_spec.rb | 203 +++--------------- 13 files changed, 360 insertions(+), 423 deletions(-) create mode 100644 spec/lib/gitlab/slash_commands/command_definition_spec.rb diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index c8eda6b27f4..d0cc4b55467 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -176,12 +176,7 @@ class Projects::IssuesController < Projects::ApplicationController protected def issue - @noteable = @issue ||= - begin - @project.issues.find_by!(iid: params[:id]) - rescue ActiveRecord::RecordNotFound - redirect_old - end + @noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old end alias_method :subscribable_resource, :issue alias_method :issuable, :issue @@ -225,7 +220,6 @@ class Projects::IssuesController < Projects::ApplicationController if issue redirect_to issue_path(issue) - return else raise ActiveRecord::RecordNotFound.new end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index e9f37e04993..a36008c3ef5 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -15,20 +15,29 @@ module Notes # **before** we save the note because if the note consists of commands # only, there is no need be create a note! slash_commands_service = SlashCommandsService.new(project, current_user) - content, command_params = slash_commands_service.extract_commands(note) - note.note = content + if slash_commands_service.supported?(note) + content, command_params = slash_commands_service.extract_commands(note) - if note.save + only_commands = content.empty? + + note.note = content + end + + if !only_commands && note.save # Finish the harder work in the background NewNoteWorker.perform_in(2.seconds, note.id, params) todo_service.new_note(note, current_user) end - # We must add the error after we call #save because errors are reset - # when #save is called - if slash_commands_service.execute(command_params, note) && note.note.blank? - note.errors.add(:commands_only, 'Your commands have been executed!') + if command_params && command_params.any? + slash_commands_service.execute(command_params, note) + + # We must add the error after we call #save because errors are reset + # when #save is called + if only_commands + note.errors.add(:commands_only, 'Your commands have been executed!') + end end note diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb index f2c43775b72..4a9a8a64653 100644 --- a/app/services/notes/slash_commands_service.rb +++ b/app/services/notes/slash_commands_service.rb @@ -5,10 +5,13 @@ module Notes 'MergeRequest' => MergeRequests::UpdateService } + def supported?(note) + noteable_update_service(note) && + can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable) + end + def extract_commands(note) - @noteable_update_service = UPDATE_SERVICES[note.noteable_type] - return [] unless @noteable_update_service - return [] unless can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable) + return [note.note, {}] unless supported?(note) SlashCommands::InterpretService.new(project, current_user). execute(note.note, note.noteable) @@ -16,9 +19,15 @@ module Notes def execute(command_params, note) return if command_params.empty? + return unless supported?(note) - @noteable_update_service.new(project, current_user, command_params). - execute(note.noteable) + noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable) + end + + private + + def noteable_update_service(note) + UPDATE_SERVICES[note.noteable_type] end end end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index f7b9547bd2b..126f97b0f9b 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -2,16 +2,16 @@ module SlashCommands class InterpretService < BaseService include Gitlab::SlashCommands::Dsl - attr_reader :noteable + attr_reader :issuable # Takes a text and interpret the commands that are extracted from it. # Returns a hash of changes to be applied to a record. - def execute(content, noteable) - @noteable = noteable + def execute(content, issuable) + @issuable = issuable @updates = {} opts = { - noteable: noteable, + issuable: issuable, current_user: current_user, project: project } @@ -35,23 +35,24 @@ module SlashCommands end desc do - "Close this #{noteable.to_ability_name.humanize(capitalize: false)}" + "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" end condition do - noteable.persisted? && - noteable.open? && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + issuable.persisted? && + issuable.open? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) end command :close do @updates[:state_event] = 'close' end desc do - "Reopen this #{noteable.to_ability_name.humanize(capitalize: false)}" + "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" end condition do - noteable.closed? && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + issuable.persisted? && + issuable.closed? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) end command :reopen, :open do @updates[:state_event] = 'reopen' @@ -60,8 +61,8 @@ module SlashCommands desc 'Change title' params '' condition do - noteable.persisted? && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + issuable.persisted? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) end command :title do |title_param| @updates[:title] = title_param @@ -70,7 +71,7 @@ module SlashCommands desc 'Assign' params '@user' condition do - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :assign do |assignee_param| user = extract_references(assignee_param, :user).first @@ -82,8 +83,9 @@ module SlashCommands desc 'Remove assignee' condition do - noteable.assignee_id? && - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + issuable.persisted? && + issuable.assignee_id? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :unassign, :remove_assignee do @updates[:assignee_id] = nil @@ -92,7 +94,7 @@ module SlashCommands desc 'Set milestone' params '%"milestone"' condition do - current_user.can?(:"admin_#{noteable.to_ability_name}", project) && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && project.milestones.active.any? end command :milestone do |milestone_param| @@ -104,8 +106,9 @@ module SlashCommands desc 'Remove milestone' condition do - noteable.milestone_id? && - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + issuable.persisted? && + issuable.milestone_id? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :clear_milestone, :remove_milestone do @updates[:milestone_id] = nil @@ -114,7 +117,7 @@ module SlashCommands desc 'Add label(s)' params '~label1 ~"label 2"' condition do - current_user.can?(:"admin_#{noteable.to_ability_name}", project) && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && project.labels.any? end command :label, :labels do |labels_param| @@ -126,8 +129,9 @@ module SlashCommands desc 'Remove label(s)' params '~label1 ~"label 2"' condition do - noteable.labels.any? && - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + issuable.persisted? && + issuable.labels.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :unlabel, :remove_label, :remove_labels do |labels_param| label_ids = find_label_ids(labels_param) @@ -137,8 +141,9 @@ module SlashCommands desc 'Remove all labels' condition do - noteable.labels.any? && - current_user.can?(:"admin_#{noteable.to_ability_name}", project) + issuable.persisted? && + issuable.labels.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :clear_labels, :clear_label do @updates[:label_ids] = [] @@ -146,8 +151,8 @@ module SlashCommands desc 'Add a todo' condition do - noteable.persisted? && - !TodoService.new.todo_exist?(noteable, current_user) + issuable.persisted? && + !TodoService.new.todo_exist?(issuable, current_user) end command :todo do @updates[:todo_event] = 'add' @@ -155,7 +160,8 @@ module SlashCommands desc 'Mark todo as done' condition do - TodoService.new.todo_exist?(noteable, current_user) + issuable.persisted? && + TodoService.new.todo_exist?(issuable, current_user) end command :done do @updates[:todo_event] = 'done' @@ -163,8 +169,8 @@ module SlashCommands desc 'Subscribe' condition do - noteable.persisted? && - !noteable.subscribed?(current_user) + issuable.persisted? && + !issuable.subscribed?(current_user) end command :subscribe do @updates[:subscription_event] = 'subscribe' @@ -172,8 +178,8 @@ module SlashCommands desc 'Unsubscribe' condition do - noteable.persisted? && - noteable.subscribed?(current_user) + issuable.persisted? && + issuable.subscribed?(current_user) end command :unsubscribe do @updates[:subscription_event] = 'unsubscribe' @@ -182,8 +188,8 @@ module SlashCommands desc 'Set due date' params '' condition do - noteable.respond_to?(:due_date) && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + issuable.respond_to?(:due_date) && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) end command :due, :due_date do |due_date_param| due_date = Chronic.parse(due_date_param).try(:to_date) @@ -193,9 +199,10 @@ module SlashCommands desc 'Remove due date' condition do - noteable.respond_to?(:due_date) && - noteable.due_date? && - current_user.can?(:"update_#{noteable.to_ability_name}", noteable) + issuable.persisted? && + issuable.respond_to?(:due_date) && + issuable.due_date? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) end command :clear_due_date do @updates[:due_date] = nil diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb index 5dec6c91869..187c1c9489f 100644 --- a/lib/gitlab/slash_commands/command_definition.rb +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -3,8 +3,8 @@ module Gitlab class CommandDefinition attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block - def valid? - name.present? + def initialize(name) + @name = name end def all_names @@ -22,13 +22,6 @@ module Gitlab context.instance_exec(&condition_block) end - def to_description(opts) - return description unless description.respond_to?(:call) - - context = OpenStruct.new(opts) - context.instance_exec(&description) rescue '' - end - def execute(context, opts, *args) return if noop? || !available?(opts) diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index 58ba7027f84..7b1a094a7e6 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -73,16 +73,13 @@ module Gitlab def command(*command_names, &block) name, *aliases = command_names - definition = CommandDefinition.new - definition.name = name + definition = CommandDefinition.new(name) definition.aliases = aliases definition.description = @description || '' definition.params = @params || [] definition.condition_block = @condition_block definition.action_block = block - return unless definition.valid? - self.command_definitions << definition definition.all_names.each do |name| diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb index a6838cb5e7c..02c4c8c492e 100644 --- a/lib/gitlab/slash_commands/extractor.rb +++ b/lib/gitlab/slash_commands/extractor.rb @@ -29,8 +29,8 @@ module Gitlab # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']] # msg #=> "hello\nworld" # ``` - def extract_commands(content, opts) - return [] unless content + def extract_commands(content, opts = {}) + return [content, []] unless content content = content.dup @@ -107,7 +107,13 @@ module Gitlab # Command not in a blockquote, blockcode, or HTML tag: # /close - ^\/(?#{Regexp.union(names)})(?:$|\ (?[^\/\n]*)$) + ^\/ + (?#{Regexp.union(names)}) + (?: + [ ] + (?[^\/\n]*) + )? + (?:\n|$) ) }mx end diff --git a/spec/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb new file mode 100644 index 00000000000..2a75fab24b0 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/command_definition_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::CommandDefinition do + subject { described_class.new(:command) } + + describe "#all_names" do + context "when the command has aliases" do + before do + subject.aliases = [:alias1, :alias2] + end + + it "returns an array with the name and aliases" do + expect(subject.all_names).to eq([:command, :alias1, :alias2]) + end + end + + context "when the command doesn't have aliases" do + it "returns an array with the name" do + expect(subject.all_names).to eq([:command]) + end + end + end + + describe "#noop?" do + context "when the command has an action block" do + before do + subject.action_block = -> { } + end + + it "returns false" do + expect(subject.noop?).to be false + end + end + + context "when the command doesn't have an action block" do + it "returns true" do + expect(subject.noop?).to be true + end + end + end + + describe "#available?" do + let(:opts) { { go: false } } + + context "when the command has a condition block" do + before do + subject.condition_block = -> { go } + end + + context "when the condition block returns true" do + before do + opts[:go] = true + end + + it "returns true" do + expect(subject.available?(opts)).to be true + end + end + + context "when the condition block returns false" do + it "returns false" do + expect(subject.available?(opts)).to be false + end + end + end + + context "when the command doesn't have a condition block" do + it "returns true" do + expect(subject.available?(opts)).to be true + end + end + end + + describe "#execute" do + let(:context) { OpenStruct.new(run: false) } + + context "when the command is a noop" do + it "doesn't execute the command" do + expect(context).not_to receive(:instance_exec) + + subject.execute(context, {}) + + expect(context.run).to be false + end + end + + context "when the command is not a noop" do + before do + subject.action_block = -> { self.run = true } + end + + context "when the command is not available" do + before do + subject.condition_block = -> { false } + end + + it "doesn't execute the command" do + subject.execute(context, {}) + + expect(context.run).to be false + end + end + + context "when the command is available" do + context "when the command has an exact number of arguments" do + before do + subject.action_block = ->(arg) { self.run = arg } + end + + context "when the command is provided a wrong number of arguments" do + it "doesn't execute the command" do + subject.execute(context, {}, true, true) + + expect(context.run).to be false + end + end + + context "when the command is provided the right number of arguments" do + it "executes the command" do + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + end + + context "when the command has a variable number of arguments" do + before do + subject.action_block = ->(*args) { self.run = args.first } + end + + context "when the command is provided any number of arguments" do + it "executes the command" do + subject.execute(context, {}, true, true) + + expect(context.run).to be true + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 500ff3ca1fe..87be3455baf 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -10,9 +10,9 @@ describe Gitlab::SlashCommands::Dsl do "Hello World!" end - desc 'A command returning a value' + desc { "A command with #{something}" } command :returning do - return 42 + 42 end params 'The first argument' @@ -28,7 +28,7 @@ describe Gitlab::SlashCommands::Dsl do [arg1, arg2] end - command :cc, noop: true + command :cc condition do project == 'foo' @@ -49,182 +49,74 @@ describe Gitlab::SlashCommands::Dsl do { name: :no_args, aliases: [:none], description: 'A command with no args', params: [], - condition_block: nil, action_block: a_kind_of(Proc), - opts: {} + condition_block: nil, action_block: a_kind_of(Proc) }, { name: :returning, aliases: [], description: 'A command returning a value', params: [], - condition_block: nil, action_block: a_kind_of(Proc), - opts: {} + condition_block: nil, action_block: a_kind_of(Proc) }, { name: :one_arg, aliases: [:once, :first], description: '', params: ['The first argument'], - condition_block: nil, action_block: a_kind_of(Proc), - opts: {} + condition_block: nil, action_block: a_kind_of(Proc) }, { name: :two_args, aliases: [], description: '', params: ['The first argument', 'The second argument'], - condition_block: nil, action_block: a_kind_of(Proc), - opts: {} + condition_block: nil, action_block: a_kind_of(Proc) }, { name: :cc, aliases: [], description: '', params: [], - condition_block: nil, action_block: nil, - opts: { noop: true } + condition_block: nil, action_block: nil }, { name: :wildcard, aliases: [], description: '', params: [], - condition_block: nil, action_block: a_kind_of(Proc), - opts: {} + condition_block: nil, action_block: a_kind_of(Proc) } ] end it 'returns an array with commands definitions' do - expect(DummyClass.command_definitions).to match_array base_expected - end + no_args_def, returning_def, one_arg_def, two_args_def, cc_def, cond_action_def, wildcard_def = DummyClass.command_definitions - context 'with options passed' do - context 'when condition is met' do - let(:expected) do - base_expected << { - name: :cond_action, aliases: [], - description: '', params: [], - condition_block: a_kind_of(Proc), action_block: a_kind_of(Proc), - opts: {} - } - end + expect(no_args_def.name).to eq(:no_args) + expect(no_args_def.aliases).to eq([:none]) + expect(no_args_def.description).to eq('A command with no args') + expect(no_args_def.params).to eq([]) + expect(no_args_def.condition_block).to be_nil + expect(no_args_def.action_block).to be_a_kind_of(Proc) - it 'returns an array with commands definitions' do - expect(DummyClass.command_definitions(project: 'foo')).to match_array expected - end - end + expect(returning_def.name).to eq(:returning) + expect(returning_def.aliases).to eq([]) + expect(returning_def.description).to be_a_kind_of(Proc) + expect(returning_def.to_h(something: "a block description")[:description]).to eq('A command with a block description') + expect(returning_def.params).to eq([]) + expect(returning_def.condition_block).to be_nil + expect(returning_def.action_block).to be_a_kind_of(Proc) - context 'when condition is not met' do - it 'returns an array with commands definitions without actions that did not met conditions' do - expect(DummyClass.command_definitions(project: 'bar')).to match_array base_expected - end - end + expect(one_arg_def.name).to eq(:one_arg) + expect(one_arg_def.aliases).to eq([:once, :first]) + expect(one_arg_def.description).to eq('') + expect(one_arg_def.params).to eq(['The first argument']) + expect(one_arg_def.condition_block).to be_nil + expect(one_arg_def.action_block).to be_a_kind_of(Proc) - context 'when description can be generated dynamically' do - it 'returns an array with commands definitions with dynamic descriptions' do - base_expected[3][:description] = 'A dynamic description for MERGE REQUEST' + expect(cc_def.name).to eq(:cc) + expect(cc_def.aliases).to eq([]) + expect(cc_def.description).to eq('') + expect(cc_def.params).to eq([]) + expect(cc_def.condition_block).to be_nil + expect(cc_def.action_block).to be_nil - expect(DummyClass.command_definitions(noteable: 'merge request')).to match_array base_expected - end - end - end - end - - describe '.command_names' do - let(:base_expected) do - [ - :no_args, :none, :returning, :one_arg, - :once, :first, :two_args, :wildcard - ] - end - - it 'returns an array with commands definitions' do - expect(DummyClass.command_names).to eq base_expected - end - - context 'with options passed' do - context 'when condition is met' do - let(:expected) { base_expected << :cond_action } - - it 'returns an array with commands definitions' do - expect(DummyClass.command_names(project: 'foo')).to match_array expected - end - end - - context 'when condition is not met' do - it 'returns an array with commands definitions without action that did not met conditions' do - expect(DummyClass.command_names(project: 'bar')).to match_array base_expected - end - end - end - end - - let(:dummy) { DummyClass.new(nil) } - - describe '#execute_command' do - describe 'command with no args' do - context 'called with no args' do - it 'succeeds' do - expect(dummy.execute_command(: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.execute_command(:returning) }.to raise_error(LocalJumpError) - end - end - end - - describe 'command with one arg' do - context 'called with one arg' do - it 'succeeds' do - expect(dummy.execute_command(: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.execute_command(:two_args, 42, 'foo')).to eq [42, 'foo'] - end - end - end - - describe 'noop command' do - it 'returns nil' do - expect(dummy.execute_command(:cc)).to be_nil - end - end - - describe 'command with condition' do - context 'when condition is not met' do - it 'returns nil' do - expect(dummy.execute_command(:cond_action)).to be_nil - end - end - - context 'when condition is met' do - let(:dummy) { DummyClass.new('foo') } - - it 'succeeds' do - expect(dummy.execute_command(:cond_action, 42)).to eq 42 - end - end - end - - describe 'command with wildcard' do - context 'called with no args' do - it 'succeeds' do - expect(dummy.execute_command(:wildcard)).to eq [] - end - end - - context 'called with one arg' do - it 'succeeds' do - expect(dummy.execute_command(:wildcard, 42)).to eq [42] - end - end - - context 'called with two args' do - it 'succeeds' do - expect(dummy.execute_command(:wildcard, 42, 'foo')).to eq [42, 'foo'] - end - end + expect(wildcard_def.name).to eq(:wildcard) + expect(wildcard_def.aliases).to eq([]) + expect(wildcard_def.description).to eq('') + expect(wildcard_def.params).to eq([]) + expect(wildcard_def.condition_block).to be_nil + expect(wildcard_def.action_block).to be_a_kind_of(Proc) end end end diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb index 8a6801205fa..09f909dcdd2 100644 --- a/spec/lib/gitlab/slash_commands/extractor_spec.rb +++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb @@ -1,32 +1,43 @@ require 'spec_helper' describe Gitlab::SlashCommands::Extractor do - let(:extractor) { described_class.new([:open, :assign, :labels, :power]) } + let(:definitions) do + Class.new do + include Gitlab::SlashCommands::Dsl + + command(:reopen, :open) { } + command(:assign) { } + command(:labels) { } + command(:power) { } + end.command_definitions + end + + let(:extractor) { described_class.new(definitions) } shared_examples 'command with no argument' do it 'extracts command' do - commands = extractor.extract_commands(original_msg) + msg, commands = extractor.extract_commands(original_msg) expect(commands).to eq [['open']] - expect(original_msg).to eq final_msg + expect(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) + msg, commands = extractor.extract_commands(original_msg) expect(commands).to eq [['assign', '@joe']] - expect(original_msg).to eq final_msg + expect(msg).to eq final_msg end end shared_examples 'command with multiple arguments' do it 'extracts command' do - commands = extractor.extract_commands(original_msg) + msg, commands = extractor.extract_commands(original_msg) expect(commands).to eq [['labels', '~foo ~"bar baz" label']] - expect(original_msg).to eq final_msg + expect(msg).to eq final_msg end end @@ -49,7 +60,7 @@ describe Gitlab::SlashCommands::Extractor do context 'in the middle of a line' do it 'does not extract command' do msg = "hello\nworld /open" - commands = extractor.extract_commands(msg) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq "hello\nworld /open" @@ -59,7 +70,7 @@ describe Gitlab::SlashCommands::Extractor do 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" } + let(:final_msg) { "hello" } end end end @@ -82,7 +93,7 @@ describe Gitlab::SlashCommands::Extractor do context 'in the middle of a line' do it 'does not extract command' do msg = "hello\nworld /assign @joe" - commands = extractor.extract_commands(msg) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq "hello\nworld /assign @joe" @@ -92,14 +103,14 @@ describe Gitlab::SlashCommands::Extractor do 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" } + let(:final_msg) { "hello" } 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) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq "hello\n/assign@joe\nworld" @@ -125,7 +136,7 @@ describe Gitlab::SlashCommands::Extractor do 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) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label) @@ -135,14 +146,14 @@ describe Gitlab::SlashCommands::Extractor do 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" } + let(:final_msg) { "hello" } 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) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld) @@ -152,7 +163,7 @@ describe Gitlab::SlashCommands::Extractor do 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) + msg, commands = extractor.extract_commands(msg) expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']] expect(msg).to eq "hello\nworld" @@ -160,15 +171,15 @@ describe Gitlab::SlashCommands::Extractor do 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) + msg, 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" + expect(msg).to eq "hello\nworld" end it 'does not alter original content if no command is found' do msg = 'Fixes #123' - commands = extractor.extract_commands(msg) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq 'Fixes #123' @@ -177,7 +188,7 @@ describe Gitlab::SlashCommands::Extractor do it 'does not extract commands inside a blockcode' do msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld" expected = msg.delete("\r") - commands = extractor.extract_commands(msg) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq expected @@ -186,7 +197,7 @@ describe Gitlab::SlashCommands::Extractor do it 'does not extract commands inside a blockquote' do msg = "Hello\r\n>>>\r\nThis is some text\r\n/close\r\n/assign @user\r\n>>>\r\n\r\nWorld" expected = msg.delete("\r") - commands = extractor.extract_commands(msg) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq expected @@ -195,7 +206,7 @@ describe Gitlab::SlashCommands::Extractor do it 'does not extract commands inside a HTML tag' do msg = "Hello\r\n
    \r\nThis is some text\r\n/close\r\n/assign @user\r\n
    \r\n\r\nWorld" expected = msg.delete("\r") - commands = extractor.extract_commands(msg) + msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty expect(msg).to eq expected diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 92dbccf0729..93885c84dc3 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -56,7 +56,7 @@ describe Notes::CreateService, services: true do it "creates regular note if emoji name is invalid" do opts = { - note: ':smile: moretext: ', + note: ':smile: moretext:', noteable_type: 'Issue', noteable_id: issue.id } diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index 5632ec09834..9a262fcf32f 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -12,7 +12,6 @@ describe Notes::SlashCommandsService, services: true do before do note.note = note_text - described_class.new(project, master).execute(note) end describe 'note with only command' do @@ -20,7 +19,10 @@ describe Notes::SlashCommandsService, services: true do let(:note_text) { %(/close\n/assign @#{assignee.username}") } it 'saves the note and does not alter the note text' do - expect(note.note).to eq note_text + content, command_params = service.extract_commands(note) + + expect(content).to eq note_text + expect(command_params).to be_empty end end end @@ -30,7 +32,10 @@ describe Notes::SlashCommandsService, services: true do let(:note_text) { %(HELLO\n/close\n/assign @#{assignee.username}\nWORLD) } it 'saves the note and does not alter the note text' do - expect(note.note).to eq note_text + content, command_params = service.extract_commands(note) + + expect(content).to eq note_text + expect(command_params).to be_empty end end end @@ -53,9 +58,10 @@ describe Notes::SlashCommandsService, services: true do end it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do - described_class.new(project, master).execute(note) + content, command_params = service.extract_commands(note) + service.execute(command_params, note) - expect(note.note).to eq '' + expect(content).to eq '' expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) expect(note.noteable.assignee).to eq(assignee) @@ -71,9 +77,10 @@ describe Notes::SlashCommandsService, services: true do let(:note_text) { '/open' } it 'opens the noteable, and leave no note' do - described_class.new(project, master).execute(note) + content, command_params = service.extract_commands(note) + service.execute(command_params, note) - expect(note.note).to eq '' + expect(content).to eq '' expect(note.noteable).to be_open end end @@ -86,9 +93,10 @@ describe Notes::SlashCommandsService, services: true do end it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do - described_class.new(project, master).execute(note) + content, command_params = service.extract_commands(note) + service.execute(command_params, note) - expect(note.note).to eq "HELLO\nWORLD" + expect(content).to eq "HELLO\nWORLD" expect(note.noteable).to be_closed expect(note.noteable.labels).to match_array(labels) expect(note.noteable.assignee).to eq(assignee) @@ -104,9 +112,10 @@ describe Notes::SlashCommandsService, services: true do let(:note_text) { "HELLO\n/open\nWORLD" } it 'opens the noteable' do - described_class.new(project, master).execute(note) + content, command_params = service.extract_commands(note) + service.execute(command_params, note) - expect(note.note).to eq "HELLO\nWORLD" + expect(content).to eq "HELLO\nWORLD" expect(note.noteable).to be_open end end @@ -114,6 +123,8 @@ describe Notes::SlashCommandsService, services: true do end describe '#execute' do + let(:service) { described_class.new(project, master) } + it_behaves_like 'note on noteable that supports slash commands' do let(:note) { build(:note_on_issue, project: project) } end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 0cf77e53435..c20aa90ddde 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -12,141 +12,6 @@ describe SlashCommands::InterpretService, services: true do project.team << [user, :developer] end - describe '#command_names' do - subject do - described_class.command_names( - project: project, - noteable: issue, - current_user: user - ) - end - - it 'returns the basic known commands' do - is_expected.to match_array([ - :close, - :title, - :assign, :reassign, - :todo, - :subscribe, - :due_date, :due - ]) - end - - context 'when noteable is open' do - it 'includes the :close command' do - is_expected.to include(*[:close]) - end - end - - context 'when noteable is closed' do - before do - issue.close! - end - - it 'includes the :open, :reopen commands' do - is_expected.to include(*[:open, :reopen]) - end - end - - context 'when noteable has an assignee' do - before do - issue.update(assignee_id: user.id) - end - - it 'includes the :unassign, :remove_assignee commands' do - is_expected.to include(*[:unassign, :remove_assignee]) - end - end - - context 'when noteable has a milestone' do - before do - issue.update(milestone: milestone) - end - - it 'includes the :clear_milestone, :remove_milestone commands' do - is_expected.to include(*[:milestone, :clear_milestone, :remove_milestone]) - end - end - - context 'when project has a milestone' do - before do - milestone - end - - it 'includes the :milestone command' do - is_expected.to include(*[:milestone]) - end - end - - context 'when noteable has a label' do - before do - issue.update(label_ids: [bug.id]) - end - - it 'includes the :unlabel, :remove_labels, :remove_label, :clear_labels, :clear_label commands' do - is_expected.to include(*[:unlabel, :remove_labels, :remove_label, :clear_labels, :clear_label]) - end - end - - context 'when project has a label' do - before do - inprogress - end - - it 'includes the :labels, :label commands' do - is_expected.to include(*[:labels, :label]) - end - end - - context 'when user has no todo' do - it 'includes the :todo command' do - is_expected.to include(*[:todo]) - end - end - - context 'when user has a todo' do - before do - TodoService.new.mark_todo(issue, user) - end - - it 'includes the :done command' do - is_expected.to include(*[:done]) - end - end - - context 'when user is not subscribed' do - it 'includes the :subscribe command' do - is_expected.to include(*[:subscribe]) - end - end - - context 'when user is subscribed' do - before do - issue.subscribe(user) - end - - it 'includes the :unsubscribe command' do - is_expected.to include(*[:unsubscribe]) - end - end - - context 'when noteable has a no due date' do - it 'includes the :due_date, :due commands' do - is_expected.to include(*[:due_date, :due]) - end - end - - context 'when noteable has a due date' do - before do - issue.update(due_date: Date.today) - end - - it 'includes the :clear_due_date command' do - is_expected.to include(*[:due_date, :due, :clear_due_date]) - end - end - end - describe '#execute' do let(:service) { described_class.new(project, user) } let(:merge_request) { create(:merge_request, source_project: project) } @@ -154,60 +19,60 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'open command' do it 'returns state_event: "open" if content contains /open' do issuable.close! - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(state_event: 'reopen') + expect(updates).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, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(state_event: 'close') + expect(updates).to eq(state_event: 'close') end end shared_examples 'title command' do it 'populates title: "A brand new title" if content contains /title A brand new title' do - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(title: 'A brand new title') + expect(updates).to eq(title: 'A brand new title') end end shared_examples 'assign command' do it 'fetches assignee and populates assignee_id if content contains /assign' do - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(assignee_id: user.id) + expect(updates).to eq(assignee_id: user.id) end end shared_examples 'unassign command' do it 'populates assignee_id: nil if content contains /unassign' do issuable.update(assignee_id: user.id) - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(assignee_id: nil) + expect(updates).to eq(assignee_id: nil) end end shared_examples 'milestone command' do it 'fetches milestone and populates milestone_id if content contains /milestone' do milestone # populate the milestone - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(milestone_id: milestone.id) + expect(updates).to eq(milestone_id: milestone.id) end end shared_examples 'clear_milestone command' do it 'populates milestone_id: nil if content contains /clear_milestone' do issuable.update(milestone_id: milestone.id) - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(milestone_id: nil) + expect(updates).to eq(milestone_id: nil) end end @@ -215,86 +80,86 @@ describe SlashCommands::InterpretService, services: true do it 'fetches label ids and populates add_label_ids if content contains /label' do bug # populate the label inprogress # populate the label - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(add_label_ids: [bug.id, inprogress.id]) + expect(updates).to eq(add_label_ids: [bug.id, inprogress.id]) end end shared_examples 'unlabel command' do it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do issuable.update(label_ids: [inprogress.id]) # populate the label - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(remove_label_ids: [inprogress.id]) + expect(updates).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 issuable.update(label_ids: [inprogress.id]) # populate the label - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(label_ids: []) + expect(updates).to eq(label_ids: []) end end shared_examples 'todo command' do it 'populates todo_event: "add" if content contains /todo' do - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(todo_event: 'add') + expect(updates).to eq(todo_event: 'add') end end shared_examples 'done command' do it 'populates todo_event: "done" if content contains /done' do TodoService.new.mark_todo(issuable, user) - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(todo_event: 'done') + expect(updates).to eq(todo_event: 'done') end end shared_examples 'subscribe command' do it 'populates subscription_event: "subscribe" if content contains /subscribe' do - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(subscription_event: 'subscribe') + expect(updates).to eq(subscription_event: 'subscribe') end end shared_examples 'unsubscribe command' do it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do issuable.subscribe(user) - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(subscription_event: 'unsubscribe') + expect(updates).to eq(subscription_event: 'unsubscribe') end end shared_examples 'due_date command' do it 'populates due_date: Date.new(2016, 8, 28) if content contains /due_date 2016-08-28' do - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28)) + expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28)) end end shared_examples 'clear_due_date command' do it 'populates due_date: nil if content contains /clear_due_date' do issuable.update(due_date: Date.today) - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to eq(due_date: nil) + expect(updates).to eq(due_date: nil) end end shared_examples 'empty command' do it 'populates {} if content contains an unsupported command' do - changes = service.execute(content, issuable) + _, updates = service.execute(content, issuable) - expect(changes).to be_empty + expect(updates).to be_empty end end From 8b8a4626c601a13683599fd1a127e2c502af38a3 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 16 Aug 2016 19:59:55 -0500 Subject: [PATCH 21/25] Fix specs and implement fixes based on failing specs --- app/controllers/projects_controller.rb | 6 +- app/services/issuable_base_service.rb | 41 ++++++++---- app/services/issues/create_service.rb | 7 +- app/services/merge_requests/create_service.rb | 2 +- app/services/projects/autocomplete_service.rb | 2 +- .../slash_commands/interpret_service.rb | 4 +- doc/workflow/slash_commands.md | 3 +- .../slash_commands/command_definition.rb | 8 ++- lib/gitlab/slash_commands/dsl.rb | 18 +++--- spec/lib/gitlab/slash_commands/dsl_spec.rb | 64 +++++-------------- ...issuable_slash_commands_shared_examples.rb | 4 +- 11 files changed, 75 insertions(+), 84 deletions(-) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 93338dba51e..fc52cd2f367 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -137,13 +137,13 @@ class ProjectsController < Projects::ApplicationController noteable = case params[:type] when 'Issue' - IssuesFinder.new(current_user, project_id: project.id, state: 'all'). + IssuesFinder.new(current_user, project_id: @project.id, state: 'all'). execute.find_by(iid: params[:type_id]) when 'MergeRequest' - MergeRequestsFinder.new(current_user, project_id: project.id, state: 'all'). + MergeRequestsFinder.new(current_user, project_id: @project.id, state: 'all'). execute.find_by(iid: params[:type_id]) when 'Commit' - project.commit(params[:type_id]) + @project.commit(params[:type_id]) else nil end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index aa08eef081c..b60afd1560e 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -80,13 +80,16 @@ class IssuableBaseService < BaseService params[key] = project.labels.where(id: params[key]).pluck(:id) end - def process_label_ids(attributes, base_label_ids: [], merge_all: false) + def process_label_ids(attributes, existing_label_ids: []) label_ids = attributes.delete(:label_ids) add_label_ids = attributes.delete(:add_label_ids) remove_label_ids = attributes.delete(:remove_label_ids) - new_label_ids = base_label_ids - new_label_ids = label_ids if label_ids && (merge_all || (add_label_ids.blank? && remove_label_ids.blank?)) + new_label_ids = existing_label_ids + + override_existing = new_label_ids.empty? || (add_label_ids.blank? && remove_label_ids.blank?) + new_label_ids = label_ids if label_ids && override_existing + new_label_ids |= add_label_ids if add_label_ids new_label_ids -= remove_label_ids if remove_label_ids @@ -103,14 +106,8 @@ class IssuableBaseService < BaseService params.merge!(command_params) end - def create_issuable(issuable, attributes) + def create_issuable(issuable, attributes, label_ids:) issuable.with_transaction_returning_status do - attributes.delete(:state_event) - params[:author] ||= current_user - label_ids = process_label_ids(attributes, merge_all: true) - - issuable.assign_attributes(attributes) - if issuable.save issuable.update_attributes(label_ids: label_ids) end @@ -121,8 +118,16 @@ class IssuableBaseService < BaseService merge_slash_commands_into_params!(issuable) filter_params - if params.present? && create_issuable(issuable, params) - handle_creation(issuable) + params.delete(:state_event) + params[:author] ||= current_user + label_ids = process_label_ids(params) + + issuable.assign_attributes(params) + + before_create(issuable) + + if params.present? && create_issuable(issuable, params, label_ids: label_ids) + after_create(issuable) issuable.create_cross_references!(current_user) execute_hooks(issuable) end @@ -130,10 +135,16 @@ class IssuableBaseService < BaseService issuable end + def before_create(issuable) + # To be overridden by subclasses + end + + def after_create(issuable) + # To be overridden by subclasses + end + def update_issuable(issuable, attributes) issuable.with_transaction_returning_status do - attributes[:label_ids] = process_label_ids(attributes, base_label_ids: issuable.label_ids) - issuable.update(attributes.merge(updated_by: current_user)) end end @@ -145,6 +156,8 @@ class IssuableBaseService < BaseService filter_params old_labels = issuable.labels.to_a + params[:label_ids] = process_label_ids(params, existing_label_ids: issuable.label_ids) + if params.present? && update_issuable(issuable, params) issuable.reset_events_cache handle_common_system_notes(issuable, old_labels: old_labels) diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 8314c9971ff..ea1690f3e38 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -5,12 +5,15 @@ module Issues @api = params.delete(:api) @issue = project.issues.new - @issue.spam = spam_service.check(@api) create(@issue) end - def handle_creation(issuable) + def before_create(issuable) + issuable.spam = spam_service.check(@api) + end + + def after_create(issuable) event_service.open_issue(issuable, current_user) notification_service.new_issue(issuable, current_user) todo_service.new_issue(issuable, current_user) diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 0b592cd5620..73247e62421 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -16,7 +16,7 @@ module MergeRequests create(merge_request) end - def handle_creation(issuable) + def after_create(issuable) event_service.open_mr(issuable, current_user) notification_service.new_merge_request(issuable, current_user) todo_service.new_merge_request(issuable, current_user) diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index cb85ee6694d..f578f8dbea2 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -29,7 +29,7 @@ module Projects opts = { project: project, - noteable: noteable, + issuable: noteable, current_user: current_user } SlashCommands::InterpretService.command_definitions.map do |definition| diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 126f97b0f9b..cb0dca94036 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -4,8 +4,8 @@ module SlashCommands attr_reader :issuable - # Takes a text and interpret the commands that are extracted from it. - # Returns a hash of changes to be applied to a record. + # Takes a text and interprets the commands that are extracted from it. + # Returns the content without commands, and hash of changes to be applied to a record. def execute(content, issuable) @issuable = issuable @updates = {} diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md index e787d421fa8..75fec7bcdb9 100644 --- a/doc/workflow/slash_commands.md +++ b/doc/workflow/slash_commands.md @@ -4,7 +4,8 @@ Slash commands are textual shortcuts for common actions on issues or merge requests that are usually done by clicking buttons or dropdowns in GitLab's UI. You can enter these commands while creating a new issue or merge request, and in comments. Each command should be on a separate line in order to be properly -detected and executed. +detected and executed. The commands are removed from the issue, merge request or +comment body before it is saved and will not be visible as such to anyone else. Here is a list of all of the available commands and descriptions about what they do. diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb index 187c1c9489f..641c92e77da 100644 --- a/lib/gitlab/slash_commands/command_definition.rb +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -3,8 +3,14 @@ module Gitlab class CommandDefinition attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block - def initialize(name) + def initialize(name, attributes = {}) @name = name + + @aliases = attributes[:aliases] || [] + @description = attributes[:description] || '' + @params = attributes[:params] || [] + @condition_block = attributes[:condition_block] + @action_block = attributes[:action_block] end def all_names diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb index 7b1a094a7e6..50b0937d267 100644 --- a/lib/gitlab/slash_commands/dsl.rb +++ b/lib/gitlab/slash_commands/dsl.rb @@ -17,7 +17,7 @@ module Gitlab # Allows to give a description to the next slash command. # This description is shown in the autocomplete menu. # It accepts a block that will be evaluated with the context given to - # `.command_definitions` or `.command_names`. + # `CommandDefintion#to_h`. # # Example: # @@ -47,7 +47,7 @@ module Gitlab # Allows to define conditions that must be met in order for the command # to be returned by `.command_names` & `.command_definitions`. # It accepts a block that will be evaluated with the context given to - # `.command_definitions`, `.command_names`, and the actual command method. + # `CommandDefintion#to_h`. # # Example: # @@ -73,12 +73,14 @@ module Gitlab def command(*command_names, &block) name, *aliases = command_names - definition = CommandDefinition.new(name) - definition.aliases = aliases - definition.description = @description || '' - definition.params = @params || [] - definition.condition_block = @condition_block - definition.action_block = block + definition = CommandDefinition.new( + name, + aliases: aliases, + description: @description, + params: @params, + condition_block: @condition_block, + action_block: block + ) self.command_definitions << definition diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 87be3455baf..9ec00c25787 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -10,11 +10,6 @@ describe Gitlab::SlashCommands::Dsl do "Hello World!" end - desc { "A command with #{something}" } - command :returning do - 42 - end - params 'The first argument' command :one_arg, :once, :first do |arg1| arg1 @@ -44,43 +39,8 @@ describe Gitlab::SlashCommands::Dsl do end describe '.command_definitions' do - let(:base_expected) do - [ - { - name: :no_args, aliases: [:none], - description: 'A command with no args', params: [], - condition_block: nil, action_block: a_kind_of(Proc) - }, - { - name: :returning, aliases: [], - description: 'A command returning a value', params: [], - condition_block: nil, action_block: a_kind_of(Proc) - }, - { - name: :one_arg, aliases: [:once, :first], - description: '', params: ['The first argument'], - condition_block: nil, action_block: a_kind_of(Proc) - }, - { - name: :two_args, aliases: [], - description: '', params: ['The first argument', 'The second argument'], - condition_block: nil, action_block: a_kind_of(Proc) - }, - { - name: :cc, aliases: [], - description: '', params: [], - condition_block: nil, action_block: nil - }, - { - name: :wildcard, aliases: [], - description: '', params: [], - condition_block: nil, action_block: a_kind_of(Proc) - } - ] - end - it 'returns an array with commands definitions' do - no_args_def, returning_def, one_arg_def, two_args_def, cc_def, cond_action_def, wildcard_def = DummyClass.command_definitions + no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def, wildcard_def = DummyClass.command_definitions expect(no_args_def.name).to eq(:no_args) expect(no_args_def.aliases).to eq([:none]) @@ -89,14 +49,6 @@ describe Gitlab::SlashCommands::Dsl do expect(no_args_def.condition_block).to be_nil expect(no_args_def.action_block).to be_a_kind_of(Proc) - expect(returning_def.name).to eq(:returning) - expect(returning_def.aliases).to eq([]) - expect(returning_def.description).to be_a_kind_of(Proc) - expect(returning_def.to_h(something: "a block description")[:description]).to eq('A command with a block description') - expect(returning_def.params).to eq([]) - expect(returning_def.condition_block).to be_nil - expect(returning_def.action_block).to be_a_kind_of(Proc) - expect(one_arg_def.name).to eq(:one_arg) expect(one_arg_def.aliases).to eq([:once, :first]) expect(one_arg_def.description).to eq('') @@ -104,6 +56,13 @@ describe Gitlab::SlashCommands::Dsl do expect(one_arg_def.condition_block).to be_nil expect(one_arg_def.action_block).to be_a_kind_of(Proc) + expect(two_args_def.name).to eq(:two_args) + expect(two_args_def.aliases).to eq([]) + expect(two_args_def.to_h(noteable: "issue")[:description]).to eq('A dynamic description for ISSUE') + expect(two_args_def.params).to eq(['The first argument', 'The second argument']) + expect(two_args_def.condition_block).to be_nil + expect(two_args_def.action_block).to be_a_kind_of(Proc) + expect(cc_def.name).to eq(:cc) expect(cc_def.aliases).to eq([]) expect(cc_def.description).to eq('') @@ -111,6 +70,13 @@ describe Gitlab::SlashCommands::Dsl do expect(cc_def.condition_block).to be_nil expect(cc_def.action_block).to be_nil + expect(cond_action_def.name).to eq(:cond_action) + expect(cond_action_def.aliases).to eq([]) + expect(cond_action_def.description).to eq('') + expect(cond_action_def.params).to eq([]) + expect(cond_action_def.condition_block).to be_a_kind_of(Proc) + expect(cond_action_def.action_block).to be_a_kind_of(Proc) + expect(wildcard_def.name).to eq(:wildcard) expect(wildcard_def.aliases).to eq([]) expect(wildcard_def.description).to eq('') diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb index ace14c19fd8..d2a49ea5c5e 100644 --- a/spec/support/issuable_slash_commands_shared_examples.rb +++ b/spec/support/issuable_slash_commands_shared_examples.rb @@ -28,7 +28,7 @@ shared_examples 'issuable record that supports slash commands in its description issuable = project.public_send(issuable_type.to_s.pluralize).first - expect(issuable.description).to eq "bug description\n" + expect(issuable.description).to eq "bug description" expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone expect(page).to have_content 'bug 345' @@ -57,7 +57,7 @@ shared_examples 'issuable record that supports slash commands in its description issuable.reload note = issuable.notes.user.first - expect(note.note).to eq "Awesome!\n" + expect(note.note).to eq "Awesome!" expect(issuable.assignee).to eq assignee expect(issuable.labels).to eq [label_bug] expect(issuable.milestone).to eq milestone From 3e7eeefc939f2ce5234e36684c00b8d1c7e1c7dc Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 17 Aug 2016 18:58:44 -0500 Subject: [PATCH 22/25] Address feedback --- app/services/issuable_base_service.rb | 4 +--- .../slash_commands/interpret_service.rb | 5 ++--- lib/gitlab/slash_commands/command_definition.rb | 16 ++++++++-------- lib/gitlab/slash_commands/extractor.rb | 17 ++++++++--------- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index b60afd1560e..57286f255f4 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -193,9 +193,7 @@ class IssuableBaseService < BaseService todo_service.mark_todo(issuable, current_user) when 'done' todo = TodosFinder.new(current_user).execute.find_by(target: issuable) - if todo - todo_service.mark_todos_as_done([todo], current_user) - end + todo_service.mark_todos_as_done([todo], current_user) if todo end end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index cb0dca94036..dc4a892b8b1 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -18,11 +18,11 @@ module SlashCommands content, commands = extractor.extract_commands(content, opts) - commands.each do |name, *args| + commands.each do |name, args| definition = self.class.command_definitions_by_name[name.to_sym] next unless definition - definition.execute(self, opts, *args) + definition.execute(self, opts, args) end [content, @updates] @@ -76,7 +76,6 @@ module SlashCommands command :assign do |assignee_param| user = extract_references(assignee_param, :user).first user ||= User.find_by(username: assignee_param) - user ||= User.find_by(name: assignee_param) @updates[:assignee_id] = user.id if user end diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb index 641c92e77da..2ff8f4eddf0 100644 --- a/lib/gitlab/slash_commands/command_definition.rb +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -6,11 +6,11 @@ module Gitlab def initialize(name, attributes = {}) @name = name - @aliases = attributes[:aliases] || [] - @description = attributes[:description] || '' - @params = attributes[:params] || [] - @condition_block = attributes[:condition_block] - @action_block = attributes[:action_block] + @aliases = attributes[:aliases] || [] + @description = attributes[:description] || '' + @params = attributes[:params] || [] + @condition_block = attributes[:condition_block] + @action_block = attributes[:action_block] end def all_names @@ -28,13 +28,13 @@ module Gitlab context.instance_exec(&condition_block) end - def execute(context, opts, *args) + def execute(context, opts, args) return if noop? || !available?(opts) block_arity = action_block.arity - return unless block_arity == -1 || block_arity == args.size + return unless (args.present? && block_arity == 1) || (args.blank? && block_arity <= 0) - context.instance_exec(*args, &action_block) + context.instance_exec(args, &action_block) end def to_h(opts) diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb index 02c4c8c492e..c790b825347 100644 --- a/lib/gitlab/slash_commands/extractor.rb +++ b/lib/gitlab/slash_commands/extractor.rb @@ -50,15 +50,6 @@ module Gitlab end private - - def command_names(opts) - command_definitions.flat_map do |command| - next if command.noop? - - command.all_names - end.compact - end - # Builds a regular expression to match known commands. # First match group captures the command name and # second match group captures its arguments. @@ -117,6 +108,14 @@ module Gitlab ) }mx end + + def command_names(opts) + command_definitions.flat_map do |command| + next if command.noop? + + command.all_names + end.compact + end end end end From 9aa3edc61586fd79ce0760b7af0946ddfadaa65a Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 17 Aug 2016 18:58:52 -0500 Subject: [PATCH 23/25] Remove unneeded aliases --- .../slash_commands/interpret_service.rb | 35 +++-- doc/workflow/slash_commands.md | 40 +++--- .../issues/user_uses_slash_commands_spec.rb | 8 +- .../user_uses_slash_commands_spec.rb | 4 +- .../gitlab/slash_commands/extractor_spec.rb | 16 +-- .../notes/slash_commands_service_spec.rb | 8 +- .../slash_commands/interpret_service_spec.rb | 123 ++++++++---------- ..._service_slash_commands_shared_examples.rb | 4 +- 8 files changed, 115 insertions(+), 123 deletions(-) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index dc4a892b8b1..0d7838055fe 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -54,7 +54,7 @@ module SlashCommands issuable.closed? && current_user.can?(:"update_#{issuable.to_ability_name}", issuable) end - command :reopen, :open do + command :reopen do @updates[:state_event] = 'reopen' end @@ -86,7 +86,7 @@ module SlashCommands issuable.assignee_id? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :unassign, :remove_assignee do + command :unassign do @updates[:assignee_id] = nil end @@ -109,7 +109,7 @@ module SlashCommands issuable.milestone_id? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :clear_milestone, :remove_milestone do + command :remove_milestone do @updates[:milestone_id] = nil end @@ -119,33 +119,40 @@ module SlashCommands current_user.can?(:"admin_#{issuable.to_ability_name}", project) && project.labels.any? end - command :label, :labels do |labels_param| + command :label do |labels_param| label_ids = find_label_ids(labels_param) @updates[:add_label_ids] = label_ids unless label_ids.empty? end - desc 'Remove label(s)' + desc 'Remove all or specific label(s)' params '~label1 ~"label 2"' condition do issuable.persisted? && issuable.labels.any? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :unlabel, :remove_label, :remove_labels do |labels_param| - label_ids = find_label_ids(labels_param) + command :unlabel do |labels_param = nil| + if labels_param.present? + label_ids = find_label_ids(labels_param) - @updates[:remove_label_ids] = label_ids unless label_ids.empty? + @updates[:remove_label_ids] = label_ids unless label_ids.empty? + else + @updates[:label_ids] = [] + end end - desc 'Remove all labels' + desc 'Replace all label(s)' + params '~label1 ~"label 2"' condition do issuable.persisted? && issuable.labels.any? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :clear_labels, :clear_label do - @updates[:label_ids] = [] + command :relabel do |labels_param| + label_ids = find_label_ids(labels_param) + + @updates[:label_ids] = label_ids unless label_ids.empty? end desc 'Add a todo' @@ -185,12 +192,12 @@ module SlashCommands end desc 'Set due date' - params '' + params '' condition do issuable.respond_to?(:due_date) && current_user.can?(:"update_#{issuable.to_ability_name}", issuable) end - command :due, :due_date do |due_date_param| + command :due do |due_date_param| due_date = Chronic.parse(due_date_param).try(:to_date) @updates[:due_date] = due_date if due_date @@ -203,7 +210,7 @@ module SlashCommands issuable.due_date? && current_user.can?(:"update_#{issuable.to_ability_name}", issuable) end - command :clear_due_date do + command :remove_due_date do @updates[:due_date] = nil end diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md index 75fec7bcdb9..91d69d4e77e 100644 --- a/doc/workflow/slash_commands.md +++ b/doc/workflow/slash_commands.md @@ -5,26 +5,26 @@ requests that are usually done by clicking buttons or dropdowns in GitLab's UI. You can enter these commands while creating a new issue or merge request, and in comments. Each command should be on a separate line in order to be properly detected and executed. The commands are removed from the issue, merge request or -comment body before it is saved and will not be visible as such to anyone else. +comment body before it is saved and will not be visible to anyone else. -Here is a list of all of the available commands and descriptions about what they +Below is a list of all of the available commands and descriptions about what they do. -| Command | Aliases | Action | -|:---------------------------|:--------------------|:-------------| -| `/close` | None | Close the issue or merge request | -| `/reopen` | `/open` | Reopen the issue or merge request | -| `/title ` | None | Change title | -| `/assign @username` | None | Assign | -| `/unassign` | `/remove_assignee` | Remove assignee | -| `/milestone %milestone` | None | Set milestone | -| `/clear_milestone` | `/remove_milestone` | Remove milestone | -| `/label ~foo ~"bar baz"` | `/labels` | Add label(s) | -| `/unlabel ~foo ~"bar baz"` | `/remove_label`, `remove_labels` | Remove label(s) | -| `/clear_labels` | `/clear_label` | Clear all labels | -| `/todo` | None | Add a todo | -| `/done` | None | Mark todo as done | -| `/subscribe` | None | Subscribe | -| `/unsubscribe` | None | Unsubscribe | -| `/due ` | `/due_date` | Set due date | -| `/clear_due_date` | None | Remove due date | +| Command | Action | +|:---------------------------|:-------------| +| `/close` | Close the issue or merge request | +| `/reopen` | Reopen the issue or merge request | +| `/title ` | Change title | +| `/assign @username` | Assign | +| `/unassign` | Remove assignee | +| `/milestone %milestone` | Set milestone | +| `/remove_milestone` | Remove milestone | +| `/label ~foo ~"bar baz"` | Add label(s) | +| `/unlabel ~foo ~"bar baz"` | Remove all or specific label(s) | +| `/relabel ~foo ~"bar baz"` | Replace all label(s) | +| `/todo` | Add a todo | +| `/done` | Mark todo as done | +| `/subscribe` | Subscribe | +| `/unsubscribe` | Unsubscribe | +| `/due ` | Set due date | +| `/remove_due_date` | Remove due date | diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index 510f4254b54..2883e392694 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -22,11 +22,11 @@ feature 'Issues > User uses slash commands', feature: true, js: true do 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" + fill_in 'note[note]', with: "/due 2016-08-28" click_button 'Comment' end - expect(page).not_to have_content '/due_date 2016-08-28' + expect(page).not_to have_content '/due 2016-08-28' expect(page).to have_content 'Your commands have been executed!' issue.reload @@ -42,11 +42,11 @@ feature 'Issues > User uses slash commands', feature: true, js: true 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" + fill_in 'note[note]', with: "/remove_due_date" click_button 'Comment' end - expect(page).not_to have_content '/clear_due_date' + expect(page).not_to have_content '/remove_due_date' expect(page).to have_content 'Your commands have been executed!' issue.reload diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb index 08c452c6e59..d9ef0d18074 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -22,11 +22,11 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do 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" + fill_in 'note[note]', with: "/due 2016-08-28" click_button 'Comment' end - expect(page).not_to have_content '/due_date 2016-08-28' + expect(page).not_to have_content '/due 2016-08-28' end end end diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb index 09f909dcdd2..1e4954c4af8 100644 --- a/spec/lib/gitlab/slash_commands/extractor_spec.rb +++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb @@ -18,7 +18,7 @@ describe Gitlab::SlashCommands::Extractor do it 'extracts command' do msg, commands = extractor.extract_commands(original_msg) - expect(commands).to eq [['open']] + expect(commands).to eq [['reopen']] expect(msg).to eq final_msg end end @@ -45,31 +45,31 @@ describe Gitlab::SlashCommands::Extractor 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(:original_msg) { "/reopen\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(:original_msg) { "hello\n/reopen\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" + msg = "hello\nworld /reopen" msg, commands = extractor.extract_commands(msg) expect(commands).to be_empty - expect(msg).to eq "hello\nworld /open" + expect(msg).to eq "hello\nworld /reopen" end end context 'at the end of content' do it_behaves_like 'command with no argument' do - let(:original_msg) { "hello\n/open" } + let(:original_msg) { "hello\n/reopen" } let(:final_msg) { "hello" } end end @@ -170,10 +170,10 @@ describe Gitlab::SlashCommands::Extractor do end it 'extracts multiple commands' do - msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/open) + msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen) msg, commands = extractor.extract_commands(msg) - expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['open']] + expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['reopen']] expect(msg).to eq "hello\nworld" end diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb index 9a262fcf32f..4f231aab161 100644 --- a/spec/services/notes/slash_commands_service_spec.rb +++ b/spec/services/notes/slash_commands_service_spec.rb @@ -69,12 +69,12 @@ describe Notes::SlashCommandsService, services: true do end end - describe '/open' do + describe '/reopen' do before do note.noteable.close! expect(note.noteable).to be_closed end - let(:note_text) { '/open' } + let(:note_text) { '/reopen' } it 'opens the noteable, and leave no note' do content, command_params = service.extract_commands(note) @@ -104,12 +104,12 @@ describe Notes::SlashCommandsService, services: true do end end - describe '/open' do + describe '/reopen' do before do note.noteable.close expect(note.noteable).to be_closed end - let(:note_text) { "HELLO\n/open\nWORLD" } + let(:note_text) { "HELLO\n/reopen\nWORLD" } it 'opens the noteable' do content, command_params = service.extract_commands(note) diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index c20aa90ddde..a616275e883 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -16,8 +16,8 @@ describe SlashCommands::InterpretService, services: true do let(:service) { described_class.new(project, user) } let(:merge_request) { create(:merge_request, source_project: project) } - shared_examples 'open command' do - it 'returns state_event: "open" if content contains /open' do + shared_examples 'reopen command' do + it 'returns state_event: "reopen" if content contains /reopen' do issuable.close! _, updates = service.execute(content, issuable) @@ -26,7 +26,7 @@ describe SlashCommands::InterpretService, services: true do end shared_examples 'close command' do - it 'returns state_event: "close" if content contains /open' do + it 'returns state_event: "close" if content contains /close' do _, updates = service.execute(content, issuable) expect(updates).to eq(state_event: 'close') @@ -67,8 +67,8 @@ describe SlashCommands::InterpretService, services: true do end end - shared_examples 'clear_milestone command' do - it 'populates milestone_id: nil if content contains /clear_milestone' do + shared_examples 'remove_milestone command' do + it 'populates milestone_id: nil if content contains /remove_milestone' do issuable.update(milestone_id: milestone.id) _, updates = service.execute(content, issuable) @@ -95,8 +95,8 @@ describe SlashCommands::InterpretService, services: true do end end - shared_examples 'clear_labels command' do - it 'populates label_ids: [] if content contains /clear_labels' do + shared_examples 'unlabel command with no argument' do + it 'populates label_ids: [] if content contains /unlabel with no arguments' do issuable.update(label_ids: [inprogress.id]) # populate the label _, updates = service.execute(content, issuable) @@ -104,6 +104,16 @@ describe SlashCommands::InterpretService, services: true do end end + shared_examples 'relabel command' do + it 'populates label_ids: [] if content contains /relabel' do + issuable.update(label_ids: [bug.id]) # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(label_ids: [inprogress.id]) + end + end + shared_examples 'todo command' do it 'populates todo_event: "add" if content contains /todo' do _, updates = service.execute(content, issuable) @@ -138,16 +148,16 @@ describe SlashCommands::InterpretService, services: true do end end - shared_examples 'due_date command' do - it 'populates due_date: Date.new(2016, 8, 28) if content contains /due_date 2016-08-28' do + shared_examples 'due command' do + it 'populates due_date: Date.new(2016, 8, 28) if content contains /due 2016-08-28' do _, updates = service.execute(content, issuable) expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28)) end end - shared_examples 'clear_due_date command' do - it 'populates due_date: nil if content contains /clear_due_date' do + shared_examples 'remove_due_date command' do + it 'populates due_date: nil if content contains /remove_due_date' do issuable.update(due_date: Date.today) _, updates = service.execute(content, issuable) @@ -163,21 +173,16 @@ describe SlashCommands::InterpretService, services: true do end end - it_behaves_like 'open command' do - let(:content) { '/open' } - let(:issuable) { issue } - end - - it_behaves_like 'open command' do - let(:content) { '/open' } - let(:issuable) { merge_request } - end - - it_behaves_like 'open command' do + it_behaves_like 'reopen command' do let(:content) { '/reopen' } let(:issuable) { issue } end + it_behaves_like 'reopen command' do + let(:content) { '/reopen' } + let(:issuable) { merge_request } + end + it_behaves_like 'close command' do let(:content) { '/close' } let(:issuable) { issue } @@ -233,11 +238,6 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { merge_request } end - it_behaves_like 'unassign command' do - let(:content) { '/remove_assignee' } - let(:issuable) { issue } - end - it_behaves_like 'milestone command' do let(:content) { "/milestone %#{milestone.title}" } let(:issuable) { issue } @@ -248,21 +248,16 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { merge_request } end - it_behaves_like 'clear_milestone command' do - let(:content) { '/clear_milestone' } - let(:issuable) { issue } - end - - it_behaves_like 'clear_milestone command' do - let(:content) { '/clear_milestone' } - let(:issuable) { merge_request } - end - - it_behaves_like 'clear_milestone command' do + it_behaves_like 'remove_milestone command' do let(:content) { '/remove_milestone' } let(:issuable) { issue } end + it_behaves_like 'remove_milestone command' do + let(:content) { '/remove_milestone' } + let(:issuable) { merge_request } + end + it_behaves_like 'label command' do let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } let(:issuable) { issue } @@ -273,11 +268,6 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { merge_request } end - it_behaves_like 'label command' do - let(:content) { %(/labels ~"#{inprogress.title}" ~#{bug.title} ~unknown) } - let(:issuable) { issue } - end - it_behaves_like 'unlabel command' do let(:content) { %(/unlabel ~"#{inprogress.title}") } let(:issuable) { issue } @@ -288,31 +278,26 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { merge_request } end - it_behaves_like 'unlabel command' do - let(:content) { %(/remove_labels ~"#{inprogress.title}") } + it_behaves_like 'unlabel command with no argument' do + let(:content) { %(/unlabel) } let(:issuable) { issue } end - it_behaves_like 'unlabel command' do - let(:content) { %(/remove_label ~"#{inprogress.title}") } - let(:issuable) { issue } - end - - it_behaves_like 'clear_labels command' do - let(:content) { '/clear_labels' } - let(:issuable) { issue } - end - - it_behaves_like 'clear_labels command' do - let(:content) { '/clear_labels' } + it_behaves_like 'unlabel command with no argument' do + let(:content) { %(/unlabel) } let(:issuable) { merge_request } end - it_behaves_like 'clear_labels command' do - let(:content) { '/clear_label' } + it_behaves_like 'relabel command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } let(:issuable) { issue } end + it_behaves_like 'relabel command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { merge_request } + end + it_behaves_like 'todo command' do let(:content) { '/todo' } let(:issuable) { issue } @@ -353,46 +338,46 @@ describe SlashCommands::InterpretService, services: true do let(:issuable) { merge_request } end - it_behaves_like 'due_date command' do - let(:content) { '/due_date 2016-08-28' } + it_behaves_like 'due command' do + let(:content) { '/due 2016-08-28' } let(:issuable) { issue } end - it_behaves_like 'due_date command' do + it_behaves_like 'due command' do let(:content) { '/due tomorrow' } let(:issuable) { issue } let(:expected_date) { Date.tomorrow } end - it_behaves_like 'due_date command' do + it_behaves_like 'due command' do let(:content) { '/due 5 days from now' } let(:issuable) { issue } let(:expected_date) { 5.days.from_now.to_date } end - it_behaves_like 'due_date command' do + it_behaves_like 'due command' do let(:content) { '/due in 2 days' } let(:issuable) { issue } let(:expected_date) { 2.days.from_now.to_date } end it_behaves_like 'empty command' do - let(:content) { '/due_date foo bar' } + let(:content) { '/due foo bar' } let(:issuable) { issue } end it_behaves_like 'empty command' do - let(:content) { '/due_date 2016-08-28' } + let(:content) { '/due 2016-08-28' } let(:issuable) { merge_request } end - it_behaves_like 'clear_due_date command' do - let(:content) { '/clear_due_date' } + it_behaves_like 'remove_due_date command' do + let(:content) { '/remove_due_date' } let(:issuable) { issue } end it_behaves_like 'empty command' do - let(:content) { '/clear_due_date' } + let(:content) { '/remove_due_date' } let(:issuable) { merge_request } 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 index bd0201c866f..5f9645ed44f 100644 --- a/spec/support/issuable_create_service_slash_commands_shared_examples.rb +++ b/spec/support/issuable_create_service_slash_commands_shared_examples.rb @@ -14,7 +14,7 @@ shared_examples 'new issuable record that supports slash commands' do 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}" + description: "/label ~#{labels.first.name} ~#{labels.second.name}\n/unlabel ~#{labels.third.name}" } end @@ -28,7 +28,7 @@ shared_examples 'new issuable record that supports slash commands' do let(:example_params) do { label_ids: [labels.second.id], - description: "/label ~#{labels.first.name}\n/remove_label ~#{labels.third.name}" + description: "/label ~#{labels.first.name}\n/unlabel ~#{labels.third.name}" } end From 6f2f2a6baae128cd4f8111d91bdcfb7b76e39b97 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 18 Aug 2016 14:19:13 -0500 Subject: [PATCH 24/25] Fix behavior of label_ids and add/remove_label_ids --- app/services/issuable_base_service.rb | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 57286f255f4..3100bd58818 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -80,18 +80,19 @@ class IssuableBaseService < BaseService params[key] = project.labels.where(id: params[key]).pluck(:id) end - def process_label_ids(attributes, existing_label_ids: []) + def process_label_ids(attributes, existing_label_ids: nil) label_ids = attributes.delete(:label_ids) add_label_ids = attributes.delete(:add_label_ids) remove_label_ids = attributes.delete(:remove_label_ids) - new_label_ids = existing_label_ids + new_label_ids = existing_label_ids || label_ids || [] - override_existing = new_label_ids.empty? || (add_label_ids.blank? && remove_label_ids.blank?) - new_label_ids = label_ids if label_ids && override_existing - - new_label_ids |= add_label_ids if add_label_ids - new_label_ids -= remove_label_ids if remove_label_ids + if add_label_ids.blank? && remove_label_ids.blank? + new_label_ids = label_ids if label_ids + else + new_label_ids |= add_label_ids if add_label_ids + new_label_ids -= remove_label_ids if remove_label_ids + end new_label_ids end From 2703330a19e813351e9c33241a59d6b7f54741df Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 18 Aug 2016 14:21:52 -0500 Subject: [PATCH 25/25] Fix behavior around commands with optional arguments --- .../slash_commands/interpret_service.rb | 4 +- .../slash_commands/command_definition.rb | 11 +-- lib/gitlab/slash_commands/extractor.rb | 13 ++-- .../slash_commands/command_definition_spec.rb | 72 +++++++++++++------ spec/lib/gitlab/slash_commands/dsl_spec.rb | 13 +--- 5 files changed, 67 insertions(+), 46 deletions(-) diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 0d7838055fe..9ac1124abc1 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -18,11 +18,11 @@ module SlashCommands content, commands = extractor.extract_commands(content, opts) - commands.each do |name, args| + commands.each do |name, arg| definition = self.class.command_definitions_by_name[name.to_sym] next unless definition - definition.execute(self, opts, args) + definition.execute(self, opts, arg) end [content, @updates] diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb index 2ff8f4eddf0..60d35be2599 100644 --- a/lib/gitlab/slash_commands/command_definition.rb +++ b/lib/gitlab/slash_commands/command_definition.rb @@ -28,13 +28,14 @@ module Gitlab context.instance_exec(&condition_block) end - def execute(context, opts, args) + def execute(context, opts, arg) return if noop? || !available?(opts) - block_arity = action_block.arity - return unless (args.present? && block_arity == 1) || (args.blank? && block_arity <= 0) - - context.instance_exec(args, &action_block) + if arg.present? + context.instance_exec(arg, &action_block) + elsif action_block.arity == 0 + context.instance_exec(&action_block) + end end def to_h(opts) diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb index c790b825347..a672e5e4855 100644 --- a/lib/gitlab/slash_commands/extractor.rb +++ b/lib/gitlab/slash_commands/extractor.rb @@ -39,7 +39,7 @@ module Gitlab content.delete!("\r") content.gsub!(commands_regex(opts)) do if $~[:cmd] - commands << [$~[:cmd], $~[:args]].reject(&:blank?) + commands << [$~[:cmd], $~[:arg]].reject(&:blank?) '' else $~[0] @@ -50,13 +50,14 @@ module Gitlab end private + # Builds a regular expression to match known commands. # First match group captures the command name and # second match group captures its arguments. # # It looks something like: # - # /^\/(?close|reopen|...)(?:( |$))(?[^\/\n]*)(?:\n|$)/ + # /^\/(?close|reopen|...)(?:( |$))(?[^\/\n]*)(?:\n|$)/ def commands_regex(opts) names = command_names(opts).map(&:to_s) @@ -64,7 +65,7 @@ module Gitlab (? # Code blocks: # ``` - # Anything, including `/cmd args` which are ignored by this filter + # Anything, including `/cmd arg` which are ignored by this filter # ``` ^``` @@ -75,7 +76,7 @@ module Gitlab (? # HTML block: # - # Anything, including `/cmd args` which are ignored by this filter + # Anything, including `/cmd arg` which are ignored by this filter # ^<[^>]+?>\n @@ -86,7 +87,7 @@ module Gitlab (? # Quote block: # >>> - # Anything, including `/cmd args` which are ignored by this filter + # Anything, including `/cmd arg` which are ignored by this filter # >>> ^>>> @@ -102,7 +103,7 @@ module Gitlab (?#{Regexp.union(names)}) (?: [ ] - (?[^\/\n]*) + (?[^\/\n]*) )? (?:\n|$) ) diff --git a/spec/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb index 2a75fab24b0..c9c2f314e57 100644 --- a/spec/lib/gitlab/slash_commands/command_definition_spec.rb +++ b/spec/lib/gitlab/slash_commands/command_definition_spec.rb @@ -24,7 +24,7 @@ describe Gitlab::SlashCommands::CommandDefinition do describe "#noop?" do context "when the command has an action block" do before do - subject.action_block = -> { } + subject.action_block = proc { } end it "returns false" do @@ -44,7 +44,7 @@ describe Gitlab::SlashCommands::CommandDefinition do context "when the command has a condition block" do before do - subject.condition_block = -> { go } + subject.condition_block = proc { go } end context "when the condition block returns true" do @@ -78,7 +78,7 @@ describe Gitlab::SlashCommands::CommandDefinition do it "doesn't execute the command" do expect(context).not_to receive(:instance_exec) - subject.execute(context, {}) + subject.execute(context, {}, nil) expect(context.run).to be false end @@ -86,52 +86,82 @@ describe Gitlab::SlashCommands::CommandDefinition do context "when the command is not a noop" do before do - subject.action_block = -> { self.run = true } + subject.action_block = proc { self.run = true } end context "when the command is not available" do before do - subject.condition_block = -> { false } + subject.condition_block = proc { false } end it "doesn't execute the command" do - subject.execute(context, {}) + subject.execute(context, {}, nil) expect(context.run).to be false end end context "when the command is available" do - context "when the command has an exact number of arguments" do + context "when the commnd has no arguments" do before do - subject.action_block = ->(arg) { self.run = arg } + subject.action_block = proc { self.run = true } end - context "when the command is provided a wrong number of arguments" do - it "doesn't execute the command" do - subject.execute(context, {}, true, true) - - expect(context.run).to be false - end - end - - context "when the command is provided the right number of arguments" do + context "when the command is provided an argument" do it "executes the command" do subject.execute(context, {}, true) expect(context.run).to be true end end + + context "when the command is not provided an argument" do + it "executes the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be true + end + end end - context "when the command has a variable number of arguments" do + context "when the command has 1 required argument" do before do - subject.action_block = ->(*args) { self.run = args.first } + subject.action_block = ->(arg) { self.run = arg } end - context "when the command is provided any number of arguments" do + context "when the command is provided an argument" do it "executes the command" do - subject.execute(context, {}, true, true) + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + + context "when the command is not provided an argument" do + it "doesn't execute the command" do + subject.execute(context, {}, nil) + + expect(context.run).to be false + end + end + end + + context "when the command has 1 optional argument" do + before do + subject.action_block = proc { |arg = nil| self.run = arg || true } + end + + context "when the command is provided an argument" do + it "executes the command" do + subject.execute(context, {}, true) + + expect(context.run).to be true + end + end + + context "when the command is not provided an argument" do + it "executes the command" do + subject.execute(context, {}, nil) expect(context.run).to be true end diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb index 9ec00c25787..26217a0e3b2 100644 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb @@ -31,16 +31,12 @@ describe Gitlab::SlashCommands::Dsl do command :cond_action do |arg| arg end - - command :wildcard do |*args| - args - end end end describe '.command_definitions' do it 'returns an array with commands definitions' do - no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def, wildcard_def = DummyClass.command_definitions + no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def = DummyClass.command_definitions expect(no_args_def.name).to eq(:no_args) expect(no_args_def.aliases).to eq([:none]) @@ -76,13 +72,6 @@ describe Gitlab::SlashCommands::Dsl do expect(cond_action_def.params).to eq([]) expect(cond_action_def.condition_block).to be_a_kind_of(Proc) expect(cond_action_def.action_block).to be_a_kind_of(Proc) - - expect(wildcard_def.name).to eq(:wildcard) - expect(wildcard_def.aliases).to eq([]) - expect(wildcard_def.description).to eq('') - expect(wildcard_def.params).to eq([]) - expect(wildcard_def.condition_block).to be_nil - expect(wildcard_def.action_block).to be_a_kind_of(Proc) end end end