summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSean McGivern <sean@gitlab.com>2016-08-18 22:50:28 +0100
committerSean McGivern <sean@gitlab.com>2016-08-18 22:50:28 +0100
commitaba9cc6f227a670390cbac0c5da97784afefc5ee (patch)
tree4eaafa3fb7fc948361a63b8b24170d69bddeeced
parent883b96ab6a77175d9bac7f03c325428327359cdd (diff)
parent717366d28da11acc6dbe60301bf7e2394400b3c1 (diff)
downloadgitlab-ce-aba9cc6f227a670390cbac0c5da97784afefc5ee.tar.gz
Merge branch 'master' into expiration-date-on-memberships
-rw-r--r--CHANGELOG14
-rw-r--r--Gemfile7
-rw-r--r--Gemfile.lock10
-rw-r--r--app/assets/javascripts/abuse_reports.js.es639
-rw-r--r--app/assets/javascripts/application.js2
-rw-r--r--app/assets/javascripts/awards_handler.js54
-rw-r--r--app/assets/javascripts/boards/vue_resource_interceptor.js.es68
-rw-r--r--app/assets/javascripts/dispatcher.js3
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js.es6 (renamed from app/assets/javascripts/gfm_auto_complete.js)65
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js5
-rw-r--r--app/assets/javascripts/merge_request_tabs.js5
-rw-r--r--app/assets/javascripts/notes.js47
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss5
-rw-r--r--app/assets/stylesheets/mailers/repository_push_email.scss5
-rw-r--r--app/assets/stylesheets/pages/admin.scss42
-rw-r--r--app/assets/stylesheets/pages/profile.scss6
-rw-r--r--app/controllers/dashboard/todos_controller.rb6
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb12
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb7
-rw-r--r--app/controllers/projects/application_controller.rb1
-rw-r--r--app/controllers/projects/commit_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb7
-rw-r--r--app/controllers/projects/merge_requests_controller.rb3
-rw-r--r--app/controllers/projects/notes_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb21
-rw-r--r--app/finders/todos_finder.rb2
-rw-r--r--app/helpers/blob_helper.rb9
-rw-r--r--app/helpers/issues_helper.rb14
-rw-r--r--app/models/u2f_registration.rb7
-rw-r--r--app/services/issuable_base_service.rb94
-rw-r--r--app/services/issues/close_service.rb2
-rw-r--r--app/services/issues/create_service.rb27
-rw-r--r--app/services/issues/reopen_service.rb2
-rw-r--r--app/services/merge_requests/close_service.rb2
-rw-r--r--app/services/merge_requests/create_service.rb25
-rw-r--r--app/services/merge_requests/reopen_service.rb2
-rw-r--r--app/services/notes/create_service.rb27
-rw-r--r--app/services/notes/slash_commands_service.rb33
-rw-r--r--app/services/projects/autocomplete_service.rb27
-rw-r--r--app/services/projects/participants_service.rb38
-rw-r--r--app/services/slash_commands/interpret_service.rb236
-rw-r--r--app/services/todo_service.rb10
-rw-r--r--app/views/admin/abuse_reports/_abuse_report.html.haml17
-rw-r--r--app/views/admin/abuse_reports/index.html.haml33
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml4
-rw-r--r--app/views/notify/repository_push_email.html.haml2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml31
-rw-r--r--app/views/projects/_zen.html.haml3
-rw-r--r--app/views/projects/diffs/_file.html.haml7
-rw-r--r--app/views/projects/diffs/_line.html.haml7
-rw-r--r--app/views/projects/merge_requests/_new_submit.html.haml11
-rw-r--r--app/views/projects/notes/_form.html.haml8
-rw-r--r--app/views/projects/notes/_hints.html.haml11
-rw-r--r--app/views/shared/issuable/_form.html.haml5
-rw-r--r--app/views/u2f/_register.html.haml13
-rw-r--r--config/routes.rb2
-rw-r--r--db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb29
-rw-r--r--db/schema.rb1
-rw-r--r--doc/api/README.md1
-rw-r--r--doc/api/builds.md46
-rw-r--r--doc/api/deployments.md218
-rw-r--r--doc/api/pipelines.md207
-rw-r--r--doc/ci/yaml/README.md2
-rw-r--r--doc/legal/corporate_contributor_license_agreement.md4
-rw-r--r--doc/workflow/README.md1
-rw-r--r--doc/workflow/slash_commands.md30
-rw-r--r--features/steps/project/issues/award_emoji.rb2
-rw-r--r--features/steps/project/issues/issues.rb2
-rw-r--r--lib/api/api.rb2
-rw-r--r--lib/api/builds.rb21
-rw-r--r--lib/api/deployments.rb40
-rw-r--r--lib/api/entities.rb20
-rw-r--r--lib/api/pipelines.rb74
-rw-r--r--lib/gitlab/email/handler/base_handler.rb1
-rw-r--r--lib/gitlab/slash_commands/command_definition.rb57
-rw-r--r--lib/gitlab/slash_commands/dsl.rb98
-rw-r--r--lib/gitlab/slash_commands/extractor.rb122
-rw-r--r--spec/factories/ci/pipelines.rb18
-rw-r--r--spec/features/boards/boards_spec.rb26
-rw-r--r--spec/features/issues/user_uses_slash_commands_spec.rb58
-rw-r--r--spec/features/merge_requests/create_new_mr_spec.rb12
-rw-r--r--spec/features/merge_requests/user_uses_slash_commands_spec.rb32
-rw-r--r--spec/features/u2f_spec.rb36
-rw-r--r--spec/fixtures/emails/commands_in_reply.eml43
-rw-r--r--spec/fixtures/emails/commands_only_reply.eml41
-rw-r--r--spec/helpers/blob_helper_spec.rb26
-rw-r--r--spec/helpers/issues_helper_spec.rb26
-rw-r--r--spec/javascripts/abuse_reports_spec.js.es641
-rw-r--r--spec/javascripts/awards_handler_spec.js46
-rw-r--r--spec/javascripts/fixtures/abuse_reports.html.haml16
-rw-r--r--spec/lib/gitlab/email/handler/create_note_handler_spec.rb61
-rw-r--r--spec/lib/gitlab/slash_commands/command_definition_spec.rb173
-rw-r--r--spec/lib/gitlab/slash_commands/dsl_spec.rb77
-rw-r--r--spec/lib/gitlab/slash_commands/extractor_spec.rb215
-rw-r--r--spec/requests/api/builds_spec.rb23
-rw-r--r--spec/requests/api/deployments_spec.rb60
-rw-r--r--spec/requests/api/environments_spec.rb1
-rw-r--r--spec/requests/api/pipelines_spec.rb133
-rw-r--r--spec/services/issues/close_service_spec.rb18
-rw-r--r--spec/services/issues/create_service_spec.rb2
-rw-r--r--spec/services/issues/reopen_service_spec.rb25
-rw-r--r--spec/services/merge_requests/close_service_spec.rb16
-rw-r--r--spec/services/merge_requests/create_service_spec.rb11
-rw-r--r--spec/services/merge_requests/reopen_service_spec.rb19
-rw-r--r--spec/services/notes/create_service_spec.rb32
-rw-r--r--spec/services/notes/slash_commands_service_spec.rb140
-rw-r--r--spec/services/slash_commands/interpret_service_spec.rb384
-rw-r--r--spec/services/todo_service_spec.rb42
-rw-r--r--spec/support/fake_u2f_device.rb5
-rw-r--r--spec/support/issuable_create_service_slash_commands_shared_examples.rb83
-rw-r--r--spec/support/issuable_slash_commands_shared_examples.rb289
111 files changed, 4007 insertions, 290 deletions
diff --git a/CHANGELOG b/CHANGELOG
index 6c5b61beb33..9a6c6d9730f 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -11,6 +11,7 @@ v 8.11.0 (unreleased)
- Rename `markdown_preview` routes to `preview_markdown`. (Christopher Bartz)
- Update to Ruby 2.3.1. !4948
- Add Issues Board !5548
+ - Allow resolving merge conflicts in the UI !5479
- Improve diff performance by eliminating redundant checks for text blobs
- Ensure that branch names containing escapable characters (e.g. %20) aren't unescaped indiscriminately. !5770 (ewiltshi)
- Convert switch icon into icon font (ClemMakesApps)
@@ -20,6 +21,7 @@ v 8.11.0 (unreleased)
- Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell)
- GitLab Performance Monitoring can now track custom events such as the number of tags pushed to a repository
- Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell)
+ - Allow naming U2F devices !5833
- Ignore URLs starting with // in Markdown links !5677 (winniehell)
- Fix CI status icon link underline (ClemMakesApps)
- The Repository class is now instrumented
@@ -28,6 +30,8 @@ v 8.11.0 (unreleased)
- Expand commit message width in repo view (ClemMakesApps)
- Cache highlighted diff lines for merge requests
- Pre-create all builds for a Pipeline when the new Pipeline is created !5295
+ - API: Add deployment endpoints
+ - API: Add Play endpoint on Builds
- Fix of 'Commits being passed to custom hooks are already reachable when using the UI'
- Show member roles to all users on members page
- Project.visible_to_user is instrumented again
@@ -51,8 +55,10 @@ v 8.11.0 (unreleased)
- Show deployment status on merge requests with external URLs
- Clean up unused routes (Josef Strzibny)
- Fix issue on empty project to allow developers to only push to protected branches if given permission
+ - API: Add enpoints for pipelines
- Add green outline to New Branch button. !5447 (winniehell)
- Optimize generating of cache keys for issues and notes
+ - Fix repository push email formatting in Outlook
- Improve performance of syntax highlighting Markdown code blocks
- Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects
- Remove delay when hitting "Reply..." button on page with a lot of discussions
@@ -64,6 +70,8 @@ v 8.11.0 (unreleased)
- Update version_sorter and use new interface for faster tag sorting
- Optimize checking if a user has read access to a list of issues !5370
- Store all DB secrets in secrets.yml, under descriptive names !5274
+ - 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
- Add archived badge to project list !5798
- Add simple identifier to public SSH keys (muteor)
@@ -81,6 +89,8 @@ v 8.11.0 (unreleased)
- Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le)
- Load project invited groups and members eagerly in `ProjectTeam#fetch_members`
- Add pipeline events hook
+ - Award emoji tooltips containing more than 10 usernames are now truncated !4780 (jlogandavison)
+ - Fix duplicate "me" in award emoji tooltip !5218 (jlogandavison)
- Bump gitlab_git to speedup DiffCollection iterations
- Rewrite description of a blocked user in admin settings. (Elias Werberich)
- Make branches sortable without push permission !5462 (winniehell)
@@ -103,6 +113,7 @@ v 8.11.0 (unreleased)
- Add commit stats in commit api. !5517 (dixpac)
- Add CI configuration button on project page
- Fix merge request new view not changing code view rendering style
+ - edit_blob_link will use blob passed onto the options parameter
- Make error pages responsive (Takuya Noguchi)
- The performance of the project dropdown used for moving issues has been improved
- Fix skip_repo parameter being ignored when destroying a namespace
@@ -127,10 +138,13 @@ v 8.11.0 (unreleased)
- Sort folders with submodules in Files view !5521
- Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0
- Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska)
+ - Add pipelines tab to merge requests
- Fix a memory leak caused by Banzai::Filter::SanitizationFilter
- Speed up todos queries by limiting the projects set we join with
- Ensure file editing in UI does not overwrite commited changes without warning user
- Eliminate unneeded calls to Repository#blob_at when listing commits with no path
+ - Update gitlab_git gem to 10.4.7
+ - Simplify SQL queries of marking a todo as done
v 8.10.6
- Upgrade Rails to 4.2.7.1 for security fixes. !5781
diff --git a/Gemfile b/Gemfile
index a6fcc3575ff..986bcd831c8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -53,7 +53,7 @@ gem 'browser', '~> 2.2'
# Extracting information from a git repository
# Provide access to Gitlab::Git library
-gem 'gitlab_git', '~> 10.4.5'
+gem 'gitlab_git', '~> 10.4.7'
# LDAP Auth
# GitLab fork with several improvements to original library. For full list of changes
@@ -201,7 +201,7 @@ gem 'licensee', '~> 8.0.0'
gem 'rack-attack', '~> 4.3.1'
# Ace editor
-gem 'ace-rails-ap', '~> 4.0.2'
+gem 'ace-rails-ap', '~> 4.1.0'
# Keyboard shortcuts
gem 'mousetrap-rails', '~> 1.4.6'
@@ -209,7 +209,8 @@ gem 'mousetrap-rails', '~> 1.4.6'
# Detect and convert string character encoding
gem 'charlock_holmes', '~> 0.7.3'
-# Parse duration
+# Parse time & duration
+gem 'chronic', '~> 0.10.2'
gem 'chronic_duration', '~> 0.10.6'
gem 'sass-rails', '~> 5.0.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 58c84c47575..f32d30bccd8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
RedCloth (4.3.2)
- ace-rails-ap (4.0.2)
+ ace-rails-ap (4.1.0)
actionmailer (4.2.7.1)
actionpack (= 4.2.7.1)
actionview (= 4.2.7.1)
@@ -128,6 +128,7 @@ GEM
mime-types (>= 1.16)
cause (0.1)
charlock_holmes (0.7.3)
+ chronic (0.10.2)
chronic_duration (0.10.6)
numerizer (~> 0.1.1)
chunky_png (1.3.5)
@@ -278,7 +279,7 @@ GEM
diff-lcs (~> 1.1)
mime-types (>= 1.16, < 3)
posix-spawn (~> 0.3)
- gitlab_git (10.4.5)
+ gitlab_git (10.4.7)
activesupport (~> 4.0)
charlock_holmes (~> 0.7.3)
github-linguist (~> 4.7.0)
@@ -798,7 +799,7 @@ PLATFORMS
DEPENDENCIES
RedCloth (~> 4.3.2)
- ace-rails-ap (~> 4.0.2)
+ ace-rails-ap (~> 4.1.0)
activerecord-session_store (~> 1.0.0)
acts-as-taggable-on (~> 3.4)
addressable (~> 2.3.8)
@@ -824,6 +825,7 @@ DEPENDENCIES
capybara-screenshot (~> 1.0.0)
carrierwave (~> 0.10.0)
charlock_holmes (~> 0.7.3)
+ chronic (~> 0.10.2)
chronic_duration (~> 0.10.6)
coffee-rails (~> 4.1.0)
connection_pool (~> 2.0)
@@ -857,7 +859,7 @@ DEPENDENCIES
github-linguist (~> 4.7.0)
github-markup (~> 1.4)
gitlab-flowdock-git-hook (~> 1.0.1)
- gitlab_git (~> 10.4.5)
+ gitlab_git (~> 10.4.7)
gitlab_meta (= 7.0)
gitlab_omniauth-ldap (~> 1.2.1)
gollum-lib (~> 4.2)
diff --git a/app/assets/javascripts/abuse_reports.js.es6 b/app/assets/javascripts/abuse_reports.js.es6
new file mode 100644
index 00000000000..748084b0307
--- /dev/null
+++ b/app/assets/javascripts/abuse_reports.js.es6
@@ -0,0 +1,39 @@
+window.gl = window.gl || {};
+((global) => {
+ const MAX_MESSAGE_LENGTH = 500;
+ const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
+
+ class AbuseReports {
+ constructor() {
+ $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
+ $(document)
+ .off('click', MESSAGE_CELL_SELECTOR)
+ .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
+ }
+
+ truncateLongMessage() {
+ const $messageCellElement = $(this);
+ const reportMessage = $messageCellElement.text();
+ if (reportMessage.length > MAX_MESSAGE_LENGTH) {
+ $messageCellElement.data('original-message', reportMessage);
+ $messageCellElement.data('message-truncated', 'true');
+ $messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH));
+ }
+ }
+
+ toggleMessageTruncation() {
+ const $messageCellElement = $(this);
+ const originalMessage = $messageCellElement.data('original-message');
+ if (!originalMessage) return;
+ if ($messageCellElement.data('message-truncated') === 'true') {
+ $messageCellElement.data('message-truncated', 'false');
+ $messageCellElement.text(originalMessage);
+ } else {
+ $messageCellElement.data('message-truncated', 'true');
+ $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
+ }
+ }
+ }
+
+ global.AbuseReports = AbuseReports;
+})(window.gl);
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 85ec52cee37..f6e8e770527 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -26,7 +26,7 @@
/*= require bootstrap/tooltip */
/*= require bootstrap/popover */
/*= require select2 */
-/*= require ace/ace */
+/*= require ace-rails-ap */
/*= require ace/ext-searchbox */
/*= require underscore */
/*= require dropzone */
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 2c5b83e4f1e..aee1c29eee3 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,5 +1,6 @@
(function() {
this.AwardsHandler = (function() {
+ const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; //For separating lists produced by ruby's Array#toSentence
function AwardsHandler() {
this.aliases = gl.emojiAliases();
$(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) {
@@ -130,7 +131,7 @@
counter = $emojiButton.find('.js-counter');
counter.text(parseInt(counter.text()) + 1);
$emojiButton.addClass('active');
- this.addMeToUserList(votesBlock, emoji);
+ this.addYouToUserList(votesBlock, emoji);
return this.animateEmoji($emojiButton);
}
} else {
@@ -176,11 +177,11 @@
counterNumber = parseInt(counter.text(), 10);
if (counterNumber > 1) {
counter.text(counterNumber - 1);
- this.removeMeFromUserList($emojiButton, emoji);
+ this.removeYouFromUserList($emojiButton, emoji);
} else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
$emojiButton.tooltip('destroy');
counter.text('0');
- this.removeMeFromUserList($emojiButton, emoji);
+ this.removeYouFromUserList($emojiButton, emoji);
if ($emojiButton.parents('.note').length) {
this.removeEmoji($emojiButton);
}
@@ -204,43 +205,48 @@
return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
};
- AwardsHandler.prototype.removeMeFromUserList = function($emojiButton, emoji) {
+ AwardsHandler.prototype.toSentence = function(list) {
+ if(list.length <= 2){
+ return list.join(' and ');
+ }
+ else{
+ return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1];
+ }
+ };
+
+ AwardsHandler.prototype.removeYouFromUserList = function($emojiButton, emoji) {
var authors, awardBlock, newAuthors, originalTitle;
awardBlock = $emojiButton;
originalTitle = this.getAwardTooltip(awardBlock);
- authors = originalTitle.split(', ');
- authors.splice(authors.indexOf('me'), 1);
- newAuthors = authors.join(', ');
- awardBlock.closest('.js-emoji-btn').removeData('original-title').attr('data-original-title', newAuthors);
- return this.resetTooltip(awardBlock);
+ authors = originalTitle.split(FROM_SENTENCE_REGEX);
+ authors.splice(authors.indexOf('You'), 1);
+ return awardBlock
+ .closest('.js-emoji-btn')
+ .removeData('title')
+ .removeAttr('data-title')
+ .removeAttr('data-original-title')
+ .attr('title', this.toSentence(authors))
+ .tooltip('fixTitle');
};
- AwardsHandler.prototype.addMeToUserList = function(votesBlock, emoji) {
+ AwardsHandler.prototype.addYouToUserList = function(votesBlock, emoji) {
var awardBlock, origTitle, users;
awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
origTitle = this.getAwardTooltip(awardBlock);
users = [];
if (origTitle) {
- users = origTitle.trim().split(', ');
+ users = origTitle.trim().split(FROM_SENTENCE_REGEX);
}
- users.push('me');
- awardBlock.attr('title', users.join(', '));
- return this.resetTooltip(awardBlock);
- };
-
- AwardsHandler.prototype.resetTooltip = function(award) {
- var cb;
- award.tooltip('destroy');
- cb = function() {
- return award.tooltip();
- };
- return setTimeout(cb, 200);
+ users.unshift('You');
+ return awardBlock
+ .attr('title', this.toSentence(users))
+ .tooltip('fixTitle');
};
AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) {
var $emojiButton, buttonHtml, emojiCssClass;
emojiCssClass = this.resolveNameToCssClass(emoji);
- buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>";
+ buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='You' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>";
$emojiButton = $(buttonHtml);
$emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji);
this.animateEmoji($emojiButton);
diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
index 66f645a4b61..f9f9f7999d4 100644
--- a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
+++ b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
@@ -1,8 +1,10 @@
Vue.http.interceptors.push((request, next) => {
Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
- setTimeout(() => {
- Vue.activeResources--;
- }, 500);
+ Vue.nextTick(() => {
+ setTimeout(() => {
+ Vue.activeResources--;
+ }, 500);
+ });
next();
});
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 78713d379e7..be473bc4795 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -199,6 +199,9 @@
case 'edit':
new Labels();
}
+ case 'abuse_reports':
+ new gl.AbuseReports();
+ break;
}
break;
case 'dashboard':
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js.es6
index 2e5b15f4b77..3dca06d36b1 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js.es6
@@ -223,7 +223,7 @@
}
}
});
- return this.input.atwho({
+ this.input.atwho({
at: '~',
alias: 'labels',
searchKey: 'search',
@@ -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() {
return this.input.atwho('destroy');
@@ -265,6 +327,7 @@
this.input.atwho('load', 'mergerequests', data.mergerequests);
this.input.atwho('load', ':', data.emojis);
this.input.atwho('load', '~', data.labels);
+ this.input.atwho('load', '/', data.commands);
return $(':focus').trigger('keyup');
}
};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 130479642f3..b6636de5767 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -104,9 +104,12 @@
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
});
};
- return gl.text.removeListeners = function(form) {
+ gl.text.removeListeners = function(form) {
return $('.js-md', form).off();
};
+ return gl.text.truncate = function(string, maxLength) {
+ return string.substr(0, (maxLength - 3)) + '...';
+ };
})(window);
}).call(this);
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 1bba69a255a..4e2273f5aa8 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -15,6 +15,7 @@
function MergeRequestTabs(opts) {
this.opts = opts != null ? opts : {};
+ this.opts.setUrl = this.opts.setUrl !== undefined ? this.opts.setUrl : true;
this.setCurrentAction = bind(this.setCurrentAction, this);
this.tabShown = bind(this.tabShown, this);
this.showTab = bind(this.showTab, this);
@@ -58,7 +59,9 @@
} else {
this.expandView();
}
- return this.setCurrentAction(action);
+ if (this.opts.setUrl) {
+ this.setCurrentAction(action);
+ }
};
MergeRequestTabs.prototype.scrollToElement = function(container) {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 9ece474d994..2484a07f363 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -201,7 +201,7 @@
Increase @pollingInterval up to 120 seconds on every function call,
if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
will reset to @basePollingInterval.
-
+
Note: this function is used to gradually increase the polling interval
if there aren't new notes coming from the server
*/
@@ -223,7 +223,7 @@
/*
Render note in main comments area.
-
+
Note: for rendering inline notes use renderDiscussionNote
*/
@@ -231,7 +231,13 @@
var $notesList, votesBlock;
if (!note.valid) {
if (note.award) {
- new Flash('You have already awarded this emoji!', 'alert');
+ new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline);
+ }
+ else {
+ if (note.errors.commands_only) {
+ new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
+ this.refresh();
+ }
}
return;
}
@@ -245,6 +251,7 @@
$notesList.append(note.html).syntaxHighlight();
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
this.initTaskList();
+ this.refresh();
return this.updateNotesCount(1);
}
};
@@ -265,7 +272,7 @@
/*
Render note in discussion area.
-
+
Note: for rendering inline notes use renderDiscussionNote
*/
@@ -304,7 +311,7 @@
/*
Called in response the main target form has been successfully submitted.
-
+
Removes any errors.
Resets text and preview.
Resets buttons.
@@ -329,7 +336,7 @@
/*
Shows the main form and does some setup on it.
-
+
Sets some hidden fields in the form.
*/
@@ -349,7 +356,7 @@
/*
General note form setup.
-
+
deactivates the submit button when text is empty
hides the preview button when text is empty
setup GFM auto complete
@@ -366,7 +373,7 @@
/*
Called in response to the new note form being submitted
-
+
Adds new note to list.
*/
@@ -381,7 +388,7 @@
/*
Called in response to the new note form being submitted
-
+
Adds new note to list.
*/
@@ -393,7 +400,7 @@
/*
Called in response to the edit note form being submitted
-
+
Updates the current note field.
*/
@@ -410,7 +417,7 @@
/*
Called in response to clicking the edit note link
-
+
Replaces the note text with the note edit form
Adds a data attribute to the form with the original content of the note for cancellations
*/
@@ -450,7 +457,7 @@
/*
Called in response to clicking the edit note link
-
+
Hides edit form and restores the original note text to the editor textarea.
*/
@@ -472,7 +479,7 @@
/*
Called in response to deleting a note of any kind.
-
+
Removes the actual note from view.
Removes the whole discussion if the last note is being removed.
*/
@@ -498,7 +505,7 @@
/*
Called in response to clicking the delete attachment link
-
+
Removes the attachment wrapper view, including image tag if it exists
Resets the note editing form
*/
@@ -515,7 +522,7 @@
/*
Called when clicking on the "reply" button for a diff line.
-
+
Shows the note form below the notes.
*/
@@ -531,9 +538,9 @@
/*
Shows the diff or discussion form and does some setup on it.
-
+
Sets some hidden fields in the form.
-
+
Note: dataHolder must have the "discussionId", "lineCode", "noteableType"
and "noteableId" data attributes set.
*/
@@ -557,7 +564,7 @@
/*
Called when clicking on the "add a comment" button on the side of a diff line.
-
+
Inserts a temporary row for the form below the line.
Sets up the form and shows it.
*/
@@ -605,7 +612,7 @@
/*
Called in response to "cancel" on a diff note form.
-
+
Shows the reply button again.
Removes the form and if necessary it's temporary row.
*/
@@ -634,7 +641,7 @@
/*
Called after an attachment file has been selected.
-
+
Updates the file name for the selected attachment.
*/
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 96565da1bc9..edea4ad00eb 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -147,3 +147,8 @@
color: $gl-link-color;
}
}
+
+.atwho-view small.description {
+ float: right;
+ padding: 3px 5px;
+}
diff --git a/app/assets/stylesheets/mailers/repository_push_email.scss b/app/assets/stylesheets/mailers/repository_push_email.scss
index 33aedf1f7c1..5bfe9bcb443 100644
--- a/app/assets/stylesheets/mailers/repository_push_email.scss
+++ b/app/assets/stylesheets/mailers/repository_push_email.scss
@@ -45,7 +45,6 @@
.line_content {
padding-left: 0.5em;
padding-right: 0.5em;
- white-space: pre;
&.old {
background-color: $line-removed;
@@ -71,6 +70,10 @@
}
}
+pre {
+ margin: 0;
+}
+
span.highlight_word {
background-color: #fafe3d !important;
}
diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss
index 5607239d92d..c9cdfdcd29c 100644
--- a/app/assets/stylesheets/pages/admin.scss
+++ b/app/assets/stylesheets/pages/admin.scss
@@ -72,7 +72,6 @@
margin-bottom: 20px;
}
-
// Users List
.users-list {
@@ -98,3 +97,44 @@
}
}
}
+
+.abuse-reports {
+ .table {
+ table-layout: fixed;
+ }
+ .subheading {
+ padding-bottom: $gl-padding;
+ }
+ .message {
+ word-wrap: break-word;
+ }
+ .btn {
+ white-space: normal;
+ padding: $gl-btn-padding;
+ }
+ th {
+ width: 15%;
+ &.wide {
+ width: 55%;
+ }
+ }
+ @media (max-width: $screen-sm-max) {
+ th {
+ width: 100%;
+ }
+ td {
+ width: 100%;
+ float: left;
+ }
+ }
+
+ .no-reports {
+ .emoji-icon {
+ margin-left: $btn-side-margin;
+ margin-top: 3px;
+ }
+ span {
+ font-size: 19px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 46371ec6871..6f58203f49c 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -228,3 +228,9 @@
}
}
}
+
+table.u2f-registrations {
+ th:not(:last-child), td:not(:last-child) {
+ border-right: solid 1px transparent;
+ }
+} \ No newline at end of file
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 1243bb96d4d..c8390af3b36 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -6,7 +6,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
end
def destroy
- TodoService.new.mark_todos_as_done([todo], current_user)
+ TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user)
respond_to do |format|
format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' }
@@ -27,10 +27,6 @@ class Dashboard::TodosController < Dashboard::ApplicationController
private
- def todo
- @todo ||= find_todos.find(params[:id])
- end
-
def find_todos
@todos ||= TodosFinder.new(current_user, params).execute
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index e37e9e136db..9eb75bb3891 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# A U2F (universal 2nd factor) device's information is stored after successful
# registration, which is then used while 2FA authentication is taking place.
def create_u2f
- @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, params[:device_response], session[:challenges])
+ @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, u2f_registration_params, session[:challenges])
if @u2f_registration.persisted?
session.delete(:challenges)
- redirect_to profile_account_path, notice: "Your U2F device was registered!"
+ redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!"
else
@qr_code = build_qr_code
setup_u2f_registration
@@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# Actual communication is performed using a Javascript API
def setup_u2f_registration
@u2f_registration ||= U2fRegistration.new
- @registration_key_handles = current_user.u2f_registrations.pluck(:key_handle)
+ @u2f_registrations = current_user.u2f_registrations
u2f = U2F::U2F.new(u2f_app_id)
registration_requests = u2f.registration_requests
- sign_requests = u2f.authentication_requests(@registration_key_handles)
+ sign_requests = u2f.authentication_requests(@u2f_registrations.map(&:key_handle))
session[:challenges] = registration_requests.map(&:challenge)
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
register_requests: registration_requests,
sign_requests: sign_requests })
end
+
+ def u2f_registration_params
+ params.require(:u2f_registration).permit(:device_response, :name)
+ end
end
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
new file mode 100644
index 00000000000..c02fe85c3cc
--- /dev/null
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -0,0 +1,7 @@
+class Profiles::U2fRegistrationsController < Profiles::ApplicationController
+ def destroy
+ u2f_registration = current_user.u2f_registrations.find(params[:id])
+ u2f_registration.destroy
+ redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device."
+ end
+end
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 996909a28c6..91315a07deb 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -83,6 +83,7 @@ class Projects::ApplicationController < ApplicationController
end
def apply_diff_view_cookie!
+ @show_changes_tab = params[:view].present?
cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present?
end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index f44e9bb3fd7..02fb3f56890 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController
end
def commit
- @commit ||= @project.commit(params[:id])
+ @noteable = @commit ||= @project.commit(params[:id])
end
def pipelines
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index e9fb11e8f94..639cf4c0ef2 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -177,11 +177,7 @@ class Projects::IssuesController < Projects::ApplicationController
protected
def issue
- @issue ||= begin
- @project.issues.find_by!(iid: params[:id])
- rescue ActiveRecord::RecordNotFound
- redirect_old
- end
+ @noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old
end
alias_method :subscribable_resource, :issue
alias_method :issuable, :issue
@@ -226,7 +222,6 @@ class Projects::IssuesController < Projects::ApplicationController
if issue
redirect_to issue_path(issue)
- return
else
raise ActiveRecord::RecordNotFound.new
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 00a3022cbf7..0f52a5f96a3 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -216,7 +216,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@base_commit = @merge_request.diff_base_commit
@diffs = @merge_request.diffs(diff_options) if @merge_request.compare
@diff_notes_disabled = true
-
@pipeline = @merge_request.pipeline
@statuses = @pipeline.statuses.relevant if @pipeline
@@ -382,7 +381,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
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
alias_method :subscribable_resource, :merge_request
alias_method :issuable, :merge_request
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 766b7e9cf22..f2422729364 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -125,7 +125,7 @@ class Projects::NotesController < Projects::ApplicationController
id: note.id,
name: note.name
}
- elsif note.valid?
+ elsif note.persisted?
Banzai::NoteRenderer.render([note], @project, current_user)
attrs = {
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 47efbd4a939..fc52cd2f367 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController
end
def autocomplete_sources
- note_type = params['type']
- note_id = params['type_id']
+ noteable =
+ 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)
- participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id)
+ participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
@suggestions = {
emojis: Gitlab::AwardEmoji.urls,
@@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController
milestones: autocomplete.milestones,
mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels,
- members: participants
+ members: participants,
+ commands: autocomplete.commands(noteable, params[:type])
}
respond_to do |format|
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index 4fe0070552e..37bad596a16 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -17,7 +17,7 @@ class TodosFinder
attr_accessor :current_user, :params
- def initialize(current_user, params)
+ def initialize(current_user, params = {})
@current_user = current_user
@params = params
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 1cb5d847626..9ea03720c1e 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -11,17 +11,14 @@ module BlobHelper
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
return unless current_user
- blob = project.repository.blob_at(ref, path) rescue nil
+ blob = options.delete(:blob)
+ blob ||= project.repository.blob_at(ref, path) rescue nil
return unless blob
- from_mr = options[:from_merge_request_id]
- link_opts = {}
- link_opts[:from_merge_request_id] = from_mr if from_mr
-
edit_path = namespace_project_edit_blob_path(project.namespace, project,
tree_join(ref, path),
- link_opts)
+ options[:link_opts])
if !on_top_of_branch?(project, ref)
button_tag "Edit", class: "btn disabled has-tooltip btn-file-option", title: "You can only edit files when you are on a branch", data: { container: 'body' }
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 2e82b44437b..8b212b0327a 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -114,9 +114,17 @@ module IssuesHelper
end
def award_user_list(awards, current_user)
- awards.map do |award|
- award.user == current_user ? 'me' : award.user.name
- end.join(', ')
+ names = awards.map do |award|
+ award.user == current_user ? 'You' : award.user.name
+ end
+
+ # Take first 9 OR current user + first 9
+ current_user_name = names.delete('You')
+ names = names.first(9).insert(0, current_user_name).compact
+
+ names << "#{awards.size - names.size} more." if awards.size > names.size
+
+ names.to_sentence
end
def award_active_class(awards, current_user)
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index 00b19686d48..808acec098f 100644
--- a/app/models/u2f_registration.rb
+++ b/app/models/u2f_registration.rb
@@ -3,18 +3,19 @@
class U2fRegistration < ActiveRecord::Base
belongs_to :user
- def self.register(user, app_id, json_response, challenges)
+ def self.register(user, app_id, params, challenges)
u2f = U2F::U2F.new(app_id)
registration = self.new
begin
- response = U2F::RegisterResponse.load_from_json(json_response)
+ response = U2F::RegisterResponse.load_from_json(params[:device_response])
registration_data = u2f.register!(challenges, response)
registration.update(certificate: registration_data.certificate,
key_handle: registration_data.key_handle,
public_key: registration_data.public_key,
counter: registration_data.counter,
- user: user)
+ user: user,
+ name: params[:name])
rescue JSON::ParserError, NoMethodError, ArgumentError
registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.')
rescue U2F::Error => e
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index b0ea7c905f8..e06c37c323e 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -69,14 +69,9 @@ class IssuableBaseService < BaseService
end
def filter_labels
- if params[:add_label_ids].present? || params[:remove_label_ids].present?
- params.delete(:label_ids)
-
- filter_labels_in_param(:add_label_ids)
- filter_labels_in_param(:remove_label_ids)
- else
- filter_labels_in_param(:label_ids)
- end
+ filter_labels_in_param(:add_label_ids)
+ filter_labels_in_param(:remove_label_ids)
+ filter_labels_in_param(:label_ids)
end
def filter_labels_in_param(key)
@@ -85,27 +80,86 @@ class IssuableBaseService < BaseService
params[key] = project.labels.where(id: params[key]).pluck(:id)
end
- def update_issuable(issuable, attributes)
+ def process_label_ids(attributes, existing_label_ids: nil)
+ label_ids = attributes.delete(:label_ids)
+ add_label_ids = attributes.delete(:add_label_ids)
+ remove_label_ids = attributes.delete(:remove_label_ids)
+
+ new_label_ids = existing_label_ids || 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)
+
+ params[:description] = description
+
+ params.merge!(command_params)
+ end
+
+ def create_issuable(issuable, attributes, label_ids:)
issuable.with_transaction_returning_status do
- add_label_ids = attributes.delete(:add_label_ids)
- remove_label_ids = attributes.delete(:remove_label_ids)
+ if issuable.save
+ issuable.update_attributes(label_ids: label_ids)
+ end
+ end
+ end
- issuable.label_ids |= add_label_ids if add_label_ids
- issuable.label_ids -= remove_label_ids if remove_label_ids
+ 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
- issuable.assign_attributes(attributes.merge(updated_by: current_user))
+ def before_create(issuable)
+ # To be overridden by subclasses
+ end
+
+ def after_create(issuable)
+ # To be overridden by subclasses
+ end
- issuable.save
+ def update_issuable(issuable, attributes)
+ issuable.with_transaction_returning_status do
+ issuable.update(attributes.merge(updated_by: current_user))
end
end
def update(issuable)
change_state(issuable)
change_subscription(issuable)
+ change_todo(issuable)
filter_params
old_labels = issuable.labels.to_a
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)
issuable.reset_events_cache
handle_common_system_notes(issuable, old_labels: old_labels)
@@ -135,6 +189,16 @@ class IssuableBaseService < BaseService
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: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index 859c934ea3b..45cca216ccc 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -1,6 +1,8 @@
module Issues
class CloseService < Issues::BaseService
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
project.jira_service.execute(commit, issue)
todo_service.close_issue(issue, current_user)
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 65550ab8ec6..ea1690f3e38 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -1,26 +1,23 @@
module Issues
class CreateService < Issues::BaseService
def execute
- filter_params
- label_params = params.delete(:label_ids)
@request = params.delete(:request)
@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
- @issue.update_attributes(label_ids: label_params)
- notification_service.new_issue(@issue, current_user)
- todo_service.new_issue(@issue, current_user)
- event_service.open_issue(@issue, current_user)
- user_agent_detail_service.create
- @issue.create_cross_references!(current_user)
- execute_hooks(@issue, 'open')
- end
+ create(@issue)
+ end
+
+ def before_create(issuable)
+ issuable.spam = spam_service.check(@api)
+ 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
private
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index e48ca359f4f..40fbe354492 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -1,6 +1,8 @@
module Issues
class ReopenService < Issues::BaseService
def execute(issue)
+ return issue unless can?(current_user, :update_issue, issue)
+
if issue.reopen
event_service.reopen_issue(issue, current_user)
create_note(issue)
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index 27ee81fe3e7..f2053bda83a 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -1,6 +1,8 @@
module MergeRequests
class CloseService < MergeRequests::BaseService
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
# so we can close broken one (Ex. fork project removed)
merge_request.allow_broken = true
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 96a25330af1..73247e62421 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -7,26 +7,19 @@ module MergeRequests
source_project = @project
@project = Project.find(params[:target_project_id]) if params[:target_project_id]
- filter_params
- label_params = params.delete(:label_ids)
- force_remove_source_branch = params.delete(:force_remove_source_branch)
+ params[:target_project_id] ||= source_project.id
- merge_request = MergeRequest.new(params)
+ merge_request = MergeRequest.new
merge_request.source_project = source_project
- merge_request.target_project ||= source_project
- merge_request.author = current_user
- merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
+ merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
- if merge_request.save
- merge_request.update_attributes(label_ids: label_params)
- event_service.open_mr(merge_request, current_user)
- notification_service.new_merge_request(merge_request, current_user)
- todo_service.new_merge_request(merge_request, current_user)
- merge_request.create_cross_references!(current_user)
- execute_hooks(merge_request)
- end
+ create(merge_request)
+ 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
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index eb88ae9d11c..fadcce5d9b6 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -1,6 +1,8 @@
module MergeRequests
class ReopenService < MergeRequests::BaseService
def execute(merge_request)
+ return merge_request unless can?(current_user, :update_merge_request, merge_request)
+
if merge_request.reopen
event_service.reopen_mr(merge_request, current_user)
create_note(merge_request)
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 18971bd0be3..a36008c3ef5 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -11,10 +11,33 @@ module Notes
return noteable.create_award_emoji(note.award_emoji_name, current_user)
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
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
note
diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb
new file mode 100644
index 00000000000..4a9a8a64653
--- /dev/null
+++ b/app/services/notes/slash_commands_service.rb
@@ -0,0 +1,33 @@
+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
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 23b6668e0d1..f578f8dbea2 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -1,7 +1,7 @@
module Projects
class AutocompleteService < BaseService
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
def milestones
@@ -9,11 +9,34 @@ module Projects
end
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
def labels
@project.labels.select([:title, :color])
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
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index 02c4eee3d02..d38328403c1 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -1,40 +1,28 @@
module Projects
class ParticipantsService < BaseService
- def execute(noteable_type, noteable_id)
- @noteable_type = noteable_type
- @noteable_id = noteable_id
+ attr_reader :noteable
+
+ def execute(noteable)
+ @noteable = noteable
+
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
end
- def target
- @target ||=
- 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?
+ def noteable_owner
+ return [] unless noteable && noteable.author.present?
[{
- name: target.author.name,
- username: target.author.username
+ name: noteable.author.name,
+ username: noteable.author.username
}]
end
- def participants_in_target
- return [] unless target
+ def participants_in_noteable
+ return [] unless noteable
- users = target.participants(current_user)
+ users = noteable.participants(current_user)
sorted(users)
end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
new file mode 100644
index 00000000000..9ac1124abc1
--- /dev/null
+++ b/app/services/slash_commands/interpret_service.rb
@@ -0,0 +1,236 @@
+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
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index eb833dd82ac..e0ccb654590 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -142,7 +142,11 @@ class TodoService
# When user marks some todos as done
def mark_todos_as_done(todos, current_user)
- todos = current_user.todos.where(id: todos.map(&:id)) unless todos.respond_to?(:update_all)
+ mark_todos_as_done_by_ids(todos.select(&:id), current_user)
+ end
+
+ def mark_todos_as_done_by_ids(ids, current_user)
+ todos = current_user.todos.where(id: ids)
marked_todos = todos.update_all(state: :done)
current_user.update_todos_count_cache
@@ -155,6 +159,10 @@ class TodoService
create_todos(current_user, attributes)
end
+ def todo_exist?(issuable, current_user)
+ TodosFinder.new(current_user).execute.exists?(target: issuable)
+ end
+
private
def create_todos(users, attributes)
diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml
index dd2e7ebd030..56bf6194914 100644
--- a/app/views/admin/abuse_reports/_abuse_report.html.haml
+++ b/app/views/admin/abuse_reports/_abuse_report.html.haml
@@ -1,6 +1,8 @@
- reporter = abuse_report.reporter
- user = abuse_report.user
%tr
+ %th.visible-xs-block.visible-sm-block
+ %strong User
%td
- if user
= link_to user.name, user
@@ -9,6 +11,7 @@
- else
(removed)
%td
+ %strong.subheading.visible-xs-block.visible-sm-block Reported by
- if reporter
= link_to reporter.name, reporter
- else
@@ -16,16 +19,16 @@
.light.small
= time_ago_with_tooltip(abuse_report.created_at)
%td
- = markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter)
+ %strong.subheading.visible-xs-block.visible-sm-block Message
+ .message
+ = markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter)
%td
- if user
= link_to 'Remove user & report', admin_abuse_report_path(abuse_report, remove_user: true),
- data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-xs btn-remove js-remove-tr"
-
- %td
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-sm btn-block btn-remove js-remove-tr"
- if user && !user.blocked?
- = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs"
+ = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm btn-block"
- else
- .btn.btn-xs.disabled
+ .btn.btn-sm.disabled.btn-block
Already Blocked
- = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-xs btn-close js-remove-tr"
+ = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr"
diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml
index bc4a9cedb2c..7bbc75db9ff 100644
--- a/app/views/admin/abuse_reports/index.html.haml
+++ b/app/views/admin/abuse_reports/index.html.haml
@@ -1,17 +1,20 @@
-- page_title "Abuse Reports"
+- page_title 'Abuse Reports'
%h3.page-title Abuse Reports
%hr
-- if @abuse_reports.present?
- .table-holder
- %table.table
- %thead
- %tr
- %th User
- %th Reported by
- %th Message
- %th Primary action
- %th
- = render @abuse_reports
- = paginate @abuse_reports
-- else
- %h4 There are no abuse reports
+.abuse-reports
+ - if @abuse_reports.present?
+ .table-holder
+ %table.table
+ %thead.hidden-sm.hidden-xs
+ %tr
+ %th User
+ %th Reported by
+ %th.wide Message
+ %th Action
+ = render @abuse_reports
+ - else
+ .no-reports
+ %span.pull-left
+ There are no abuse reports!
+ .pull-left
+ = emoji_icon 'tada'
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 351100f3523..67ff4b272b9 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -1,7 +1,7 @@
- project = @target_project || @project
-- noteable_class = @noteable.class if @noteable.present?
+- noteable_type = @noteable.class if @noteable.present?
: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.setup();
diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml
index c161ecc3463..4dc39a72225 100644
--- a/app/views/notify/repository_push_email.html.haml
+++ b/app/views/notify/repository_push_email.html.haml
@@ -76,7 +76,7 @@
- if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob)
%table.code.white
- diff_file.highlighted_diff_lines.each do |line|
- = render "projects/diffs/line", line: line, diff_file: diff_file, plain: true
+ = render "projects/diffs/line", line: line, diff_file: diff_file, plain: true, email: true
- else
No preview for this file type
%br
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 366f1fed35b..03ac739ade5 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -60,13 +60,38 @@
two-factor authentication app before a U2F device. That way you'll always be able to
log in - even when you're using an unsupported browser.
.col-lg-9
- %p
- - if @registration_key_handles.present?
- = icon "check inverse", base: "circle", class: "text-success", text: "You have #{pluralize(@registration_key_handles.size, 'U2F device')} registered with GitLab."
- if @u2f_registration.errors.present?
= form_errors(@u2f_registration)
= render "u2f/register"
+ %hr
+
+ %h5 U2F Devices (#{@u2f_registrations.length})
+
+ - if @u2f_registrations.present?
+ .table-responsive
+ %table.table.table-bordered.u2f-registrations
+ %colgroup
+ %col{ width: "50%" }
+ %col{ width: "30%" }
+ %col{ width: "20%" }
+ %thead
+ %tr
+ %th Name
+ %th Registered On
+ %th
+ %tbody
+ - @u2f_registrations.each do |registration|
+ %tr
+ %td= registration.name.presence || "<no name set>"
+ %td= registration.created_at.to_date.to_s(:medium)
+ %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." }
+
+ - else
+ .settings-message.text-center
+ You don't have any U2F devices registered yet.
+
+
- if two_factor_skippable?
:javascript
var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>";
diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml
index 413477a2d3a..3978fa60d66 100644
--- a/app/views/projects/_zen.html.haml
+++ b/app/views/projects/_zen.html.haml
@@ -1,7 +1,8 @@
+- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area'
- 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
= text_area_tag attr, nil, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 8fbd89100ca..ad2eb3e504f 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -10,10 +10,9 @@
\
- if editable_diff?(diff_file)
- = edit_blob_link(@merge_request.source_project,
- @merge_request.source_branch, diff_file.new_path,
- from_merge_request_id: @merge_request.id,
- skip_visible_check: true)
+ - link_opts = @merge_request.id ? { from_merge_request_id: @merge_request.id } : {}
+ = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
+ blob: blob, link_opts: link_opts)
= view_file_btn(diff_commit.id, diff_file.new_path, project)
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index 2d6a370b848..891b2bd9802 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -1,3 +1,4 @@
+- email = local_assigns.fetch(:email, false)
- plain = local_assigns.fetch(:plain, false)
- type = line.type
- line_code = diff_file.line_code(line) unless plain
@@ -22,4 +23,8 @@
= link_text
- else
%a{href: "##{line_code}", data: { linenumber: link_text }}
- %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }= diff_line_content(line.text, type)
+ %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }<
+ - if email
+ %pre= diff_line_content(line.text, type)
+ - else
+ = diff_line_content(line.text, type)
diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml
index 598bd743676..00bd4e143df 100644
--- a/app/views/projects/merge_requests/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/_new_submit.html.haml
@@ -20,7 +20,7 @@
.mr-compare.merge-request
%ul.merge-request-tabs.nav-links.no-top.no-bottom
%li.commits-tab
- = link_to url_for(params), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do
+ = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
Commits
%span.badge= @commits.size
- if @pipeline
@@ -52,11 +52,8 @@
$('#merge_request_assignee_id').val("#{current_user.id}").trigger("change");
e.preventDefault();
});
-
:javascript
- var merge_request
- merge_request = new MergeRequest({
- action: 'new',
- diffs_loaded: true,
- commits_loaded: true
+ var merge_request = new MergeRequest({
+ action: "#{(@show_changes_tab ? 'diffs' : 'new')}",
+ setUrl: false
});
diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml
index 7c61ba750fe..759c72b2477 100644
--- a/app/views/projects/notes/_form.html.haml
+++ b/app/views/projects/notes/_form.html.haml
@@ -10,8 +10,12 @@
= f.hidden_field :position
= 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/notes/hints'
+ = render 'projects/zen', f: f,
+ 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
.note-form-actions.clearfix
diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/projects/notes/_hints.html.haml
index 25466e7562e..cf6e14648cc 100644
--- a/app/views/projects/notes/_hints.html.haml
+++ b/app/views/projects/notes/_hints.html.haml
@@ -1,8 +1,15 @@
+- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.comment-toolbar.clearfix
.toolbar-text
Styling with
= 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' }
= icon('file-image-o', class: 'toolbar-button-icon')
- Attach a file \ No newline at end of file
+ Attach a file
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 9e2e096d5f9..d717c3d92ee 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -52,8 +52,9 @@
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
= render 'projects/zen', f: f, attr: :description,
classes: 'note-textarea',
- placeholder: "Write a comment or drag your files here..."
- = render 'projects/notes/hints'
+ placeholder: "Write a comment or drag your files here...",
+ supports_slash_commands: !issuable.persisted?
+ = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted?
.clearfix
.error-alert
diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml
index cbb8dfb7829..8f7b42eb351 100644
--- a/app/views/u2f/_register.html.haml
+++ b/app/views/u2f/_register.html.haml
@@ -28,10 +28,15 @@
%script#js-register-u2f-registered{ type: "text/template" }
%div.row.append-bottom-10
- %p Your device was successfully set up! Click this button to register with the GitLab server.
- = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do
- = hidden_field_tag :device_response, nil, class: 'form-control', required: true, id: "js-device-response"
- = submit_tag "Register U2F Device", class: "btn btn-success"
+ .col-md-12
+ %p Your device was successfully set up! Give it a name and register it with the GitLab server.
+ = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do
+ .row.append-bottom-10
+ .col-md-3
+ = text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: "Pick a name"
+ .col-md-3
+ = hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
+ = submit_tag "Register U2F Device", class: "btn btn-success"
:javascript
var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f);
diff --git a/config/routes.rb b/config/routes.rb
index 9c74df31de5..5c48bf233d6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -375,6 +375,8 @@ Rails.application.routes.draw do
patch :skip
end
end
+
+ resources :u2f_registrations, only: [:destroy]
end
end
diff --git a/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb b/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb
new file mode 100644
index 00000000000..7152bd04331
--- /dev/null
+++ b/db/migrate/20160816161312_add_column_name_to_u2f_registrations.rb
@@ -0,0 +1,29 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddColumnNameToU2fRegistrations < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ # When a migration requires downtime you **must** uncomment the following
+ # constant and define a short and easy to understand explanation as to why the
+ # migration requires downtime.
+ # DOWNTIME_REASON = ''
+
+ # When using the methods "add_concurrent_index" or "add_column_with_default"
+ # you must disable the use of transactions as these methods can not run in an
+ # existing transaction. When using "add_concurrent_index" make sure that this
+ # method is the _only_ method called in the migration, any other changes
+ # should go in a separate migration. This ensures that upon failure _only_ the
+ # index creation fails and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ # disable_ddl_transaction!
+
+ def change
+ add_column :u2f_registrations, :name, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 02964d71351..0398aa673cb 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1018,6 +1018,7 @@ ActiveRecord::Schema.define(version: 20160818205718) do
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "name"
end
add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree
diff --git a/doc/api/README.md b/doc/api/README.md
index f3117815c7c..3e79cce0120 100644
--- a/doc/api/README.md
+++ b/doc/api/README.md
@@ -26,6 +26,7 @@ following locations:
- [Open source license templates](licenses.md)
- [Namespaces](namespaces.md)
- [Notes](notes.md) (comments)
+- [Pipelines](pipelines.md)
- [Projects](projects.md) including setting Webhooks
- [Project Access Requests](access_requests.md)
- [Project Members](members.md)
diff --git a/doc/api/builds.md b/doc/api/builds.md
index 8864df03c98..dce666445d0 100644
--- a/doc/api/builds.md
+++ b/doc/api/builds.md
@@ -532,3 +532,49 @@ Example response:
"user": null
}
```
+
+## Play a build
+
+Triggers a manual action to start a build.
+
+```
+POST /projects/:id/builds/:build_id/play
+```
+
+| Attribute | Type | Required | Description |
+|------------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a project |
+| `build_id` | integer | yes | The ID of a build |
+
+```
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/play"
+```
+
+Example of response
+
+```json
+{
+ "commit": {
+ "author_email": "admin@example.com",
+ "author_name": "Administrator",
+ "created_at": "2015-12-24T16:51:14.000+01:00",
+ "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd",
+ "message": "Test the CI integration.",
+ "short_id": "0ff3ae19",
+ "title": "Test the CI integration."
+ },
+ "coverage": null,
+ "created_at": "2016-01-11T10:13:33.506Z",
+ "artifacts_file": null,
+ "finished_at": null,
+ "id": 69,
+ "name": "rubocop",
+ "ref": "master",
+ "runner": null,
+ "stage": "test",
+ "started_at": null,
+ "status": "started",
+ "tag": false,
+ "user": null
+}
+```
diff --git a/doc/api/deployments.md b/doc/api/deployments.md
new file mode 100644
index 00000000000..417962de82d
--- /dev/null
+++ b/doc/api/deployments.md
@@ -0,0 +1,218 @@
+# Deployments API
+
+## List project deployments
+
+Get a list of deployments in a project.
+
+```
+GET /projects/:id/deployments
+```
+
+| Attribute | Type | Required | Description |
+|-----------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a project |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/deployments"
+```
+
+Example of response
+
+```json
+[
+ {
+ "created_at": "2016-08-11T07:36:40.222Z",
+ "deployable": {
+ "commit": {
+ "author_email": "admin@example.com",
+ "author_name": "Administrator",
+ "created_at": "2016-08-11T09:36:01.000+02:00",
+ "id": "99d03678b90d914dbb1b109132516d71a4a03ea8",
+ "message": "Merge branch 'new-title' into 'master'\r\n\r\nUpdate README\r\n\r\n\r\n\r\nSee merge request !1",
+ "short_id": "99d03678",
+ "title": "Merge branch 'new-title' into 'master'\r"
+ },
+ "coverage": null,
+ "created_at": "2016-08-11T07:36:27.357Z",
+ "finished_at": "2016-08-11T07:36:39.851Z",
+ "id": 657,
+ "name": "deploy",
+ "ref": "master",
+ "runner": null,
+ "stage": "deploy",
+ "started_at": null,
+ "status": "success",
+ "tag": false,
+ "user": {
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "bio": null,
+ "created_at": "2016-08-11T07:09:20.351Z",
+ "id": 1,
+ "is_admin": true,
+ "linkedin": "",
+ "location": null,
+ "name": "Administrator",
+ "skype": "",
+ "state": "active",
+ "twitter": "",
+ "username": "root",
+ "web_url": "http://localhost:3000/u/root",
+ "website_url": ""
+ }
+ },
+ "environment": {
+ "external_url": "https://about.gitlab.com",
+ "id": 9,
+ "name": "production"
+ },
+ "id": 41,
+ "iid": 1,
+ "ref": "master",
+ "sha": "99d03678b90d914dbb1b109132516d71a4a03ea8",
+ "user": {
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "id": 1,
+ "name": "Administrator",
+ "state": "active",
+ "username": "root",
+ "web_url": "http://localhost:3000/u/root"
+ }
+ },
+ {
+ "created_at": "2016-08-11T11:32:35.444Z",
+ "deployable": {
+ "commit": {
+ "author_email": "admin@example.com",
+ "author_name": "Administrator",
+ "created_at": "2016-08-11T13:28:26.000+02:00",
+ "id": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "message": "Merge branch 'rename-readme' into 'master'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2",
+ "short_id": "a91957a8",
+ "title": "Merge branch 'rename-readme' into 'master'\r"
+ },
+ "coverage": null,
+ "created_at": "2016-08-11T11:32:24.456Z",
+ "finished_at": "2016-08-11T11:32:35.145Z",
+ "id": 664,
+ "name": "deploy",
+ "ref": "master",
+ "runner": null,
+ "stage": "deploy",
+ "started_at": null,
+ "status": "success",
+ "tag": false,
+ "user": {
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "bio": null,
+ "created_at": "2016-08-11T07:09:20.351Z",
+ "id": 1,
+ "is_admin": true,
+ "linkedin": "",
+ "location": null,
+ "name": "Administrator",
+ "skype": "",
+ "state": "active",
+ "twitter": "",
+ "username": "root",
+ "web_url": "http://localhost:3000/u/root",
+ "website_url": ""
+ }
+ },
+ "environment": {
+ "external_url": "https://about.gitlab.com",
+ "id": 9,
+ "name": "production"
+ },
+ "id": 42,
+ "iid": 2,
+ "ref": "master",
+ "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "user": {
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "id": 1,
+ "name": "Administrator",
+ "state": "active",
+ "username": "root",
+ "web_url": "http://localhost:3000/u/root"
+ }
+ }
+]
+```
+
+## Get a specific deployment
+
+```
+GET /projects/:id/deployments/:deployment_id
+```
+
+| Attribute | Type | Required | Description |
+|-----------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a project |
+| `deployment_id` | integer | yes | The ID of the deployment |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/deployments/1"
+```
+
+Example of response
+
+```json
+{
+ "id": 42,
+ "iid": 2,
+ "ref": "master",
+ "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "created_at": "2016-08-11T11:32:35.444Z",
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/u/root"
+ },
+ "environment": {
+ "id": 9,
+ "name": "production",
+ "external_url": "https://about.gitlab.com"
+ },
+ "deployable": {
+ "id": 664,
+ "status": "success",
+ "stage": "deploy",
+ "name": "deploy",
+ "ref": "master",
+ "tag": false,
+ "coverage": null,
+ "created_at": "2016-08-11T11:32:24.456Z",
+ "started_at": null,
+ "finished_at": "2016-08-11T11:32:35.145Z",
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/u/root",
+ "created_at": "2016-08-11T07:09:20.351Z",
+ "is_admin": true,
+ "bio": null,
+ "location": null,
+ "skype": "",
+ "linkedin": "",
+ "twitter": "",
+ "website_url": ""
+ },
+ "commit": {
+ "id": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "short_id": "a91957a8",
+ "title": "Merge branch 'rename-readme' into 'master'\r",
+ "author_name": "Administrator",
+ "author_email": "admin@example.com",
+ "created_at": "2016-08-11T13:28:26.000+02:00",
+ "message": "Merge branch 'rename-readme' into 'master'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2"
+ },
+ "runner": null
+ }
+}
+```
diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md
new file mode 100644
index 00000000000..847408a7f61
--- /dev/null
+++ b/doc/api/pipelines.md
@@ -0,0 +1,207 @@
+# Pipelines API
+
+## List project pipelines
+
+> [Introduced][ce-5837] in GitLab 8.11
+
+```
+GET /projects/:id/pipelines
+```
+
+| Attribute | Type | Required | Description |
+|-----------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a project |
+
+```
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines"
+```
+
+Example of response
+
+```json
+[
+ {
+ "id": 47,
+ "status": "pending",
+ "ref": "new-pipeline",
+ "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "tag": false,
+ "yaml_errors": null,
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/u/root"
+ },
+ "created_at": "2016-08-16T10:23:19.007Z",
+ "updated_at": "2016-08-16T10:23:19.216Z",
+ "started_at": null,
+ "finished_at": null,
+ "committed_at": null,
+ "duration": null
+ },
+ {
+ "id": 48,
+ "status": "pending",
+ "ref": "new-pipeline",
+ "sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a",
+ "before_sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a",
+ "tag": false,
+ "yaml_errors": null,
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/u/root"
+ },
+ "created_at": "2016-08-16T10:23:21.184Z",
+ "updated_at": "2016-08-16T10:23:21.314Z",
+ "started_at": null,
+ "finished_at": null,
+ "committed_at": null,
+ "duration": null
+ }
+]
+```
+
+## Get a single pipeline
+
+> [Introduced][ce-5837] in GitLab 8.11
+
+```
+GET /projects/:id/pipelines/:pipeline_id
+```
+
+| Attribute | Type | Required | Description |
+|------------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a project |
+| `pipeline_id` | integer | yes | The ID of a pipeline |
+
+```
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipeline/46"
+```
+
+Example of response
+
+```json
+{
+ "id": 46,
+ "status": "success",
+ "ref": "master",
+ "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "tag": false,
+ "yaml_errors": null,
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/u/root"
+ },
+ "created_at": "2016-08-11T11:28:34.085Z",
+ "updated_at": "2016-08-11T11:32:35.169Z",
+ "started_at": null,
+ "finished_at": "2016-08-11T11:32:35.145Z",
+ "committed_at": null,
+ "duration": null
+}
+```
+
+## Retry failed builds in a pipeline
+
+> [Introduced][ce-5837] in GitLab 8.11
+
+```
+POST /projects/:id/pipelines/:pipeline_id/retry
+```
+
+| Attribute | Type | Required | Description |
+|------------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a project |
+| `pipeline_id` | integer | yes | The ID of a pipeline |
+
+```
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines/46/retry"
+```
+
+Response:
+
+```json
+{
+ "id": 46,
+ "status": "pending",
+ "ref": "master",
+ "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "tag": false,
+ "yaml_errors": null,
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/u/root"
+ },
+ "created_at": "2016-08-11T11:28:34.085Z",
+ "updated_at": "2016-08-11T11:32:35.169Z",
+ "started_at": null,
+ "finished_at": "2016-08-11T11:32:35.145Z",
+ "committed_at": null,
+ "duration": null
+}
+```
+
+## Cancel a pipelines builds
+
+> [Introduced][ce-5837] in GitLab 8.11
+
+```
+POST /projects/:id/pipelines/:pipeline_id/cancel
+```
+
+| Attribute | Type | Required | Description |
+|------------|---------|----------|---------------------|
+| `id` | integer | yes | The ID of a project |
+| `pipeline_id` | integer | yes | The ID of a pipeline |
+
+```
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/pipelines/46/cancel"
+```
+
+Response:
+
+```json
+{
+ "id": 46,
+ "status": "canceled",
+ "ref": "master",
+ "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+ "tag": false,
+ "yaml_errors": null,
+ "user": {
+ "name": "Administrator",
+ "username": "root",
+ "id": 1,
+ "state": "active",
+ "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+ "web_url": "http://localhost:3000/u/root"
+ },
+ "created_at": "2016-08-11T11:28:34.085Z",
+ "updated_at": "2016-08-11T11:32:35.169Z",
+ "started_at": null,
+ "finished_at": "2016-08-11T11:32:35.145Z",
+ "committed_at": null,
+ "duration": null
+}
+```
+
+[ce-5837]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5837
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index 01d71088543..e7850aa2c9d 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -353,7 +353,7 @@ job_name:
| except | no | Defines a list of git refs for which build is not created |
| tags | no | Defines a list of tags which are used to select Runner |
| allow_failure | no | Allow build to fail. Failed build doesn't contribute to commit status |
-| when | no | Define when to run build. Can be `on_success`, `on_failure` or `always` |
+| when | no | Define when to run build. Can be `on_success`, `on_failure`, `always` or `manual` |
| dependencies | no | Define other builds that a build depends on so that you can pass artifacts between them|
| artifacts | no | Define list of build artifacts |
| cache | no | Define list of files that should be cached between subsequent runs |
diff --git a/doc/legal/corporate_contributor_license_agreement.md b/doc/legal/corporate_contributor_license_agreement.md
index edd6c59138f..7f08188bd65 100644
--- a/doc/legal/corporate_contributor_license_agreement.md
+++ b/doc/legal/corporate_contributor_license_agreement.md
@@ -16,7 +16,7 @@ Subject to the terms and conditions of this Agreement, You hereby grant to GitLa
Subject to the terms and conditions of this Agreement, You hereby grant to GitLab B.V. and to recipients of software distributed by GitLab B.V. a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
-4. You represent that You are legally entitled to grant the above license. You represent further that each employee of the Corporation is authorized to submit Contributions on behalf of the Corporation, but excluding employees that are designated in writing by You as "Not authorized to submit Contributions on behalf of [name of corporation here]."
+4. You represent that You are legally entitled to grant the above license. You represent further that each of Your employees is authorized to submit Contributions on Your behalf, but excluding employees that are designated in writing by You as "Not authorized to submit Contributions on behalf of [name of Your corporation here]." Such designations of exclusion for unauthorized employees are to be submitted via email to legal@gitlab.com.
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others).
@@ -24,6 +24,6 @@ Subject to the terms and conditions of this Agreement, You hereby grant to GitLa
7. Should You wish to submit work that is not Your original creation, You may submit it to GitLab B.V. separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
-8. It is your responsibility to notify GitLab B.V. when any change is required to the designation of employees not authorized to submit Contributions on behalf of the Corporation, or to the Corporation's Point of Contact with GitLab B.V..
+8. It is Your responsibility to notify GitLab.com when any change is required to the list of designated employees excluded from submitting Contributions on Your behalf per Section 4. Such notification should be sent via email to legal@gitlab.com.
This text is licensed under the [Creative Commons Attribution 3.0 License](https://creativecommons.org/licenses/by/3.0/) and the original source is the Google Open Source Programs Office.
diff --git a/doc/workflow/README.md b/doc/workflow/README.md
index 3055411c484..1653d95e722 100644
--- a/doc/workflow/README.md
+++ b/doc/workflow/README.md
@@ -7,6 +7,7 @@
- [GitLab Flow](gitlab_flow.md)
- [Groups](groups.md)
- [Keyboard shortcuts](shortcuts.md)
+- [Slash commands](slash_commands.md)
- [File finder](file_finder.md)
- [Labels](../user/project/labels.md)
- [Notification emails](notifications.md)
diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md
new file mode 100644
index 00000000000..91d69d4e77e
--- /dev/null
+++ b/doc/workflow/slash_commands.md
@@ -0,0 +1,30 @@
+# 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 |
diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb
index 1498f899cf5..cbe5738e7e4 100644
--- a/features/steps/project/issues/award_emoji.rb
+++ b/features/steps/project/issues/award_emoji.rb
@@ -48,7 +48,7 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps
page.within '.awards' do
expect(page).to have_selector '.js-emoji-btn'
expect(page.find('.js-emoji-btn.active .js-counter')).to have_content '1'
- expect(page).to have_css(".js-emoji-btn.active[data-original-title='me']")
+ expect(page).to have_css(".js-emoji-btn.active[data-original-title='You']")
end
end
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index 056462a7152..e21f76d00d9 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -299,7 +299,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
end
step 'I fill in issue search with \'Rock and roll\'' do
- filter_issue 'Description for issue'
+ filter_issue 'Rock and roll'
end
step 'I should see \'Bugfix1\' in issues' do
diff --git a/lib/api/api.rb b/lib/api/api.rb
index d43af3f24e9..6b8bfbbdae6 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -43,6 +43,7 @@ module API
mount ::API::CommitStatuses
mount ::API::Commits
mount ::API::DeployKeys
+ mount ::API::Deployments
mount ::API::Environments
mount ::API::Files
mount ::API::Groups
@@ -56,6 +57,7 @@ module API
mount ::API::Milestones
mount ::API::Namespaces
mount ::API::Notes
+ mount ::API::Pipelines
mount ::API::ProjectHooks
mount ::API::ProjectSnippets
mount ::API::Projects
diff --git a/lib/api/builds.rb b/lib/api/builds.rb
index be5a3484ec8..52bdbcae5a8 100644
--- a/lib/api/builds.rb
+++ b/lib/api/builds.rb
@@ -189,6 +189,27 @@ module API
present build, with: Entities::Build,
user_can_download_artifacts: can?(current_user, :read_build, user_project)
end
+
+ desc 'Trigger a manual build' do
+ success Entities::Build
+ detail 'This feature was added in GitLab 8.11'
+ end
+ params do
+ requires :build_id, type: Integer, desc: 'The ID of a Build'
+ end
+ post ":id/builds/:build_id/play" do
+ authorize_read_builds!
+
+ build = get_build!(params[:build_id])
+
+ bad_request!("Unplayable Build") unless build.playable?
+
+ build.play(current_user)
+
+ status 200
+ present build, with: Entities::Build,
+ user_can_download_artifacts: can?(current_user, :read_build, user_project)
+ end
end
helpers do
diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb
new file mode 100644
index 00000000000..f782bcaf7e9
--- /dev/null
+++ b/lib/api/deployments.rb
@@ -0,0 +1,40 @@
+module API
+ # Deployments RESTfull API endpoints
+ class Deployments < Grape::API
+ before { authenticate! }
+
+ params do
+ requires :id, type: String, desc: 'The project ID'
+ end
+ resource :projects do
+ desc 'Get all deployments of the project' do
+ detail 'This feature was introduced in GitLab 8.11.'
+ success Entities::Deployment
+ end
+ params do
+ optional :page, type: Integer, desc: 'Page number of the current request'
+ optional :per_page, type: Integer, desc: 'Number of items per page'
+ end
+ get ':id/deployments' do
+ authorize! :read_deployment, user_project
+
+ present paginate(user_project.deployments), with: Entities::Deployment
+ end
+
+ desc 'Gets a specific deployment' do
+ detail 'This feature was introduced in GitLab 8.11.'
+ success Entities::Deployment
+ end
+ params do
+ requires :deployment_id, type: Integer, desc: 'The deployment ID'
+ end
+ get ':id/deployments/:deployment_id' do
+ authorize! :read_deployment, user_project
+
+ deployment = user_project.deployments.find(params[:deployment_id])
+
+ present deployment, with: Entities::Deployment
+ end
+ end
+ end
+end
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 64ee511bbd7..54ce2dcfa57 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -506,8 +506,28 @@ module API
expose :key, :value
end
+ class Pipeline < Grape::Entity
+ expose :id, :status, :ref, :sha, :before_sha, :tag, :yaml_errors
+
+ expose :user, with: Entities::UserBasic
+ expose :created_at, :updated_at, :started_at, :finished_at, :committed_at
+ expose :duration
+ end
+
class Environment < Grape::Entity
expose :id, :name, :external_url
+ expose :project, using: Entities::Project
+ end
+
+ class EnvironmentBasic < Grape::Entity
+ expose :id, :name, :external_url
+ end
+
+ class Deployment < Grape::Entity
+ expose :id, :iid, :ref, :sha, :created_at
+ expose :user, using: Entities::UserBasic
+ expose :environment, using: Entities::EnvironmentBasic
+ expose :deployable, using: Entities::Build
end
class RepoLicense < Grape::Entity
diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb
new file mode 100644
index 00000000000..2aae75c471d
--- /dev/null
+++ b/lib/api/pipelines.rb
@@ -0,0 +1,74 @@
+module API
+ class Pipelines < Grape::API
+ before { authenticate! }
+
+ params do
+ requires :id, type: String, desc: 'The project ID'
+ end
+ resource :projects do
+ desc 'Get all Pipelines of the project' do
+ detail 'This feature was introduced in GitLab 8.11.'
+ success Entities::Pipeline
+ end
+ params do
+ optional :page, type: Integer, desc: 'Page number of the current request'
+ optional :per_page, type: Integer, desc: 'Number of items per page'
+ end
+ get ':id/pipelines' do
+ authorize! :read_pipeline, user_project
+
+ present paginate(user_project.pipelines), with: Entities::Pipeline
+ end
+
+ desc 'Gets a specific pipeline for the project' do
+ detail 'This feature was introduced in GitLab 8.11'
+ success Entities::Pipeline
+ end
+ params do
+ requires :pipeline_id, type: Integer, desc: 'The pipeline ID'
+ end
+ get ':id/pipelines/:pipeline_id' do
+ authorize! :read_pipeline, user_project
+
+ present pipeline, with: Entities::Pipeline
+ end
+
+ desc 'Retry failed builds in the pipeline' do
+ detail 'This feature was introduced in GitLab 8.11.'
+ success Entities::Pipeline
+ end
+ params do
+ requires :pipeline_id, type: Integer, desc: 'The pipeline ID'
+ end
+ post ':id/pipelines/:pipeline_id/retry' do
+ authorize! :update_pipeline, user_project
+
+ pipeline.retry_failed(current_user)
+
+ present pipeline, with: Entities::Pipeline
+ end
+
+ desc 'Cancel all builds in the pipeline' do
+ detail 'This feature was introduced in GitLab 8.11.'
+ success Entities::Pipeline
+ end
+ params do
+ requires :pipeline_id, type: Integer, desc: 'The pipeline ID'
+ end
+ post ':id/pipelines/:pipeline_id/cancel' do
+ authorize! :update_pipeline, user_project
+
+ pipeline.cancel_running
+
+ status 200
+ present pipeline.reload, with: Entities::Pipeline
+ end
+ end
+
+ helpers do
+ def pipeline
+ @pipeline ||= user_project.pipelines.find(params[:pipeline_id])
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/email/handler/base_handler.rb b/lib/gitlab/email/handler/base_handler.rb
index b7ed11cb638..7cccf465334 100644
--- a/lib/gitlab/email/handler/base_handler.rb
+++ b/lib/gitlab/email/handler/base_handler.rb
@@ -45,6 +45,7 @@ module Gitlab
def verify_record!(record:, invalid_exception:, record_name:)
return if record.persisted?
+ return if record.errors.key?(:commands_only)
error_title = "The #{record_name} could not be created for the following reasons:"
diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb
new file mode 100644
index 00000000000..60d35be2599
--- /dev/null
+++ b/lib/gitlab/slash_commands/command_definition.rb
@@ -0,0 +1,57 @@
+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
diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb
new file mode 100644
index 00000000000..50b0937d267
--- /dev/null
+++ b/lib/gitlab/slash_commands/dsl.rb
@@ -0,0 +1,98 @@
+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
diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb
new file mode 100644
index 00000000000..a672e5e4855
--- /dev/null
+++ b/lib/gitlab/slash_commands/extractor.rb
@@ -0,0 +1,122 @@
+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
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index 04d66020c87..ac2a1ba5dff 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -1,21 +1,3 @@
-# == Schema Information
-#
-# Table name: commits
-#
-# id :integer not null, primary key
-# project_id :integer
-# ref :string(255)
-# sha :string(255)
-# before_sha :string(255)
-# push_data :text
-# created_at :datetime
-# updated_at :datetime
-# tag :boolean default(FALSE)
-# yaml_errors :text
-# committed_at :datetime
-# gl_project_id :integer
-#
-
FactoryGirl.define do
factory :ci_empty_pipeline, class: Ci::Pipeline do
ref 'master'
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index e4c5a10ce7e..8910c50c294 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -147,6 +147,7 @@ describe 'Issue Boards', feature: true, js: true do
expect(page).to have_selector('.card', count: 20)
evaluate_script("document.querySelectorAll('.board .board-list')[0].scrollTop = document.querySelectorAll('.board .board-list')[0].scrollHeight")
+ wait_for_vue_resource(spinner: false)
expect(page.find('.board-header')).to have_content('40')
expect(page).to have_selector('.card', count: 40)
@@ -165,6 +166,8 @@ describe 'Issue Boards', feature: true, js: true do
page.within(find('.board', match: :first)) do
find('.form-control').set issue1.title
+ wait_for_vue_resource(spinner: false)
+
expect(page).to have_selector('.card', count: 1)
end
end
@@ -176,7 +179,11 @@ describe 'Issue Boards', feature: true, js: true do
expect(page).to have_selector('.card', count: 1)
find('.board-search-clear-btn').click
+ end
+ wait_for_vue_resource
+
+ page.within(find('.board', match: :first)) do
expect(page).to have_selector('.card', count: 6)
end
end
@@ -189,6 +196,8 @@ describe 'Issue Boards', feature: true, js: true do
expect(page).to have_selector('.card', count: 5)
end
+ wait_for_vue_resource
+
page.within(find('.board:nth-child(2)')) do
expect(page.find('.board-header')).to have_content('3')
expect(page).to have_selector('.card', count: 3)
@@ -263,6 +272,7 @@ describe 'Issue Boards', feature: true, js: true do
context 'new list' do
it 'shows all labels in new list dropdown' do
click_button 'Create new list'
+ wait_for_ajax
page.within('.dropdown-menu-issues-board-new') do
expect(page).to have_content(planning.title)
@@ -273,6 +283,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'creates new list for label' do
click_button 'Create new list'
+ wait_for_ajax
page.within('.dropdown-menu-issues-board-new') do
click_link testing.title
@@ -285,6 +296,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'creates new list for Backlog label' do
click_button 'Create new list'
+ wait_for_ajax
page.within('.dropdown-menu-issues-board-new') do
click_link backlog.title
@@ -297,6 +309,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'creates new list for Done label' do
click_button 'Create new list'
+ wait_for_ajax
page.within('.dropdown-menu-issues-board-new') do
click_link done.title
@@ -314,6 +327,7 @@ describe 'Issue Boards', feature: true, js: true do
end
click_button 'Create new list'
+ wait_for_ajax
page.within('.dropdown-menu-issues-board-new') do
click_link testing.title
@@ -333,6 +347,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'filters by author' do
page.within '.issues-filters' do
click_button('Author')
+ wait_for_ajax
page.within '.dropdown-menu-author' do
click_link(user2.name)
@@ -358,6 +373,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'filters by assignee' do
page.within '.issues-filters' do
click_button('Assignee')
+ wait_for_ajax
page.within '.dropdown-menu-assignee' do
click_link(user.name)
@@ -383,6 +399,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'filters by milestone' do
page.within '.issues-filters' do
click_button('Milestone')
+ wait_for_ajax
page.within '.milestone-filter' do
click_link(milestone.title)
@@ -408,6 +425,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'filters by label' do
page.within '.issues-filters' do
click_button('Label')
+ wait_for_ajax
page.within '.dropdown-menu-labels' do
click_link(testing.title)
@@ -436,6 +454,7 @@ describe 'Issue Boards', feature: true, js: true do
page.within '.issues-filters' do
click_button('Label')
+ wait_for_ajax
page.within '.dropdown-menu-labels' do
click_link(testing.title)
@@ -460,8 +479,9 @@ describe 'Issue Boards', feature: true, js: true do
it 'filters by multiple labels' do
page.within '.issues-filters' do
click_button('Label')
+ wait_for_ajax
- page.within '.dropdown-menu-labels' do
+ page.within(find('.dropdown-menu-labels')) do
click_link(testing.title)
wait_for_vue_resource(spinner: false)
click_link(bug.title)
@@ -486,6 +506,7 @@ describe 'Issue Boards', feature: true, js: true do
it 'filters by no label' do
page.within '.issues-filters' do
click_button('Label')
+ wait_for_ajax
page.within '.dropdown-menu-labels' do
click_link("No Label")
@@ -510,10 +531,13 @@ describe 'Issue Boards', feature: true, js: true do
it 'filters by clicking label button on issue' do
page.within(find('.board', match: :first)) do
expect(page).to have_selector('.card', count: 6)
+ expect(find('.card', match: :first)).to have_content(bug.title)
click_button(bug.title)
wait_for_vue_resource(spinner: false)
end
+ wait_for_vue_resource
+
page.within(find('.board', match: :first)) do
expect(page.find('.board-header')).to have_content('1')
expect(page).to have_selector('.card', count: 1)
diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb
new file mode 100644
index 00000000000..2883e392694
--- /dev/null
+++ b/spec/features/issues/user_uses_slash_commands_spec.rb
@@ -0,0 +1,58 @@
+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
diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb
index b63931d9d35..b963d1305b5 100644
--- a/spec/features/merge_requests/create_new_mr_spec.rb
+++ b/spec/features/merge_requests/create_new_mr_spec.rb
@@ -49,14 +49,14 @@ feature 'Create New Merge Request', feature: true, js: true do
click_link 'Changes'
- expect(page.find_link('Inline')[:class]).to match(/\bactive\b/)
- expect(page.find_link('Side-by-side')[:class]).not_to match(/\bactive\b/)
+ expect(page).to have_css('a.btn.active', text: 'Inline')
+ expect(page).not_to have_css('a.btn.active', text: 'Side-by-side')
click_link 'Side-by-side'
- click_link 'Changes'
-
- expect(page.find_link('Inline')[:class]).not_to match(/\bactive\b/)
- expect(page.find_link('Side-by-side')[:class]).to match(/\bactive\b/)
+ within '.merge-request' do
+ expect(page).not_to have_css('a.btn.active', text: 'Inline')
+ expect(page).to have_css('a.btn.active', text: 'Side-by-side')
+ end
end
end
diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
new file mode 100644
index 00000000000..d9ef0d18074
--- /dev/null
+++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
@@ -0,0 +1,32 @@
+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
diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb
index d370f90f7d9..a46e48c76ed 100644
--- a/spec/features/u2f_spec.rb
+++ b/spec/features/u2f_spec.rb
@@ -12,10 +12,12 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature:
end
def register_u2f_device(u2f_device = nil)
- u2f_device ||= FakeU2fDevice.new(page)
+ name = FFaker::Name.first_name
+ u2f_device ||= FakeU2fDevice.new(page, name)
u2f_device.respond_to_u2f_registration
click_on 'Setup New U2F Device'
expect(page).to have_content('Your device was successfully set up')
+ fill_in "Pick a name", with: name
click_on 'Register U2F Device'
u2f_device
end
@@ -40,13 +42,14 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature:
end
describe 'when 2FA via OTP is enabled' do
- it 'allows registering a new device' do
+ it 'allows registering a new device with a name' do
visit profile_account_path
manage_two_factor_authentication
expect(page.body).to match("You've already enabled two-factor authentication using mobile")
- register_u2f_device
+ u2f_device = register_u2f_device
+ expect(page.body).to match(u2f_device.name)
expect(page.body).to match('Your U2F device was registered')
end
@@ -55,15 +58,31 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature:
# First device
manage_two_factor_authentication
- register_u2f_device
+ first_device = register_u2f_device
expect(page.body).to match('Your U2F device was registered')
# Second device
- manage_two_factor_authentication
- register_u2f_device
+ second_device = register_u2f_device
expect(page.body).to match('Your U2F device was registered')
+
+ expect(page.body).to match(first_device.name)
+ expect(page.body).to match(second_device.name)
+ expect(U2fRegistration.count).to eq(2)
+ end
+
+ it 'allows deleting a device' do
+ visit profile_account_path
manage_two_factor_authentication
- expect(page.body).to match('You have 2 U2F devices registered')
+ expect(page.body).to match("You've already enabled two-factor authentication using mobile")
+
+ first_u2f_device = register_u2f_device
+ second_u2f_device = register_u2f_device
+
+ click_on "Delete", match: :first
+
+ expect(page.body).to match('Successfully deleted')
+ expect(page.body).not_to match(first_u2f_device.name)
+ expect(page.body).to match(second_u2f_device.name)
end
end
@@ -208,7 +227,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature:
describe "when a given U2F device has not been registered" do
it "does not allow logging in with that particular device" do
- unregistered_device = FakeU2fDevice.new(page)
+ unregistered_device = FakeU2fDevice.new(page, FFaker::Name.first_name)
login_as(user)
unregistered_device.respond_to_u2f_authentication
click_on "Login Via U2F Device"
@@ -262,6 +281,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature:
end
it "deletes u2f registrations" do
+ visit profile_account_path
expect { click_on "Disable" }.to change { U2fRegistration.count }.by(-1)
end
end
diff --git a/spec/fixtures/emails/commands_in_reply.eml b/spec/fixtures/emails/commands_in_reply.eml
new file mode 100644
index 00000000000..06bf60ab734
--- /dev/null
+++ b/spec/fixtures/emails/commands_in_reply.eml
@@ -0,0 +1,43 @@
+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).
+>
diff --git a/spec/fixtures/emails/commands_only_reply.eml b/spec/fixtures/emails/commands_only_reply.eml
new file mode 100644
index 00000000000..aed64224b06
--- /dev/null
+++ b/spec/fixtures/emails/commands_only_reply.eml
@@ -0,0 +1,41 @@
+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).
+>
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index 94972eed945..a43a7238c70 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -69,18 +69,40 @@ describe BlobHelper do
end
describe "#edit_blob_link" do
- let(:project) { create(:project) }
+ let(:namespace) { create(:namespace, name: 'gitlab' )}
+ let(:project) { create(:project, namespace: namespace) }
before do
allow(self).to receive(:current_user).and_return(double)
+ allow(self).to receive(:can_collaborate_with_project?).and_return(true)
end
it 'verifies blob is text' do
- expect(self).not_to receive(:blob_text_viewable?)
+ expect(helper).not_to receive(:blob_text_viewable?)
button = edit_blob_link(project, 'refs/heads/master', 'README.md')
expect(button).to start_with('<button')
end
+
+ it 'uses the passed blob instead retrieve from repository' do
+ blob = project.repository.blob_at('refs/heads/master', 'README.md')
+
+ expect(project.repository).not_to receive(:blob_at)
+
+ edit_blob_link(project, 'refs/heads/master', 'README.md', blob: blob)
+ end
+
+ it 'returns a link with the proper route' do
+ link = edit_blob_link(project, 'master', 'README.md')
+
+ expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md')
+ end
+
+ it 'returns a link with the passed link_opts on the expected route' do
+ link = edit_blob_link(project, 'master', 'README.md', link_opts: { mr_id: 10 })
+
+ expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md?mr_id=10')
+ end
end
end
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 5e4655dfc95..67bac782591 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -62,6 +62,32 @@ describe IssuesHelper do
it { is_expected.to eq("!1, !2, or !3") }
end
+ describe '#award_user_list' do
+ let!(:awards) { build_list(:award_emoji, 15) }
+
+ it "returns a comma seperated list of 1-9 users" do
+ expect(award_user_list(awards.first(9), nil)).to eq(awards.first(9).map { |a| a.user.name }.to_sentence)
+ end
+
+ it "displays the current user's name as 'You'" do
+ expect(award_user_list(awards.first(1), awards[0].user)).to eq('You')
+ end
+
+ it "truncates lists of larger than 9 users" do
+ expect(award_user_list(awards, nil)).to eq(awards.first(9).map { |a| a.user.name }.join(', ') + ", and 6 more.")
+ end
+
+ it "displays the current user in front of 0-9 other users" do
+ expect(award_user_list(awards, awards[0].user)).
+ to eq("You, " + awards[1..9].map { |a| a.user.name }.join(', ') + ", and 5 more.")
+ end
+
+ it "displays the current user in front regardless of position in the list" do
+ expect(award_user_list(awards, awards[12].user)).
+ to eq("You, " + awards[0..8].map { |a| a.user.name }.join(', ') + ", and 5 more.")
+ end
+ end
+
describe '#award_active_class' do
let!(:upvote) { create(:award_emoji) }
diff --git a/spec/javascripts/abuse_reports_spec.js.es6 b/spec/javascripts/abuse_reports_spec.js.es6
new file mode 100644
index 00000000000..6bcfdf191c2
--- /dev/null
+++ b/spec/javascripts/abuse_reports_spec.js.es6
@@ -0,0 +1,41 @@
+/*= require abuse_reports */
+
+/*= require jquery */
+
+((global) => {
+ const FIXTURE = 'abuse_reports.html';
+ const MAX_MESSAGE_LENGTH = 500;
+
+ function assertMaxLength($message) {
+ expect($message.text().length).toEqual(MAX_MESSAGE_LENGTH);
+ }
+
+ describe('Abuse Reports', function() {
+ fixture.preload(FIXTURE);
+
+ beforeEach(function() {
+ fixture.load(FIXTURE);
+ new global.AbuseReports();
+ });
+
+ it('should truncate long messages', function() {
+ const $longMessage = $('#long');
+ expect($longMessage.data('original-message')).toEqual(jasmine.anything());
+ assertMaxLength($longMessage);
+ });
+
+ it('should not truncate short messages', function() {
+ const $shortMessage = $('#short');
+ expect($shortMessage.data('original-message')).not.toEqual(jasmine.anything());
+ });
+
+ it('should allow clicking a truncated message to expand and collapse the full message', function() {
+ const $longMessage = $('#long');
+ $longMessage.click();
+ expect($longMessage.data('original-message').length).toEqual($longMessage.text().length);
+ $longMessage.click();
+ assertMaxLength($longMessage);
+ });
+ });
+
+})(window.gl);
diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js
index 3ddc163033e..fa32d0d7da5 100644
--- a/spec/javascripts/awards_handler_spec.js
+++ b/spec/javascripts/awards_handler_spec.js
@@ -143,6 +143,52 @@
return expect($votesBlock.find('[data-emoji=fire]').length).toBe(0);
});
});
+ describe('::addYouToUserList', function() {
+ it('should prepend "You" to the award tooltip', function() {
+ var $thumbsUpEmoji, $votesBlock, awardUrl;
+ awardUrl = awardsHandler.getAwardUrl();
+ $votesBlock = $('.js-awards-block').eq(0);
+ $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
+ $thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy');
+ awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
+ $thumbsUpEmoji.tooltip();
+ return expect($thumbsUpEmoji.data("original-title")).toBe('You, sam, jerry, max, and andy');
+ });
+ return it('handles the special case where "You" is not cleanly comma seperated', function() {
+ var $thumbsUpEmoji, $votesBlock, awardUrl;
+ awardUrl = awardsHandler.getAwardUrl();
+ $votesBlock = $('.js-awards-block').eq(0);
+ $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
+ $thumbsUpEmoji.attr('data-title', 'sam');
+ awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
+ $thumbsUpEmoji.tooltip();
+ return expect($thumbsUpEmoji.data("original-title")).toBe('You and sam');
+ });
+ });
+ describe('::removeYouToUserList', function() {
+ it('removes "You" from the front of the tooltip', function() {
+ var $thumbsUpEmoji, $votesBlock, awardUrl;
+ awardUrl = awardsHandler.getAwardUrl();
+ $votesBlock = $('.js-awards-block').eq(0);
+ $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
+ $thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy');
+ $thumbsUpEmoji.addClass('active');
+ awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
+ $thumbsUpEmoji.tooltip();
+ return expect($thumbsUpEmoji.data("original-title")).toBe('sam, jerry, max, and andy');
+ });
+ return it('handles the special case where "You" is not cleanly comma seperated', function() {
+ var $thumbsUpEmoji, $votesBlock, awardUrl;
+ awardUrl = awardsHandler.getAwardUrl();
+ $votesBlock = $('.js-awards-block').eq(0);
+ $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent();
+ $thumbsUpEmoji.attr('data-title', 'You and sam');
+ $thumbsUpEmoji.addClass('active');
+ awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
+ $thumbsUpEmoji.tooltip();
+ return expect($thumbsUpEmoji.data("original-title")).toBe('sam');
+ });
+ });
describe('search', function() {
return it('should filter the emoji', function() {
$('.js-add-award').eq(0).click();
diff --git a/spec/javascripts/fixtures/abuse_reports.html.haml b/spec/javascripts/fixtures/abuse_reports.html.haml
new file mode 100644
index 00000000000..2ec302abcb7
--- /dev/null
+++ b/spec/javascripts/fixtures/abuse_reports.html.haml
@@ -0,0 +1,16 @@
+.abuse-reports
+ .message#long
+ Cat ipsum dolor sit amet, hide head under blanket so no one can see.
+ Gate keepers of hell eat and than sleep on your face but hunt by meowing
+ loudly at 5am next to human slave food dispenser cats go for world
+ domination or chase laser, yet poop on grasses chirp at birds. Cat is love,
+ cat is life chase after silly colored fish toys around the house climb a
+ tree, wait for a fireman jump to fireman then scratch his face fall asleep
+ on the washing machine lies down always hungry so caticus cuteicus. Sit on
+ human. Spot something, big eyes, big eyes, crouch, shake butt, prepare to
+ pounce sleep in the bathroom sink hiss at vacuum cleaner hide head under
+ blanket so no one can see throwup on your pillow.
+ .message#short
+ Cat ipsum dolor sit amet, groom yourself 4 hours - checked, have your
+ beauty sleep 18 hours - checked, be fabulous for the rest of the day -
+ checked! for shake treat bag.
diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
index a2119b0dadf..4909fed6b77 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -60,6 +60,67 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do
it "raises an InvalidNoteError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
+
+ context 'because the note was commands only' do
+ let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") }
+
+ 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
context "when the reply is blank" do
diff --git a/spec/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb
new file mode 100644
index 00000000000..c9c2f314e57
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/command_definition_spec.rb
@@ -0,0 +1,173 @@
+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
diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb
new file mode 100644
index 00000000000..26217a0e3b2
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb
@@ -0,0 +1,77 @@
+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
diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb
new file mode 100644
index 00000000000..1e4954c4af8
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb
@@ -0,0 +1,215 @@
+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
diff --git a/spec/requests/api/builds_spec.rb b/spec/requests/api/builds_spec.rb
index 41503885dd9..9a17a705b1e 100644
--- a/spec/requests/api/builds_spec.rb
+++ b/spec/requests/api/builds_spec.rb
@@ -407,4 +407,27 @@ describe API::API, api: true do
end
end
end
+
+ describe 'POST /projects/:id/builds/:build_id/play' do
+ before do
+ post api("/projects/#{project.id}/builds/#{build.id}/play", user)
+ end
+
+ context 'on an playable build' do
+ let(:build) { create(:ci_build, :manual, project: project, pipeline: pipeline) }
+
+ it 'plays the build' do
+ expect(response).to have_http_status 200
+ expect(json_response['user']['id']).to eq(user.id)
+ expect(json_response['id']).to eq(build.id)
+ end
+ end
+
+ context 'on a non-playable build' do
+ it 'returns a status code 400, Bad Request' do
+ expect(response).to have_http_status 400
+ expect(response.body).to match("Unplayable Build")
+ end
+ end
+ end
end
diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb
new file mode 100644
index 00000000000..8fa8c66db6c
--- /dev/null
+++ b/spec/requests/api/deployments_spec.rb
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe API::API, api: true do
+ include ApiHelpers
+
+ let(:user) { create(:user) }
+ let(:non_member) { create(:user) }
+ let(:project) { deployment.environment.project }
+ let!(:deployment) { create(:deployment) }
+
+ before do
+ project.team << [user, :master]
+ end
+
+ describe 'GET /projects/:id/deployments' do
+ context 'as member of the project' do
+ it_behaves_like 'a paginated resources' do
+ let(:request) { get api("/projects/#{project.id}/deployments", user) }
+ end
+
+ it 'returns projects deployments' do
+ get api("/projects/#{project.id}/deployments", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['iid']).to eq(deployment.iid)
+ expect(json_response.first['sha']).to match /\A\h{40}\z/
+ end
+ end
+
+ context 'as non member' do
+ it 'returns a 404 status code' do
+ get api("/projects/#{project.id}/deployments", non_member)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/deployments/:deployment_id' do
+ context 'as a member of the project' do
+ it 'returns the projects deployment' do
+ get api("/projects/#{project.id}/deployments/#{deployment.id}", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['sha']).to match /\A\h{40}\z/
+ expect(json_response['id']).to eq(deployment.id)
+ end
+ end
+
+ context 'as non member' do
+ it 'returns a 404 status code' do
+ get api("/projects/#{project.id}/deployments/#{deployment.id}", non_member)
+
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb
index 05e57905343..1898b07835d 100644
--- a/spec/requests/api/environments_spec.rb
+++ b/spec/requests/api/environments_spec.rb
@@ -26,6 +26,7 @@ describe API::API, api: true do
expect(json_response.size).to eq(1)
expect(json_response.first['name']).to eq(environment.name)
expect(json_response.first['external_url']).to eq(environment.external_url)
+ expect(json_response.first['project']['id']).to eq(project.id)
end
end
diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb
new file mode 100644
index 00000000000..7011bdc9ec0
--- /dev/null
+++ b/spec/requests/api/pipelines_spec.rb
@@ -0,0 +1,133 @@
+require 'spec_helper'
+
+describe API::API, api: true do
+ include ApiHelpers
+
+ let(:user) { create(:user) }
+ let(:non_member) { create(:user) }
+ let(:project) { create(:project, creator_id: user.id) }
+
+ let!(:pipeline) do
+ create(:ci_empty_pipeline, project: project, sha: project.commit.id,
+ ref: project.default_branch)
+ end
+
+ before { project.team << [user, :master] }
+
+ describe 'GET /projects/:id/pipelines ' do
+ it_behaves_like 'a paginated resources' do
+ let(:request) { get api("/projects/#{project.id}/pipelines", user) }
+ end
+
+ context 'authorized user' do
+ it 'returns project pipelines' do
+ get api("/projects/#{project.id}/pipelines", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response).to be_an Array
+ expect(json_response.first['sha']).to match /\A\h{40}\z/
+ expect(json_response.first['id']).to eq pipeline.id
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'does not return project pipelines' do
+ get api("/projects/#{project.id}/pipelines", non_member)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(json_response).not_to be_an Array
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/pipelines/:pipeline_id' do
+ context 'authorized user' do
+ it 'returns project pipelines' do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['sha']).to match /\A\h{40}\z/
+ end
+
+ it 'returns 404 when it does not exist' do
+ get api("/projects/#{project.id}/pipelines/123456", user)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq '404 Not found'
+ expect(json_response['id']).to be nil
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'should not return a project pipeline' do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}", non_member)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(json_response['id']).to be nil
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/pipelines/:pipeline_id/retry' do
+ context 'authorized user' do
+ let!(:pipeline) do
+ create(:ci_pipeline, project: project, sha: project.commit.id,
+ ref: project.default_branch)
+ end
+
+ let!(:build) { create(:ci_build, :failed, pipeline: pipeline) }
+
+ it 'retries failed builds' do
+ expect do
+ post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", user)
+ end.to change { pipeline.builds.count }.from(1).to(2)
+
+ expect(response).to have_http_status(201)
+ expect(build.reload.retried?).to be true
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'should not return a project pipeline' do
+ post api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", non_member)
+
+ expect(response).to have_http_status(404)
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(json_response['id']).to be nil
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/pipelines/:pipeline_id/cancel' do
+ let!(:pipeline) do
+ create(:ci_empty_pipeline, project: project, sha: project.commit.id,
+ ref: project.default_branch)
+ end
+
+ let!(:build) { create(:ci_build, :running, pipeline: pipeline) }
+
+ context 'authorized user' do
+ it 'retries failed builds' do
+ post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['status']).to eq('canceled')
+ end
+ end
+
+ context 'user without proper access rights' do
+ let!(:reporter) { create(:user) }
+
+ before { project.team << [reporter, :reporter] }
+
+ it 'rejects the action' do
+ post api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", reporter)
+
+ expect(response).to have_http_status(403)
+ expect(pipeline.reload.status).to eq('pending')
+ end
+ end
+ end
+end
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index 1318607a388..aff022a573e 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -3,6 +3,7 @@ require 'spec_helper'
describe Issues::CloseService, services: true do
let(:user) { create(:user) }
let(:user2) { create(:user) }
+ let(:guest) { create(:user) }
let(:issue) { create(:issue, assignee: user2) }
let(:project) { issue.project }
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) }
@@ -10,13 +11,14 @@ describe Issues::CloseService, services: true do
before do
project.team << [user, :master]
project.team << [user2, :developer]
+ project.team << [guest, :guest]
end
describe '#execute' do
context "valid params" do
before do
perform_enqueued_jobs do
- @issue = Issues::CloseService.new(project, user, {}).execute(issue)
+ @issue = described_class.new(project, user, {}).execute(issue)
end
end
@@ -39,10 +41,22 @@ describe Issues::CloseService, services: true do
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
before do
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
it { expect(@issue).to be_valid }
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 1ee9f3aae4d..fcc3c0a00bd 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -73,5 +73,7 @@ describe Issues::CreateService, services: true do
end
end
end
+
+ it_behaves_like 'new issuable record that supports slash commands'
end
end
diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb
new file mode 100644
index 00000000000..34a89fcd4e1
--- /dev/null
+++ b/spec/services/issues/reopen_service_spec.rb
@@ -0,0 +1,25 @@
+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
diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb
index 403533be5d9..24c25e4350f 100644
--- a/spec/services/merge_requests/close_service_spec.rb
+++ b/spec/services/merge_requests/close_service_spec.rb
@@ -3,6 +3,7 @@ require 'spec_helper'
describe MergeRequests::CloseService, services: true do
let(:user) { create(:user) }
let(:user2) { create(:user) }
+ let(:guest) { create(:user) }
let(:merge_request) { create(:merge_request, assignee: user2) }
let(:project) { merge_request.project }
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: merge_request, author: user2) }
@@ -10,11 +11,12 @@ describe MergeRequests::CloseService, services: true do
before do
project.team << [user, :master]
project.team << [user2, :developer]
+ project.team << [guest, :guest]
end
describe '#execute' do
context 'valid params' do
- let(:service) { MergeRequests::CloseService.new(project, user, {}) }
+ let(:service) { described_class.new(project, user, {}) }
before do
allow(service).to receive(:execute_hooks)
@@ -47,5 +49,17 @@ describe MergeRequests::CloseService, services: true do
expect(todo.reload).to be_done
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
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index b84a580967a..c1e4f8bd96b 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -17,7 +17,7 @@ describe MergeRequests::CreateService, services: true do
}
end
- let(:service) { MergeRequests::CreateService.new(project, user, opts) }
+ let(:service) { described_class.new(project, user, opts) }
before do
project.team << [user, :master]
@@ -74,5 +74,14 @@ describe MergeRequests::CreateService, services: true do
end
end
end
+
+ it_behaves_like 'new issuable record that supports slash commands' do
+ let(:default_params) do
+ {
+ source_branch: 'feature',
+ target_branch: 'master'
+ }
+ end
+ end
end
end
diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb
index 3419b8bf5e6..af7424a76a9 100644
--- a/spec/services/merge_requests/reopen_service_spec.rb
+++ b/spec/services/merge_requests/reopen_service_spec.rb
@@ -3,22 +3,23 @@ require 'spec_helper'
describe MergeRequests::ReopenService, services: true do
let(:user) { 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 }
before do
project.team << [user, :master]
project.team << [user2, :developer]
+ project.team << [guest, :guest]
end
describe '#execute' do
context 'valid params' do
- let(:service) { MergeRequests::ReopenService.new(project, user, {}) }
+ let(:service) { described_class.new(project, user, {}) }
before do
allow(service).to receive(:execute_hooks)
- merge_request.state = :closed
perform_enqueued_jobs do
service.execute(merge_request)
end
@@ -43,5 +44,17 @@ describe MergeRequests::ReopenService, services: true do
expect(note.note).to include 'Status changed to reopened'
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
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 32753e84b31..93885c84dc3 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -4,22 +4,36 @@ describe Notes::CreateService, services: true do
let(:project) { create(:empty_project) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
+ let(:opts) do
+ { note: 'Awesome comment', noteable_type: 'Issue', noteable_id: issue.id }
+ end
describe '#execute' do
+ before do
+ project.team << [user, :master]
+ end
+
context "valid params" do
before do
- project.team << [user, :master]
- opts = {
- note: 'Awesome comment',
- noteable_type: 'Issue',
- noteable_id: issue.id
- }
-
@note = Notes::CreateService.new(project, user, opts).execute
end
it { expect(@note).to be_valid }
- it { expect(@note.note).to eq('Awesome comment') }
+ it { expect(@note.note).to eq(opts[:note]) }
+ 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
@@ -42,7 +56,7 @@ describe Notes::CreateService, services: true do
it "creates regular note if emoji name is invalid" do
opts = {
- note: ':smile: moretext: ',
+ note: ':smile: moretext:',
noteable_type: 'Issue',
noteable_id: issue.id
}
diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb
new file mode 100644
index 00000000000..4f231aab161
--- /dev/null
+++ b/spec/services/notes/slash_commands_service_spec.rb
@@ -0,0 +1,140 @@
+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
diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb
new file mode 100644
index 00000000000..a616275e883
--- /dev/null
+++ b/spec/services/slash_commands/interpret_service_spec.rb
@@ -0,0 +1,384 @@
+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
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index 6c3cbeae13c..296fd1bd5a4 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -194,12 +194,12 @@ describe TodoService, services: true do
end
end
- describe '#mark_todos_as_done' do
- it 'marks related todos for the user as done' do
- first_todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author)
- second_todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author)
+ shared_examples 'marking todos as done' do |meth|
+ let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) }
+ let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) }
- service.mark_todos_as_done([first_todo, second_todo], john_doe)
+ it 'marks related todos for the user as done' do
+ service.send(meth, collection, john_doe)
expect(first_todo.reload).to be_done
expect(second_todo.reload).to be_done
@@ -207,20 +207,30 @@ describe TodoService, services: true do
describe 'cached counts' do
it 'updates when todos change' do
- todo = create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author)
-
expect(john_doe.todos_done_count).to eq(0)
- expect(john_doe.todos_pending_count).to eq(1)
+ expect(john_doe.todos_pending_count).to eq(2)
expect(john_doe).to receive(:update_todos_count_cache).and_call_original
- service.mark_todos_as_done([todo], john_doe)
+ service.send(meth, collection, john_doe)
- expect(john_doe.todos_done_count).to eq(1)
+ expect(john_doe.todos_done_count).to eq(2)
expect(john_doe.todos_pending_count).to eq(0)
end
end
end
+ describe '#mark_todos_as_done' do
+ it_behaves_like 'marking todos as done', :mark_todos_as_done do
+ let(:collection) { [first_todo, second_todo] }
+ end
+ end
+
+ describe '#mark_todos_as_done_by_ids' do
+ it_behaves_like 'marking todos as done', :mark_todos_as_done_by_ids do
+ let(:collection) { [first_todo, second_todo].map(&:id) }
+ end
+ end
+
describe '#new_note' do
let!(:first_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) }
let!(:second_todo) { create(:todo, :assigned, user: john_doe, project: project, target: issue, author: author) }
@@ -290,6 +300,18 @@ describe TodoService, services: true do
should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED)
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
describe 'Merge Requests' do
diff --git a/spec/support/fake_u2f_device.rb b/spec/support/fake_u2f_device.rb
index f550e9a0160..8c407b867fe 100644
--- a/spec/support/fake_u2f_device.rb
+++ b/spec/support/fake_u2f_device.rb
@@ -1,6 +1,9 @@
class FakeU2fDevice
- def initialize(page)
+ attr_reader :name
+
+ def initialize(page, name)
@page = page
+ @name = name
end
def respond_to_u2f_registration
diff --git a/spec/support/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/issuable_create_service_slash_commands_shared_examples.rb
new file mode 100644
index 00000000000..5f9645ed44f
--- /dev/null
+++ b/spec/support/issuable_create_service_slash_commands_shared_examples.rb
@@ -0,0 +1,83 @@
+# Specifications for behavior common to all objects with executable attributes.
+# It can take a `default_params`.
+
+shared_examples 'new issuable record that supports slash commands' do
+ let!(:project) { create(:project) }
+ let(:user) { create(:user).tap { |u| project.team << [u, :master] } }
+ let(:assignee) { create(:user) }
+ let!(:milestone) { create(:milestone, project: project) }
+ let!(:labels) { create_list(:label, 3, project: project) }
+ let(:base_params) { { title: FFaker::Lorem.sentence(3) } }
+ let(:params) { base_params.merge(defined?(default_params) ? default_params : {}).merge(example_params) }
+ let(:issuable) { described_class.new(project, user, params).execute }
+
+ context 'with labels in command only' do
+ let(:example_params) do
+ {
+ description: "/label ~#{labels.first.name} ~#{labels.second.name}\n/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
diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb
new file mode 100644
index 00000000000..d2a49ea5c5e
--- /dev/null
+++ b/spec/support/issuable_slash_commands_shared_examples.rb
@@ -0,0 +1,289 @@
+# 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