Commit 42e30a50 authored by Rémy Coutable's avatar Rémy Coutable

Accept blocks for `.desc` and `.condition` slash commands DSL

Also, pass options as instance variables, making the DSL more
user-friendly / natural.
Signed-off-by: default avatarRémy Coutable <remy@rymai.me>
parent 65349c22
...@@ -26,25 +26,23 @@ module SlashCommands ...@@ -26,25 +26,23 @@ module SlashCommands
Gitlab::SlashCommands::Extractor.new(self.class.command_names(opts)) Gitlab::SlashCommands::Extractor.new(self.class.command_names(opts))
end end
desc ->(opts) { "Close this #{opts[:noteable].to_ability_name.humanize(capitalize: false)}" } desc do
condition ->(opts) do "Close this #{noteable.to_ability_name.humanize(capitalize: false)}"
opts[:noteable] && end
opts[:noteable].open? && condition do
opts[:current_user] && noteable.open? &&
opts[:project] && current_user.can?(:"update_#{noteable.to_ability_name}", project)
opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :close do command :close do
@updates[:state_event] = 'close' @updates[:state_event] = 'close'
end end
desc ->(opts) { "Reopen this #{opts[:noteable].to_ability_name.humanize(capitalize: false)}" } desc do
condition ->(opts) do "Reopen this #{noteable.to_ability_name.humanize(capitalize: false)}"
opts[:noteable] && end
opts[:noteable].closed? && condition do
opts[:current_user] && noteable.closed? &&
opts[:project] && current_user.can?(:"update_#{noteable.to_ability_name}", project)
opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :open, :reopen do command :open, :reopen do
@updates[:state_event] = 'reopen' @updates[:state_event] = 'reopen'
...@@ -52,12 +50,9 @@ module SlashCommands ...@@ -52,12 +50,9 @@ module SlashCommands
desc 'Change title' desc 'Change title'
params '<New title>' params '<New title>'
condition ->(opts) do condition do
opts[:noteable] && noteable.persisted? &&
opts[:noteable].persisted? && current_user.can?(:"update_#{noteable.to_ability_name}", project)
opts[:current_user] &&
opts[:project] &&
opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :title do |title_param| command :title do |title_param|
@updates[:title] = title_param @updates[:title] = title_param
...@@ -65,11 +60,8 @@ module SlashCommands ...@@ -65,11 +60,8 @@ module SlashCommands
desc 'Assign' desc 'Assign'
params '@user' params '@user'
condition ->(opts) do condition do
opts[:noteable] && current_user.can?(:"admin_#{noteable.to_ability_name}", project)
opts[:current_user] &&
opts[:project] &&
opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :assign, :reassign do |assignee_param| command :assign, :reassign do |assignee_param|
user = extract_references(assignee_param, :user).first user = extract_references(assignee_param, :user).first
...@@ -79,12 +71,9 @@ module SlashCommands ...@@ -79,12 +71,9 @@ module SlashCommands
end end
desc 'Remove assignee' desc 'Remove assignee'
condition ->(opts) do condition do
opts[:noteable] && noteable.assignee_id? &&
opts[:noteable].assignee_id? && current_user.can?(:"admin_#{noteable.to_ability_name}", project)
opts[:current_user] &&
opts[:project] &&
opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :unassign, :remove_assignee do command :unassign, :remove_assignee do
@updates[:assignee_id] = nil @updates[:assignee_id] = nil
...@@ -92,12 +81,9 @@ module SlashCommands ...@@ -92,12 +81,9 @@ module SlashCommands
desc 'Set milestone' desc 'Set milestone'
params '%"milestone"' params '%"milestone"'
condition ->(opts) do condition do
opts[:noteable] && current_user.can?(:"admin_#{noteable.to_ability_name}", project) &&
opts[:current_user] && project.milestones.active.any?
opts[:project] &&
opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) &&
opts[:project].milestones.active.any?
end end
command :milestone do |milestone_param| command :milestone do |milestone_param|
milestone = extract_references(milestone_param, :milestone).first milestone = extract_references(milestone_param, :milestone).first
...@@ -107,12 +93,9 @@ module SlashCommands ...@@ -107,12 +93,9 @@ module SlashCommands
end end
desc 'Remove milestone' desc 'Remove milestone'
condition ->(opts) do condition do
opts[:noteable] && noteable.milestone_id? &&
opts[:noteable].milestone_id? && current_user.can?(:"admin_#{noteable.to_ability_name}", project)
opts[:current_user] &&
opts[:project] &&
opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :clear_milestone, :remove_milestone do command :clear_milestone, :remove_milestone do
@updates[:milestone_id] = nil @updates[:milestone_id] = nil
...@@ -120,12 +103,9 @@ module SlashCommands ...@@ -120,12 +103,9 @@ module SlashCommands
desc 'Add label(s)' desc 'Add label(s)'
params '~label1 ~"label 2"' params '~label1 ~"label 2"'
condition ->(opts) do condition do
opts[:noteable] && current_user.can?(:"admin_#{noteable.to_ability_name}", project) &&
opts[:current_user] && project.labels.any?
opts[:project] &&
opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project]) &&
opts[:project].labels.any?
end end
command :label, :labels do |labels_param| command :label, :labels do |labels_param|
label_ids = find_label_ids(labels_param) label_ids = find_label_ids(labels_param)
...@@ -136,12 +116,9 @@ module SlashCommands ...@@ -136,12 +116,9 @@ module SlashCommands
desc 'Remove label(s)' desc 'Remove label(s)'
params '~label1 ~"label 2"' params '~label1 ~"label 2"'
condition ->(opts) do condition do
opts[:noteable] && noteable.labels.any? &&
opts[:noteable].labels.any? && current_user.can?(:"admin_#{noteable.to_ability_name}", project)
opts[:current_user] &&
opts[:project] &&
opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :unlabel, :remove_label, :remove_labels do |labels_param| command :unlabel, :remove_label, :remove_labels do |labels_param|
label_ids = find_label_ids(labels_param) label_ids = find_label_ids(labels_param)
...@@ -151,55 +128,46 @@ module SlashCommands ...@@ -151,55 +128,46 @@ module SlashCommands
end end
desc 'Remove all labels' desc 'Remove all labels'
condition ->(opts) do condition do
opts[:noteable] && noteable.labels.any? &&
opts[:noteable].labels.any? && current_user.can?(:"admin_#{noteable.to_ability_name}", project)
opts[:current_user] &&
opts[:project] &&
opts[:current_user].can?(:"admin_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :clear_labels, :clear_label do command :clear_labels, :clear_label do
@updates[:label_ids] = [] @updates[:label_ids] = []
end end
desc 'Add a todo' desc 'Add a todo'
condition ->(opts) do condition do
opts[:noteable] && noteable.persisted? &&
opts[:noteable].persisted? && current_user &&
opts[:current_user] && !TodosFinder.new(current_user).execute.exists?(target: noteable)
!TodosFinder.new(opts[:current_user]).execute.exists?(target: opts[:noteable])
end end
command :todo do command :todo do
@updates[:todo_event] = 'add' @updates[:todo_event] = 'add'
end end
desc 'Mark todo as done' desc 'Mark todo as done'
condition ->(opts) do condition do
opts[:noteable] && current_user &&
opts[:current_user] && TodosFinder.new(current_user).execute.exists?(target: noteable)
TodosFinder.new(opts[:current_user]).execute.exists?(target: opts[:noteable])
end end
command :done do command :done do
@updates[:todo_event] = 'done' @updates[:todo_event] = 'done'
end end
desc 'Subscribe' desc 'Subscribe'
condition ->(opts) do condition do
opts[:noteable] && noteable.persisted? &&
opts[:current_user] && !noteable.subscribed?(current_user)
opts[:noteable].persisted? &&
!opts[:noteable].subscribed?(opts[:current_user])
end end
command :subscribe do command :subscribe do
@updates[:subscription_event] = 'subscribe' @updates[:subscription_event] = 'subscribe'
end end
desc 'Unsubscribe' desc 'Unsubscribe'
condition ->(opts) do condition do
opts[:noteable] && noteable.persisted? &&
opts[:current_user] && noteable.subscribed?(current_user)
opts[:noteable].persisted? &&
opts[:noteable].subscribed?(opts[:current_user])
end end
command :unsubscribe do command :unsubscribe do
@updates[:subscription_event] = 'unsubscribe' @updates[:subscription_event] = 'unsubscribe'
...@@ -207,12 +175,9 @@ module SlashCommands ...@@ -207,12 +175,9 @@ module SlashCommands
desc 'Set due date' desc 'Set due date'
params 'a date in natural language' params 'a date in natural language'
condition ->(opts) do condition do
opts[:noteable] && noteable.respond_to?(:due_date) &&
opts[:noteable].respond_to?(:due_date) && current_user.can?(:"update_#{noteable.to_ability_name}", project)
opts[:current_user] &&
opts[:project] &&
opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :due_date, :due do |due_date_param| command :due_date, :due do |due_date_param|
due_date = Chronic.parse(due_date_param).try(:to_date) due_date = Chronic.parse(due_date_param).try(:to_date)
...@@ -221,13 +186,10 @@ module SlashCommands ...@@ -221,13 +186,10 @@ module SlashCommands
end end
desc 'Remove due date' desc 'Remove due date'
condition ->(opts) do condition do
opts[:noteable] && noteable.respond_to?(:due_date) &&
opts[:noteable].respond_to?(:due_date) && noteable.due_date? &&
opts[:noteable].due_date? && current_user.can?(:"update_#{noteable.to_ability_name}", project)
opts[:current_user] &&
opts[:project] &&
opts[:current_user].can?(:"update_#{opts[:noteable].to_ability_name}", opts[:project])
end end
command :clear_due_date do command :clear_due_date do
@updates[:due_date] = nil @updates[:due_date] = nil
...@@ -236,10 +198,7 @@ module SlashCommands ...@@ -236,10 +198,7 @@ module SlashCommands
# This is a dummy command, so that it appears in the autocomplete commands # This is a dummy command, so that it appears in the autocomplete commands
desc 'CC' desc 'CC'
params '@user' params '@user'
noop true command :cc, noop: true
command :cc do
return
end
def find_label_ids(labels_param) def find_label_ids(labels_param)
extract_references(labels_param, :label).map(&:id) extract_references(labels_param, :label).map(&:id)
......
...@@ -8,20 +8,27 @@ module Gitlab ...@@ -8,20 +8,27 @@ module Gitlab
end end
module ClassMethods module ClassMethods
# This method is used to generate the autocompletion menu
# It returns no-op slash commands (such as `/cc`)
def command_definitions(opts = {}) def command_definitions(opts = {})
@command_definitions.map do |cmd_def| @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 cmd_def = cmd_def.dup
if cmd_def[:description].present? && cmd_def[:description].respond_to?(:call) 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 end
cmd_def cmd_def
end.compact end.compact
end 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 = {}) def command_names(opts = {})
command_definitions(opts).flat_map do |command_definition| command_definitions(opts).flat_map do |command_definition|
next if command_definition[:noop] next if command_definition[:noop]
...@@ -30,59 +37,88 @@ module Gitlab ...@@ -30,59 +37,88 @@ module Gitlab
end.compact end.compact
end end
# Allows to give a description to the next slash command # Allows to give a description to the next slash command.
def desc(text) # This description is shown in the autocomplete menu.
@description = text # 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 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) def params(*params)
@params = params @params = params
end end
# Allows to define if a command is a no-op, but should appear in autocomplete # Allows to define conditions that must be met in order for the command
def noop(noop) # to be returned by `.command_names` & `.command_definitions`.
@noop = noop # It accepts a block that will be evaluated with the context given to
end # `.command_definitions`, `.command_names`, and the actual command method.
#
# 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: # Example:
# #
# condition do
# project.public?
# end
# command :command_key do |arguments| # command :command_key do |arguments|
# # Awesome code block # # Awesome code block
# end # 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) def command(*command_names, &block)
opts = command_names.extract_options!
command_name, *aliases = command_names command_name, *aliases = command_names
proxy_method_name = "__#{command_name}__" proxy_method_name = "__#{command_name}__"
# This proxy method is needed because calling `return` from inside a if block_given?
# block/proc, causes a `return` from the enclosing method or lambda, # This proxy method is needed because calling `return` from inside a
# otherwise a LocalJumpError error is raised. # block/proc, causes a `return` from the enclosing method or lambda,
define_method(proxy_method_name, &block) # otherwise a LocalJumpError error is raised.
define_method(proxy_method_name, &block)
define_method(command_name) do |*args| define_method(command_name) do |*args|
unless @cond_lambda.nil? || @cond_lambda.call(project: project, current_user: current_user, noteable: noteable) return if @cond_block && !instance_exec(&@cond_block)
return
end
proxy_method = method(proxy_method_name) proxy_method = method(proxy_method_name)
if proxy_method.arity == -1 || proxy_method.arity == args.size if proxy_method.arity == -1 || proxy_method.arity == args.size
instance_exec(*args, &proxy_method) instance_exec(*args, &proxy_method)
end
end end
end
private command_name private command_name
aliases.each do |alias_command| aliases.each do |alias_command|
alias_method alias_command, command_name alias_method alias_command, command_name
private alias_command private alias_command
end
end end
command_definition = { command_definition = {
...@@ -91,14 +127,13 @@ module Gitlab ...@@ -91,14 +127,13 @@ module Gitlab
description: @description || '', description: @description || '',
params: @params || [] params: @params || []
} }
command_definition[:noop] = @noop unless @noop.nil? command_definition[:noop] = opts[:noop] || false
command_definition[:cond_lambda] = @cond_lambda unless @cond_lambda.nil? command_definition[:cond_block] = @cond_block
@command_definitions << command_definition @command_definitions << command_definition
@description = nil @description = nil
@params = nil @params = nil
@noop = nil @cond_block = nil
@cond_lambda = nil
end end
end end
end end
......
require 'spec_helper' require 'spec_helper'
describe Gitlab::SlashCommands::Dsl do describe Gitlab::SlashCommands::Dsl do
COND_LAMBDA = ->(opts) { opts[:project] == 'foo' }
before :all do before :all do
DummyClass = Class.new do DummyClass = Struct.new(:project) do
include Gitlab::SlashCommands::Dsl include Gitlab::SlashCommands::Dsl
desc 'A command with no args' desc 'A command with no args'
...@@ -21,20 +20,21 @@ describe Gitlab::SlashCommands::Dsl do ...@@ -21,20 +20,21 @@ describe Gitlab::SlashCommands::Dsl do
arg1 arg1
end 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' params 'The first argument', 'The second argument'
command :two_args do |arg1, arg2| command :two_args do |arg1, arg2|
[arg1, arg2] [arg1, arg2]
end end
noop true command :cc, noop: true
command :cc do |*args|
args
end
condition COND_LAMBDA condition do
command :cond_action do |*args| project == 'foo'
args end
command :cond_action do |arg|
arg
end end
command :wildcard do |*args| command :wildcard do |*args|
...@@ -42,17 +42,16 @@ describe Gitlab::SlashCommands::Dsl do ...@@ -42,17 +42,16 @@ describe Gitlab::SlashCommands::Dsl do
end end
end end
end end
let(:dummy) { DummyClass.new }
describe '.command_definitions' do describe '.command_definitions' do
let(:base_expected) do let(:base_expected) do
[ [
{ name: :no_args, aliases: [:none], description: 'A command with no args', 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: [] }, { 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'] }, { 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'] }, { name: :two_args, aliases: [], description: '', params: ['The first argument', 'The second argument'], noop: false, cond_block: nil },
{ name: :cc, aliases: [], description: '', params: [], noop: true }, { name: :cc, aliases: [], description: '', params: [], noop: true, cond_block: nil },
{ name: :wildcard, aliases: [], description: '', params: [] } { name: :wildcard, aliases: [], description: '', params: [], noop: false, cond_block: nil}
] ]
end end
...@@ -62,7 +61,7 @@ describe Gitlab::SlashCommands::Dsl do ...@@ -62,7 +61,7 @@ describe Gitlab::SlashCommands::Dsl do
context 'with options passed' do context 'with options passed' do
context 'when condition is met' 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 it 'returns an array with commands definitions' do
expect(DummyClass.command_definitions(project: 'foo')).to match_array expected expect(DummyClass.command_definitions(project: 'foo')).to match_array expected
...@@ -77,7 +76,7 @@ describe Gitlab::SlashCommands::Dsl do ...@@ -77,7 +76,7 @@ describe Gitlab::SlashCommands::Dsl do
context 'when description can be generated dynamically' do context 'when description can be generated dynamically' do
it 'returns an array with commands definitions with dynamic descriptions' 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 expect(DummyClass.command_definitions(noteable: 'merge request')).to match_array base_expected
end end
...@@ -114,6 +113,8 @@ describe Gitlab::SlashCommands::Dsl do ...@@ -114,6 +113,8 @@ describe Gitlab::SlashCommands::Dsl do
end end
end end
let(:dummy) { DummyClass.new(nil) }
describe 'command with no args' do describe 'command with no args' do
context 'called with no args' do context 'called with no args' do
it 'succeeds' do it 'succeeds' do
...@@ -146,6 +147,28 @@ describe Gitlab::SlashCommands::Dsl do ...@@ -146,6 +147,28 @@ describe Gitlab::SlashCommands::Dsl do
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
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 describe 'command with wildcard' do
context 'called with no args' do context 'called with no args' do
it 'succeeds' do it 'succeeds' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment