Support slash commands in noteable description and notes
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 <remy@rymai.me>
This commit is contained in:
parent
11eefba891
commit
0eea8c8857
|
@ -53,6 +53,7 @@ v 8.11.0 (unreleased)
|
|||
- Update version_sorter and use new interface for faster tag sorting
|
||||
- Optimize checking if a user has read access to a list of issues !5370
|
||||
- Store all DB secrets in secrets.yml, under descriptive names !5274
|
||||
- Support slash commands in issue and merge request descriptions as well as comments. !5021
|
||||
- Nokogiri's various parsing methods are now instrumented
|
||||
- Add simple identifier to public SSH keys (muteor)
|
||||
- Admin page now references docs instead of a specific file !5600 (AnAverageHuman)
|
||||
|
|
|
@ -223,7 +223,7 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
return this.input.atwho({
|
||||
this.input.atwho({
|
||||
at: '~',
|
||||
alias: 'labels',
|
||||
searchKey: 'search',
|
||||
|
@ -249,6 +249,41 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
return this.input.atwho({
|
||||
at: '/',
|
||||
alias: 'commands',
|
||||
displayTpl: function(value) {
|
||||
var tpl = '<li>/${name}';
|
||||
if (value.aliases.length > 0) {
|
||||
tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
|
||||
}
|
||||
if (value.params.length > 0) {
|
||||
tpl += ' <small><%- params.join(" ") %></small>';
|
||||
}
|
||||
if (value.description !== '') {
|
||||
tpl += '<small class="description"><i><%- description %></i></small>';
|
||||
}
|
||||
tpl += '</li>';
|
||||
return _.template(tpl)(value);
|
||||
},
|
||||
insertTpl: function(value) {
|
||||
var tpl = "\n/${name} ";
|
||||
var reference_prefix = null;
|
||||
if (value.params.length > 0) {
|
||||
reference_prefix = value.params[0][0];
|
||||
if (/^[@%~]/.test(reference_prefix)) {
|
||||
tpl += '<%- reference_prefix %>';
|
||||
}
|
||||
}
|
||||
return _.template(tpl)({ reference_prefix: reference_prefix });
|
||||
},
|
||||
suffix: '',
|
||||
callbacks: {
|
||||
sorter: this.DefaultOptions.sorter,
|
||||
filter: this.DefaultOptions.filter,
|
||||
beforeInsert: this.DefaultOptions.beforeInsert
|
||||
}
|
||||
});
|
||||
},
|
||||
destroyAtWho: function() {
|
||||
return this.input.atwho('destroy');
|
||||
|
@ -265,6 +300,7 @@
|
|||
this.input.atwho('load', 'mergerequests', data.mergerequests);
|
||||
this.input.atwho('load', ':', data.emojis);
|
||||
this.input.atwho('load', '~', data.labels);
|
||||
this.input.atwho('load', '/', data.commands);
|
||||
return $(':focus').trigger('keyup');
|
||||
}
|
||||
};
|
|
@ -231,7 +231,12 @@
|
|||
var $notesList, votesBlock;
|
||||
if (!note.valid) {
|
||||
if (note.award) {
|
||||
new Flash('You have already awarded this emoji!', 'alert');
|
||||
new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline);
|
||||
}
|
||||
else {
|
||||
if (note.errors.commands_only) {
|
||||
new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -147,3 +147,8 @@
|
|||
color: $gl-link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.atwho-view small.description {
|
||||
float: right;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
|
|
@ -125,7 +125,7 @@ class Projects::NotesController < Projects::ApplicationController
|
|||
id: note.id,
|
||||
name: note.name
|
||||
}
|
||||
elsif note.valid?
|
||||
elsif note.persisted?
|
||||
Banzai::NoteRenderer.render([note], @project, current_user)
|
||||
|
||||
attrs = {
|
||||
|
|
|
@ -145,7 +145,8 @@ class ProjectsController < Projects::ApplicationController
|
|||
milestones: autocomplete.milestones,
|
||||
mergerequests: autocomplete.merge_requests,
|
||||
labels: autocomplete.labels,
|
||||
members: participants
|
||||
members: participants,
|
||||
commands: autocomplete.commands
|
||||
}
|
||||
|
||||
respond_to do |format|
|
||||
|
|
|
@ -17,7 +17,7 @@ class TodosFinder
|
|||
|
||||
attr_accessor :current_user, :params
|
||||
|
||||
def initialize(current_user, params)
|
||||
def initialize(current_user, params = {})
|
||||
@current_user = current_user
|
||||
@params = params
|
||||
end
|
||||
|
|
|
@ -69,15 +69,10 @@ 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
|
||||
end
|
||||
|
||||
def filter_labels_in_param(key)
|
||||
return if params[key].to_a.empty?
|
||||
|
@ -85,23 +80,65 @@ class IssuableBaseService < BaseService
|
|||
params[key] = project.labels.where(id: params[key]).pluck(:id)
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
def create(issuable)
|
||||
merge_slash_commands_into_params!
|
||||
filter_params
|
||||
|
||||
if params.present? && create_issuable(issuable, params)
|
||||
handle_creation(issuable)
|
||||
issuable.create_cross_references!(current_user)
|
||||
execute_hooks(issuable)
|
||||
end
|
||||
|
||||
issuable
|
||||
end
|
||||
|
||||
def update_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[:label_ids] = process_label_ids(attributes, base_label_ids: issuable.label_ids)
|
||||
|
||||
issuable.label_ids |= add_label_ids if add_label_ids
|
||||
issuable.label_ids -= remove_label_ids if remove_label_ids
|
||||
|
||||
issuable.assign_attributes(attributes.merge(updated_by: current_user))
|
||||
|
||||
issuable.save
|
||||
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]
|
||||
|
||||
|
|
|
@ -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')
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -15,5 +15,9 @@ module Projects
|
|||
def labels
|
||||
@project.labels.select([:title, :color])
|
||||
end
|
||||
|
||||
def commands
|
||||
SlashCommands::InterpretService.command_definitions
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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 '<YYYY-MM-DD> | <N days>'
|
||||
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
|
|
@ -6,6 +6,7 @@
|
|||
- [GitLab Flow](gitlab_flow.md)
|
||||
- [Groups](groups.md)
|
||||
- [Keyboard shortcuts](shortcuts.md)
|
||||
- [Slash commands](slash_commands.md)
|
||||
- [File finder](file_finder.md)
|
||||
- [Labels](../user/project/labels.md)
|
||||
- [Notification emails](notifications.md)
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
# GitLab slash commands
|
||||
|
||||
Slash commands are textual shortcuts for common actions on issues or merge
|
||||
requests that are usually done by clicking buttons or dropdowns in GitLab's UI.
|
||||
You can enter these commands while creating a new issue or merge request, and
|
||||
in comments. Each command should be on a separate line in order to be properly
|
||||
detected and executed.
|
||||
|
||||
Here is a list of all of the available commands and descriptions about what they
|
||||
do.
|
||||
|
||||
| Command | Aliases | Action |
|
||||
|:---------------------------|:--------------------|:-------------|
|
||||
| `/close` | None | Close the issue or merge request |
|
||||
| `/open` | `/reopen` | Reopen the issue or merge request |
|
||||
| `/assign @username` | `/reassign` | Reassign |
|
||||
| `/unassign` | `/remove_assignee` | Remove assignee |
|
||||
| `/milestone %milestone` | None | Change milestone |
|
||||
| `/clear_milestone` | `/remove_milestone` | Remove milestone |
|
||||
| `/label ~foo ~"bar baz"` | `/labels` | Add label(s) |
|
||||
| `/unlabel ~foo ~"bar baz"` | `/remove_label`, `remove_labels` | Remove label(s) |
|
||||
| `/clear_labels` | `/clear_label` | Clear all labels |
|
||||
| `/todo` | None | Add a todo |
|
||||
| `/done` | None | Mark todo as done |
|
||||
| `/subscribe` | None | Subscribe |
|
||||
| `/unsubscribe` | None | Unsubscribe |
|
||||
| `/due_date` | None | Set a due date |
|
||||
| `/clear_due_date` | None | Remove due date |
|
|
@ -45,6 +45,7 @@ module Gitlab
|
|||
|
||||
def verify_record!(record:, invalid_exception:, record_name:)
|
||||
return if record.persisted?
|
||||
return if record.errors.key?(:commands_only)
|
||||
|
||||
error_title = "The #{record_name} could not be created for the following reasons:"
|
||||
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
module Gitlab
|
||||
module SlashCommands
|
||||
module Dsl
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
@command_definitions = []
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def command_definitions
|
||||
@command_definitions
|
||||
end
|
||||
|
||||
def command_names
|
||||
command_definitions.flat_map do |command_definition|
|
||||
[command_definition[:name], command_definition[:aliases]].flatten
|
||||
end
|
||||
end
|
||||
|
||||
# Allows to give a description to the next slash command
|
||||
def desc(text)
|
||||
@description = text
|
||||
end
|
||||
|
||||
# Allows to define params for the next slash command
|
||||
def params(*params)
|
||||
@params = params
|
||||
end
|
||||
|
||||
# Registers a new command which is recognizeable
|
||||
# from body of email or comment.
|
||||
# Example:
|
||||
#
|
||||
# command :command_key do |arguments|
|
||||
# # Awesome code block
|
||||
# end
|
||||
#
|
||||
def command(*command_names, &block)
|
||||
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)
|
||||
|
||||
define_method(command_name) do |*args|
|
||||
proxy_method = method(proxy_method_name)
|
||||
|
||||
if proxy_method.arity == -1 || proxy_method.arity == args.size
|
||||
instance_exec(*args, &proxy_method)
|
||||
end
|
||||
end
|
||||
|
||||
private command_name
|
||||
aliases.each do |alias_command|
|
||||
alias_method alias_command, command_name
|
||||
private alias_command
|
||||
end
|
||||
|
||||
command_definition = {
|
||||
name: command_name,
|
||||
aliases: aliases,
|
||||
description: @description || '',
|
||||
params: @params || []
|
||||
}
|
||||
@command_definitions << command_definition
|
||||
|
||||
@description = nil
|
||||
@params = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,59 @@
|
|||
module Gitlab
|
||||
module SlashCommands
|
||||
# This class takes an array of commands that should be extracted from a
|
||||
# given text.
|
||||
#
|
||||
# ```
|
||||
# extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels])
|
||||
# ```
|
||||
class Extractor
|
||||
attr_reader :command_names
|
||||
|
||||
def initialize(command_names)
|
||||
@command_names = command_names
|
||||
end
|
||||
|
||||
# Extracts commands from content and return an array of commands.
|
||||
# The array looks like the following:
|
||||
# [
|
||||
# ['command1'],
|
||||
# ['command3', 'arg1 arg2'],
|
||||
# ]
|
||||
# The command and the arguments are stripped.
|
||||
# The original command text is removed from the given `content`.
|
||||
#
|
||||
# Usage:
|
||||
# ```
|
||||
# extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels])
|
||||
# msg = %(hello\n/labels ~foo ~"bar baz"\nworld)
|
||||
# commands = extractor.extract_commands! #=> [['labels', '~foo ~"bar baz"']]
|
||||
# msg #=> "hello\nworld"
|
||||
# ```
|
||||
def extract_commands!(content)
|
||||
return [] unless content
|
||||
|
||||
commands = []
|
||||
|
||||
content.gsub!(commands_regex) do
|
||||
commands << [$1, $2].flatten.reject(&:blank?)
|
||||
''
|
||||
end
|
||||
|
||||
commands
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Builds a regular expression to match known commands.
|
||||
# First match group captures the command name and
|
||||
# second match group captures its arguments.
|
||||
#
|
||||
# It looks something like:
|
||||
#
|
||||
# /^\/(?<cmd>close|reopen|...)(?:( |$))(?<args>[^\/\n]*)(?:\n|$)/
|
||||
def commands_regex
|
||||
/^\/(?<cmd>#{command_names.join('|')})(?:( |$))(?<args>[^\/\n]*)(?:\n|$)/
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,59 @@
|
|||
require 'rails_helper'
|
||||
|
||||
feature 'Issues > User uses slash commands', feature: true, js: true do
|
||||
include WaitForAjax
|
||||
|
||||
it_behaves_like 'issuable record that supports slash commands in its description and notes', :issue do
|
||||
let(:issuable) { create(:issue, project: project) }
|
||||
end
|
||||
|
||||
describe 'issue-only commands' do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project, :public) }
|
||||
|
||||
before do
|
||||
project.team << [user, :master]
|
||||
login_with(user)
|
||||
visit namespace_project_issue_path(project.namespace, project, issue)
|
||||
end
|
||||
|
||||
describe 'adding a due date from note' do
|
||||
let(:issue) { create(:issue, project: project) }
|
||||
|
||||
it 'does not create a note, and sets the due date accordingly' do
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "/due_date 2016-08-28"
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).not_to have_content '/due_date 2016-08-28'
|
||||
expect(page).to have_content 'Your commands are being executed.'
|
||||
|
||||
issue.reload
|
||||
|
||||
expect(issue.due_date).to eq Date.new(2016, 8, 28)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'removing a due date from note' do
|
||||
let(:issue) { create(:issue, project: project, due_date: Date.new(2016, 8, 28)) }
|
||||
|
||||
it 'does not create a note, and removes the due date accordingly' do
|
||||
expect(issue.due_date).to eq Date.new(2016, 8, 28)
|
||||
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "/clear_due_date"
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).not_to have_content '/clear_due_date'
|
||||
expect(page).to have_content 'Your commands are being executed.'
|
||||
|
||||
issue.reload
|
||||
|
||||
expect(issue.due_date).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,59 @@
|
|||
require 'rails_helper'
|
||||
|
||||
feature 'Merge Requests > User uses slash commands', feature: true, js: true do
|
||||
include WaitForAjax
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project, :public) }
|
||||
let(:merge_request) { create(:merge_request, source_project: project) }
|
||||
let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
|
||||
|
||||
it_behaves_like 'issuable record that supports slash commands in its description and notes', :merge_request do
|
||||
let(:issuable) { create(:merge_request, source_project: project) }
|
||||
let(:new_url_opts) { { merge_request: { source_branch: 'feature' } } }
|
||||
end
|
||||
|
||||
describe 'adding a due date from note' do
|
||||
before do
|
||||
project.team << [user, :master]
|
||||
login_with(user)
|
||||
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
|
||||
end
|
||||
|
||||
it 'does not recognize the command nor create a note' do
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "/due_date 2016-08-28"
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).not_to have_content '/due_date 2016-08-28'
|
||||
end
|
||||
end
|
||||
|
||||
# Postponed because of high complexity
|
||||
xdescribe 'merging from note' do
|
||||
before do
|
||||
project.team << [user, :master]
|
||||
login_with(user)
|
||||
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
|
||||
end
|
||||
|
||||
it 'creates a note without the commands and interpret the commands accordingly' do
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "Let's merge this!\n/merge\n/milestone %ASAP"
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).to have_content("Let's merge this!")
|
||||
expect(page).not_to have_content('/merge')
|
||||
expect(page).not_to have_content('/milestone %ASAP')
|
||||
|
||||
merge_request.reload
|
||||
note = merge_request.notes.user.first
|
||||
|
||||
expect(note.note).to eq "Let's merge this!\r\n"
|
||||
expect(merge_request).to be_merged
|
||||
expect(merge_request.milestone).to eq milestone
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,40 @@
|
|||
Return-Path: <jake@adventuretime.ooo>
|
||||
Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
|
||||
Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
|
||||
Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
|
||||
Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
|
||||
Date: Thu, 13 Jun 2013 17:03:48 -0400
|
||||
From: Jake the Dog <jake@adventuretime.ooo>
|
||||
To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo
|
||||
Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
|
||||
In-Reply-To: <issue_1@localhost>
|
||||
References: <issue_1@localhost> <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>
|
||||
Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'
|
||||
Mime-Version: 1.0
|
||||
Content-Type: text/plain;
|
||||
charset=ISO-8859-1
|
||||
Content-Transfer-Encoding: 7bit
|
||||
X-Sieve: CMU Sieve 2.2
|
||||
X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
|
||||
13 Jun 2013 14:03:48 -0700 (PDT)
|
||||
X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
|
||||
|
||||
/close
|
||||
/unsubscribe
|
||||
|
||||
|
||||
On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta
|
||||
<reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo> wrote:
|
||||
>
|
||||
>
|
||||
>
|
||||
> eviltrout posted in 'Adventure Time Sux' on Discourse Meta:
|
||||
>
|
||||
> ---
|
||||
> hey guys everyone knows adventure time sucks!
|
||||
>
|
||||
> ---
|
||||
> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3
|
||||
>
|
||||
> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences).
|
||||
>
|
|
@ -60,6 +60,15 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do
|
|||
it "raises an InvalidNoteError" do
|
||||
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
|
||||
end
|
||||
|
||||
context 'because the note was commands only' do
|
||||
let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") }
|
||||
|
||||
it 'raises a CommandsOnlyNoteError' do
|
||||
expect { receiver.execute }.not_to raise_error
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
context "when the reply is blank" do
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::SlashCommands::Dsl do
|
||||
before :all do
|
||||
DummyClass = Class.new do
|
||||
include Gitlab::SlashCommands::Dsl
|
||||
|
||||
desc 'A command with no args'
|
||||
command :no_args, :none do
|
||||
"Hello World!"
|
||||
end
|
||||
|
||||
desc 'A command returning a value'
|
||||
command :returning do
|
||||
return 42
|
||||
end
|
||||
|
||||
params 'The first argument'
|
||||
command :one_arg, :once, :first do |arg1|
|
||||
arg1
|
||||
end
|
||||
|
||||
desc 'A command with two args'
|
||||
params 'The first argument', 'The second argument'
|
||||
command :two_args do |arg1, arg2|
|
||||
[arg1, arg2]
|
||||
end
|
||||
|
||||
command :wildcard do |*args|
|
||||
args
|
||||
end
|
||||
end
|
||||
end
|
||||
let(:dummy) { DummyClass.new }
|
||||
|
||||
describe '.command_definitions' do
|
||||
it 'returns an array with commands definitions' do
|
||||
expected = [
|
||||
{ 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: 'A command with two args', params: ['The first argument', 'The second argument'] },
|
||||
{ name: :wildcard, aliases: [], description: '', params: [] }
|
||||
]
|
||||
|
||||
expect(DummyClass.command_definitions).to eq expected
|
||||
end
|
||||
end
|
||||
|
||||
describe '.command_names' do
|
||||
it 'returns an array with commands definitions' do
|
||||
expect(DummyClass.command_names).to eq [
|
||||
:no_args, :none, :returning, :one_arg,
|
||||
:once, :first, :two_args, :wildcard
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe 'command with no args' do
|
||||
context 'called with no args' do
|
||||
it 'succeeds' do
|
||||
expect(dummy.__send__(:no_args)).to eq 'Hello World!'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'command with an explicit return' do
|
||||
context 'called with no args' do
|
||||
it 'succeeds' do
|
||||
expect(dummy.__send__(:returning)).to eq 42
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'command with one arg' do
|
||||
context 'called with one arg' do
|
||||
it 'succeeds' do
|
||||
expect(dummy.__send__(:one_arg, 42)).to eq 42
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'command with two args' do
|
||||
context 'called with two args' do
|
||||
it 'succeeds' do
|
||||
expect(dummy.__send__(:two_args, 42, 'foo')).to eq [42, 'foo']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'command with wildcard' do
|
||||
context 'called with no args' do
|
||||
it 'succeeds' do
|
||||
expect(dummy.__send__(:wildcard)).to eq []
|
||||
end
|
||||
end
|
||||
|
||||
context 'called with one arg' do
|
||||
it 'succeeds' do
|
||||
expect(dummy.__send__(:wildcard, 42)).to eq [42]
|
||||
end
|
||||
end
|
||||
|
||||
context 'called with two args' do
|
||||
it 'succeeds' do
|
||||
expect(dummy.__send__(:wildcard, 42, 'foo')).to eq [42, 'foo']
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,177 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Gitlab::SlashCommands::Extractor do
|
||||
let(:extractor) { described_class.new([:open, :assign, :labels, :power]) }
|
||||
|
||||
shared_examples 'command with no argument' do
|
||||
it 'extracts command' do
|
||||
commands = extractor.extract_commands!(original_msg)
|
||||
|
||||
expect(commands).to eq [['open']]
|
||||
expect(original_msg).to eq final_msg
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'command with a single argument' do
|
||||
it 'extracts command' do
|
||||
commands = extractor.extract_commands!(original_msg)
|
||||
|
||||
expect(commands).to eq [['assign', '@joe']]
|
||||
expect(original_msg).to eq final_msg
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'command with multiple arguments' do
|
||||
it 'extracts command' do
|
||||
commands = extractor.extract_commands!(original_msg)
|
||||
|
||||
expect(commands).to eq [['labels', '~foo ~"bar baz" label']]
|
||||
expect(original_msg).to eq final_msg
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_commands!' do
|
||||
describe 'command with no argument' do
|
||||
context 'at the start of content' do
|
||||
it_behaves_like 'command with no argument' do
|
||||
let(:original_msg) { "/open\nworld" }
|
||||
let(:final_msg) { "world" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'in the middle of content' do
|
||||
it_behaves_like 'command with no argument' do
|
||||
let(:original_msg) { "hello\n/open\nworld" }
|
||||
let(:final_msg) { "hello\nworld" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'in the middle of a line' do
|
||||
it 'does not extract command' do
|
||||
msg = "hello\nworld /open"
|
||||
commands = extractor.extract_commands!(msg)
|
||||
|
||||
expect(commands).to be_empty
|
||||
expect(msg).to eq "hello\nworld /open"
|
||||
end
|
||||
end
|
||||
|
||||
context 'at the end of content' do
|
||||
it_behaves_like 'command with no argument' do
|
||||
let(:original_msg) { "hello\n/open" }
|
||||
let(:final_msg) { "hello\n" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'command with a single argument' do
|
||||
context 'at the start of content' do
|
||||
it_behaves_like 'command with a single argument' do
|
||||
let(:original_msg) { "/assign @joe\nworld" }
|
||||
let(:final_msg) { "world" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'in the middle of content' do
|
||||
it_behaves_like 'command with a single argument' do
|
||||
let(:original_msg) { "hello\n/assign @joe\nworld" }
|
||||
let(:final_msg) { "hello\nworld" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'in the middle of a line' do
|
||||
it 'does not extract command' do
|
||||
msg = "hello\nworld /assign @joe"
|
||||
commands = extractor.extract_commands!(msg)
|
||||
|
||||
expect(commands).to be_empty
|
||||
expect(msg).to eq "hello\nworld /assign @joe"
|
||||
end
|
||||
end
|
||||
|
||||
context 'at the end of content' do
|
||||
it_behaves_like 'command with a single argument' do
|
||||
let(:original_msg) { "hello\n/assign @joe" }
|
||||
let(:final_msg) { "hello\n" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when argument is not separated with a space' do
|
||||
it 'does not extract command' do
|
||||
msg = "hello\n/assign@joe\nworld"
|
||||
commands = extractor.extract_commands!(msg)
|
||||
|
||||
expect(commands).to be_empty
|
||||
expect(msg).to eq "hello\n/assign@joe\nworld"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'command with multiple arguments' do
|
||||
context 'at the start of content' do
|
||||
it_behaves_like 'command with multiple arguments' do
|
||||
let(:original_msg) { %(/labels ~foo ~"bar baz" label\nworld) }
|
||||
let(:final_msg) { "world" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'in the middle of content' do
|
||||
it_behaves_like 'command with multiple arguments' do
|
||||
let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label\nworld) }
|
||||
let(:final_msg) { "hello\nworld" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'in the middle of a line' do
|
||||
it 'does not extract command' do
|
||||
msg = %(hello\nworld /labels ~foo ~"bar baz" label)
|
||||
commands = extractor.extract_commands!(msg)
|
||||
|
||||
expect(commands).to be_empty
|
||||
expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label)
|
||||
end
|
||||
end
|
||||
|
||||
context 'at the end of content' do
|
||||
it_behaves_like 'command with multiple arguments' do
|
||||
let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label) }
|
||||
let(:final_msg) { "hello\n" }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when argument is not separated with a space' do
|
||||
it 'does not extract command' do
|
||||
msg = %(hello\n/labels~foo ~"bar baz" label\nworld)
|
||||
commands = extractor.extract_commands!(msg)
|
||||
|
||||
expect(commands).to be_empty
|
||||
expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'extracts command with multiple arguments and various prefixes' do
|
||||
msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld)
|
||||
commands = extractor.extract_commands!(msg)
|
||||
|
||||
expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']]
|
||||
expect(msg).to eq "hello\nworld"
|
||||
end
|
||||
|
||||
it 'extracts multiple commands' do
|
||||
msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/open)
|
||||
commands = extractor.extract_commands!(msg)
|
||||
|
||||
expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['open']]
|
||||
expect(msg).to eq "hello\nworld\n"
|
||||
end
|
||||
|
||||
it 'does not alter original content if no command is found' do
|
||||
msg = 'Fixes #123'
|
||||
commands = extractor.extract_commands!(msg)
|
||||
|
||||
expect(commands).to be_empty
|
||||
expect(msg).to eq 'Fixes #123'
|
||||
end
|
||||
end
|
||||
end
|
|
@ -73,5 +73,7 @@ describe Issues::CreateService, services: true do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'new issuable record that supports slash commands'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,7 +17,7 @@ describe MergeRequests::CreateService, services: true do
|
|||
}
|
||||
end
|
||||
|
||||
let(:service) { MergeRequests::CreateService.new(project, user, opts) }
|
||||
let(:service) { described_class.new(project, user, opts) }
|
||||
|
||||
before do
|
||||
project.team << [user, :master]
|
||||
|
@ -74,5 +74,14 @@ describe MergeRequests::CreateService, services: true do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'new issuable record that supports slash commands' do
|
||||
let(:default_params) do
|
||||
{
|
||||
source_branch: 'feature',
|
||||
target_branch: 'master'
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,22 +4,31 @@ describe Notes::CreateService, services: true do
|
|||
let(:project) { create(:empty_project) }
|
||||
let(:issue) { create(:issue, project: project) }
|
||||
let(:user) { create(:user) }
|
||||
let(:opts) do
|
||||
{ note: 'Awesome comment', noteable_type: 'Issue', noteable_id: issue.id }
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context "valid params" do
|
||||
before do
|
||||
project.team << [user, :master]
|
||||
opts = {
|
||||
note: 'Awesome comment',
|
||||
noteable_type: 'Issue',
|
||||
noteable_id: issue.id
|
||||
}
|
||||
|
||||
@note = Notes::CreateService.new(project, user, opts).execute
|
||||
end
|
||||
|
||||
it { expect(@note).to be_valid }
|
||||
it { expect(@note.note).to eq('Awesome comment') }
|
||||
it { expect(@note.note).to eq(opts[:note]) }
|
||||
|
||||
it_behaves_like 'note on noteable that supports slash commands' do
|
||||
let(:noteable) { create(:issue, project: project) }
|
||||
end
|
||||
|
||||
it_behaves_like 'note on noteable that supports slash commands' do
|
||||
let(:noteable) { create(:merge_request, source_project: project) }
|
||||
end
|
||||
|
||||
it_behaves_like 'note on noteable that does not support slash commands' do
|
||||
let(:noteable) { create(:commit, project: project) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,217 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe SlashCommands::InterpretService, services: true do
|
||||
let(:project) { create(:project) }
|
||||
let(:user) { create(:user) }
|
||||
let(:issue) { create(:issue, project: project) }
|
||||
let(:milestone) { create(:milestone, project: project, title: '9.10') }
|
||||
let(:inprogress) { create(:label, project: project, title: 'In Progress') }
|
||||
let(:bug) { create(:label, project: project, title: 'Bug') }
|
||||
|
||||
describe '#command_names' do
|
||||
subject { described_class.command_names }
|
||||
|
||||
it 'returns the known commands' do
|
||||
is_expected.to match_array([
|
||||
:open, :reopen,
|
||||
:close,
|
||||
:assign, :reassign,
|
||||
:unassign, :remove_assignee,
|
||||
:milestone,
|
||||
:remove_milestone,
|
||||
:clear_milestone,
|
||||
:labels, :label,
|
||||
:unlabel, :remove_labels, :remove_label,
|
||||
:clear_labels, :clear_label,
|
||||
:todo,
|
||||
:done,
|
||||
:subscribe,
|
||||
:unsubscribe,
|
||||
:due_date,
|
||||
:clear_due_date
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
let(:service) { described_class.new(project, user) }
|
||||
|
||||
shared_examples 'open command' do
|
||||
it 'returns state_event: "open" if content contains /open' do
|
||||
changes = service.execute(content)
|
||||
|
||||
expect(changes).to eq(state_event: 'reopen')
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'close command' do
|
||||
it 'returns state_event: "close" if content contains /open' do
|
||||
changes = service.execute(content)
|
||||
|
||||
expect(changes).to eq(state_event: 'close')
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'assign command' do
|
||||
it 'fetches assignee and populates assignee_id if content contains /assign' do
|
||||
changes = service.execute(content)
|
||||
|
||||
expect(changes).to eq(assignee_id: user.id)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'milestone command' do
|
||||
it 'fetches milestone and populates milestone_id if content contains /milestone' do
|
||||
changes = service.execute(content)
|
||||
|
||||
expect(changes).to eq(milestone_id: milestone.id)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'label command' do
|
||||
it 'fetches label ids and populates add_label_ids if content contains /label' do
|
||||
changes = service.execute(content)
|
||||
|
||||
expect(changes).to eq(add_label_ids: [bug.id, inprogress.id])
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'remove_labels command' do
|
||||
it 'fetches label ids and populates remove_label_ids if content contains /label' do
|
||||
changes = service.execute(content)
|
||||
|
||||
expect(changes).to eq(remove_label_ids: [inprogress.id])
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'clear_labels command' do
|
||||
it 'populates label_ids: [] if content contains /clear_labels' do
|
||||
changes = service.execute(content)
|
||||
|
||||
expect(changes).to eq(label_ids: [])
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'command returning no changes' do
|
||||
it 'returns an empty hash if content contains /open' do
|
||||
changes = service.execute(content)
|
||||
|
||||
expect(changes).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'open command' do
|
||||
let(:content) { '/open' }
|
||||
end
|
||||
|
||||
it_behaves_like 'open command' do
|
||||
let(:content) { '/reopen' }
|
||||
end
|
||||
|
||||
it_behaves_like 'close command' do
|
||||
let(:content) { '/close' }
|
||||
end
|
||||
|
||||
it_behaves_like 'assign command' do
|
||||
let(:content) { "/assign @#{user.username}" }
|
||||
end
|
||||
|
||||
it 'does not populate assignee_id if content contains /assign with an unknown user' do
|
||||
changes = service.execute('/assign joe')
|
||||
|
||||
expect(changes).to be_empty
|
||||
end
|
||||
|
||||
it 'does not populate assignee_id if content contains /assign without user' do
|
||||
changes = service.execute('/assign')
|
||||
|
||||
expect(changes).to be_empty
|
||||
end
|
||||
|
||||
it 'populates assignee_id: nil if content contains /unassign' do
|
||||
changes = service.execute('/unassign')
|
||||
|
||||
expect(changes).to eq(assignee_id: nil)
|
||||
end
|
||||
|
||||
it_behaves_like 'milestone command' do
|
||||
let(:content) { "/milestone %#{milestone.title}" }
|
||||
end
|
||||
|
||||
it 'populates milestone_id: nil if content contains /clear_milestone' do
|
||||
changes = service.execute('/clear_milestone')
|
||||
|
||||
expect(changes).to eq(milestone_id: nil)
|
||||
end
|
||||
|
||||
it_behaves_like 'label command' do
|
||||
let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
|
||||
end
|
||||
|
||||
it_behaves_like 'label command' do
|
||||
let(:content) { %(/labels ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
|
||||
end
|
||||
|
||||
it_behaves_like 'remove_labels command' do
|
||||
let(:content) { %(/unlabel ~"#{inprogress.title}") }
|
||||
end
|
||||
|
||||
it_behaves_like 'remove_labels command' do
|
||||
let(:content) { %(/remove_labels ~"#{inprogress.title}") }
|
||||
end
|
||||
|
||||
it_behaves_like 'remove_labels command' do
|
||||
let(:content) { %(/remove_label ~"#{inprogress.title}") }
|
||||
end
|
||||
|
||||
it_behaves_like 'clear_labels command' do
|
||||
let(:content) { '/clear_labels' }
|
||||
end
|
||||
|
||||
it_behaves_like 'clear_labels command' do
|
||||
let(:content) { '/clear_label' }
|
||||
end
|
||||
|
||||
it 'populates todo: :mark if content contains /todo' do
|
||||
changes = service.execute('/todo')
|
||||
|
||||
expect(changes).to eq(todo_event: 'mark')
|
||||
end
|
||||
|
||||
it 'populates todo: :done if content contains /done' do
|
||||
changes = service.execute('/done')
|
||||
|
||||
expect(changes).to eq(todo_event: 'done')
|
||||
end
|
||||
|
||||
it 'populates subscription: :subscribe if content contains /subscribe' do
|
||||
changes = service.execute('/subscribe')
|
||||
|
||||
expect(changes).to eq(subscription_event: 'subscribe')
|
||||
end
|
||||
|
||||
it 'populates subscription: :unsubscribe if content contains /unsubscribe' do
|
||||
changes = service.execute('/unsubscribe')
|
||||
|
||||
expect(changes).to eq(subscription_event: 'unsubscribe')
|
||||
end
|
||||
|
||||
it 'populates due_date: Time.now.tomorrow if content contains /due_date 2016-08-28' do
|
||||
changes = service.execute('/due_date 2016-08-28')
|
||||
|
||||
expect(changes).to eq(due_date: Date.new(2016, 8, 28))
|
||||
end
|
||||
|
||||
it 'populates due_date: Time.now.tomorrow if content contains /due_date foo' do
|
||||
changes = service.execute('/due_date foo')
|
||||
|
||||
expect(changes).to be_empty
|
||||
end
|
||||
|
||||
it 'populates due_date: nil if content contains /clear_due_date' do
|
||||
changes = service.execute('/clear_due_date')
|
||||
|
||||
expect(changes).to eq(due_date: nil)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,83 @@
|
|||
# Specifications for behavior common to all objects with executable attributes.
|
||||
# It can take a `default_params`.
|
||||
|
||||
shared_examples 'new issuable record that supports slash commands' do
|
||||
let!(:project) { create(:project) }
|
||||
let(:user) { create(:user).tap { |u| project.team << [u, :master] } }
|
||||
let(:assignee) { create(:user) }
|
||||
let!(:milestone) { create(:milestone, project: project) }
|
||||
let!(:labels) { create_list(:label, 3, project: project) }
|
||||
let(:base_params) { { title: FFaker::Lorem.sentence(3) } }
|
||||
let(:params) { base_params.merge(defined?(default_params) ? default_params : {}).merge(example_params) }
|
||||
let(:issuable) { described_class.new(project, user, params).execute }
|
||||
|
||||
context 'with labels in command only' do
|
||||
let(:example_params) do
|
||||
{
|
||||
description: "/label ~#{labels.first.name} ~#{labels.second.name}\n/remove_label ~#{labels.third.name}"
|
||||
}
|
||||
end
|
||||
|
||||
it 'attaches labels to issuable' do
|
||||
expect(issuable).to be_persisted
|
||||
expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with labels in params and command' do
|
||||
let(:example_params) do
|
||||
{
|
||||
label_ids: [labels.second.id],
|
||||
description: "/label ~#{labels.first.name}\n/remove_label ~#{labels.third.name}"
|
||||
}
|
||||
end
|
||||
|
||||
it 'attaches all labels to issuable' do
|
||||
expect(issuable).to be_persisted
|
||||
expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with assignee and milestone in command only' do
|
||||
let(:example_params) do
|
||||
{
|
||||
description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
|
||||
}
|
||||
end
|
||||
|
||||
it 'assigns and sets milestone to issuable' do
|
||||
expect(issuable).to be_persisted
|
||||
expect(issuable.assignee).to eq(assignee)
|
||||
expect(issuable.milestone).to eq(milestone)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with assignee and milestone in params and command' do
|
||||
let(:example_params) do
|
||||
{
|
||||
assignee: build_stubbed(:user),
|
||||
milestone_id: double(:milestone),
|
||||
description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
|
||||
}
|
||||
end
|
||||
|
||||
it 'assigns and sets milestone to issuable from command' do
|
||||
expect(issuable).to be_persisted
|
||||
expect(issuable.assignee).to eq(assignee)
|
||||
expect(issuable.milestone).to eq(milestone)
|
||||
end
|
||||
end
|
||||
|
||||
describe '/close' do
|
||||
let(:example_params) do
|
||||
{
|
||||
description: '/close'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns an open issue' do
|
||||
expect(issuable).to be_persisted
|
||||
expect(issuable).to be_open
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,170 @@
|
|||
# Specifications for behavior common to all objects with executable attributes.
|
||||
# It takes a `issuable_type`, and expect an `issuable`.
|
||||
|
||||
shared_examples 'issuable record that supports slash commands in its description and notes' do |issuable_type|
|
||||
let(:user) { create(:user) }
|
||||
let(:assignee) { create(:user, username: 'bob') }
|
||||
let(:project) { create(:project, :public) }
|
||||
let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
|
||||
let!(:label_bug) { create(:label, project: project, title: 'bug') }
|
||||
let!(:label_feature) { create(:label, project: project, title: 'feature') }
|
||||
let(:new_url_opts) { {} }
|
||||
|
||||
before do
|
||||
project.team << [user, :master]
|
||||
project.team << [assignee, :developer]
|
||||
login_with(user)
|
||||
end
|
||||
|
||||
describe "new #{issuable_type}" do
|
||||
context 'with commands in the description' do
|
||||
it "creates the #{issuable_type} and interpret commands accordingly" do
|
||||
visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts)
|
||||
fill_in "#{issuable_type}_title", with: 'bug 345'
|
||||
fill_in "#{issuable_type}_description", with: "bug description\n/label ~bug\n/milestone %\"ASAP\""
|
||||
click_button "Submit #{issuable_type}".humanize
|
||||
|
||||
issuable = project.public_send(issuable_type.to_s.pluralize).first
|
||||
|
||||
expect(issuable.description).to eq "bug description\r\n"
|
||||
expect(issuable.labels).to eq [label_bug]
|
||||
expect(issuable.milestone).to eq milestone
|
||||
expect(page).to have_content 'bug 345'
|
||||
expect(page).to have_content 'bug description'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "note on #{issuable_type}" do
|
||||
before do
|
||||
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
|
||||
end
|
||||
|
||||
context 'with a note containing commands' do
|
||||
it 'creates a note without the commands and interpret the commands accordingly' do
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "Awesome!\n/assign @bob\n/label ~bug\n/milestone %\"ASAP\""
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).to have_content 'Awesome!'
|
||||
expect(page).not_to have_content '/assign @bob'
|
||||
expect(page).not_to have_content '/label ~bug'
|
||||
expect(page).not_to have_content '/milestone %"ASAP"'
|
||||
|
||||
issuable.reload
|
||||
note = issuable.notes.user.first
|
||||
|
||||
expect(note.note).to eq "Awesome!\r\n"
|
||||
expect(issuable.assignee).to eq assignee
|
||||
expect(issuable.labels).to eq [label_bug]
|
||||
expect(issuable.milestone).to eq milestone
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a note containing only commands' do
|
||||
it 'does not create a note but interpret the commands accordingly' do
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "/assign @bob\n/label ~bug\n/milestone %\"ASAP\""
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).not_to have_content '/assign @bob'
|
||||
expect(page).not_to have_content '/label ~bug'
|
||||
expect(page).not_to have_content '/milestone %"ASAP"'
|
||||
expect(page).to have_content 'Your commands are being executed.'
|
||||
|
||||
issuable.reload
|
||||
|
||||
expect(issuable.notes.user).to be_empty
|
||||
expect(issuable.assignee).to eq assignee
|
||||
expect(issuable.labels).to eq [label_bug]
|
||||
expect(issuable.milestone).to eq milestone
|
||||
end
|
||||
end
|
||||
|
||||
context "with a note marking the #{issuable_type} as todo" do
|
||||
it "creates a new todo for the #{issuable_type}" do
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "/todo"
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).not_to have_content '/todo'
|
||||
expect(page).to have_content 'Your commands are being executed.'
|
||||
|
||||
todos = TodosFinder.new(user).execute
|
||||
todo = todos.first
|
||||
|
||||
expect(todos.size).to eq 1
|
||||
expect(todo).to be_pending
|
||||
expect(todo.target).to eq issuable
|
||||
expect(todo.author).to eq user
|
||||
expect(todo.user).to eq user
|
||||
end
|
||||
end
|
||||
|
||||
context "with a note marking the #{issuable_type} as done" do
|
||||
before do
|
||||
TodoService.new.mark_todo(issuable, user)
|
||||
end
|
||||
|
||||
it "creates a new todo for the #{issuable_type}" do
|
||||
todos = TodosFinder.new(user).execute
|
||||
todo = todos.first
|
||||
|
||||
expect(todos.size).to eq 1
|
||||
expect(todos.first).to be_pending
|
||||
expect(todo.target).to eq issuable
|
||||
expect(todo.author).to eq user
|
||||
expect(todo.user).to eq user
|
||||
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "/done"
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).not_to have_content '/done'
|
||||
expect(page).to have_content 'Your commands are being executed.'
|
||||
|
||||
expect(todo.reload).to be_done
|
||||
end
|
||||
end
|
||||
|
||||
context "with a note subscribing to the #{issuable_type}" do
|
||||
it "creates a new todo for the #{issuable_type}" do
|
||||
expect(issuable.subscribed?(user)).to be_falsy
|
||||
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "/subscribe"
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).not_to have_content '/subscribe'
|
||||
expect(page).to have_content 'Your commands are being executed.'
|
||||
|
||||
expect(issuable.subscribed?(user)).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context "with a note unsubscribing to the #{issuable_type} as done" do
|
||||
before do
|
||||
issuable.subscribe(user)
|
||||
end
|
||||
|
||||
it "creates a new todo for the #{issuable_type}" do
|
||||
expect(issuable.subscribed?(user)).to be_truthy
|
||||
|
||||
page.within('.js-main-target-form') do
|
||||
fill_in 'note[note]', with: "/unsubscribe"
|
||||
click_button 'Comment'
|
||||
end
|
||||
|
||||
expect(page).not_to have_content '/unsubscribe'
|
||||
expect(page).to have_content 'Your commands are being executed.'
|
||||
|
||||
expect(issuable.subscribed?(user)).to be_falsy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,116 @@
|
|||
# Specifications for behavior common to all note objects with executable attributes.
|
||||
# It expects a `noteable` object for which the note is posted.
|
||||
|
||||
shared_context 'note on noteable' do
|
||||
let!(:project) { create(:project) }
|
||||
let(:user) { create(:user).tap { |u| project.team << [u, :master] } }
|
||||
let(:assignee) { create(:user) }
|
||||
let(:base_params) { { noteable: noteable } }
|
||||
let(:params) { base_params.merge(example_params) }
|
||||
let(:note) { described_class.new(project, user, params).execute }
|
||||
end
|
||||
|
||||
shared_examples 'note on noteable that does not support slash commands' do
|
||||
include_context 'note on noteable'
|
||||
|
||||
let(:params) { { commit_id: noteable.id, noteable_type: 'Commit' }.merge(example_params) }
|
||||
|
||||
describe 'note with only command' do
|
||||
describe '/close, /label, /assign & /milestone' do
|
||||
let(:note_text) { %(/close\n/assign @#{assignee.username}") }
|
||||
let(:example_params) { { note: note_text } }
|
||||
|
||||
it 'saves the note and does not alter the note text' do
|
||||
expect(note).to be_persisted
|
||||
expect(note.note).to eq note_text
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'note with command & text' do
|
||||
describe '/close, /label, /assign & /milestone' do
|
||||
let(:note_text) { %(HELLO\n/close\n/assign @#{assignee.username}\nWORLD) }
|
||||
let(:example_params) { { note: note_text } }
|
||||
|
||||
it 'saves the note and does not alter the note text' do
|
||||
expect(note).to be_persisted
|
||||
expect(note.note).to eq note_text
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'note on noteable that supports slash commands' do
|
||||
include_context 'note on noteable'
|
||||
|
||||
let!(:milestone) { create(:milestone, project: project) }
|
||||
let!(:labels) { create_pair(:label, project: project) }
|
||||
|
||||
describe 'note with only command' do
|
||||
describe '/close, /label, /assign & /milestone' do
|
||||
let(:example_params) do
|
||||
{
|
||||
note: %(/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
|
||||
}
|
||||
end
|
||||
|
||||
it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do
|
||||
expect(note).not_to be_persisted
|
||||
expect(note.note).to eq ''
|
||||
expect(noteable).to be_closed
|
||||
expect(noteable.labels).to match_array(labels)
|
||||
expect(noteable.assignee).to eq(assignee)
|
||||
expect(noteable.milestone).to eq(milestone)
|
||||
end
|
||||
end
|
||||
|
||||
describe '/open' do
|
||||
let(:noteable) { create(:issue, project: project, state: :closed) }
|
||||
let(:example_params) do
|
||||
{
|
||||
note: '/open'
|
||||
}
|
||||
end
|
||||
|
||||
it 'opens the noteable, and leave no note' do
|
||||
expect(note).not_to be_persisted
|
||||
expect(note.note).to eq ''
|
||||
expect(noteable).to be_open
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'note with command & text' do
|
||||
describe '/close, /label, /assign & /milestone' do
|
||||
let(:example_params) do
|
||||
{
|
||||
note: %(HELLO\n/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}"\nWORLD)
|
||||
}
|
||||
end
|
||||
|
||||
it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do
|
||||
expect(note).to be_persisted
|
||||
expect(note.note).to eq "HELLO\nWORLD"
|
||||
expect(noteable).to be_closed
|
||||
expect(noteable.labels).to match_array(labels)
|
||||
expect(noteable.assignee).to eq(assignee)
|
||||
expect(noteable.milestone).to eq(milestone)
|
||||
end
|
||||
end
|
||||
|
||||
describe '/open' do
|
||||
let(:noteable) { create(:issue, project: project, state: :closed) }
|
||||
let(:example_params) do
|
||||
{
|
||||
note: "HELLO\n/open\nWORLD"
|
||||
}
|
||||
end
|
||||
|
||||
it 'opens the noteable' do
|
||||
expect(note).to be_persisted
|
||||
expect(note.note).to eq "HELLO\nWORLD"
|
||||
expect(noteable).to be_open
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue