summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/services/slash_commands/interpret_service.rb153
-rw-r--r--lib/gitlab/slash_commands/dsl.rb111
-rw-r--r--spec/lib/gitlab/slash_commands/dsl_spec.rb61
3 files changed, 171 insertions, 154 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 '<New title>'
- 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,59 +37,88 @@ 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
- end
+ define_method(command_name) do |*args|
+ return if @cond_block && !instance_exec(&@cond_block)
- proxy_method = method(proxy_method_name)
+ proxy_method = method(proxy_method_name)
- if proxy_method.arity == -1 || proxy_method.arity == args.size
- instance_exec(*args, &proxy_method)
+ if proxy_method.arity == -1 || proxy_method.arity == args.size
+ instance_exec(*args, &proxy_method)
+ end
end
- end
- private command_name
- aliases.each do |alias_command|
- alias_method alias_command, command_name
- private alias_command
+ private command_name
+ aliases.each do |alias_command|
+ alias_method alias_command, command_name
+ private alias_command
+ end
end
command_definition = {
@@ -91,14 +127,13 @@ module Gitlab
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