Fixes #29385: Add /shrug and /tableflip commands
- Updated DSL to support substitution definitions - Added substitution definition, inherits from command definition - Added tabelflip and shrug substitutions to interpret service - Added support for substitution definitions to the extractor for preview mode. - Added substitution handling in the interpret service Signed-off-by: Alex Ives <alex@ives.mn>
This commit is contained in:
		
							parent
							
								
									da967803cc
								
							
						
					
					
						commit
						a07fe9d7f8
					
				|  | @ -4,6 +4,9 @@ module QuickActions | ||||||
| 
 | 
 | ||||||
|     attr_reader :issuable |     attr_reader :issuable | ||||||
| 
 | 
 | ||||||
|  |     SHRUG = '¯\\_(ツ)_/¯'.freeze | ||||||
|  |     TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze | ||||||
|  | 
 | ||||||
|     # Takes a text and interprets the commands that are extracted from it. |     # 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. |     # Returns the content without commands, and hash of changes to be applied to a record. | ||||||
|     def execute(content, issuable) |     def execute(content, issuable) | ||||||
|  | @ -14,6 +17,7 @@ module QuickActions | ||||||
| 
 | 
 | ||||||
|       content, commands = extractor.extract_commands(content, context) |       content, commands = extractor.extract_commands(content, context) | ||||||
|       extract_updates(commands, context) |       extract_updates(commands, context) | ||||||
|  | 
 | ||||||
|       [content, @updates] |       [content, @updates] | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  | @ -423,6 +427,18 @@ module QuickActions | ||||||
|       @updates[:spend_time] = { duration: :reset, user: current_user } |       @updates[:spend_time] = { duration: :reset, user: current_user } | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     desc "Append the comment with #{SHRUG}" | ||||||
|  |     params '<Comment>' | ||||||
|  |     substitution :shrug do |comment| | ||||||
|  |       "#{comment} #{SHRUG}" | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     desc "Append the comment with #{TABLEFLIP}" | ||||||
|  |     params '<Comment>' | ||||||
|  |     substitution :tableflip do |comment| | ||||||
|  |       "#{comment} #{TABLEFLIP}" | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     # 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' | ||||||
|  |  | ||||||
|  | @ -0,0 +1,4 @@ | ||||||
|  | --- | ||||||
|  | title: Add /shrug and /tableflip commands | ||||||
|  | merge_request: 10068 | ||||||
|  | author: Alex Ives | ||||||
|  | @ -105,9 +105,32 @@ module Gitlab | ||||||
|         #     # Awesome code block |         #     # Awesome code block | ||||||
|         #   end |         #   end | ||||||
|         def command(*command_names, &block) |         def command(*command_names, &block) | ||||||
|  |           define_command(CommandDefinition, *command_names, &block) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         # Registers a new substitution which is recognizable from body of email or | ||||||
|  |         # comment. | ||||||
|  |         # It accepts aliases and takes a block with the formatted content. | ||||||
|  |         # | ||||||
|  |         # Example: | ||||||
|  |         # | ||||||
|  |         #   command :my_substitution, :alias_for_my_substitution do |text| | ||||||
|  |         #     "#{text} MY AWESOME SUBSTITUTION" | ||||||
|  |         #   end | ||||||
|  |         def substitution(*substitution_names, &block) | ||||||
|  |           define_command(SubstitutionDefinition, *substitution_names, &block) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         def definition_by_name(name) | ||||||
|  |           command_definitions_by_name[name.to_sym] | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         private | ||||||
|  | 
 | ||||||
|  |         def define_command(klass, *command_names, &block) | ||||||
|           name, *aliases = command_names |           name, *aliases = command_names | ||||||
| 
 | 
 | ||||||
