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: 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 --- app/services/issuable_base_service.rb | 79 +++++++++++--- 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 + app/services/slash_commands/interpret_service.rb | 133 +++++++++++++++++++++++ 6 files changed, 266 insertions(+), 45 deletions(-) create mode 100644 app/services/slash_commands/interpret_service.rb (limited to 'app/services') 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 update_issuable(issuable, attributes) + 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 - add_label_ids = attributes.delete(:add_label_ids) - remove_label_ids = attributes.delete(:remove_label_ids) + 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 - issuable.label_ids |= add_label_ids if add_label_ids - issuable.label_ids -= remove_label_ids if remove_label_ids + def create(issuable) + merge_slash_commands_into_params! + filter_params - issuable.assign_attributes(attributes.merge(updated_by: current_user)) + if params.present? && create_issuable(issuable, params) + handle_creation(issuable) + issuable.create_cross_references!(current_user) + execute_hooks(issuable) + end + + issuable + end - issuable.save + 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 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 -- cgit v1.2.3