Refactor slash command definition

This commit is contained in:
Douwe Maan 2016-08-12 20:17:18 -05:00
parent 5d4993d623
commit 5a07b760df
11 changed files with 191 additions and 165 deletions

View File

@ -134,8 +134,22 @@ class ProjectsController < Projects::ApplicationController
end end
def autocomplete_sources def autocomplete_sources
autocomplete = ::Projects::AutocompleteService.new(@project, current_user, params) noteable =
participants = ::Projects::ParticipantsService.new(@project, current_user, params).execute 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 = { @suggestions = {
emojis: Gitlab::AwardEmoji.urls, emojis: Gitlab::AwardEmoji.urls,
@ -144,7 +158,7 @@ class ProjectsController < Projects::ApplicationController
mergerequests: autocomplete.merge_requests, mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels, labels: autocomplete.labels,
members: participants, members: participants,
commands: autocomplete.commands commands: autocomplete.commands(noteable, params[:type])
} }
respond_to do |format| respond_to do |format|

View File

@ -94,10 +94,13 @@ class IssuableBaseService < BaseService
end end
def merge_slash_commands_into_params!(issuable) 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) execute(params[:description], issuable)
params.merge!(commands) params[:description] = description
params.merge!(command_params)
end end
def create_issuable(issuable, attributes) def create_issuable(issuable, attributes)

View File

@ -15,7 +15,9 @@ module Notes
# **before** we save the note because if the note consists of commands # **before** we save the note because if the note consists of commands
# only, there is no need be create a note! # only, there is no need be create a note!
slash_commands_service = SlashCommandsService.new(project, current_user) 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 if note.save
# Finish the harder work in the background # 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 # We must add the error after we call #save because errors are reset
# when #save is called # 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.') note.errors.add(:commands_only, 'Your commands are being executed.')
end end

View File

@ -1,6 +1,5 @@
module Notes module Notes
class SlashCommandsService < BaseService class SlashCommandsService < BaseService
UPDATE_SERVICES = { UPDATE_SERVICES = {
'Issue' => Issues::UpdateService, 'Issue' => Issues::UpdateService,
'MergeRequest' => MergeRequests::UpdateService 'MergeRequest' => MergeRequests::UpdateService
@ -15,11 +14,11 @@ module Notes
execute(note.note, note.noteable) execute(note.note, note.noteable)
end end
def execute(commands, note) def execute(command_params, note)
if commands.any? return if command_params.empty?
@noteable_update_service.new(project, current_user, commands).
execute(note.noteable) @noteable_update_service.new(project, current_user, command_params).
end execute(note.noteable)
end end
end end
end end

View File

@ -1,7 +1,7 @@
module Projects module Projects
class AutocompleteService < BaseService class AutocompleteService < BaseService
def issues 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 end
def milestones def milestones
@ -9,42 +9,34 @@ module Projects
end end
def merge_requests 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 end
def labels def labels
@project.labels.select([:title, :color]) @project.labels.select([:title, :color])
end end
def commands def commands(noteable, type)
# We don't return commands when editing an issue or merge request noteable ||=
# This should be improved by not enabling autocomplete at the JS-level case type
# 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]
when 'Issue' when 'Issue'
IssuesFinder.new(current_user, project_id: project.id, state: 'all'). @project.issues.build
execute.find_or_initialize_by(iid: noteable_id)
when 'MergeRequest' when 'MergeRequest'
MergeRequestsFinder.new(current_user, project_id: project.id, state: 'all'). @project.merge_requests.build
execute.find_or_initialize_by(iid: noteable_id)
else
nil
end 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 end
end end

View File

@ -1,45 +1,28 @@
module Projects module Projects
class ParticipantsService < BaseService class ParticipantsService < BaseService
attr_reader :noteable_type, :noteable_id attr_reader :noteable
def execute def execute(noteable)
@noteable_type = params[:type] @noteable = noteable
@noteable_id = params[:type_id]
project_members = sorted(project.team.members) 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 participants.uniq
end end
def target def noteable_owner
@target ||= return [] unless noteable && noteable.author.present?
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?
[{ [{
name: target.author.name, name: noteable.author.name,
username: target.author.username username: noteable.author.username
}] }]
end end
def participants_in_target def participants_in_noteable
return [] unless target return [] unless noteable
users = target.participants(current_user) users = noteable.participants(current_user)
sorted(users) sorted(users)
end end

View File

@ -10,20 +10,28 @@ module SlashCommands
@noteable = noteable @noteable = noteable
@updates = {} @updates = {}
commands = extractor(noteable: noteable).extract_commands!(content) opts = {
commands.each do |command, *args| noteable: noteable,
execute_command(command, *args) 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 end
@updates [content, @updates]
end end
private private
def extractor(opts = {}) def extractor
opts.merge!(current_user: current_user, project: project) Gitlab::SlashCommands::Extractor.new(self.class.command_definitions)
Gitlab::SlashCommands::Extractor.new(self.class.command_names(opts))
end end
desc do desc do

View File

@ -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

View File

@ -4,64 +4,16 @@ module Gitlab
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
cattr_accessor :definitions cattr_accessor :command_definitions, instance_accessor: false do
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)
end 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 cattr_accessor :command_definitions_by_name, instance_accessor: false do
if block_arity == -1 || block_arity == args.size {}
instance_exec(*args, &cmd_def[:action_block])
end end
end end
class_methods do 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. # Allows to give a description to the next slash command.
# This description is shown in the autocomplete menu. # This description is shown in the autocomplete menu.
# It accepts a block that will be evaluated with the context given to # It accepts a block that will be evaluated with the context given to
@ -119,19 +71,23 @@ module Gitlab
# # Awesome code block # # Awesome code block
# end # end
def command(*command_names, &block) def command(*command_names, &block)
opts = command_names.extract_options!
name, *aliases = command_names name, *aliases = command_names
self.definitions ||= [] definition = CommandDefinition.new
self.definitions << { definition.name = name
name: name, definition.aliases = aliases
aliases: aliases, definition.description = @description || ''
description: @description || '', definition.params = @params || []
params: @params || [], definition.condition_block = @condition_block
condition_block: @condition_block, definition.action_block = block
action_block: block,
opts: opts return unless definition.valid?
}
self.command_definitions << definition
definition.all_names.each do |name|
self.command_definitions_by_name[name] = definition
end
@description = nil @description = nil
@params = nil @params = nil

View File

@ -7,10 +7,10 @@ module Gitlab
# extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels])
# ``` # ```
class Extractor class Extractor
attr_reader :command_names attr_reader :command_definitions
def initialize(command_names) def initialize(command_definitions)
@command_names = command_names @command_definitions = command_definitions
end end
# Extracts commands from content and return an array of commands. # Extracts commands from content and return an array of commands.
@ -26,16 +26,18 @@ module Gitlab
# ``` # ```
# extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels]) # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels])
# msg = %(hello\n/labels ~foo ~"bar baz"\nworld) # 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" # msg #=> "hello\nworld"
# ``` # ```
def extract_commands!(content) def extract_commands(content, opts)
return [] unless content return [] unless content
content = content.dup
commands = [] commands = []
content.delete!("\r") content.delete!("\r")
content.gsub!(commands_regex) do content.gsub!(commands_regex(opts)) do
if $~[:cmd] if $~[:cmd]
commands << [$~[:cmd], $~[:args]].reject(&:blank?) commands << [$~[:cmd], $~[:args]].reject(&:blank?)
'' ''
@ -44,11 +46,19 @@ module Gitlab
end end
end end
commands [content.strip, commands]
end end
private 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. # Builds a regular expression to match known commands.
# First match group captures the command name and # First match group captures the command name and
# second match group captures its arguments. # second match group captures its arguments.
@ -56,7 +66,9 @@ module Gitlab
# It looks something like: # It looks something like:
# #
# /^\/(?<cmd>close|reopen|...)(?:( |$))(?<args>[^\/\n]*)(?:\n|$)/ # /^\/(?<cmd>close|reopen|...)(?:( |$))(?<args>[^\/\n]*)(?:\n|$)/
def commands_regex def commands_regex(opts)
names = command_names(opts).map(&:to_s)
@commands_regex ||= %r{ @commands_regex ||= %r{
(?<code> (?<code>
# Code blocks: # Code blocks:
@ -95,7 +107,7 @@ module Gitlab
# Command not in a blockquote, blockcode, or HTML tag: # Command not in a blockquote, blockcode, or HTML tag:
# /close # /close
^\/(?<cmd>#{command_names.join('|')})(?:(\ |$))(?<args>[^\/\n]*)(?:\n|$) ^\/(?<cmd>#{Regexp.union(names)})(?:$|\ (?<args>[^\/\n]*)$)
) )
}mx }mx
end end

View File

@ -5,7 +5,7 @@ describe Gitlab::SlashCommands::Extractor do
shared_examples 'command with no argument' do shared_examples 'command with no argument' do
it 'extracts command' do it 'extracts command' do
commands = extractor.extract_commands!(original_msg) commands = extractor.extract_commands(original_msg)
expect(commands).to eq [['open']] expect(commands).to eq [['open']]
expect(original_msg).to eq final_msg expect(original_msg).to eq final_msg
@ -14,7 +14,7 @@ describe Gitlab::SlashCommands::Extractor do
shared_examples 'command with a single argument' do shared_examples 'command with a single argument' do
it 'extracts command' do it 'extracts command' do
commands = extractor.extract_commands!(original_msg) commands = extractor.extract_commands(original_msg)
expect(commands).to eq [['assign', '@joe']] expect(commands).to eq [['assign', '@joe']]
expect(original_msg).to eq final_msg expect(original_msg).to eq final_msg
@ -23,14 +23,14 @@ describe Gitlab::SlashCommands::Extractor do
shared_examples 'command with multiple arguments' do shared_examples 'command with multiple arguments' do
it 'extracts command' 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(commands).to eq [['labels', '~foo ~"bar baz" label']]
expect(original_msg).to eq final_msg expect(original_msg).to eq final_msg
end end
end end
describe '#extract_commands!' do describe '#extract_commands' do
describe 'command with no argument' do describe 'command with no argument' do
context 'at the start of content' do context 'at the start of content' do
it_behaves_like 'command with no argument' 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 context 'in the middle of a line' do
it 'does not extract command' do it 'does not extract command' do
msg = "hello\nworld /open" msg = "hello\nworld /open"
commands = extractor.extract_commands!(msg) commands = extractor.extract_commands(msg)
expect(commands).to be_empty expect(commands).to be_empty
expect(msg).to eq "hello\nworld /open" expect(msg).to eq "hello\nworld /open"
@ -82,7 +82,7 @@ describe Gitlab::SlashCommands::Extractor do
context 'in the middle of a line' do context 'in the middle of a line' do
it 'does not extract command' do it 'does not extract command' do
msg = "hello\nworld /assign @joe" msg = "hello\nworld /assign @joe"
commands = extractor.extract_commands!(msg) commands = extractor.extract_commands(msg)
expect(commands).to be_empty expect(commands).to be_empty
expect(msg).to eq "hello\nworld /assign @joe" 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 context 'when argument is not separated with a space' do
it 'does not extract command' do it 'does not extract command' do
msg = "hello\n/assign@joe\nworld" msg = "hello\n/assign@joe\nworld"
commands = extractor.extract_commands!(msg) commands = extractor.extract_commands(msg)
expect(commands).to be_empty expect(commands).to be_empty
expect(msg).to eq "hello\n/assign@joe\nworld" 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 context 'in the middle of a line' do
it 'does not extract command' do it 'does not extract command' do
msg = %(hello\nworld /labels ~foo ~"bar baz" label) msg = %(hello\nworld /labels ~foo ~"bar baz" label)
commands = extractor.extract_commands!(msg) commands = extractor.extract_commands(msg)
expect(commands).to be_empty expect(commands).to be_empty
expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label) 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 context 'when argument is not separated with a space' do
it 'does not extract command' do it 'does not extract command' do
msg = %(hello\n/labels~foo ~"bar baz" label\nworld) 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(commands).to be_empty
expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld) 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 it 'extracts command with multiple arguments and various prefixes' do
msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld) 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(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']]
expect(msg).to eq "hello\nworld" expect(msg).to eq "hello\nworld"
@ -160,7 +160,7 @@ describe Gitlab::SlashCommands::Extractor do
it 'extracts multiple commands' do 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/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(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\n"
@ -168,7 +168,7 @@ describe Gitlab::SlashCommands::Extractor do
it 'does not alter original content if no command is found' do it 'does not alter original content if no command is found' do
msg = 'Fixes #123' msg = 'Fixes #123'
commands = extractor.extract_commands!(msg) commands = extractor.extract_commands(msg)
expect(commands).to be_empty expect(commands).to be_empty
expect(msg).to eq 'Fixes #123' expect(msg).to eq 'Fixes #123'
@ -177,7 +177,7 @@ describe Gitlab::SlashCommands::Extractor do
it 'does not extract commands inside a blockcode' 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" 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") expected = msg.delete("\r")
commands = extractor.extract_commands!(msg) commands = extractor.extract_commands(msg)
expect(commands).to be_empty expect(commands).to be_empty
expect(msg).to eq expected expect(msg).to eq expected
@ -186,7 +186,7 @@ describe Gitlab::SlashCommands::Extractor do
it 'does not extract commands inside a blockquote' 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" 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") expected = msg.delete("\r")
commands = extractor.extract_commands!(msg) commands = extractor.extract_commands(msg)
expect(commands).to be_empty expect(commands).to be_empty
expect(msg).to eq expected expect(msg).to eq expected
@ -195,7 +195,7 @@ describe Gitlab::SlashCommands::Extractor do
it 'does not extract commands inside a HTML tag' do it 'does not extract commands inside a HTML tag' do
msg = "Hello\r\n<div>\r\nThis is some text\r\n/close\r\n/assign @user\r\n</div>\r\n\r\nWorld" msg = "Hello\r\n<div>\r\nThis is some text\r\n/close\r\n/assign @user\r\n</div>\r\n\r\nWorld"
expected = msg.delete("\r") expected = msg.delete("\r")
commands = extractor.extract_commands!(msg) commands = extractor.extract_commands(msg)
expect(commands).to be_empty expect(commands).to be_empty
expect(msg).to eq expected expect(msg).to eq expected