Commit 16884719 authored by Robert Speicher's avatar Robert Speicher Committed by Ruben Davila

Merge branch '4273-slash-commands' into 'master'

Support slash commands in issues / MR description & comments

See merge request !5021
parent abb1a595
...@@ -72,6 +72,7 @@ v 8.11.0 (unreleased) ...@@ -72,6 +72,7 @@ v 8.11.0 (unreleased)
- Optimize checking if a user has read access to a list of issues !5370 - 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 - Store all DB secrets in secrets.yml, under descriptive names !5274
- Fix syntax highlighting in file editor - Fix syntax highlighting in file editor
- Support slash commands in issue and merge request descriptions as well as comments. !5021
- Nokogiri's various parsing methods are now instrumented - Nokogiri's various parsing methods are now instrumented
- Add archived badge to project list !5798 - Add archived badge to project list !5798
- Add simple identifier to public SSH keys (muteor) - Add simple identifier to public SSH keys (muteor)
......
...@@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6' ...@@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6'
# Detect and convert string character encoding # Detect and convert string character encoding
gem 'charlock_holmes', '~> 0.7.3' gem 'charlock_holmes', '~> 0.7.3'
# Parse duration # Parse time & duration
gem 'chronic', '~> 0.10.2'
gem 'chronic_duration', '~> 0.10.6' gem 'chronic_duration', '~> 0.10.6'
gem 'sass-rails', '~> 5.0.0' gem 'sass-rails', '~> 5.0.0'
......
...@@ -128,6 +128,7 @@ GEM ...@@ -128,6 +128,7 @@ GEM
mime-types (>= 1.16) mime-types (>= 1.16)
cause (0.1) cause (0.1)
charlock_holmes (0.7.3) charlock_holmes (0.7.3)
chronic (0.10.2)
chronic_duration (0.10.6) chronic_duration (0.10.6)
numerizer (~> 0.1.1) numerizer (~> 0.1.1)
chunky_png (1.3.5) chunky_png (1.3.5)
...@@ -824,6 +825,7 @@ DEPENDENCIES ...@@ -824,6 +825,7 @@ DEPENDENCIES
capybara-screenshot (~> 1.0.0) capybara-screenshot (~> 1.0.0)
carrierwave (~> 0.10.0) carrierwave (~> 0.10.0)
charlock_holmes (~> 0.7.3) charlock_holmes (~> 0.7.3)
chronic (~> 0.10.2)
chronic_duration (~> 0.10.6) chronic_duration (~> 0.10.6)
coffee-rails (~> 4.1.0) coffee-rails (~> 4.1.0)
connection_pool (~> 2.0) connection_pool (~> 2.0)
......
...@@ -223,7 +223,7 @@ ...@@ -223,7 +223,7 @@
} }
} }
}); });
return this.input.atwho({ this.input.atwho({
at: '~', at: '~',
alias: 'labels', alias: 'labels',
searchKey: 'search', searchKey: 'search',
...@@ -249,6 +249,68 @@ ...@@ -249,6 +249,68 @@
} }
} }
}); });
// We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
this.input.filter('[data-supports-slash-commands="true"]').atwho({
at: '/',
alias: 'commands',
searchKey: 'search',
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 = "/${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,
beforeSave: function(commands) {
return $.map(commands, function(c) {
var search = c.name;
if (c.aliases.length > 0) {
search = search + " " + c.aliases.join(" ");
}
return {
name: c.name,
aliases: c.aliases,
params: c.params,
description: c.description,
search: search
};
});
},
matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi
var match = regexp.exec(subtext);
if (match) {
return match[1];
} else {
return null;
}
}
}
});
return;
}, },
destroyAtWho: function() { destroyAtWho: function() {
return this.input.atwho('destroy'); return this.input.atwho('destroy');
...@@ -265,6 +327,7 @@ ...@@ -265,6 +327,7 @@
this.input.atwho('load', 'mergerequests', data.mergerequests); this.input.atwho('load', 'mergerequests', data.mergerequests);
this.input.atwho('load', ':', data.emojis); this.input.atwho('load', ':', data.emojis);
this.input.atwho('load', '~', data.labels); this.input.atwho('load', '~', data.labels);
this.input.atwho('load', '/', data.commands);
return $(':focus').trigger('keyup'); return $(':focus').trigger('keyup');
} }
}; };
......
...@@ -231,7 +231,13 @@ ...@@ -231,7 +231,13 @@
var $notesList, votesBlock; var $notesList, votesBlock;
if (!note.valid) { if (!note.valid) {
if (note.award) { 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);
this.refresh();
}
} }
return; return;
} }
...@@ -245,6 +251,7 @@ ...@@ -245,6 +251,7 @@
$notesList.append(note.html).syntaxHighlight(); $notesList.append(note.html).syntaxHighlight();
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
this.initTaskList(); this.initTaskList();
this.refresh();
return this.updateNotesCount(1); return this.updateNotesCount(1);
} }
}; };
......
...@@ -147,3 +147,8 @@ ...@@ -147,3 +147,8 @@
color: $gl-link-color; color: $gl-link-color;
} }
} }
.atwho-view small.description {
float: right;
padding: 3px 5px;
}
...@@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController ...@@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController
end end
def commit def commit
@commit ||= @project.commit(params[:id]) @noteable = @commit ||= @project.commit(params[:id])
end end
def pipelines def pipelines
......
...@@ -177,11 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -177,11 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController
protected protected
def issue def issue
@issue ||= begin @noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old
@project.issues.find_by!(iid: params[:id])
rescue ActiveRecord::RecordNotFound
redirect_old
end
end end
alias_method :subscribable_resource, :issue alias_method :subscribable_resource, :issue
alias_method :issuable, :issue alias_method :issuable, :issue
...@@ -226,7 +222,6 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -226,7 +222,6 @@ class Projects::IssuesController < Projects::ApplicationController
if issue if issue
redirect_to issue_path(issue) redirect_to issue_path(issue)
return
else else
raise ActiveRecord::RecordNotFound.new raise ActiveRecord::RecordNotFound.new
end end
......
...@@ -381,7 +381,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -381,7 +381,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end end
def merge_request def merge_request
@merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
end end
alias_method :subscribable_resource, :merge_request alias_method :subscribable_resource, :merge_request
alias_method :issuable, :merge_request alias_method :issuable, :merge_request
......
...@@ -125,7 +125,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -125,7 +125,7 @@ class Projects::NotesController < Projects::ApplicationController
id: note.id, id: note.id,
name: note.name name: note.name
} }
elsif note.valid? elsif note.persisted?
Banzai::NoteRenderer.render([note], @project, current_user) Banzai::NoteRenderer.render([note], @project, current_user)
attrs = { attrs = {
......
...@@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController ...@@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController
end end
def autocomplete_sources def autocomplete_sources
note_type = params['type'] noteable =
note_id = params['type_id'] case params[:type]
when 'Issue'
IssuesFinder.new(current_user, project_id: @project.id, state: 'all').
execute.find_by(iid: params[:type_id])
when 'MergeRequest'
MergeRequestsFinder.new(current_user, project_id: @project.id, state: 'all').
execute.find_by(iid: params[:type_id])
when 'Commit'
@project.commit(params[:type_id])
else
nil
end
autocomplete = ::Projects::AutocompleteService.new(@project, current_user) autocomplete = ::Projects::AutocompleteService.new(@project, current_user)
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
@suggestions = { @suggestions = {
emojis: Gitlab::AwardEmoji.urls, emojis: Gitlab::AwardEmoji.urls,
...@@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController ...@@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController
milestones: autocomplete.milestones, milestones: autocomplete.milestones,
mergerequests: autocomplete.merge_requests, mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels, labels: autocomplete.labels,
members: participants members: participants,
commands: autocomplete.commands(noteable, params[:type])
} }
respond_to do |format| respond_to do |format|
......
...@@ -17,7 +17,7 @@ class TodosFinder ...@@ -17,7 +17,7 @@ class TodosFinder
attr_accessor :current_user, :params attr_accessor :current_user, :params
def initialize(current_user, params) def initialize(current_user, params = {})
@current_user = current_user @current_user = current_user
@params = params @params = params
end end
......
...@@ -69,15 +69,10 @@ class IssuableBaseService < BaseService ...@@ -69,15 +69,10 @@ class IssuableBaseService < BaseService
end end
def filter_labels 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(:add_label_ids)
filter_labels_in_param(:remove_label_ids) filter_labels_in_param(:remove_label_ids)
else
filter_labels_in_param(:label_ids) filter_labels_in_param(:label_ids)
end end
end
def filter_labels_in_param(key) def filter_labels_in_param(key)
return if params[key].to_a.empty? return if params[key].to_a.empty?
...@@ -85,27 +80,86 @@ class IssuableBaseService < BaseService ...@@ -85,27 +80,86 @@ class IssuableBaseService < BaseService
params[key] = project.labels.where(id: params[key]).pluck(:id) params[key] = project.labels.where(id: params[key]).pluck(:id)
end end
def update_issuable(issuable, attributes) def process_label_ids(attributes, existing_label_ids: nil)
issuable.with_transaction_returning_status do label_ids = attributes.delete(:label_ids)
add_label_ids = attributes.delete(:add_label_ids) add_label_ids = attributes.delete(:add_label_ids)
remove_label_ids = attributes.delete(:remove_label_ids) remove_label_ids = attributes.delete(:remove_label_ids)
issuable.label_ids |= add_label_ids if add_label_ids new_label_ids = existing_label_ids || label_ids || []
issuable.label_ids -= remove_label_ids if remove_label_ids
if add_label_ids.blank? && remove_label_ids.blank?
new_label_ids = label_ids if label_ids
else
new_label_ids |= add_label_ids if add_label_ids
new_label_ids -= remove_label_ids if remove_label_ids
end
new_label_ids
end
def merge_slash_commands_into_params!(issuable)
description, command_params =
SlashCommands::InterpretService.new(project, current_user).
execute(params[:description], issuable)
issuable.assign_attributes(attributes.merge(updated_by: current_user)) params[:description] = description
issuable.save params.merge!(command_params)
end
def create_issuable(issuable, attributes, label_ids:)
issuable.with_transaction_returning_status do
if issuable.save
issuable.update_attributes(label_ids: label_ids)
end
end
end
def create(issuable)
merge_slash_commands_into_params!(issuable)
filter_params
params.delete(:state_event)
params[:author] ||= current_user
label_ids = process_label_ids(params)
issuable.assign_attributes(params)
before_create(issuable)
if params.present? && create_issuable(issuable, params, label_ids: label_ids)
after_create(issuable)
issuable.create_cross_references!(current_user)
execute_hooks(issuable)
end
issuable
end
def before_create(issuable)
# To be overridden by subclasses
end
def after_create(issuable)
# To be overridden by subclasses
end
def update_issuable(issuable, attributes)
issuable.with_transaction_returning_status do
issuable.update(attributes.merge(updated_by: current_user))
end end
end end
def update(issuable) def update(issuable)
change_state(issuable) change_state(issuable)
change_subscription(issuable) change_subscription(issuable)
change_todo(issuable)
filter_params filter_params
old_labels = issuable.labels.to_a old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.to_a old_mentioned_users = issuable.mentioned_users.to_a
params[:label_ids] = process_label_ids(params, existing_label_ids: issuable.label_ids)
if params.present? && update_issuable(issuable, params) if params.present? && update_issuable(issuable, params)
issuable.reset_events_cache issuable.reset_events_cache
handle_common_system_notes(issuable, old_labels: old_labels) handle_common_system_notes(issuable, old_labels: old_labels)
...@@ -135,6 +189,16 @@ class IssuableBaseService < BaseService ...@@ -135,6 +189,16 @@ class IssuableBaseService < BaseService
end end
end end
def change_todo(issuable)
case params.delete(:todo_event)
when 'add'
todo_service.mark_todo(issuable, current_user)
when 'done'
todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
todo_service.mark_todos_as_done([todo], current_user) if todo
end
end
def has_changes?(issuable, old_labels: []) def has_changes?(issuable, old_labels: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
......
module Issues module Issues
class CloseService < Issues::BaseService class CloseService < Issues::BaseService
def execute(issue, commit: nil, notifications: true, system_note: true) def execute(issue, commit: nil, notifications: true, system_note: true)
return issue unless can?(current_user, :update_issue, issue)
if project.jira_tracker? && project.jira_service.active if project.jira_tracker? && project.jira_service.active
project.jira_service.execute(commit, issue) project.jira_service.execute(commit, issue)
todo_service.close_issue(issue, current_user) todo_service.close_issue(issue, current_user)
......
module Issues module Issues
class CreateService < Issues::BaseService class CreateService < Issues::BaseService
def execute def execute
filter_params
label_params = params.delete(:label_ids)
@request = params.delete(:request) @request = params.delete(:request)
@api = params.delete(:api) @api = params.delete(:api)
@issue = project.issues.new(params)
@issue.author = params[:author] || current_user
@issue.spam = spam_service.check(@api) @issue = project.issues.new
if @issue.save create(@issue)
@issue.update_attributes(label_ids: label_params) end
notification_service.new_issue(@issue, current_user)
todo_service.new_issue(@issue, current_user) def before_create(issuable)
event_service.open_issue(@issue, current_user) issuable.spam = spam_service.check(@api)
user_agent_detail_service.create
@issue.create_cross_references!(current_user)
execute_hooks(@issue, 'open')
end end
@issue def after_create(issuable)
event_service.open_issue(issuable, current_user)
notification_service.new_issue(issuable, current_user)
todo_service.new_issue(issuable, current_user)
user_agent_detail_service.create
end end
private private
......
module Issues module Issues
class ReopenService < Issues::BaseService class ReopenService < Issues::BaseService
def execute(issue) def execute(issue)
return issue unless can?(current_user, :update_issue, issue)
if issue.reopen if issue.reopen
event_service.reopen_issue(issue, current_user) event_service.reopen_issue(issue, current_user)
create_note(issue) create_note(issue)
......
module MergeRequests module MergeRequests
class CloseService < MergeRequests::BaseService class CloseService < MergeRequests::BaseService
def execute(merge_request, commit = nil) def execute(merge_request, commit = nil)
return merge_request unless can?(current_user, :update_merge_request, merge_request)
# If we close MergeRequest we want to ignore validation # If we close MergeRequest we want to ignore validation
# so we can close broken one (Ex. fork project removed) # so we can close broken one (Ex. fork project removed)
merge_request.allow_broken = true merge_request.allow_broken = true
......
...@@ -7,26 +7,19 @@ module MergeRequests ...@@ -7,26 +7,19 @@ module MergeRequests
source_project = @project source_project = @project
@project = Project.find(params[:target_project_id]) if params[:target_project_id] @project = Project.find(params[:target_project_id]) if params[:target_project_id]
filter_params params[:target_project_id] ||= source_project.id
label_params = params.delete(:label_ids)
force_remove_source_branch = params.delete(:force_remove_source_branch)
merge_request = MergeRequest.new(params) merge_request = MergeRequest.new
merge_request.source_project = source_project merge_request.source_project = source_project
merge_request.target_project ||= source_project merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
merge_request.author = current_user
merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
if merge_request.save create(merge_request)
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)
end end
merge_request def after_create(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 end
end end
module MergeRequests module MergeRequests
class ReopenService < MergeRequests::BaseService class ReopenService < MergeRequests::BaseService
def execute(merge_request) def execute(merge_request)
return merge_request unless can?(current_user, :update_merge_request, merge_request)
if merge_request.reopen if merge_request.reopen
event_service.reopen_mr(merge_request, current_user) event_service.reopen_mr(merge_request, current_user)
create_note(merge_request) create_note(merge_request)
......
...@@ -11,10 +11,33 @@ module Notes ...@@ -11,10 +11,33 @@ module Notes
return noteable.create_award_emoji(note.award_emoji_name, current_user) return noteable.create_award_emoji(note.award_emoji_name, current_user)
end end
if note.save # 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!
slash_commands_service = SlashCommandsService.new(project, current_user)
if slash_commands_service.supported?(note)
content, command_params = slash_commands_service.extract_commands(note)
only_commands = content.empty?
note.note = content
end
if !only_commands && note.save
# Finish the harder work in the background # Finish the harder work in the background
NewNoteWorker.perform_in(2.seconds, note.id, params) NewNoteWorker.perform_in(2.seconds, note.id, params)
TodoService.new.new_note(note, current_user) todo_service.new_note(note, current_user)
end
if command_params && command_params.any?
slash_commands_service.execute(command_params, note)
# We must add the error after we call #save because errors are reset
# when #save is called
if only_commands
note.errors.add(:commands_only, 'Your commands have been executed!')
end
end end
note note
......
module Notes
class SlashCommandsService < BaseService
UPDATE_SERVICES = {
'Issue' => Issues::UpdateService,
'MergeRequest' => MergeRequests::UpdateService
}
def supported?(note)
noteable_update_service(note) &&
can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable)
end
def extract_commands(note)
return [note.note, {}] unless supported?(note)
SlashCommands::InterpretService.new(project, current_user).
execute(note.note, note.noteable)
end
def execute(command_params, note)
return if command_params.empty?
return unless supported?(note)
noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable)
end
private
def noteable_update_service(note)
UPDATE_SERVICES[note.noteable_type]
end
end
end
module Projects module Projects
class AutocompleteService < BaseService class AutocompleteService < BaseService
def issues def issues
@project.issues.visible_to_user(current_user).opened.select([:iid, :title]) IssuesFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end end
def milestones def milestones
...@@ -9,11 +9,34 @@ module Projects ...@@ -9,11 +9,34 @@ module Projects
end end
def merge_requests def merge_requests
@project.merge_requests.opened.select([:iid, :title]) MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end end
def labels def labels
@project.labels.select([:title, :color]) @project.labels.select([:title, :color])
end end
def commands(noteable, type)
noteable ||=
case type
when 'Issue'
@project.issues.build
when 'MergeRequest'
@project.merge_requests.build
end
return [] unless noteable && noteable.is_a?(Issuable)
opts = {
project: project,
issuable: noteable,
current_user: current_user
}
SlashCommands::InterpretService.command_definitions.map do |definition|
next unless definition.available?(opts)
definition.to_h(opts)
end.compact
end
end end
end end
module Projects module Projects
class ParticipantsService < BaseService class ParticipantsService < BaseService
def execute(noteable_type, noteable_id) attr_reader :noteable
@noteable_type = noteable_type
@noteable_id = noteable_id def execute(noteable)
@noteable = noteable
project_members = sorted(project.team.members) project_members = sorted(project.team.members)
participants = target_owner + participants_in_target + all_members + groups + project_members participants = noteable_owner + participants_in_noteable + all_members + groups + project_members
participants.uniq participants.uniq
end end
def target def noteable_owner
@target ||= return [] unless noteable && noteable.author.present?
case @noteable_type
when "Issue"
project.issues.find_by_iid(@noteable_id)
when "MergeRequest"
project.merge_requests.find_by_iid(@noteable_id)
when "Commit"
project.commit(@noteable_id)
else
nil
end
end
def target_owner
return [] unless target && target.author.present?
[{ [{
name: target.author.name, name: noteable.author.name,
username: target.author.username username: noteable.author.username
}] }]
end end
def participants_in_target def participants_in_noteable
return [] unless target return [] unless noteable
users = target.participants(current_user) users = noteable.participants(current_user)
sorted(users) sorted(users)
end end
......
module SlashCommands
class InterpretService < BaseService
include Gitlab::SlashCommands::Dsl
attr_reader :issuable
# 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.
def execute(content, issuable)
@issuable = issuable
@updates = {}
opts = {
issuable: issuable,
current_user: current_user,
project: project
}
content, commands = extractor.extract_commands(content, opts)
commands.each do |name, arg|
definition = self.class.command_definitions_by_name[name.to_sym]
next unless definition
definition.execute(self, opts, arg)
end
[content, @updates]
end
private
def extractor
Gitlab::SlashCommands::Extractor.new(self.class.command_definitions)
end
desc do
"Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
end
condition do
issuable.persisted? &&
issuable.open? &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :close do
@updates[:state_event] = 'close'
end
desc do
"Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
end
condition do
issuable.persisted? &&
issuable.closed? &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :reopen do
@updates[:state_event] = 'reopen'
end
desc 'Change title'
params '<New title>'
condition do
issuable.persisted? &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :title do |title_param|
@updates[:title] = title_param
end
desc 'Assign'
params '@user'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :assign do |assignee_param|
user = extract_references(assignee_param, :user).first
user ||= User.find_by(username: assignee_param)
@updates[:assignee_id] = user.id if user
end
desc 'Remove assignee'
condition do
issuable.persisted? &&
issuable.assignee_id? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :unassign do
@updates[:assignee_id] = nil
end
desc 'Set milestone'
params '%"milestone"'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
project.milestones.active.any?
end
command :milestone do |milestone_param|
milestone = extract_references(milestone_param, :milestone).first
milestone ||= project.milestones.find_by(title: milestone_param.strip)
@updates[:milestone_id] = milestone.id if milestone
end
desc 'Remove milestone'
condition do
issuable.persisted? &&
issuable.milestone_id? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :remove_milestone do
@updates[:milestone_id] = nil
end
desc 'Add label(s)'
params '~label1 ~"label 2"'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
project.labels.any?
end
command :label do |labels_param|
label_ids = find_label_ids(labels_param)
@updates[:add_label_ids] = label_ids unless label_ids.empty?
end
desc 'Remove all or specific label(s)'
params '~label1 ~"label 2"'
condition do
issuable.persisted? &&
issuable.labels.any? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :unlabel do |labels_param = nil|
if labels_param.present?
label_ids = find_label_ids(labels_param)
@updates[:remove_label_ids] = label_ids unless label_ids.empty?
else
@updates[:label_ids] = []
end
end
desc 'Replace all label(s)'
params '~label1 ~"label 2"'
condition do
issuable.persisted? &&
issuable.labels.any? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :relabel do |labels_param|
label_ids = find_label_ids(labels_param)
@updates[:label_ids] = label_ids unless label_ids.empty?
end
desc 'Add a todo'
condition do
issuable.persisted? &&
!TodoService.new.todo_exist?(issuable, current_user)
end
command :todo do
@updates[:todo_event] = 'add'
end
desc 'Mark todo as done'
condition do
issuable.persisted? &&
TodoService.new.todo_exist?(issuable, current_user)
end
command :done do
@updates[:todo_event] = 'done'
end
desc 'Subscribe'
condition do
issuable.persisted? &&
!issuable.subscribed?(current_user)
end
command :subscribe do
@updates[:subscription_event] = 'subscribe'
end
desc 'Unsubscribe'
condition do
issuable.persisted? &&
issuable.subscribed?(current_user)
end
command :unsubscribe do
@updates[:subscription_event] = 'unsubscribe'
end
desc 'Set due date'
params '<in 2 days | this Friday | December 31st>'
condition do
issuable.respond_to?(:due_date) &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :due do |due_date_param|
due_date = Chronic.parse(due_date_param).try(:to_date)
@updates[:due_date] = due_date if due_date
end
desc 'Remove due date'
condition do
issuable.persisted? &&
issuable.respond_to?(:due_date) &&
issuable.due_date? &&
current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
end
command :remove_due_date do
@updates[:due_date] = nil
end
# This is a dummy command, so that it appears in the autocomplete commands
desc 'CC'
params '@user'
command :cc
def find_label_ids(labels_param)
label_ids_by_reference = extract_references(labels_param, :label).map(&:id)
labels_ids_by_name = @project.labels.where(name: labels_param.split).select(:id)
label_ids_by_reference | labels_ids_by_name
end
def extract_references(arg, type)
ext = Gitlab::ReferenceExtractor.new(project, current_user)
ext.analyze(arg, author: current_user)
ext.references(type)
end
end
end
...@@ -159,6 +159,10 @@ class TodoService ...@@ -159,6 +159,10 @@ class TodoService
create_todos(current_user, attributes) create_todos(current_user, attributes)
end end
def todo_exist?(issuable, current_user)
TodosFinder.new(current_user).execute.exists?(target: issuable)
end
private private
def create_todos(users, attributes) def create_todos(users, attributes)
......
- project = @target_project || @project - project = @target_project || @project
- noteable_class = @noteable.class if @noteable.present? - noteable_type = @noteable.class if @noteable.present?
:javascript :javascript
GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id])}" GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_type, type_id: params[:id])}"
GitLab.GfmAutoComplete.cachedData = undefined; GitLab.GfmAutoComplete.cachedData = undefined;
GitLab.GfmAutoComplete.setup(); GitLab.GfmAutoComplete.setup();
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.zen-backdrop .zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area' - classes << ' js-gfm-input js-autosize markdown-area'
- if defined?(f) && f - if defined?(f) && f
= f.text_area attr, class: classes, placeholder: placeholder = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands }
- else - else
= text_area_tag attr, nil, class: classes, placeholder: placeholder = text_area_tag attr, nil, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
......
...@@ -10,8 +10,12 @@ ...@@ -10,8 +10,12 @@
= f.hidden_field :position = f.hidden_field :position
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
= render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here..." = render 'projects/zen', f: f,
= render 'projects/notes/hints' attr: :note,
classes: 'note-textarea js-note-text',
placeholder: "Write a comment or drag your files here...",
supports_slash_commands: true
= render 'projects/notes/hints', supports_slash_commands: true
.error-alert .error-alert
.note-form-actions.clearfix .note-form-actions.clearfix
......
- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.comment-toolbar.clearfix .comment-toolbar.clearfix
.toolbar-text .toolbar-text
Styling with Styling with
= link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1 = link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1
is supported - if supports_slash_commands
and
= link_to 'slash commands', help_page_path('workflow/slash_commands'), target: '_blank', tabindex: -1
are
- else
is
supported
%button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' }
= icon('file-image-o', class: 'toolbar-button-icon') = icon('file-image-o', class: 'toolbar-button-icon')
Attach a file Attach a file
...@@ -30,8 +30,9 @@ ...@@ -30,8 +30,9 @@
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, = render 'projects/zen', f: f, attr: :description,
classes: 'note-textarea', classes: 'note-textarea',
placeholder: "Write a comment or drag your files here..." placeholder: "Write a comment or drag your files here...",
= render 'projects/notes/hints' supports_slash_commands: !issuable.persisted?
= render 'projects/notes/hints', supports_slash_commands: !issuable.persisted?
.clearfix .clearfix
.error-alert .error-alert
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
- [GitLab Flow](gitlab_flow.md) - [GitLab Flow](gitlab_flow.md)
- [Groups](groups.md) - [Groups](groups.md)
- [Keyboard shortcuts](shortcuts.md) - [Keyboard shortcuts](shortcuts.md)
- [Slash commands](slash_commands.md)
- [File finder](file_finder.md) - [File finder](file_finder.md)
- [Labels](../user/project/labels.md) - [Labels](../user/project/labels.md)
- [Notification emails](notifications.md) - [Notification emails](notifications.md)
......
# 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. The commands are removed from the issue, merge request or
comment body before it is saved and will not be visible to anyone else.
Below is a list of all of the available commands and descriptions about what they
do.
| Command | Action |
|:---------------------------|:-------------|
| `/close` | Close the issue or merge request |
| `/reopen` | Reopen the issue or merge request |
| `/title <New title>` | Change title |
| `/assign @username` | Assign |
| `/unassign` | Remove assignee |
| `/milestone %milestone` | Set milestone |
| `/remove_milestone` | Remove milestone |
| `/label ~foo ~"bar baz"` | Add label(s) |
| `/unlabel ~foo ~"bar baz"` | Remove all or specific label(s) |
| `/relabel ~foo ~"bar baz"` | Replace all label(s) |
| `/todo` | Add a todo |
| `/done` | Mark todo as done |
| `/subscribe` | Subscribe |
| `/unsubscribe` | Unsubscribe |
| `/due <in 2 days | this Friday | December 31st>` | Set due date |
| `/remove_due_date` | Remove due date |
...@@ -45,6 +45,7 @@ module Gitlab ...@@ -45,6 +45,7 @@ module Gitlab
def verify_record!(record:, invalid_exception:, record_name:) def verify_record!(record:, invalid_exception:, record_name:)
return if record.persisted? return if record.persisted?
return if record.errors.key?(:commands_only)
error_title = "The #{record_name} could not be created for the following reasons:" error_title = "The #{record_name} could not be created for the following reasons:"
......
module Gitlab
module SlashCommands
class CommandDefinition
attr_accessor :name, :aliases, :description, :params, :condition_block, :action_block
def initialize(name, attributes = {})
@name = name
@aliases = attributes[:aliases] || []
@description = attributes[:description] || ''
@params = attributes[:params] || []
@condition_block = attributes[:condition_block]
@action_block = attributes[:action_block]
end
def all_names
[name, *aliases]
end
def noop?
action_block.nil?
end
def available?(opts)
return true unless condition_block
context = OpenStruct.new(opts)
context.instance_exec(&condition_block)
end
def execute(context, opts, arg)
return if noop? || !available?(opts)
if arg.present?
context.instance_exec(arg, &action_block)
elsif action_block.arity == 0
context.instance_exec(&action_block)
end
end
def to_h(opts)
desc = description
if desc.respond_to?(:call)
context = OpenStruct.new(opts)
desc = context.instance_exec(&desc) rescue ''
end
{
name: name,
aliases: aliases,
description: desc,
params: params
}
end
end
end
end
module Gitlab
module SlashCommands
module Dsl
extend ActiveSupport::Concern
included do
cattr_accessor :command_definitions, instance_accessor: false do
[]
end
cattr_accessor :command_definitions_by_name, instance_accessor: false do
{}
end
end
class_methods do
# Allows to give a description to the next slash command.
# This description is shown in the autocomplete menu.
# It accepts a block that will be evaluated with the context given to
# `CommandDefintion#to_h`.
#
# Example:
#
# desc do
# "This is a dynamic description for #{noteable.to_ability_name}"
# end
# command :command_key do |arguments|
# # Awesome code block
# end
def desc(text = '', &block)
@description = block_given? ? block : text
end
# Allows to define params for the next slash command.
# These params are shown in the autocomplete menu.
#
# Example:
#
# params "~label ~label2"
# command :command_key do |arguments|
# # Awesome code block
# end
def params(*params)
@params = params
end
# Allows to define conditions that must be met in order for the command
# to be returned by `.command_names` & `.command_definitions`.
# It accepts a block that will be evaluated with the context given to
# `CommandDefintion#to_h`.
#
# Example:
#
# condition do
# project.public?
# end
# command :command_key do |arguments|
# # Awesome code block
# end
def condition(&block)
@condition_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)
name, *aliases = command_names
definition = CommandDefinition.new(
name,
aliases: aliases,
description: @description,
params: @params,
condition_block: @condition_block,
action_block: block
)
self.command_definitions << definition
definition.all_names.each do |name|
self.command_definitions_by_name[name] = definition
end
@description = nil
@params = nil
@condition_block = nil
end
end
end
end
end
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_definitions
def initialize(command_definitions)
@command_definitions = command_definitions
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(msg) #=> [['labels', '~foo ~"bar baz"']]
# msg #=> "hello\nworld"
# ```
def extract_commands(content, opts = {})
return [content, []] unless content
content = content.dup
commands = []
content.delete!("\r")
content.gsub!(commands_regex(opts)) do
if $~[:cmd]
commands << [$~[:cmd], $~[:arg]].reject(&:blank?)
''
else
$~[0]
end
end
[content.strip, 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|...)(?:( |$))(?<arg>[^\/\n]*)(?:\n|$)/
def commands_regex(opts)
names = command_names(opts).map(&:to_s)
@commands_regex ||= %r{
(?<code>
# Code blocks:
# ```
# Anything, including `/cmd arg` which are ignored by this filter
# ```
^```
.+?
\n```$
)
|
(?<html>
# HTML block:
# <tag>
# Anything, including `/cmd arg` which are ignored by this filter
# </tag>
^<[^>]+?>\n
.+?
\n<\/[^>]+?>$
)
|
(?<html>
# Quote block:
# >>>
# Anything, including `/cmd arg` which are ignored by this filter
# >>>
^>>>
.+?
\n>>>$
)
|
(?:
# Command not in a blockquote, blockcode, or HTML tag:
# /close
^\/
(?<cmd>#{Regexp.union(names)})
(?:
[ ]
(?<arg>[^\/\n]*)
)?
(?:\n|$)
)
}mx
end
def command_names(opts)
command_definitions.flat_map do |command|
next if command.noop?
command.all_names
end.compact
end
end
end
end
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 2016-08-28"
click_button 'Comment'
end
expect(page).not_to have_content '/due 2016-08-28'
expect(page).to have_content 'Your commands have been 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: "/remove_due_date"
click_button 'Comment'
end
expect(page).not_to have_content '/remove_due_date'
expect(page).to have_content 'Your commands have been executed!'
issue.reload
expect(issue.due_date).to be_nil
end
end
end
end
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 2016-08-28"
click_button 'Comment'
end
expect(page).not_to have_content '/due 2016-08-28'
end
end
end
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
Cool!
/close
/todo
/due tomorrow
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).
>
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
/todo
/due tomorrow
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,67 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do ...@@ -60,6 +60,67 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do
it "raises an InvalidNoteError" do it "raises an InvalidNoteError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError) expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end end
context 'because the note was commands only' do
let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") }
context 'and current user cannot update noteable' do
it 'raises a CommandsOnlyNoteError' do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
end
context 'and current user can update noteable' do
before do
project.team << [user, :developer]
end
it 'does not raise an error' do
expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
# One system note is created for the 'close' event
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
expect(noteable.reload).to be_closed
expect(noteable.due_date).to eq(Date.tomorrow)
expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy
end
end
end
end
context 'when the note contains slash commands' do
let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") }
context 'and current user cannot update noteable' do
it 'post a note and does not update the noteable' do
expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
# One system note is created for the new note
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
expect(noteable.reload).to be_open
expect(noteable.due_date).to be_nil
expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
end
end
context 'and current user can update noteable' do
before do
project.team << [user, :developer]
end
it 'post a note and updates the noteable' do
expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
# One system note is created for the new note, one for the 'close' event
expect { receiver.execute }.to change { noteable.notes.count }.by(2)
expect(noteable.reload).to be_closed
expect(noteable.due_date).to eq(Date.tomorrow)
expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy
end
end
end end
context "when the reply is blank" do context "when the reply is blank" do
......
require 'spec_helper'
describe Gitlab::SlashCommands::CommandDefinition do
subject { described_class.new(:command) }
describe "#all_names" do
context "when the command has aliases" do
before do
subject.aliases = [:alias1, :alias2]
end
it "returns an array with the name and aliases" do
expect(subject.all_names).to eq([:command, :alias1, :alias2])
end
end
context "when the command doesn't have aliases" do
it "returns an array with the name" do
expect(subject.all_names).to eq([:command])
end
end
end
describe "#noop?" do
context "when the command has an action block" do
before do
subject.action_block = proc { }
end
it "returns false" do
expect(subject.noop?).to be false
end
end
context "when the command doesn't have an action block" do
it "returns true" do
expect(subject.noop?).to be true
end
end
end
describe "#available?" do
let(:opts) { { go: false } }
context "when the command has a condition block" do
before do
subject.condition_block = proc { go }
end
context "when the condition block returns true" do
before do
opts[:go] = true
end
it "returns true" do
expect(subject.available?(opts)).to be true
end
end
context "when the condition block returns false" do
it "returns false" do
expect(subject.available?(opts)).to be false
end
end
end
context "when the command doesn't have a condition block" do
it "returns true" do
expect(subject.available?(opts)).to be true
end
end
end
describe "#execute" do
let(:context) { OpenStruct.new(run: false) }
context "when the command is a noop" do
it "doesn't execute the command" do
expect(context).not_to receive(:instance_exec)
subject.execute(context, {}, nil)
expect(context.run).to be false
end
end
context "when the command is not a noop" do
before do
subject.action_block = proc { self.run = true }
end
context "when the command is not available" do
before do
subject.condition_block = proc { false }
end
it "doesn't execute the command" do
subject.execute(context, {}, nil)
expect(context.run).to be false
end
end
context "when the command is available" do
context "when the commnd has no arguments" do
before do
subject.action_block = proc { self.run = true }
end
context "when the command is provided an argument" do
it "executes the command" do
subject.execute(context, {}, true)
expect(context.run).to be true
end
end
context "when the command is not provided an argument" do
it "executes the command" do
subject.execute(context, {}, nil)
expect(context.run).to be true
end
end
end
context "when the command has 1 required argument" do
before do
subject.action_block = ->(arg) { self.run = arg }
end
context "when the command is provided an argument" do
it "executes the command" do
subject.execute(context, {}, true)
expect(context.run).to be true
end
end
context "when the command is not provided an argument" do
it "doesn't execute the command" do
subject.execute(context, {}, nil)
expect(context.run).to be false
end
end
end
context "when the command has 1 optional argument" do
before do
subject.action_block = proc { |arg = nil| self.run = arg || true }
end
context "when the command is provided an argument" do
it "executes the command" do
subject.execute(context, {}, true)
expect(context.run).to be true
end
end
context "when the command is not provided an argument" do
it "executes the command" do
subject.execute(context, {}, nil)
expect(context.run).to be true
end
end
end
end
end
end
end
require 'spec_helper'
describe Gitlab::SlashCommands::Dsl do
before :all do
DummyClass = Struct.new(:project) do
include Gitlab::SlashCommands::Dsl
desc 'A command with no args'
command :no_args, :none do
"Hello World!"
end
params 'The first argument'
command :one_arg, :once, :first do |arg1|
arg1
end
desc do
"A dynamic description for #{noteable.upcase}"
end
params 'The first argument', 'The second argument'
command :two_args do |arg1, arg2|
[arg1, arg2]
end
command :cc
condition do
project == 'foo'
end
command :cond_action do |arg|
arg
end
end
end
describe '.command_definitions' do
it 'returns an array with commands definitions' do
no_args_def, one_arg_def, two_args_def, cc_def, cond_action_def = DummyClass.command_definitions
expect(no_args_def.name).to eq(:no_args)
expect(no_args_def.aliases).to eq([:none])
expect(no_args_def.description).to eq('A command with no args')
expect(no_args_def.params).to eq([])
expect(no_args_def.condition_block).to be_nil
expect(no_args_def.action_block).to be_a_kind_of(Proc)
expect(one_arg_def.name).to eq(:one_arg)
expect(one_arg_def.aliases).to eq([:once, :first])
expect(one_arg_def.description).to eq('')
expect(one_arg_def.params).to eq(['The first argument'])
expect(one_arg_def.condition_block).to be_nil
expect(one_arg_def.action_block).to be_a_kind_of(Proc)
expect(two_args_def.name).to eq(:two_args)
expect(two_args_def.aliases).to eq([])
expect(two_args_def.to_h(noteable: "issue")[:description]).to eq('A dynamic description for ISSUE')
expect(two_args_def.params).to eq(['The first argument', 'The second argument'])
expect(two_args_def.condition_block).to be_nil
expect(two_args_def.action_block).to be_a_kind_of(Proc)
expect(cc_def.name).to eq(:cc)
expect(cc_def.aliases).to eq([])
expect(cc_def.description).to eq('')
expect(cc_def.params).to eq([])
expect(cc_def.condition_block).to be_nil
expect(cc_def.action_block).to be_nil
expect(cond_action_def.name).to eq(:cond_action)
expect(cond_action_def.aliases).to eq([])
expect(cond_action_def.description).to eq('')
expect(cond_action_def.params).to eq([])
expect(cond_action_def.condition_block).to be_a_kind_of(Proc)
expect(cond_action_def.action_block).to be_a_kind_of(Proc)
end
end
end
require 'spec_helper'
describe Gitlab::SlashCommands::Extractor do
let(:definitions) do
Class.new do
include Gitlab::SlashCommands::Dsl
command(:reopen, :open) { }
command(:assign) { }
command(:labels) { }
command(:power) { }
end.command_definitions
end
let(:extractor) { described_class.new(definitions) }
shared_examples 'command with no argument' do
it 'extracts command' do
msg, commands = extractor.extract_commands(original_msg)
expect(commands).to eq [['reopen']]
expect(msg).to eq final_msg
end
end
shared_examples 'command with a single argument' do
it 'extracts command' do
msg, commands = extractor.extract_commands(original_msg)
expect(commands).to eq [['assign', '@joe']]
expect(msg).to eq final_msg
end
end
shared_examples 'command with multiple arguments' do
it 'extracts command' do
msg, commands = extractor.extract_commands(original_msg)
expect(commands).to eq [['labels', '~foo ~"bar baz" label']]
expect(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) { "/reopen\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/reopen\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 /reopen"
msg, commands = extractor.extract_commands(msg)
expect(commands).to be_empty
expect(msg).to eq "hello\nworld /reopen"
end
end
context 'at the end of content' do
it_behaves_like 'command with no argument' do
let(:original_msg) { "hello\n/reopen" }
let(:final_msg) { "hello" }
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"
msg, 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" }
end
end
context 'when argument is not separated with a space' do
it 'does not extract command' do
msg = "hello\n/assign@joe\nworld"
msg, 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)
msg, 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" }
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)
msg, 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)
msg, 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/reopen)
msg, commands = extractor.extract_commands(msg)
expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['reopen']]
expect(msg).to eq "hello\nworld"
end
it 'does not alter original content if no command is found' do
msg = 'Fixes #123'
msg, commands = extractor.extract_commands(msg)
expect(commands).to be_empty
expect(msg).to eq 'Fixes #123'
end
it 'does not extract commands inside a blockcode' do
msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld"
expected = msg.delete("\r")
msg, commands = extractor.extract_commands(msg)
expect(commands).to be_empty
expect(msg).to eq expected
end
it 'does not extract commands inside a blockquote' do
msg = "Hello\r\n>>>\r\nThis is some text\r\n/close\r\n/assign @user\r\n>>>\r\n\r\nWorld"
expected = msg.delete("\r")
msg, commands = extractor.extract_commands(msg)
expect(commands).to be_empty
expect(msg).to eq expected
end
it 'does not extract commands inside a HTML tag' do
msg = "Hello\r\n<div>\r\nThis is some text\r\n/close\r\n/assign @user\r\n</div>\r\n\r\nWorld"
expected = msg.delete("\r")
msg, commands = extractor.extract_commands(msg)
expect(commands).to be_empty
expect(msg).to eq expected
end
end
end
...@@ -3,6 +3,7 @@ require 'spec_helper' ...@@ -3,6 +3,7 @@ require 'spec_helper'
describe Issues::CloseService, services: true do describe Issues::CloseService, services: true do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:user2) { create(:user) } let(:user2) { create(:user) }
let(:guest) { create(:user) }
let(:issue) { create(:issue, assignee: user2) } let(:issue) { create(:issue, assignee: user2) }
let(:project) { issue.project } let(:project) { issue.project }
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) }
...@@ -10,13 +11,14 @@ describe Issues::CloseService, services: true do ...@@ -10,13 +11,14 @@ describe Issues::CloseService, services: true do
before do before do
project.team << [user, :master] project.team << [user, :master]
project.team << [user2, :developer] project.team << [user2, :developer]
project.team << [guest, :guest]
end end
describe '#execute' do describe '#execute' do
context "valid params" do context "valid params" do
before do before do
perform_enqueued_jobs do perform_enqueued_jobs do
@issue = Issues::CloseService.new(project, user, {}).execute(issue) @issue = described_class.new(project, user, {}).execute(issue)
end end
end end
...@@ -39,10 +41,22 @@ describe Issues::CloseService, services: true do ...@@ -39,10 +41,22 @@ describe Issues::CloseService, services: true do
end end
end end
context 'current user is not authorized to close issue' do
before do
perform_enqueued_jobs do
@issue = described_class.new(project, guest).execute(issue)
end
end
it 'does not close the issue' do
expect(@issue).to be_open
end
end
context "external issue tracker" do context "external issue tracker" do
before do before do
allow(project).to receive(:default_issues_tracker?).and_return(false) allow(project).to receive(:default_issues_tracker?).and_return(false)
@issue = Issues::CloseService.new(project, user, {}).execute(issue) @issue = described_class.new(project, user, {}).execute(issue)
end end
it { expect(@issue).to be_valid } it { expect(@issue).to be_valid }
......
...@@ -73,5 +73,7 @@ describe Issues::CreateService, services: true do ...@@ -73,5 +73,7 @@ describe Issues::CreateService, services: true do
end end
end end
end end
it_behaves_like 'new issuable record that supports slash commands'
end end
end end
require 'spec_helper'
describe Issues::ReopenService, services: true do
let(:guest) { create(:user) }
let(:issue) { create(:issue, :closed) }
let(:project) { issue.project }
before do
project.team << [guest, :guest]
end
describe '#execute' do
context 'current user is not authorized to reopen issue' do
before do
perform_enqueued_jobs do
@issue = described_class.new(project, guest).execute(issue)
end
end
it 'does not reopen the issue' do
expect(@issue).to be_closed
end
end
end
end
...@@ -3,6 +3,7 @@ require 'spec_helper' ...@@ -3,6 +3,7 @@ require 'spec_helper'
describe MergeRequests::CloseService, services: true do describe MergeRequests::CloseService, services: true do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:user2) { create(:user) } let(:user2) { create(:user) }
let(:guest) { create(:user) }
let(:merge_request) { create(:merge_request, assignee: user2) } let(:merge_request) { create(:merge_request, assignee: user2) }
let(:project) { merge_request.project } let(:project) { merge_request.project }
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: merge_request, author: user2) } let!(:todo) { create(:todo, :assigned, user: user, project: project, target: merge_request, author: user2) }
...@@ -10,11 +11,12 @@ describe MergeRequests::CloseService, services: true do ...@@ -10,11 +11,12 @@ describe MergeRequests::CloseService, services: true do
before do before do
project.team << [user, :master] project.team << [user, :master]
project.team << [user2, :developer] project.team << [user2, :developer]
project.team << [guest, :guest]
end end
describe '#execute' do describe '#execute' do
context 'valid params' do context 'valid params' do
let(:service) { MergeRequests::CloseService.new(project, user, {}) } let(:service) { described_class.new(project, user, {}) }
before do before do
allow(service).to receive(:execute_hooks) allow(service).to receive(:execute_hooks)
...@@ -47,5 +49,17 @@ describe MergeRequests::CloseService, services: true do ...@@ -47,5 +49,17 @@ describe MergeRequests::CloseService, services: true do
expect(todo.reload).to be_done expect(todo.reload).to be_done
end end
end end
context 'current user is not authorized to close merge request' do
before do
perform_enqueued_jobs do
@merge_request = described_class.new(project, guest).execute(merge_request)
end
end
it 'does not close the merge request' do
expect(@merge_request).to be_open
end
end
end end
end end
...@@ -17,7 +17,7 @@ describe MergeRequests::CreateService, services: true do ...@@ -17,7 +17,7 @@ describe MergeRequests::CreateService, services: true do
} }
end end
let(:service) { MergeRequests::CreateService.new(project, user, opts) } let(:service) { described_class.new(project, user, opts) }
before do before do
project.team << [user, :master] project.team << [user, :master]
...@@ -74,5 +74,14 @@ describe MergeRequests::CreateService, services: true do ...@@ -74,5 +74,14 @@ describe MergeRequests::CreateService, services: true do
end end
end 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
end end
...@@ -3,22 +3,23 @@ require 'spec_helper' ...@@ -3,22 +3,23 @@ require 'spec_helper'
describe MergeRequests::ReopenService, services: true do describe MergeRequests::ReopenService, services: true do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:user2) { create(:user) } let(:user2) { create(:user) }
let(:merge_request) { create(:merge_request, assignee: user2) } let(:guest) { create(:user) }
let(:merge_request) { create(:merge_request, :closed, assignee: user2) }
let(:project) { merge_request.project } let(:project) { merge_request.project }
before do before do
project.team << [user, :master] project.team << [user, :master]
project.team << [user2, :developer] project.team << [user2, :developer]
project.team << [guest, :guest]
end end
describe '#execute' do describe '#execute' do
context 'valid params' do context 'valid params' do
let(:service) { MergeRequests::ReopenService.new(project, user, {}) } let(:service) { described_class.new(project, user, {}) }
before do before do
allow(service).to receive(:execute_hooks) allow(service).to receive(:execute_hooks)
merge_request.state = :closed
perform_enqueued_jobs do perform_enqueued_jobs do
service.execute(merge_request) service.execute(merge_request)
end end
...@@ -43,5 +44,17 @@ describe MergeRequests::ReopenService, services: true do ...@@ -43,5 +44,17 @@ describe MergeRequests::ReopenService, services: true do
expect(note.note).to include 'Status changed to reopened' expect(note.note).to include 'Status changed to reopened'
end end
end end
context 'current user is not authorized to reopen merge request' do
before do
perform_enqueued_jobs do
@merge_request = described_class.new(project, guest).execute(merge_request)
end
end
it 'does not reopen the merge request' do
expect(@merge_request).to be_closed
end
end
end end
end end
...@@ -4,22 +4,36 @@ describe Notes::CreateService, services: true do ...@@ -4,22 +4,36 @@ describe Notes::CreateService, services: true do
let(:project) { create(:empty_project) } let(:project) { create(:empty_project) }
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:opts) do
{ note: 'Awesome comment', noteable_type: 'Issue', noteable_id: issue.id }
end
describe '#execute' do describe '#execute' do
context "valid params" do
before do before do
project.team << [user, :master] project.team << [user, :master]
opts = { end
note: 'Awesome comment',
noteable_type: 'Issue',
noteable_id: issue.id
}
context "valid params" do
before do
@note = Notes::CreateService.new(project, user, opts).execute @note = Notes::CreateService.new(project, user, opts).execute
end end
it { expect(@note).to be_valid } it { expect(@note).to be_valid }
it { expect(@note.note).to eq('Awesome comment') } it { expect(@note.note).to eq(opts[:note]) }
end
describe 'note with commands' do
describe '/close, /label, /assign & /milestone' do
let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) }
it 'saves the note and does not alter the note text' do
expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original
note = described_class.new(project, user, opts.merge(note: note_text)).execute
expect(note.note).to eq "HELLO\nWORLD"
end
end
end end
end end
...@@ -42,7 +56,7 @@ describe Notes::CreateService, services: true do ...@@ -42,7 +56,7 @@ describe Notes::CreateService, services: true do
it "creates regular note if emoji name is invalid" do it "creates regular note if emoji name is invalid" do
opts = { opts = {
note: ':smile: moretext: ', note: ':smile: moretext:',
noteable_type: 'Issue', noteable_type: 'Issue',
noteable_id: issue.id noteable_id: issue.id
} }
......
require 'spec_helper'
describe Notes::SlashCommandsService, services: true do
shared_context 'note on noteable' do
let(:project) { create(:empty_project) }
let(:master) { create(:user).tap { |u| project.team << [u, :master] } }
let(:assignee) { create(:user) }
end
shared_examples 'note on noteable that does not support slash commands' do
include_context 'note on noteable'
before do
note.note = note_text
end
describe 'note with only command' do
describe '/close, /label, /assign & /milestone' do
let(:note_text) { %(/close\n/assign @#{assignee.username}") }
it 'saves the note and does not alter the note text' do
content, command_params = service.extract_commands(note)
expect(content).to eq note_text
expect(command_params).to be_empty
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) }
it 'saves the note and does not alter the note text' do
content, command_params = service.extract_commands(note)
expect(content).to eq note_text
expect(command_params).to be_empty
end
end
end
end
shared_examples 'note on noteable that supports slash commands' do
include_context 'note on noteable'
before do
note.note = note_text
end
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(:note_text) do
%(/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
content, command_params = service.extract_commands(note)
service.execute(command_params, note)
expect(content).to eq ''
expect(note.noteable).to be_closed
expect(note.noteable.labels).to match_array(labels)
expect(note.noteable.assignee).to eq(assignee)
expect(note.noteable.milestone).to eq(milestone)
end
end
describe '/reopen' do
before do
note.noteable.close!
expect(note.noteable).to be_closed
end
let(:note_text) { '/reopen' }
it 'opens the noteable, and leave no note' do
content, command_params = service.extract_commands(note)
service.execute(command_params, note)
expect(content).to eq ''
expect(note.noteable).to be_open
end
end
end
describe 'note with command & text' do
describe '/close, /label, /assign & /milestone' do
let(:note_text) do
%(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
content, command_params = service.extract_commands(note)
service.execute(command_params, note)
expect(content).to eq "HELLO\nWORLD"
expect(note.noteable).to be_closed
expect(note.noteable.labels).to match_array(labels)
expect(note.noteable.assignee).to eq(assignee)
expect(note.noteable.milestone).to eq(milestone)
end
end
describe '/reopen' do
before do
note.noteable.close
expect(note.noteable).to be_closed
end
let(:note_text) { "HELLO\n/reopen\nWORLD" }
it 'opens the noteable' do
content, command_params = service.extract_commands(note)
service.execute(command_params, note)
expect(content).to eq "HELLO\nWORLD"
expect(note.noteable).to be_open
end
end
end
end
describe '#execute' do
let(:service) { described_class.new(project, master) }
it_behaves_like 'note on noteable that supports slash commands' do
let(:note) { build(:note_on_issue, project: project) }
end
it_behaves_like 'note on noteable that supports slash commands' do
let(:note) { build(:note_on_merge_request, project: project) }
end
it_behaves_like 'note on noteable that does not support slash commands' do
let(:note) { build(:note_on_commit, project: project) }
end
end
end
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') }
before do
project.team << [user, :developer]
end
describe '#execute' do
let(:service) { described_class.new(project, user) }
let(:merge_request) { create(:merge_request, source_project: project) }
shared_examples 'reopen command' do
it 'returns state_event: "reopen" if content contains /reopen' do
issuable.close!
_, updates = service.execute(content, issuable)
expect(updates).to eq(state_event: 'reopen')
end
end
shared_examples 'close command' do
it 'returns state_event: "close" if content contains /close' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(state_event: 'close')
end
end
shared_examples 'title command' do
it 'populates title: "A brand new title" if content contains /title A brand new title' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(title: 'A brand new title')
end
end
shared_examples 'assign command' do
it 'fetches assignee and populates assignee_id if content contains /assign' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(assignee_id: user.id)
end
end
shared_examples 'unassign command' do
it 'populates assignee_id: nil if content contains /unassign' do
issuable.update(assignee_id: user.id)
_, updates = service.execute(content, issuable)
expect(updates).to eq(assignee_id: nil)
end
end
shared_examples 'milestone command' do
it 'fetches milestone and populates milestone_id if content contains /milestone' do
milestone # populate the milestone
_, updates = service.execute(content, issuable)
expect(updates).to eq(milestone_id: milestone.id)
end
end
shared_examples 'remove_milestone command' do
it 'populates milestone_id: nil if content contains /remove_milestone' do
issuable.update(milestone_id: milestone.id)
_, updates = service.execute(content, issuable)
expect(updates).to eq(milestone_id: nil)
end
end
shared_examples 'label command' do
it 'fetches label ids and populates add_label_ids if content contains /label' do
bug # populate the label
inprogress # populate the label
_, updates = service.execute(content, issuable)
expect(updates).to eq(add_label_ids: [bug.id, inprogress.id])
end
end
shared_examples 'unlabel command' do
it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do
issuable.update(label_ids: [inprogress.id]) # populate the label
_, updates = service.execute(content, issuable)
expect(updates).to eq(remove_label_ids: [inprogress.id])
end
end
shared_examples 'unlabel command with no argument' do
it 'populates label_ids: [] if content contains /unlabel with no arguments' do
issuable.update(label_ids: [inprogress.id]) # populate the label
_, updates = service.execute(content, issuable)
expect(updates).to eq(label_ids: [])
end
end
shared_examples 'relabel command' do
it 'populates label_ids: [] if content contains /relabel' do
issuable.update(label_ids: [bug.id]) # populate the label
inprogress # populate the label
_, updates = service.execute(content, issuable)
expect(updates).to eq(label_ids: [inprogress.id])
end
end
shared_examples 'todo command' do
it 'populates todo_event: "add" if content contains /todo' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(todo_event: 'add')
end
end
shared_examples 'done command' do
it 'populates todo_event: "done" if content contains /done' do
TodoService.new.mark_todo(issuable, user)
_, updates = service.execute(content, issuable)
expect(updates).to eq(todo_event: 'done')
end
end
shared_examples 'subscribe command' do
it 'populates subscription_event: "subscribe" if content contains /subscribe' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(subscription_event: 'subscribe')
end
end
shared_examples 'unsubscribe command' do
it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do
issuable.subscribe(user)
_, updates = service.execute(content, issuable)
expect(updates).to eq(subscription_event: 'unsubscribe')
end
end
shared_examples 'due command' do
it 'populates due_date: Date.new(2016, 8, 28) if content contains /due 2016-08-28' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28))
end
end
shared_examples 'remove_due_date command' do
it 'populates due_date: nil if content contains /remove_due_date' do
issuable.update(due_date: Date.today)
_, updates = service.execute(content, issuable)
expect(updates).to eq(due_date: nil)
end
end
shared_examples 'empty command' do
it 'populates {} if content contains an unsupported command' do
_, updates = service.execute(content, issuable)
expect(updates).to be_empty
end
end
it_behaves_like 'reopen command' do
let(:content) { '/reopen' }
let(:issuable) { issue }
end
it_behaves_like 'reopen command' do
let(:content) { '/reopen' }
let(:issuable) { merge_request }
end
it_behaves_like 'close command' do
let(:content) { '/close' }
let(:issuable) { issue }
end
it_behaves_like 'close command' do
let(:content) { '/close' }
let(:issuable) { merge_request }
end
it_behaves_like 'title command' do
let(:content) { '/title A brand new title' }
let(:issuable) { issue }
end
it_behaves_like 'title command' do
let(:content) { '/title A brand new title' }
let(:issuable) { merge_request }
end
it_behaves_like 'empty command' do
let(:content) { '/title' }
let(:issuable) { issue }
end
it_behaves_like 'assign command' do
let(:content) { "/assign @#{user.username}" }
let(:issuable) { issue }
end
it_behaves_like 'assign command' do
let(:content) { "/assign @#{user.username}" }
let(:issuable) { merge_request }
end
it_behaves_like 'empty command' do
let(:content) { '/assign @abcd1234' }
let(:issuable) { issue }
end
it_behaves_like 'empty command' do
let(:content) { '/assign' }
let(:issuable) { issue }
end
it_behaves_like 'unassign command' do
let(:content) { '/unassign' }
let(:issuable) { issue }
end
it_behaves_like 'unassign command' do
let(:content) { '/unassign' }
let(:issuable) { merge_request }
end
it_behaves_like 'milestone command' do
let(:content) { "/milestone %#{milestone.title}" }
let(:issuable) { issue }
end
it_behaves_like 'milestone command' do
let(:content) { "/milestone %#{milestone.title}" }
let(:issuable) { merge_request }
end
it_behaves_like 'remove_milestone command' do
let(:content) { '/remove_milestone' }
let(:issuable) { issue }
end
it_behaves_like 'remove_milestone command' do
let(:content) { '/remove_milestone' }
let(:issuable) { merge_request }
end
it_behaves_like 'label command' do
let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
let(:issuable) { issue }
end
it_behaves_like 'label command' do
let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
let(:issuable) { merge_request }
end
it_behaves_like 'unlabel command' do
let(:content) { %(/unlabel ~"#{inprogress.title}") }
let(:issuable) { issue }
end
it_behaves_like 'unlabel command' do
let(:content) { %(/unlabel ~"#{inprogress.title}") }
let(:issuable) { merge_request }
end
it_behaves_like 'unlabel command with no argument' do
let(:content) { %(/unlabel) }
let(:issuable) { issue }
end
it_behaves_like 'unlabel command with no argument' do
let(:content) { %(/unlabel) }
let(:issuable) { merge_request }
end
it_behaves_like 'relabel command' do
let(:content) { %(/relabel ~"#{inprogress.title}") }
let(:issuable) { issue }
end
it_behaves_like 'relabel command' do
let(:content) { %(/relabel ~"#{inprogress.title}") }
let(:issuable) { merge_request }
end
it_behaves_like 'todo command' do
let(:content) { '/todo' }
let(:issuable) { issue }
end
it_behaves_like 'todo command' do
let(:content) { '/todo' }
let(:issuable) { merge_request }
end
it_behaves_like 'done command' do
let(:content) { '/done' }
let(:issuable) { issue }
end
it_behaves_like 'done command' do
let(:content) { '/done' }
let(:issuable) { merge_request }
end
it_behaves_like 'subscribe command' do
let(:content) { '/subscribe' }
let(:issuable) { issue }
end
it_behaves_like 'subscribe command' do
let(:content) { '/subscribe' }
let(:issuable) { merge_request }
end
it_behaves_like 'unsubscribe command' do
let(:content) { '/unsubscribe' }
let(:issuable) { issue }
end
it_behaves_like 'unsubscribe command' do
let(:content) { '/unsubscribe' }
let(:issuable) { merge_request }
end
it_behaves_like 'due command' do
let(:content) { '/due 2016-08-28' }
let(:issuable) { issue }
end
it_behaves_like 'due command' do
let(:content) { '/due tomorrow' }
let(:issuable) { issue }
let(:expected_date) { Date.tomorrow }
end
it_behaves_like 'due command' do
let(:content) { '/due 5 days from now' }
let(:issuable) { issue }
let(:expected_date) { 5.days.from_now.to_date }
end
it_behaves_like 'due command' do
let(:content) { '/due in 2 days' }
let(:issuable) { issue }
let(:expected_date) { 2.days.from_now.to_date }
end
it_behaves_like 'empty command' do
let(:content) { '/due foo bar' }
let(:issuable) { issue }
end
it_behaves_like 'empty command' do
let(:content) { '/due 2016-08-28' }
let(:issuable) { merge_request }
end
it_behaves_like 'remove_due_date command' do
let(:content) { '/remove_due_date' }
let(:issuable) { issue }
end
it_behaves_like 'empty command' do
let(:content) { '/remove_due_date' }
let(:issuable) { merge_request }
end
end
end
...@@ -300,6 +300,18 @@ describe TodoService, services: true do ...@@ -300,6 +300,18 @@ describe TodoService, services: true do
should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED) should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED)
end end
end end
describe '#todo_exists?' do
it 'returns false when no todo exist for the given issuable' do
expect(service.todo_exist?(unassigned_issue, author)).to be_falsy
end
it 'returns true when a todo exist for the given issuable' do
service.mark_todo(unassigned_issue, author)
expect(service.todo_exist?(unassigned_issue, author)).to be_truthy
end
end
end end
describe 'Merge Requests' do describe 'Merge Requests' do
......
# 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/unlabel ~#{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/unlabel ~#{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
# 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(:master) { create(:user) }
let(:assignee) { create(:user, username: 'bob') }
let(:guest) { create(:user) }
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 << [master, :master]
project.team << [assignee, :developer]
project.team << [guest, :guest]
login_with(master)
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"
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!"
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 have been 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 closing the #{issuable_type}" do
before do
expect(issuable).to be_open
end
context "when current user can close #{issuable_type}" do
it "closes the #{issuable_type}" do
page.within('.js-main-target-form') do
fill_in 'note[note]', with: "/close"
click_button 'Comment'
end
expect(page).not_to have_content '/close'
expect(page).to have_content 'Your commands have been executed!'
expect(issuable.reload).to be_closed
end
end
context "when current user cannot close #{issuable_type}" do
before do
logout
login_with(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not close the #{issuable_type}" do
page.within('.js-main-target-form') do
fill_in 'note[note]', with: "/close"
click_button 'Comment'
end
expect(page).not_to have_content '/close'
expect(page).not_to have_content 'Your commands have been executed!'
expect(issuable).to be_open
end
end
end
context "with a note reopening the #{issuable_type}" do
before do
issuable.close
expect(issuable).to be_closed
end
context "when current user can reopen #{issuable_type}" do
it "reopens the #{issuable_type}" do
page.within('.js-main-target-form') do
fill_in 'note[note]', with: "/reopen"
click_button 'Comment'
end
expect(page).not_to have_content '/reopen'
expect(page).to have_content 'Your commands have been executed!'
expect(issuable.reload).to be_open
end
end
context "when current user cannot reopen #{issuable_type}" do
before do
logout
login_with(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not reopen the #{issuable_type}" do
page.within('.js-main-target-form') do
fill_in 'note[note]', with: "/reopen"
click_button 'Comment'
end
expect(page).not_to have_content '/reopen'
expect(page).not_to have_content 'Your commands have been executed!'
expect(issuable).to be_closed
end
end
end
context "with a note changing the #{issuable_type}'s title" do
context "when current user can change title of #{issuable_type}" do
it "reopens the #{issuable_type}" do
page.within('.js-main-target-form') do
fill_in 'note[note]', with: "/title Awesome new title"
click_button 'Comment'
end
expect(page).not_to have_content '/title'
expect(page).to have_content 'Your commands have been executed!'
expect(issuable.reload.title).to eq 'Awesome new title'
end
end
context "when current user cannot change title of #{issuable_type}" do
before do
logout
login_with(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not reopen the #{issuable_type}" do
page.within('.js-main-target-form') do
fill_in 'note[note]', with: "/title Awesome new title"
click_button 'Comment'
end
expect(page).not_to have_content '/title'
expect(page).not_to have_content 'Your commands have been executed!'
expect(issuable.reload.title).not_to eq 'Awesome new title'
end
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 have been executed!'
todos = TodosFinder.new(master).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 master
expect(todo.user).to eq master
end
end
context "with a note marking the #{issuable_type} as done" do
before do
TodoService.new.mark_todo(issuable, master)
end
it "creates a new todo for the #{issuable_type}" do
todos = TodosFinder.new(master).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 master
expect(todo.user).to eq master
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 have been 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?(master)).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 have been executed!'
expect(issuable.subscribed?(master)).to be_truthy
end
end
context "with a note unsubscribing to the #{issuable_type} as done" do
before do
issuable.subscribe(master)
end
it "creates a new todo for the #{issuable_type}" do
expect(issuable.subscribed?(master)).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 have been executed!'
expect(issuable.subscribed?(master)).to be_falsy
end
end
end
end
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