|           definition = CommandDefinition.new( |           definition = klass.new( | ||||||
|             name, |             name, | ||||||
|             aliases: aliases, |             aliases: aliases, | ||||||
|             description: @description, |             description: @description, | ||||||
|  | @ -130,10 +153,6 @@ module Gitlab | ||||||
|           @condition_block = nil |           @condition_block = nil | ||||||
|           @parse_params_block = nil |           @parse_params_block = nil | ||||||
|         end |         end | ||||||
| 
 |  | ||||||
|         def definition_by_name(name) |  | ||||||
|           command_definitions_by_name[name.to_sym] |  | ||||||
|         end |  | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -46,6 +46,8 @@ module Gitlab | ||||||
|           end |           end | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|  |         content, commands = perform_substitutions(content, commands) | ||||||
|  | 
 | ||||||
|         [content.strip, commands] |         [content.strip, commands] | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  | @ -110,6 +112,26 @@ module Gitlab | ||||||
|         }mx |         }mx | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  |       def perform_substitutions(content, commands) | ||||||
|  |         return unless content | ||||||
|  | 
 | ||||||
|  |         substitution_definitions = self.command_definitions.select do |definition| | ||||||
|  |           definition.is_a?(Gitlab::QuickActions::SubstitutionDefinition) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         substitution_definitions.each do |substitution| | ||||||
|  |           match_data = substitution.match(content) | ||||||
|  |           if match_data | ||||||
|  |             command = [substitution.name.to_s] | ||||||
|  |             command << match_data[1] unless match_data[1].empty? | ||||||
|  |             commands << command | ||||||
|  |           end | ||||||
|  |           content = substitution.perform_substitution(self, content) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         [content, commands] | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|       def command_names(opts) |       def command_names(opts) | ||||||
|         command_definitions.flat_map do |command| |         command_definitions.flat_map do |command| | ||||||
|           next if command.noop? |           next if command.noop? | ||||||
|  |  | ||||||
|  | @ -0,0 +1,24 @@ | ||||||
|  | module Gitlab | ||||||
|  |   module QuickActions | ||||||
|  |     class SubstitutionDefinition < CommandDefinition | ||||||
|  |       # noop?=>true means these won't get extracted or removed by Gitlab::QuickActions::Extractor#extract_commands | ||||||
|  |       # QuickActions::InterpretService#perform_substitutions handles them separately | ||||||
|  |       def noop? | ||||||
|  |         true | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       def match(content) | ||||||
|  |         content.match %r{^/#{all_names.join('|')} ?(.*)$} | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       def perform_substitution(context, content) | ||||||
|  |         return unless content | ||||||
|  | 
 | ||||||
|  |         all_names.each do |a_name| | ||||||
|  |           content.gsub!(%r{/#{a_name} ?(.*)$}, execute_block(action_block, context, '\1')) | ||||||
|  |         end | ||||||
|  |         content | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -42,13 +42,18 @@ describe Gitlab::QuickActions::Dsl do | ||||||
|       command :with_params_parsing do |parsed| |       command :with_params_parsing do |parsed| | ||||||
|         parsed |         parsed | ||||||
|       end |       end | ||||||
|  | 
 | ||||||
|  |       params '<Comment>' | ||||||
|  |       substitution :something do |text| | ||||||
|  |         "#{text} Some complicated thing you want in here" | ||||||
|  |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe '.command_definitions' do |   describe '.command_definitions' do | ||||||
|     it 'returns an array with commands definitions' do |     it 'returns an array with commands definitions' do | ||||||
|       no_args_def, explanation_with_aliases_def, dynamic_description_def, |       no_args_def, explanation_with_aliases_def, dynamic_description_def, | ||||||
|       cc_def, cond_action_def, with_params_parsing_def = |       cc_def, cond_action_def, with_params_parsing_def, substitution_def = | ||||||
|         DummyClass.command_definitions |         DummyClass.command_definitions | ||||||
| 
 | 
 | ||||||
|       expect(no_args_def.name).to eq(:no_args) |       expect(no_args_def.name).to eq(:no_args) | ||||||
|  | @ -104,6 +109,15 @@ describe Gitlab::QuickActions::Dsl do | ||||||
|       expect(with_params_parsing_def.condition_block).to be_nil |       expect(with_params_parsing_def.condition_block).to be_nil | ||||||
|       expect(with_params_parsing_def.action_block).to be_a_kind_of(Proc) |       expect(with_params_parsing_def.action_block).to be_a_kind_of(Proc) | ||||||
|       expect(with_params_parsing_def.parse_params_block).to be_a_kind_of(Proc) |       expect(with_params_parsing_def.parse_params_block).to be_a_kind_of(Proc) | ||||||
|  | 
 | ||||||
|  |       expect(substitution_def.name).to eq(:something) | ||||||
|  |       expect(substitution_def.aliases).to eq([]) | ||||||
|  |       expect(substitution_def.description).to eq('') | ||||||
|  |       expect(substitution_def.explanation).to eq('') | ||||||
|  |       expect(substitution_def.params).to eq(['<Comment>']) | ||||||
|  |       expect(substitution_def.condition_block).to be_nil | ||||||
|  |       expect(substitution_def.action_block.call('text')).to eq('text Some complicated thing you want in here') | ||||||
|  |       expect(substitution_def.parse_params_block).to be_nil | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -9,6 +9,11 @@ describe Gitlab::QuickActions::Extractor do | ||||||
|       command(:assign) { } |       command(:assign) { } | ||||||
|       command(:labels) { } |       command(:labels) { } | ||||||
|       command(:power) { } |       command(:power) { } | ||||||
|  |       command(:noop_command) | ||||||
|  |       substitution(:substitution) { 'foo' } | ||||||
|  |       substitution :shrug do |comment| | ||||||
|  |         "#{comment} SHRUG" | ||||||
|  |       end | ||||||
|     end.command_definitions |     end.command_definitions | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | @ -177,6 +182,38 @@ describe Gitlab::QuickActions::Extractor do | ||||||
|       expect(msg).to eq "hello\nworld" |       expect(msg).to eq "hello\nworld" | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     it 'does not extract noop commands' do | ||||||
|  |       msg = %(hello\nworld\n/reopen\n/noop_command) | ||||||
|  |       msg, commands = extractor.extract_commands(msg) | ||||||
|  | 
 | ||||||
|  |       expect(commands).to eq [['reopen']] | ||||||
|  |       expect(msg).to eq "hello\nworld\n/noop_command" | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'extracts and performs substitution commands' do | ||||||
|  |       msg = %(hello\nworld\n/reopen\n/substitution) | ||||||
|  |       msg, commands = extractor.extract_commands(msg) | ||||||
|  | 
 | ||||||
|  |       expect(commands).to eq [['reopen'], ['substitution']] | ||||||
|  |       expect(msg).to eq "hello\nworld\nfoo" | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'extracts and performs substitution commands' do | ||||||
|  |       msg = %(hello\nworld\n/reopen\n/shrug this is great?) | ||||||
|  |       msg, commands = extractor.extract_commands(msg) | ||||||
|  | 
 | ||||||
|  |       expect(commands).to eq [['reopen'], ['shrug', 'this is great?']] | ||||||
|  |       expect(msg).to eq "hello\nworld\nthis is great? SHRUG" | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'extracts and performs substitution commands with comments' do | ||||||
|  |       msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.) | ||||||
|  |       msg, commands = extractor.extract_commands(msg) | ||||||
|  | 
 | ||||||
|  |       expect(commands).to eq [['reopen'], ['substitution', 'wow this is a thing.']] | ||||||
|  |       expect(msg).to eq "hello\nworld\nfoo" | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     it 'extracts multiple commands' do |     it 'extracts multiple commands' do | ||||||
|       msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen) |       msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen) | ||||||
|       msg, commands = extractor.extract_commands(msg) |       msg, commands = extractor.extract_commands(msg) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,42 @@ | ||||||
|  | require 'spec_helper' | ||||||
|  | 
 | ||||||
|  | describe Gitlab::QuickActions::SubstitutionDefinition do | ||||||
|  |   let(:content) do | ||||||
|  |     <<EOF | ||||||
|  | Hello! Let's do this! | ||||||
|  | /sub_name I like this stuff | ||||||
|  | EOF | ||||||
|  |   end | ||||||
|  |   subject do | ||||||
|  |     described_class.new(:sub_name, action_block: proc { |text| "#{text} foo" }) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   describe '#perform_substitution!' do | ||||||
|  |     it 'returns nil if content is nil' do | ||||||
|  |       expect(subject.perform_substitution(self, nil)).to be_nil | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'performs the substitution by default' do | ||||||
|  |       expect(subject.perform_substitution(self, content)).to eq <<EOF | ||||||
|  | Hello! Let's do this! | ||||||
|  | I like this stuff foo | ||||||
|  | EOF | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   describe '#match' do | ||||||
|  |     it 'checks the content for the command' do | ||||||
|  |       expect(subject.match(content)).to be_truthy | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'returns the match data' do | ||||||
|  |       data = subject.match(content) | ||||||
|  |       expect(data).to be_a(MatchData) | ||||||
|  |       expect(data[1]).to eq('I like this stuff') | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is nil if content does not have the command' do | ||||||
|  |       expect(subject.match('blah')).to be_falsey | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -9,13 +9,13 @@ describe QuickActions::InterpretService do | ||||||
|   let(:inprogress) { create(:label, project: project, title: 'In Progress') } |   let(:inprogress) { create(:label, project: project, title: 'In Progress') } | ||||||
|   let(:bug) { create(:label, project: project, title: 'Bug') } |   let(:bug) { create(:label, project: project, title: 'Bug') } | ||||||
|   let(:note) { build(:note, commit_id: merge_request.diff_head_sha) } |   let(:note) { build(:note, commit_id: merge_request.diff_head_sha) } | ||||||
|  |   let(:service) { described_class.new(project, developer) } | ||||||
| 
 | 
 | ||||||
|   before do |   before do | ||||||
|     project.team << [developer, :developer] |     project.team << [developer, :developer] | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe '#execute' do |   describe '#execute' do | ||||||
|     let(:service) { described_class.new(project, developer) } |  | ||||||
|     let(:merge_request) { create(:merge_request, source_project: project) } |     let(:merge_request) { create(:merge_request, source_project: project) } | ||||||
| 
 | 
 | ||||||
|     shared_examples 'reopen command' do |     shared_examples 'reopen command' do | ||||||
|  | @ -270,6 +270,22 @@ describe QuickActions::InterpretService do | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     shared_examples 'shrug command' do | ||||||
|  |       it 'appends ¯\_(ツ)_/¯ to the comment' do | ||||||
|  |         new_content, _ = service.execute(content, issuable) | ||||||
|  | 
 | ||||||
|  |         expect(new_content).to end_with(described_class::SHRUG) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     shared_examples 'tableflip command' do | ||||||
|  |       it 'appends (╯°□°)╯︵ ┻━┻ to the comment' do | ||||||
|  |         new_content, _ = service.execute(content, issuable) | ||||||
|  | 
 | ||||||
|  |         expect(new_content).to end_with(described_class::TABLEFLIP) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     it_behaves_like 'reopen command' do |     it_behaves_like 'reopen command' do | ||||||
|       let(:content) { '/reopen' } |       let(:content) { '/reopen' } | ||||||
|       let(:issuable) { issue } |       let(:issuable) { issue } | ||||||
|  | @ -775,6 +791,30 @@ describe QuickActions::InterpretService do | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     context '/shrug command' do | ||||||
|  |       it_behaves_like 'shrug command' do | ||||||
|  |         let(:content) { '/shrug people are people' } | ||||||
|  |         let(:issuable) { issue } | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it_behaves_like 'shrug command' do | ||||||
|  |         let(:content) { '/shrug' } | ||||||
|  |         let(:issuable) { issue } | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context '/tableflip command' do | ||||||
|  |       it_behaves_like 'tableflip command' do | ||||||
|  |         let(:content) { '/tableflip curse your sudden but enviable betrayal' } | ||||||
|  |         let(:issuable) { issue } | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it_behaves_like 'tableflip command' do | ||||||
|  |         let(:content) { '/tableflip' } | ||||||
|  |         let(:issuable) { issue } | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     context '/target_branch command' do |     context '/target_branch command' do | ||||||
|       let(:non_empty_project) { create(:project, :repository) } |       let(:non_empty_project) { create(:project, :repository) } | ||||||
|       let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) } |       let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue