From 1427d2995492cf246bc485ff609c9ed263a52656 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 31 May 2017 00:50:53 -0500 Subject: Rename "Slash commands" to "Quick actions" Fix https://gitlab.com/gitlab-org/gitlab-ce/issues/27070 Deprecate "chat commands" in favor of "slash commands" We looked for things like: - `slash commmand` - `slash_command` - `slash-command` - `SlashCommand` --- CHANGELOG.md | 40 +- app/assets/javascripts/gfm_auto_complete.js | 6 +- app/assets/javascripts/notes.js | 22 +- app/assets/javascripts/preview_markdown.js | 2 +- .../sidebar/components/time_tracking/help_state.js | 4 +- .../time_tracking/sidebar_time_tracking.js | 8 +- app/helpers/notes_helper.rb | 4 +- app/models/merge_request.rb | 2 +- app/models/note.rb | 2 +- .../chat_slash_commands_service.rb | 4 +- app/policies/global_policy.rb | 2 +- app/services/issuable_base_service.rb | 6 +- app/services/merge_requests/update_service.rb | 6 +- app/services/notes/create_service.rb | 8 +- app/services/notes/quick_actions_service.rb | 36 + app/services/notes/slash_commands_service.rb | 36 - app/services/preview_markdown_service.rb | 12 +- app/services/projects/autocomplete_service.rb | 2 +- app/services/quick_actions/interpret_service.rb | 509 ++++++++++ app/services/slash_commands/interpret_service.rb | 509 ---------- app/views/projects/_zen.html.haml | 4 +- .../projects/issues/_issue_by_email.html.haml | 2 +- .../shared/issuable/form/_description.html.haml | 10 +- app/views/shared/notes/_form.html.haml | 10 +- app/views/shared/notes/_hints.html.haml | 6 +- ...7070-rename-slash-commands-to-quick-actions.yml | 5 + ...ressed-yourself-todo-when-using-unsubscribe.yml | 2 +- doc/README.md | 2 +- doc/development/limit_ee_conflicts.md | 4 +- doc/integration/chat_commands.md | 14 - doc/integration/slash_commands.md | 14 + doc/user/discussions/index.md | 4 +- .../project/integrations/slack_slash_commands.md | 4 +- doc/user/project/issues/issues_functionalities.md | 4 +- doc/user/project/quick_actions.md | 39 + doc/user/project/slash_commands.md | 39 - doc/workflow/README.md | 2 +- doc/workflow/time_tracking.md | 8 +- lib/api/services.rb | 4 +- lib/api/v3/services.rb | 4 +- lib/gitlab/chat_commands/base_command.rb | 47 - lib/gitlab/chat_commands/command.rb | 44 - lib/gitlab/chat_commands/deploy.rb | 50 - lib/gitlab/chat_commands/help.rb | 28 - lib/gitlab/chat_commands/issue_command.rb | 13 - lib/gitlab/chat_commands/issue_new.rb | 42 - lib/gitlab/chat_commands/issue_search.rb | 23 - lib/gitlab/chat_commands/issue_show.rb | 23 - lib/gitlab/chat_commands/presenters/access.rb | 40 - lib/gitlab/chat_commands/presenters/base.rb | 77 -- lib/gitlab/chat_commands/presenters/deploy.rb | 21 - lib/gitlab/chat_commands/presenters/help.rb | 27 - lib/gitlab/chat_commands/presenters/issue_base.rb | 43 - lib/gitlab/chat_commands/presenters/issue_new.rb | 50 - .../chat_commands/presenters/issue_search.rb | 47 - lib/gitlab/chat_commands/presenters/issue_show.rb | 61 -- lib/gitlab/chat_commands/result.rb | 5 - lib/gitlab/quick_actions/command_definition.rb | 83 ++ lib/gitlab/quick_actions/dsl.rb | 140 +++ lib/gitlab/quick_actions/extractor.rb | 122 +++ lib/gitlab/slash_commands/base_command.rb | 47 + lib/gitlab/slash_commands/command.rb | 44 + lib/gitlab/slash_commands/command_definition.rb | 83 -- lib/gitlab/slash_commands/deploy.rb | 50 + lib/gitlab/slash_commands/dsl.rb | 140 --- lib/gitlab/slash_commands/extractor.rb | 122 --- lib/gitlab/slash_commands/help.rb | 28 + lib/gitlab/slash_commands/issue_command.rb | 13 + lib/gitlab/slash_commands/issue_new.rb | 42 + lib/gitlab/slash_commands/issue_search.rb | 23 + lib/gitlab/slash_commands/issue_show.rb | 23 + lib/gitlab/slash_commands/presenters/access.rb | 40 + lib/gitlab/slash_commands/presenters/base.rb | 77 ++ lib/gitlab/slash_commands/presenters/deploy.rb | 21 + lib/gitlab/slash_commands/presenters/help.rb | 27 + lib/gitlab/slash_commands/presenters/issue_base.rb | 43 + lib/gitlab/slash_commands/presenters/issue_new.rb | 50 + .../slash_commands/presenters/issue_search.rb | 47 + lib/gitlab/slash_commands/presenters/issue_show.rb | 61 ++ lib/gitlab/slash_commands/result.rb | 5 + .../controllers/projects/issues_controller_spec.rb | 2 +- spec/features/issues/award_emoji_spec.rb | 8 +- spec/features/issues/gfm_autocomplete_spec.rb | 2 +- .../issues/user_uses_slash_commands_spec.rb | 6 +- .../user_uses_slash_commands_spec.rb | 6 +- spec/javascripts/notes_spec.js | 32 +- spec/lib/gitlab/chat_commands/command_spec.rb | 111 --- spec/lib/gitlab/chat_commands/deploy_spec.rb | 90 -- spec/lib/gitlab/chat_commands/issue_new_spec.rb | 78 -- spec/lib/gitlab/chat_commands/issue_search_spec.rb | 48 - spec/lib/gitlab/chat_commands/issue_show_spec.rb | 59 -- .../gitlab/chat_commands/presenters/access_spec.rb | 49 - .../gitlab/chat_commands/presenters/deploy_spec.rb | 47 - .../chat_commands/presenters/issue_new_spec.rb | 17 - .../chat_commands/presenters/issue_search_spec.rb | 23 - .../chat_commands/presenters/issue_show_spec.rb | 52 - .../email/handler/create_note_handler_spec.rb | 2 +- .../quick_actions/command_definition_spec.rb | 225 +++++ spec/lib/gitlab/quick_actions/dsl_spec.rb | 109 ++ spec/lib/gitlab/quick_actions/extractor_spec.rb | 223 +++++ .../slash_commands/command_definition_spec.rb | 225 ----- spec/lib/gitlab/slash_commands/command_spec.rb | 111 +++ spec/lib/gitlab/slash_commands/deploy_spec.rb | 90 ++ spec/lib/gitlab/slash_commands/dsl_spec.rb | 109 -- spec/lib/gitlab/slash_commands/extractor_spec.rb | 223 ----- spec/lib/gitlab/slash_commands/issue_new_spec.rb | 78 ++ .../lib/gitlab/slash_commands/issue_search_spec.rb | 48 + spec/lib/gitlab/slash_commands/issue_show_spec.rb | 59 ++ .../slash_commands/presenters/access_spec.rb | 49 + .../slash_commands/presenters/deploy_spec.rb | 47 + .../slash_commands/presenters/issue_new_spec.rb | 17 + .../slash_commands/presenters/issue_search_spec.rb | 23 + .../slash_commands/presenters/issue_show_spec.rb | 52 + spec/models/merge_request_spec.rb | 24 +- spec/services/issues/create_service_spec.rb | 4 +- .../services/merge_requests/create_service_spec.rb | 4 +- spec/services/notes/quick_actions_service_spec.rb | 250 +++++ spec/services/notes/slash_commands_service_spec.rb | 250 ----- spec/services/preview_markdown_service_spec.rb | 18 +- .../quick_actions/interpret_service_spec.rb | 1036 ++++++++++++++++++++ .../slash_commands/interpret_service_spec.rb | 1036 -------------------- .../support/chat_slash_commands_shared_examples.rb | 2 +- .../issuable_slash_commands_shared_examples.rb | 6 +- spec/support/quick_actions_helpers.rb | 10 + ...reate_service_slash_commands_shared_examples.rb | 2 +- spec/support/slash_commands_helpers.rb | 10 - spec/support/time_tracking_shared_examples.rb | 8 +- spec/views/shared/notes/_form.html.haml_spec.rb | 6 +- 128 files changed, 4182 insertions(+), 4177 deletions(-) create mode 100644 app/services/notes/quick_actions_service.rb delete mode 100644 app/services/notes/slash_commands_service.rb create mode 100644 app/services/quick_actions/interpret_service.rb delete mode 100644 app/services/slash_commands/interpret_service.rb create mode 100644 changelogs/unreleased/27070-rename-slash-commands-to-quick-actions.yml delete mode 100644 doc/integration/chat_commands.md create mode 100644 doc/integration/slash_commands.md create mode 100644 doc/user/project/quick_actions.md delete mode 100644 doc/user/project/slash_commands.md delete mode 100644 lib/gitlab/chat_commands/base_command.rb delete mode 100644 lib/gitlab/chat_commands/command.rb delete mode 100644 lib/gitlab/chat_commands/deploy.rb delete mode 100644 lib/gitlab/chat_commands/help.rb delete mode 100644 lib/gitlab/chat_commands/issue_command.rb delete mode 100644 lib/gitlab/chat_commands/issue_new.rb delete mode 100644 lib/gitlab/chat_commands/issue_search.rb delete mode 100644 lib/gitlab/chat_commands/issue_show.rb delete mode 100644 lib/gitlab/chat_commands/presenters/access.rb delete mode 100644 lib/gitlab/chat_commands/presenters/base.rb delete mode 100644 lib/gitlab/chat_commands/presenters/deploy.rb delete mode 100644 lib/gitlab/chat_commands/presenters/help.rb delete mode 100644 lib/gitlab/chat_commands/presenters/issue_base.rb delete mode 100644 lib/gitlab/chat_commands/presenters/issue_new.rb delete mode 100644 lib/gitlab/chat_commands/presenters/issue_search.rb delete mode 100644 lib/gitlab/chat_commands/presenters/issue_show.rb delete mode 100644 lib/gitlab/chat_commands/result.rb create mode 100644 lib/gitlab/quick_actions/command_definition.rb create mode 100644 lib/gitlab/quick_actions/dsl.rb create mode 100644 lib/gitlab/quick_actions/extractor.rb create mode 100644 lib/gitlab/slash_commands/base_command.rb create mode 100644 lib/gitlab/slash_commands/command.rb delete mode 100644 lib/gitlab/slash_commands/command_definition.rb create mode 100644 lib/gitlab/slash_commands/deploy.rb delete mode 100644 lib/gitlab/slash_commands/dsl.rb delete mode 100644 lib/gitlab/slash_commands/extractor.rb create mode 100644 lib/gitlab/slash_commands/help.rb create mode 100644 lib/gitlab/slash_commands/issue_command.rb create mode 100644 lib/gitlab/slash_commands/issue_new.rb create mode 100644 lib/gitlab/slash_commands/issue_search.rb create mode 100644 lib/gitlab/slash_commands/issue_show.rb create mode 100644 lib/gitlab/slash_commands/presenters/access.rb create mode 100644 lib/gitlab/slash_commands/presenters/base.rb create mode 100644 lib/gitlab/slash_commands/presenters/deploy.rb create mode 100644 lib/gitlab/slash_commands/presenters/help.rb create mode 100644 lib/gitlab/slash_commands/presenters/issue_base.rb create mode 100644 lib/gitlab/slash_commands/presenters/issue_new.rb create mode 100644 lib/gitlab/slash_commands/presenters/issue_search.rb create mode 100644 lib/gitlab/slash_commands/presenters/issue_show.rb create mode 100644 lib/gitlab/slash_commands/result.rb delete mode 100644 spec/lib/gitlab/chat_commands/command_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/deploy_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/issue_new_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/issue_search_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/issue_show_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/presenters/access_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/presenters/deploy_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/presenters/issue_new_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/presenters/issue_search_spec.rb delete mode 100644 spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb create mode 100644 spec/lib/gitlab/quick_actions/command_definition_spec.rb create mode 100644 spec/lib/gitlab/quick_actions/dsl_spec.rb create mode 100644 spec/lib/gitlab/quick_actions/extractor_spec.rb delete mode 100644 spec/lib/gitlab/slash_commands/command_definition_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/command_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/deploy_spec.rb delete mode 100644 spec/lib/gitlab/slash_commands/dsl_spec.rb delete mode 100644 spec/lib/gitlab/slash_commands/extractor_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/issue_new_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/issue_search_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/issue_show_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/presenters/access_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/presenters/deploy_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/presenters/issue_new_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/presenters/issue_search_spec.rb create mode 100644 spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb create mode 100644 spec/services/notes/quick_actions_service_spec.rb delete mode 100644 spec/services/notes/slash_commands_service_spec.rb create mode 100644 spec/services/quick_actions/interpret_service_spec.rb delete mode 100644 spec/services/slash_commands/interpret_service_spec.rb create mode 100644 spec/support/quick_actions_helpers.rb delete mode 100644 spec/support/slash_commands_helpers.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6d8d398a5..f7f70b333ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,13 @@ entry. - API: Add parameters to allow filtering project pipelines. !9367 (dosuken123) - Database SSL support for backup script. !9715 (Guillaume Simon) - Fix UI inconsistency different files view (find file button missing). !9847 (TM Lee) -- Display slash commands outcome when previewing Markdown. !10054 (Rares Sfirlogea) +- Display quick actions outcome when previewing Markdown. !10054 (Rares Sfirlogea) - Resolve "Add more tests for spec/controllers/projects/builds_controller_spec.rb". !10244 (dosuken123) - Add keyboard edit shotcut for wiki. !10245 (George Andrinopoulos) - Redirect old links after renaming a user/group/project. !10370 - Add system note on description change of issue/merge request. !10392 (blackst0ne) - Improve validation of namespace & project paths. !10413 -- Add board_move slash command. !10433 (Alex Sanford) +- Add board_move quick action. !10433 (Alex Sanford) - Update all instances of the old loading icon. !10490 (Andrew Torres) - Implement protected manual actions. !10494 - Implement search by extern_uid in Users API. !10509 (Robin Bobbitt) @@ -669,7 +669,7 @@ entry. - Remove es6 file extension from JavaScript files. !9241 (winniehell) - Add Runner's registration/deletion v4 API. !9246 - Add merge request count to each issue on issues list. !9252 (blackst0ne) -- Fix error in MR widget after /merge slash command. !9259 +- Fix error in MR widget after /merge quick action. !9259 - Clean-up Project navigation order. !9272 - Add Runner's jobs v4 API. !9273 - Add pipeline trigger API with user permissions. !9277 @@ -691,7 +691,7 @@ entry. - Remove markup that was showing in tooltip for renamed files. !9374 - Drop unused ci_projects table and some unused project_id columns, then rename gl_project_id to project_id. Stop exporting job trace when exporting projects. !9378 (David Wagner) - Adds remote logout functionality to the Authentiq OAuth provider. !9381 (Alexandros Keramidas) -- Introduce /award slash command; Allow posting of just an emoji in comment. !9382 (mhasbini) +- Introduce /award quick action; Allow posting of just an emoji in comment. !9382 (mhasbini) - API: Remove deprecated fields Notes#upvotes and Notes#downvotes. !9384 (Robert Schilling) - Redo internals of Incoming Mail Support. !9385 - update Vue to v2.1.10. !9386 @@ -765,7 +765,7 @@ entry. - fix milestone does not automatically assign when create issue from milestone. - Re-add Assign to me link to Merge Request and Issues. - Format timeago date to short format. -- Fix errors in slash commands matcher, add simple test coverage. (YarNayar) +- Fix errors in quick actions matcher, add simple test coverage. (YarNayar) - Make Git history follow renames again by performing the --skip in Ruby. - Added option to update to owner for group members. - Pick up option from GDK to disable webpack dev server livereload. @@ -855,7 +855,7 @@ entry. - Changed coverage reg expression placeholder text to be more like a placeholder. - Show members of parent groups on project members page. - Fix grammer issue in admin/runners. -- Allow slashes in slash command arguments. +- Allow slashes in quick action arguments. - Adds paginationd and folders view to environments table. - hide loading spinners for server-rendered sidebar fields. - Change development tanuki favicon colors to match logo color order. @@ -911,7 +911,7 @@ entry. - Use default branch as target_branch when parameter is missing. - Upgrade GitLab Pages to v0.3.2. - Add performance query regression fix for !9088 affecting #27267. -- Chat slash commands show labels correctly. +- Chat quick actions show labels correctly. ## 8.17.0 (2017-02-22) @@ -1005,7 +1005,7 @@ entry. - Do not display deploy keys in user's own ssh keys list. !9024 - upgrade babel 5.8.x to babel 6.22.x. !9072 - upgrade to webpack v2.2. !9078 -- Trigger autocomplete after selecting a slash command. !9117 +- Trigger autocomplete after selecting a quick action. !9117 - Add space between text and loading icon in Megre Request Widget. !9119 - Fix job to pipeline renaming. !9147 - Replace static fixture for merge_request_tabs_spec.js. !9172 (winniehell) @@ -1055,7 +1055,7 @@ entry. - Restore pagination to admin abuse reports. - Ensure export files are removed after a namespace is deleted. - Add `y` keyboard shortcut to move to file permalink. -- Adds /target_branch slash command functionality for merge requests. (YarNayar) +- Adds /target_branch quick action functionality for merge requests. (YarNayar) - Patch Asciidocs rendering to block XSS. - contribution calendar scrolls from right to left. - Copying a rendered issue/comment will paste into GFM textareas as actual GFM. @@ -1143,7 +1143,7 @@ entry. - Fix filtering with multiple words. !8830 - Fixed services form cancel not redirecting back the integrations settings view. !8843 - Fix filtering usernames with multiple words. !8851 -- Improve performance of slash commands. !8876 +- Improve performance of quick actions. !8876 - Remove old project members when retrying an export. - Fix permalink discussion note being collapsed. - Add project ID index to `project_authorizations` table to optimize queries. @@ -1204,7 +1204,7 @@ entry. - Reduce DB-load for build-queues by storing last_update in Redis. !8084 - Record and show last used date of SSH Keys. !8113 (Vincent Wong) - Resolves overflow in compare branch and tags dropdown. !8118 -- Replace wording for slash command confirmation message. !8123 +- Replace wording for quick action confirmation message. !8123 - remove build_user. !8162 (Arsenev Vladislav) - Prevent empty pagination when list is not empty. !8172 - Make successful pipeline emails off for watchers. !8176 @@ -1410,7 +1410,7 @@ entry. - Allow projects with 'dashboard' as path. - Disabled emoji buttons when user is not logged in. - Remove unused and void services from the database. -- Add issue search slash command. +- Add issue search quick action. - Accept issue new as command to create an issue. - Non members cannot create labels through the API. - API: expose pipeline coverage. @@ -1530,8 +1530,8 @@ entry. - Fix Latest deployment link is broken. !7839 - Don't display prompt to add SSH keys if SSH protocol is disabled. !7840 (Andrew Smith (EspadaV8)) - Allow unauthenticated access to some Project API GET endpoints. !7843 -- Refactor presenters ChatCommands. !7846 -- Improve help message for issue create slash command. !7850 +- Refactor presenters SlashCommands. !7846 +- Improve help message for issue create quick action. !7850 - change text around timestamps to make it clear which timestamp is displayed. !7860 (BM5k) - Improve Build Log scrolling experience. !7895 - Change ref property to commitRef in vue commit component. !7901 @@ -1822,7 +1822,7 @@ entry. - Trim leading and trailing whitespace on project_path (Linus Thiel) - Prevent award emoji via notes for issues/MRs authored by user (barthc) - Adds support for the `token` attribute in project hooks API (Gauvain Pocentek) -- Change auto selection behaviour of emoji and slash commands to be more UX/Type friendly (Yann Gravrand) +- Change auto selection behaviour of emoji and quick actions to be more UX/Type friendly (Yann Gravrand) - Adds an optional path parameter to the Commits API to filter commits by path (Luis HGO) - Fix Markdown styling inside reference links (Jan Zdráhal) - Create new issue board list after creating a new label @@ -2036,7 +2036,7 @@ entry. - Move Pipeline Metrics to separate worker - AbstractReferenceFilter caches project_refs on RequestStore when active - Replaced the check sign to arrow in the show build view. !6501 -- Add a /wip slash command to toggle the Work In Progress status of a merge request. !6259 (tbalthazar) +- Add a /wip quick action to toggle the Work In Progress status of a merge request. !6259 (tbalthazar) - ProjectCacheWorker updates caches at most once per 15 minutes per project - Fix Error 500 when viewing old merge requests with bad diff data - Create a new /templates namespace for the /licenses, /gitignores and /gitlab_ci_ymls API endpoints. !5717 (tbalthazar) @@ -2131,7 +2131,7 @@ entry. - Ability to batch assign issues relating to a merge request to the author. !5725 (jamedjo) - Changed Slack service user referencing from full name to username (Sebastian Poxhofer) - Retouch environments list and deployments list -- Add multiple command support for all label related slash commands !6780 (barthc) +- Add multiple command support for all label related quick actions !6780 (barthc) - Add Container Registry on/off status to Admin Area !6638 (the-undefined) - Add Nofollow for uppercased scheme in external urls !6820 (the-undefined) - Allow empty merge requests !6384 (Artem Sidorenko) @@ -2256,7 +2256,7 @@ entry. - Filter tags by name !6121 - Update gitlab shell secret file also when it is empty. !3774 (glensc) - Give project selection dropdowns responsive width, make non-wrapping. - - Fix note form hint showing slash commands supported for commits. + - Fix note form hint showing quick actions supported for commits. - Make push events have equal vertical spacing. - API: Ensure invitees are not returned in Members API. - Preserve applied filters on issues search. @@ -2333,7 +2333,7 @@ entry. - Add `wiki_page_events` to project hook APIs (Ben Boeckel) - Remove Gitorious import - Loads GFM autocomplete source only when required - - Fix issue with slash commands not loading on new issue page + - Fix issue with quick actions not loading on new issue page - Fix inconsistent background color for filter input field (ClemMakesApps) - Remove prefixes from transition CSS property (ClemMakesApps) - Add Sentry logging to API calls @@ -2605,7 +2605,7 @@ entry. - 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 + - Support quick actions 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) diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b8a923cf619..1d19593f987 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -33,7 +33,7 @@ class GfmAutoComplete { const $input = $(input); $input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input)); // This triggers at.js again - // Needed for slash commands with suffixes (ex: /label ~) + // Needed for quick actions with suffixes (ex: /label ~) $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); }); } @@ -46,8 +46,8 @@ class GfmAutoComplete { if (this.enableMap.mergeRequests) this.setupMergeRequests($input); if (this.enableMap.labels) this.setupLabels($input); - // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - $input.filter('[data-supports-slash-commands="true"]').atwho({ + // We don't instantiate the quick actions autocomplete for note and issue/MR edit forms + $input.filter('[data-supports-quick-actions="true"]').atwho({ at: '/', alias: 'commands', searchKey: 'search', diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 0ca7cabfc5a..ee07009a13a 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -31,7 +31,7 @@ const normalizeNewlines = function(str) { (function() { this.Notes = (function() { const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; - const REGEX_SLASH_COMMANDS = /^\/\w+.*$/gm; + const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; Notes.interval = null; @@ -280,7 +280,7 @@ const normalizeNewlines = function(str) { return this.initRefresh(); }; - Notes.prototype.handleSlashCommands = function(noteEntity) { + Notes.prototype.handleQuickActions = function(noteEntity) { var votesBlock; if (noteEntity.commands_changes) { if ('merge' in noteEntity.commands_changes) { @@ -1193,17 +1193,17 @@ const normalizeNewlines = function(str) { }; /** - * Identify if comment has any slash commands + * Identify if comment has any quick actions */ - Notes.prototype.hasSlashCommands = function(formContent) { - return REGEX_SLASH_COMMANDS.test(formContent); + Notes.prototype.hasQuickActions = function(formContent) { + return REGEX_QUICK_ACTIONS.test(formContent); }; /** - * Remove slash commands and leave comment with pure message + * Remove quick actions and leave comment with pure message */ - Notes.prototype.stripSlashCommands = function(formContent) { - return formContent.replace(REGEX_SLASH_COMMANDS, '').trim(); + Notes.prototype.stripQuickActions = function(formContent) { + return formContent.replace(REGEX_QUICK_ACTIONS, '').trim(); }; /** @@ -1293,8 +1293,8 @@ const normalizeNewlines = function(str) { } tempFormContent = formContent; - if (this.hasSlashCommands(formContent)) { - tempFormContent = this.stripSlashCommands(formContent); + if (this.hasQuickActions(formContent)) { + tempFormContent = this.stripQuickActions(formContent); } if (tempFormContent) { @@ -1353,7 +1353,7 @@ const normalizeNewlines = function(str) { } if (note.commands_changes) { - this.handleSlashCommands(note); + this.handleQuickActions(note); } $form.trigger('ajax:success', [note]); diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 4a3df2fd465..141333b2b4d 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -3,7 +3,7 @@ // MarkdownPreview // // Handles toggling the "Write" and "Preview" tab clicks, rendering the preview -// (including the explanation of slash commands), and showing a warning when +// (including the explanation of quick actions), and showing a warning when // more than `x` users are referenced. // (function () { diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js index b2a77462fe0..142ad437509 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js @@ -15,10 +15,10 @@ export default {

- Track time with slash commands + Track time with quick actions

- Slash commands can be used in the issues description and comment boxes. + Quick actions can be used in the issues description and comment boxes.

diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js index 244b67b3ad9..650e935b116 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -16,10 +16,10 @@ export default { 'issuable-time-tracker': timeTracker, }, methods: { - listenForSlashCommands() { - $(document).on('ajax:success', '.gfm-form', this.slashCommandListened); + listenForQuickActions() { + $(document).on('ajax:success', '.gfm-form', this.quickActionListened); }, - slashCommandListened(e, data) { + quickActionListened(e, data) { const subscribedCommands = ['spend_time', 'time_estimate']; let changedCommands; if (data !== undefined) { @@ -35,7 +35,7 @@ export default { }, }, mounted() { - this.listenForSlashCommands(); + this.listenForQuickActions(); }, template: `

diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 375110b77e2..44b463f2a7c 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -10,8 +10,8 @@ module NotesHelper Ability.can_edit_note?(current_user, note) end - def note_supports_slash_commands?(note) - Notes::SlashCommandsService.supported?(note, current_user) + def note_supports_quick_actions?(note) + Notes::QuickActionsService.supported?(note, current_user) end def noteable_json(noteable) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 356af776b8d..394c1a62693 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -893,7 +893,7 @@ class MergeRequest < ActiveRecord::Base !has_commits? end - def mergeable_with_slash_command?(current_user, autocomplete_precheck: false, last_diff_sha: nil) + def mergeable_with_quick_action?(current_user, autocomplete_precheck: false, last_diff_sha: nil) return false unless can_be_merged_by?(current_user) return true if autocomplete_precheck diff --git a/app/models/note.rb b/app/models/note.rb index 60257aac93b..d429a550b76 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -31,7 +31,7 @@ class Note < ActiveRecord::Base # Banzai::ObjectRenderer attr_accessor :user_visible_reference_count - # Attribute used to store the attributes that have ben changed by slash commands. + # Attribute used to store the attributes that have ben changed by quick actions. attr_accessor :commands_changes default_value_for :system, false diff --git a/app/models/project_services/chat_slash_commands_service.rb b/app/models/project_services/chat_slash_commands_service.rb index 8b5bc24fd3c..42692761202 100644 --- a/app/models/project_services/chat_slash_commands_service.rb +++ b/app/models/project_services/chat_slash_commands_service.rb @@ -33,10 +33,10 @@ class ChatSlashCommandsService < Service user = find_chat_user(params) if user - Gitlab::ChatCommands::Command.new(project, user, params).execute + Gitlab::SlashCommands::Command.new(project, user, params).execute else url = authorize_chat_name_url(params) - Gitlab::ChatCommands::Presenters::Access.new(url).authorize + Gitlab::SlashCommands::Presenters::Access.new(url).authorize end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 4757ba71680..2683aaad981 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -10,7 +10,7 @@ class GlobalPolicy < BasePolicy can! :access_api can! :access_git can! :receive_notifications - can! :use_slash_commands + can! :use_quick_actions end end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index e94ab3e64db..c61c4804fe7 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -142,9 +142,9 @@ class IssuableBaseService < BaseService LabelsFinder.new(current_user, project_id: @project.id).execute end - def merge_slash_commands_into_params!(issuable) + def merge_quick_actions_into_params!(issuable) description, command_params = - SlashCommands::InterpretService.new(project, current_user). + QuickActions::InterpretService.new(project, current_user). execute(params[:description], issuable) # Avoid a description already set on an issuable to be overwritten by a nil @@ -162,7 +162,7 @@ class IssuableBaseService < BaseService end def create(issuable) - merge_slash_commands_into_params!(issuable) + merge_quick_actions_into_params!(issuable) filter_params(issuable) params.delete(:state_event) diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 5c843a258fb..75a65aecd1a 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -7,7 +7,7 @@ module MergeRequests params.except!(:target_project_id) params.except!(:source_branch) - merge_from_slash_command(merge_request) if params[:merge] + merge_from_quick_action(merge_request) if params[:merge] if merge_request.closed_without_fork? params.except!(:target_branch, :force_remove_source_branch) @@ -74,9 +74,9 @@ module MergeRequests end end - def merge_from_slash_command(merge_request) + def merge_from_quick_action(merge_request) last_diff_sha = params.delete(:merge) - return unless merge_request.mergeable_with_slash_command?(current_user, last_diff_sha: last_diff_sha) + return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) merge_request.update(merge_error: nil) diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index f3954f6f8c4..06971483992 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -9,11 +9,11 @@ module Notes # 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) + quick_actions_service = QuickActionsService.new(project, current_user) - if slash_commands_service.supported?(note) + if quick_actions_service.supported?(note) options = { merge_request_diff_head_sha: merge_request_diff_head_sha } - content, command_params = slash_commands_service.extract_commands(note, options) + content, command_params = quick_actions_service.extract_commands(note, options) only_commands = content.empty? @@ -30,7 +30,7 @@ module Notes end if command_params.present? - slash_commands_service.execute(command_params, note) + quick_actions_service.execute(command_params, note) # We must add the error after we call #save because errors are reset # when #save is called diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb new file mode 100644 index 00000000000..8f81b54164a --- /dev/null +++ b/app/services/notes/quick_actions_service.rb @@ -0,0 +1,36 @@ +module Notes + class QuickActionsService < BaseService + UPDATE_SERVICES = { + 'Issue' => Issues::UpdateService, + 'MergeRequest' => MergeRequests::UpdateService + }.freeze + + def self.noteable_update_service(note) + UPDATE_SERVICES[note.noteable_type] + end + + def self.supported?(note, current_user) + noteable_update_service(note) && + current_user && + current_user.can?(:"update_#{note.to_ability_name}", note.noteable) + end + + def supported?(note) + self.class.supported?(note, current_user) + end + + def extract_commands(note, options = {}) + return [note.note, {}] unless supported?(note) + + QuickActions::InterpretService.new(project, current_user, options). + execute(note.note, note.noteable) + end + + def execute(command_params, note) + return if command_params.empty? + return unless supported?(note) + + self.class.noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable) + end + end +end diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb deleted file mode 100644 index ad1e6f6774a..00000000000 --- a/app/services/notes/slash_commands_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -module Notes - class SlashCommandsService < BaseService - UPDATE_SERVICES = { - 'Issue' => Issues::UpdateService, - 'MergeRequest' => MergeRequests::UpdateService - }.freeze - - def self.noteable_update_service(note) - UPDATE_SERVICES[note.noteable_type] - end - - def self.supported?(note, current_user) - noteable_update_service(note) && - current_user && - current_user.can?(:"update_#{note.to_ability_name}", note.noteable) - end - - def supported?(note) - self.class.supported?(note, current_user) - end - - def extract_commands(note, options = {}) - return [note.note, {}] unless supported?(note) - - SlashCommands::InterpretService.new(project, current_user, options). - execute(note.note, note.noteable) - end - - def execute(command_params, note) - return if command_params.empty? - return unless supported?(note) - - self.class.noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable) - end - end -end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index 10d45bbf73c..4ee2c1796bd 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -1,6 +1,6 @@ class PreviewMarkdownService < BaseService def execute - text, commands = explain_slash_commands(params[:text]) + text, commands = explain_quick_actions(params[:text]) users = find_user_references(text) success( @@ -12,11 +12,11 @@ class PreviewMarkdownService < BaseService private - def explain_slash_commands(text) + def explain_quick_actions(text) return text, [] unless %w(Issue MergeRequest).include?(commands_target_type) - slash_commands_service = SlashCommands::InterpretService.new(project, current_user) - slash_commands_service.explain(text, find_commands_target) + quick_actions_service = QuickActions::InterpretService.new(project, current_user) + quick_actions_service.explain(text, find_commands_target) end def find_user_references(text) @@ -36,10 +36,10 @@ class PreviewMarkdownService < BaseService end def commands_target_type - params[:slash_commands_target_type] + params[:quick_actions_target_type] end def commands_target_id - params[:slash_commands_target_id] + params[:quick_actions_target_id] end end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 015f2828921..fc85f398935 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -32,7 +32,7 @@ module Projects issuable: noteable, current_user: current_user } - SlashCommands::InterpretService.command_definitions.map do |definition| + QuickActions::InterpretService.command_definitions.map do |definition| next unless definition.available?(opts) definition.to_h(opts) diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb new file mode 100644 index 00000000000..8128d0ac49b --- /dev/null +++ b/app/services/quick_actions/interpret_service.rb @@ -0,0 +1,509 @@ +module QuickActions + class InterpretService < BaseService + include Gitlab::QuickActions::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) + return [content, {}] unless current_user.can?(:use_quick_actions) + + @issuable = issuable + @updates = {} + + content, commands = extractor.extract_commands(content, context) + extract_updates(commands, context) + [content, @updates] + end + + # Takes a text and interprets the commands that are extracted from it. + # Returns the content without commands, and array of changes explained. + def explain(content, issuable) + return [content, []] unless current_user.can?(:use_quick_actions) + + @issuable = issuable + + content, commands = extractor.extract_commands(content, context) + commands = explain_commands(commands, context) + [content, commands] + end + + private + + def extractor + Gitlab::QuickActions::Extractor.new(self.class.command_definitions) + end + + desc do + "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" + end + explanation do + "Closes 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 + explanation do + "Reopens 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 'Merge (when the pipeline succeeds)' + explanation 'Merges this merge request when the pipeline succeeds.' + condition do + last_diff_sha = params && params[:merge_request_diff_head_sha] + issuable.is_a?(MergeRequest) && + issuable.persisted? && + issuable.mergeable_with_quick_action?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha) + end + command :merge do + @updates[:merge] = params[:merge_request_diff_head_sha] + end + + desc 'Change title' + explanation do |title_param| + "Changes the title to \"#{title_param}\"." + end + params '' + 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' + explanation do |users| + "Assigns #{users.map(&:to_reference).to_sentence}." if users.any? + end + params '@user' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + parse_params do |assignee_param| + users = extract_references(assignee_param, :user) + + if users.empty? + users = User.where(username: assignee_param.split(' ').map(&:strip)) + end + + users + end + command :assign do |users| + next if users.empty? + + if issuable.is_a?(Issue) + @updates[:assignee_ids] = users.map(&:id) + else + @updates[:assignee_id] = users.last.id + end + end + + desc 'Remove assignee' + explanation do + "Removes assignee #{issuable.assignees.first.to_reference}." + end + condition do + issuable.persisted? && + issuable.assignees.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :unassign do + if issuable.is_a?(Issue) + @updates[:assignee_ids] = [] + else + @updates[:assignee_id] = nil + end + end + + desc 'Set milestone' + explanation do |milestone| + "Sets the milestone to #{milestone.to_reference}." if milestone + end + params '%"milestone"' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && + project.milestones.active.any? + end + parse_params do |milestone_param| + extract_references(milestone_param, :milestone).first || + project.milestones.find_by(title: milestone_param.strip) + end + command :milestone do |milestone| + @updates[:milestone_id] = milestone.id if milestone + end + + desc 'Remove milestone' + explanation do + "Removes #{issuable.milestone.to_reference(format: :name)} milestone." + end + 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)' + explanation do |labels_param| + labels = find_label_references(labels_param) + + "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + end + params '~label1 ~"label 2"' + condition do + available_labels = LabelsFinder.new(current_user, project_id: project.id).execute + + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && + available_labels.any? + end + command :label do |labels_param| + label_ids = find_label_ids(labels_param) + + if label_ids.any? + @updates[:add_label_ids] ||= [] + @updates[:add_label_ids] += label_ids + + @updates[:add_label_ids].uniq! + end + end + + desc 'Remove all or specific label(s)' + explanation do |labels_param = nil| + if labels_param.present? + labels = find_label_references(labels_param) + "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + else + 'Removes all labels.' + end + end + 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) + + if label_ids.any? + @updates[:remove_label_ids] ||= [] + @updates[:remove_label_ids] += label_ids + + @updates[:remove_label_ids].uniq! + end + else + @updates[:label_ids] = [] + end + end + + desc 'Replace all label(s)' + explanation do |labels_param| + labels = find_label_references(labels_param) + "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + end + 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) + + if label_ids.any? + @updates[:label_ids] ||= [] + @updates[:label_ids] += label_ids + + @updates[:label_ids].uniq! + end + end + + desc 'Add a todo' + explanation 'Adds 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' + explanation 'Marks 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' + explanation do + "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}." + end + condition do + issuable.persisted? && + !issuable.subscribed?(current_user, project) + end + command :subscribe do + @updates[:subscription_event] = 'subscribe' + end + + desc 'Unsubscribe' + explanation do + "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}." + end + condition do + issuable.persisted? && + issuable.subscribed?(current_user, project) + end + command :unsubscribe do + @updates[:subscription_event] = 'unsubscribe' + end + + desc 'Set due date' + explanation do |due_date| + "Sets the due date to #{due_date.to_s(:medium)}." if due_date + end + params '' + condition do + issuable.respond_to?(:due_date) && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + parse_params do |due_date_param| + Chronic.parse(due_date_param).try(:to_date) + end + command :due do |due_date| + @updates[:due_date] = due_date if due_date + end + + desc 'Remove due date' + explanation 'Removes the due date.' + condition do + issuable.persisted? && + issuable.respond_to?(:due_date) && + issuable.due_date? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_due_date do + @updates[:due_date] = nil + end + + desc 'Toggle the Work In Progress status' + explanation do + verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks' + noun = issuable.to_ability_name.humanize(capitalize: false) + "#{verb} this #{noun} as Work In Progress." + end + condition do + issuable.persisted? && + issuable.respond_to?(:work_in_progress?) && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :wip do + @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' + end + + desc 'Toggle emoji award' + explanation do |name| + "Toggles :#{name}: emoji award." if name + end + params ':emoji:' + condition do + issuable.persisted? + end + parse_params do |emoji_param| + match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern) + match[1] if match + end + command :award do |name| + if name && issuable.user_can_award?(current_user, name) + @updates[:emoji_award] = name + end + end + + desc 'Set time estimate' + explanation do |time_estimate| + time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate) + + "Sets time estimate to #{time_estimate}." if time_estimate + end + params '<1w 3d 2h 14m>' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + parse_params do |raw_duration| + Gitlab::TimeTrackingFormatter.parse(raw_duration) + end + command :estimate do |time_estimate| + if time_estimate + @updates[:time_estimate] = time_estimate + end + end + + desc 'Add or substract spent time' + explanation do |time_spent| + if time_spent + if time_spent > 0 + verb = 'Adds' + value = time_spent + else + verb = 'Substracts' + value = -time_spent + end + + "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time." + end + end + params '<1h 30m | -1h 30m>' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) + end + parse_params do |raw_duration| + Gitlab::TimeTrackingFormatter.parse(raw_duration) + end + command :spend do |time_spent| + if time_spent + @updates[:spend_time] = { duration: time_spent, user: current_user } + end + end + + desc 'Remove time estimate' + explanation 'Removes time estimate.' + condition do + issuable.persisted? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_estimate do + @updates[:time_estimate] = 0 + end + + desc 'Remove spent time' + explanation 'Removes spent time.' + condition do + issuable.persisted? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_time_spent do + @updates[:spend_time] = { duration: :reset, user: current_user } + end + + # This is a dummy command, so that it appears in the autocomplete commands + desc 'CC' + params '@user' + command :cc + + desc 'Define target branch for MR' + explanation do |branch_name| + "Sets target branch to #{branch_name}." + end + params '' + condition do + issuable.respond_to?(:target_branch) && + (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) || + issuable.new_record?) + end + parse_params do |target_branch_param| + target_branch_param.strip + end + command :target_branch do |branch_name| + @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name) + end + + desc 'Move issue from one column of the board to another' + explanation do |target_list_name| + label = find_label_references(target_list_name).first + "Moves issue to #{label} column in the board." if label + end + params '~"Target column"' + condition do + issuable.is_a?(Issue) && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) && + issuable.project.boards.count == 1 + end + command :board_move do |target_list_name| + label_ids = find_label_ids(target_list_name) + + if label_ids.size == 1 + label_id = label_ids.first + + # Ensure this label corresponds to a list on the board + next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists? + + @updates[:remove_label_ids] = + issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id) + @updates[:add_label_ids] = [label_id] + end + end + + def find_labels(labels_param) + extract_references(labels_param, :label) | + LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute + end + + def find_label_references(labels_param) + find_labels(labels_param).map(&:to_reference) + end + + def find_label_ids(labels_param) + find_labels(labels_param).map(&:id) + end + + def explain_commands(commands, opts) + commands.map do |name, arg| + definition = self.class.definition_by_name(name) + next unless definition + + definition.explain(self, opts, arg) + end.compact + end + + def extract_updates(commands, opts) + commands.each do |name, arg| + definition = self.class.definition_by_name(name) + next unless definition + + definition.execute(self, opts, arg) + end + end + + def extract_references(arg, type) + ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(arg, author: current_user) + + ext.references(type) + end + + def context + { + issuable: issuable, + current_user: current_user, + project: project, + params: params + } + end + end +end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb deleted file mode 100644 index a7e13648b54..00000000000 --- a/app/services/slash_commands/interpret_service.rb +++ /dev/null @@ -1,509 +0,0 @@ -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) - return [content, {}] unless current_user.can?(:use_slash_commands) - - @issuable = issuable - @updates = {} - - content, commands = extractor.extract_commands(content, context) - extract_updates(commands, context) - [content, @updates] - end - - # Takes a text and interprets the commands that are extracted from it. - # Returns the content without commands, and array of changes explained. - def explain(content, issuable) - return [content, []] unless current_user.can?(:use_slash_commands) - - @issuable = issuable - - content, commands = extractor.extract_commands(content, context) - commands = explain_commands(commands, context) - [content, commands] - 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 - explanation do - "Closes 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 - explanation do - "Reopens 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 'Merge (when the pipeline succeeds)' - explanation 'Merges this merge request when the pipeline succeeds.' - condition do - last_diff_sha = params && params[:merge_request_diff_head_sha] - issuable.is_a?(MergeRequest) && - issuable.persisted? && - issuable.mergeable_with_slash_command?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha) - end - command :merge do - @updates[:merge] = params[:merge_request_diff_head_sha] - end - - desc 'Change title' - explanation do |title_param| - "Changes the title to \"#{title_param}\"." - end - params '' - 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' - explanation do |users| - "Assigns #{users.map(&:to_reference).to_sentence}." if users.any? - end - params '@user' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - parse_params do |assignee_param| - users = extract_references(assignee_param, :user) - - if users.empty? - users = User.where(username: assignee_param.split(' ').map(&:strip)) - end - - users - end - command :assign do |users| - next if users.empty? - - if issuable.is_a?(Issue) - @updates[:assignee_ids] = users.map(&:id) - else - @updates[:assignee_id] = users.last.id - end - end - - desc 'Remove assignee' - explanation do - "Removes assignee #{issuable.assignees.first.to_reference}." - end - condition do - issuable.persisted? && - issuable.assignees.any? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :unassign do - if issuable.is_a?(Issue) - @updates[:assignee_ids] = [] - else - @updates[:assignee_id] = nil - end - end - - desc 'Set milestone' - explanation do |milestone| - "Sets the milestone to #{milestone.to_reference}." if milestone - end - params '%"milestone"' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) && - project.milestones.active.any? - end - parse_params do |milestone_param| - extract_references(milestone_param, :milestone).first || - project.milestones.find_by(title: milestone_param.strip) - end - command :milestone do |milestone| - @updates[:milestone_id] = milestone.id if milestone - end - - desc 'Remove milestone' - explanation do - "Removes #{issuable.milestone.to_reference(format: :name)} milestone." - end - 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)' - explanation do |labels_param| - labels = find_label_references(labels_param) - - "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? - end - params '~label1 ~"label 2"' - condition do - available_labels = LabelsFinder.new(current_user, project_id: project.id).execute - - current_user.can?(:"admin_#{issuable.to_ability_name}", project) && - available_labels.any? - end - command :label do |labels_param| - label_ids = find_label_ids(labels_param) - - if label_ids.any? - @updates[:add_label_ids] ||= [] - @updates[:add_label_ids] += label_ids - - @updates[:add_label_ids].uniq! - end - end - - desc 'Remove all or specific label(s)' - explanation do |labels_param = nil| - if labels_param.present? - labels = find_label_references(labels_param) - "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? - else - 'Removes all labels.' - end - end - 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) - - if label_ids.any? - @updates[:remove_label_ids] ||= [] - @updates[:remove_label_ids] += label_ids - - @updates[:remove_label_ids].uniq! - end - else - @updates[:label_ids] = [] - end - end - - desc 'Replace all label(s)' - explanation do |labels_param| - labels = find_label_references(labels_param) - "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? - end - 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) - - if label_ids.any? - @updates[:label_ids] ||= [] - @updates[:label_ids] += label_ids - - @updates[:label_ids].uniq! - end - end - - desc 'Add a todo' - explanation 'Adds 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' - explanation 'Marks 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' - explanation do - "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}." - end - condition do - issuable.persisted? && - !issuable.subscribed?(current_user, project) - end - command :subscribe do - @updates[:subscription_event] = 'subscribe' - end - - desc 'Unsubscribe' - explanation do - "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}." - end - condition do - issuable.persisted? && - issuable.subscribed?(current_user, project) - end - command :unsubscribe do - @updates[:subscription_event] = 'unsubscribe' - end - - desc 'Set due date' - explanation do |due_date| - "Sets the due date to #{due_date.to_s(:medium)}." if due_date - end - params '' - condition do - issuable.respond_to?(:due_date) && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - parse_params do |due_date_param| - Chronic.parse(due_date_param).try(:to_date) - end - command :due do |due_date| - @updates[:due_date] = due_date if due_date - end - - desc 'Remove due date' - explanation 'Removes the due date.' - condition do - issuable.persisted? && - issuable.respond_to?(:due_date) && - issuable.due_date? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_due_date do - @updates[:due_date] = nil - end - - desc 'Toggle the Work In Progress status' - explanation do - verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks' - noun = issuable.to_ability_name.humanize(capitalize: false) - "#{verb} this #{noun} as Work In Progress." - end - condition do - issuable.persisted? && - issuable.respond_to?(:work_in_progress?) && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - command :wip do - @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' - end - - desc 'Toggle emoji award' - explanation do |name| - "Toggles :#{name}: emoji award." if name - end - params ':emoji:' - condition do - issuable.persisted? - end - parse_params do |emoji_param| - match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern) - match[1] if match - end - command :award do |name| - if name && issuable.user_can_award?(current_user, name) - @updates[:emoji_award] = name - end - end - - desc 'Set time estimate' - explanation do |time_estimate| - time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate) - - "Sets time estimate to #{time_estimate}." if time_estimate - end - params '<1w 3d 2h 14m>' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - parse_params do |raw_duration| - Gitlab::TimeTrackingFormatter.parse(raw_duration) - end - command :estimate do |time_estimate| - if time_estimate - @updates[:time_estimate] = time_estimate - end - end - - desc 'Add or substract spent time' - explanation do |time_spent| - if time_spent - if time_spent > 0 - verb = 'Adds' - value = time_spent - else - verb = 'Substracts' - value = -time_spent - end - - "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time." - end - end - params '<1h 30m | -1h 30m>' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) - end - parse_params do |raw_duration| - Gitlab::TimeTrackingFormatter.parse(raw_duration) - end - command :spend do |time_spent| - if time_spent - @updates[:spend_time] = { duration: time_spent, user: current_user } - end - end - - desc 'Remove time estimate' - explanation 'Removes time estimate.' - condition do - issuable.persisted? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_estimate do - @updates[:time_estimate] = 0 - end - - desc 'Remove spent time' - explanation 'Removes spent time.' - condition do - issuable.persisted? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_time_spent do - @updates[:spend_time] = { duration: :reset, user: current_user } - end - - # This is a dummy command, so that it appears in the autocomplete commands - desc 'CC' - params '@user' - command :cc - - desc 'Define target branch for MR' - explanation do |branch_name| - "Sets target branch to #{branch_name}." - end - params '' - condition do - issuable.respond_to?(:target_branch) && - (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) || - issuable.new_record?) - end - parse_params do |target_branch_param| - target_branch_param.strip - end - command :target_branch do |branch_name| - @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name) - end - - desc 'Move issue from one column of the board to another' - explanation do |target_list_name| - label = find_label_references(target_list_name).first - "Moves issue to #{label} column in the board." if label - end - params '~"Target column"' - condition do - issuable.is_a?(Issue) && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) && - issuable.project.boards.count == 1 - end - command :board_move do |target_list_name| - label_ids = find_label_ids(target_list_name) - - if label_ids.size == 1 - label_id = label_ids.first - - # Ensure this label corresponds to a list on the board - next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists? - - @updates[:remove_label_ids] = - issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id) - @updates[:add_label_ids] = [label_id] - end - end - - def find_labels(labels_param) - extract_references(labels_param, :label) | - LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute - end - - def find_label_references(labels_param) - find_labels(labels_param).map(&:to_reference) - end - - def find_label_ids(labels_param) - find_labels(labels_param).map(&:id) - end - - def explain_commands(commands, opts) - commands.map do |name, arg| - definition = self.class.definition_by_name(name) - next unless definition - - definition.explain(self, opts, arg) - end.compact - end - - def extract_updates(commands, opts) - commands.each do |name, arg| - definition = self.class.definition_by_name(name) - next unless definition - - definition.execute(self, opts, arg) - end - end - - def extract_references(arg, type) - ext = Gitlab::ReferenceExtractor.new(project, current_user) - ext.analyze(arg, author: current_user) - - ext.references(type) - end - - def context - { - issuable: issuable, - current_user: current_user, - project: project, - params: params - } - end - end -end diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index 3b3d08ddd3c..bff11d888a7 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -1,10 +1,10 @@ - @gfm_form = true - current_text ||= nil -- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) +- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) .zen-backdrop - classes << ' js-gfm-input js-autosize markdown-area' - if defined?(f) && f - = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands } + = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_quick_actions: supports_quick_actions } - else = text_area_tag attr, current_text, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } diff --git a/app/views/projects/issues/_issue_by_email.html.haml b/app/views/projects/issues/_issue_by_email.html.haml index da65157a10b..35b7d1b920c 100644 --- a/app/views/projects/issues/_issue_by_email.html.haml +++ b/app/views/projects/issues/_issue_by_email.html.haml @@ -20,7 +20,7 @@ %p The subject will be used as the title of the new issue, and the message will be the description. - = link_to 'Slash commands', help_page_path('user/project/slash_commands'), target: '_blank', tabindex: -1 + = link_to 'Quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1 and styling with = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1 are supported. diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/issuable/form/_description.html.haml index 7ef0ae96be2..84e55ae1799 100644 --- a/app/views/shared/issuable/form/_description.html.haml +++ b/app/views/shared/issuable/form/_description.html.haml @@ -1,10 +1,10 @@ - project = local_assigns.fetch(:project) - issuable = local_assigns.fetch(:issuable) - form = local_assigns.fetch(:form) -- supports_slash_commands = issuable.new_record? +- supports_quick_actions = issuable.new_record? -- if supports_slash_commands - - preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name) +- if supports_quick_actions + - preview_url = preview_markdown_path(project, quick_actions_target_type: issuable.class.name) - else - preview_url = preview_markdown_path(project) @@ -16,7 +16,7 @@ = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea', placeholder: "Write a comment or drag your files here...", - supports_slash_commands: supports_slash_commands - = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands + supports_quick_actions: supports_quick_actions + = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions .clearfix .error-alert diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index eaf50bc2115..32ff505cbda 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -1,6 +1,6 @@ -- supports_slash_commands = note_supports_slash_commands?(@note) -- if supports_slash_commands - - preview_url = preview_markdown_path(@project, slash_commands_target_type: @note.noteable_type, slash_commands_target_id: @note.noteable_id) +- supports_quick_actions = note_supports_quick_actions?(@note) +- if supports_quick_actions + - preview_url = preview_markdown_path(@project, quick_actions_target_type: @note.noteable_type, quick_actions_target_id: @note.noteable_id) - else - preview_url = preview_markdown_path(@project) @@ -27,8 +27,8 @@ attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here...", - supports_slash_commands: supports_slash_commands - = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands + supports_quick_actions: supports_quick_actions + = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions .error-alert .note-form-actions.clearfix diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 7ce6130de60..bc1ac3d8ac2 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -1,10 +1,10 @@ -- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) +- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) .comment-toolbar.clearfix .toolbar-text = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1 - - if supports_slash_commands + - if supports_quick_actions and - = link_to 'slash commands', help_page_path('user/project/slash_commands'), target: '_blank', tabindex: -1 + = link_to 'quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1 are - else is diff --git a/changelogs/unreleased/27070-rename-slash-commands-to-quick-actions.yml b/changelogs/unreleased/27070-rename-slash-commands-to-quick-actions.yml new file mode 100644 index 00000000000..497239db808 --- /dev/null +++ b/changelogs/unreleased/27070-rename-slash-commands-to-quick-actions.yml @@ -0,0 +1,5 @@ +--- +title: Rename "Slash commands" to "Quick actions" and deprecate "chat commands" in favor + of "slash commands" +merge_request: +author: diff --git a/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml b/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml index 1eaa0d0124e..eb2ef18f048 100644 --- a/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml +++ b/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml @@ -1,5 +1,5 @@ --- -title: Fix /unsubscribe slash command creating extra todos when you were already mentioned +title: Fix /unsubscribe quick action creating extra todos when you were already mentioned in an issue merge_request: author: diff --git a/doc/README.md b/doc/README.md index 9f12eed1471..d9e93ed4002 100644 --- a/doc/README.md +++ b/doc/README.md @@ -24,7 +24,7 @@ Shortcuts to GitLab's most visited docs: - [GitLab Workflow](workflow/README.md): Enhance your workflow with the best of GitLab Workflow. - See also [GitLab Workflow - an overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/). - [GitLab Markdown](user/markdown.md): GitLab's advanced formatting system (GitLab Flavored Markdown). -- [GitLab Slash Commands](user/project/slash_commands.md): Textual shortcuts for common actions on issues or merge requests that are usually done by clicking buttons or dropdowns in GitLab's UI. +- [GitLab Quick Actions](user/project/quick_actions.md): Textual shortcuts for common actions on issues or merge requests that are usually done by clicking buttons or dropdowns in GitLab's UI. ### User account diff --git a/doc/development/limit_ee_conflicts.md b/doc/development/limit_ee_conflicts.md index 51b4b398f2c..899be9eae4b 100644 --- a/doc/development/limit_ee_conflicts.md +++ b/doc/development/limit_ee_conflicts.md @@ -166,8 +166,8 @@ For instance this kind of thing: = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea', placeholder: "Write a comment or drag your files here...", - supports_slash_commands: !issuable.persisted? - = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted? + supports_quick_actions: !issuable.persisted? + = render 'projects/notes/hints', supports_quick_actions: !issuable.persisted? .clearfix .error-alert - if issuable.is_a?(Issue) diff --git a/doc/integration/chat_commands.md b/doc/integration/chat_commands.md deleted file mode 100644 index c878dc7e650..00000000000 --- a/doc/integration/chat_commands.md +++ /dev/null @@ -1,14 +0,0 @@ -# Chat Commands - -Chat commands in Mattermost and Slack (also called Slack slash commands) allow you to control GitLab and view GitLab content right inside your chat client, without having to leave it. For Slack, this requires a [project service configuration](../user/project/integrations/slack_slash_commands.md). Simply type the command as a message in your chat client to activate it. - -Commands are scoped to a project, with a trigger term that is specified during configuration. (We suggest you use the project name as the trigger term for simplicty and clarity.) Taking the trigger term as `project-name`, the commands are: - - -| Command | Effect | -| ------- | ------ | -| `/project-name help` | Shows all available chat commands | -| `/project-name issue new <shift+return> <description>` | Creates a new issue with title `<title>` and description `<description>` | -| `/project-name issue show <id>` | Shows the issue with id `<id>` | -| `/project-name issue search <query>` | Shows up to 5 issues matching `<query>` | -| `/project-name deploy <from> to <to>` | Deploy from the `<from>` environment to the `<to>` environment | \ No newline at end of file diff --git a/doc/integration/slash_commands.md b/doc/integration/slash_commands.md new file mode 100644 index 00000000000..cca649e9294 --- /dev/null +++ b/doc/integration/slash_commands.md @@ -0,0 +1,14 @@ +# Slash Commands + +Slash commands in Mattermost and Slack (also called Slack slash commands) allow you to control GitLab and view GitLab content right inside your chat client, without having to leave it. For Slack, this requires a [project service configuration](../user/project/integrations/slack_slash_commands.md). Simply type the command as a message in your chat client to activate it. + +Commands are scoped to a project, with a trigger term that is specified during configuration. (We suggest you use the project name as the trigger term for simplicty and clarity.) Taking the trigger term as `project-name`, the commands are: + + +| Command | Effect | +| ------- | ------ | +| `/project-name help` | Shows all available slash commands | +| `/project-name issue new <title> <shift+return> <description>` | Creates a new issue with title `<title>` and description `<description>` | +| `/project-name issue show <id>` | Shows the issue with id `<id>` | +| `/project-name issue search <query>` | Shows up to 5 issues matching `<query>` | +| `/project-name deploy <from> to <to>` | Deploy from the `<from>` environment to the `<to>` environment | diff --git a/doc/user/discussions/index.md b/doc/user/discussions/index.md index 59e343ebe51..8b1d299484c 100644 --- a/doc/user/discussions/index.md +++ b/doc/user/discussions/index.md @@ -10,7 +10,7 @@ You can leave a comment in the following places: - commits - commit diffs -The comment area supports [Markdown] and [slash commands]. One can edit their +The comment area supports [Markdown] and [quick actions]. One can edit their own comment at any time, and anyone with [Master access level][permissions] or higher can also edit a comment made by someone else. @@ -146,5 +146,5 @@ comments in greater detail. [discussion-view]: img/discussion_view.png [discussions-resolved]: img/discussions_resolved.png [markdown]: ../markdown.md -[slash commands]: ../project/slash_commands.md +[quick actions]: ../project/quick_actions.md [permissions]: ../permissions.md diff --git a/doc/user/project/integrations/slack_slash_commands.md b/doc/user/project/integrations/slack_slash_commands.md index 54e0ee611cb..e2f9209c632 100644 --- a/doc/user/project/integrations/slack_slash_commands.md +++ b/doc/user/project/integrations/slack_slash_commands.md @@ -2,7 +2,7 @@ > Introduced in GitLab 8.15 -Slack slash commands (also known as chat commmands) allow you to control GitLab and view content right inside Slack, without having to leave it. This requires configurations in both Slack and GitLab. +Slack slash commands (also known as chat commmands) allow you to control GitLab and view content right inside Slack, without having to leave it. This requires configurations in both Slack and GitLab. > Note: GitLab can also send events (e.g. issue created) to Slack as notifications. This is the separately configured [Slack Notifications Service](slack.md). @@ -20,4 +20,4 @@ Slack slash commands (also known as chat commmands) allow you to control GitLab ## Usage -You can now use the [Slack slash commands](../../../integration/chat_commands.md). \ No newline at end of file +You can now use the [Slack slash commands](../../../integration/slash_commands.md). diff --git a/doc/user/project/issues/issues_functionalities.md b/doc/user/project/issues/issues_functionalities.md index ba843201e1a..294176e61f9 100644 --- a/doc/user/project/issues/issues_functionalities.md +++ b/doc/user/project/issues/issues_functionalities.md @@ -68,7 +68,7 @@ This feature is available only in [GitLab Enterprise Edition](https://about.gitl - Spend: add the time spent on the implementation of that issue > **Note:** -both estimate and spend times are set via [GitLab Slash Commands](../slash_commands.md). +both estimate and spend times are set via [GitLab Quick Actions](../quick_actions.md). Learn more on the [Time Tracking documentation](https://docs.gitlab.com/ee/workflow/time_tracking.html). @@ -147,7 +147,7 @@ or in the issue thread. #### 15. Award emoji -- Award an emoji to that issue. +- Award an emoji to that issue. > **Tip:** Posting "+1" as comments in threads spam all diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md new file mode 100644 index 00000000000..19b51c83222 --- /dev/null +++ b/doc/user/project/quick_actions.md @@ -0,0 +1,39 @@ +# GitLab quick actions + +Quick actions 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 | +| `/merge` | Merge (when pipeline succeeds) | +| `/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 | +| <code>/due <in 2 days | this Friday | December 31st></code> | Set due date | +| `/remove_due_date` | Remove due date | +| `/wip` | Toggle the Work In Progress status | +| <code>/estimate <1w 3d 2h 14m></code> | Set time estimate | +| `/remove_estimate` | Remove estimated time | +| <code>/spend <1h 30m | -1h 5m></code> | Add or subtract spent time | +| `/remove_time_spent` | Remove time spent | +| `/target_branch <Branch Name>` | Set target branch for current merge request | +| `/award :emoji:` | Toggle award for :emoji: | +| `/board_move ~column` | Move issue to column on the board | diff --git a/doc/user/project/slash_commands.md b/doc/user/project/slash_commands.md deleted file mode 100644 index 08452ca75cd..00000000000 --- a/doc/user/project/slash_commands.md +++ /dev/null @@ -1,39 +0,0 @@ -# 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 | -| `/merge` | Merge (when pipeline succeeds) | -| `/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 | -| <code>/due <in 2 days | this Friday | December 31st></code> | Set due date | -| `/remove_due_date` | Remove due date | -| `/wip` | Toggle the Work In Progress status | -| <code>/estimate <1w 3d 2h 14m></code> | Set time estimate | -| `/remove_estimate` | Remove estimated time | -| <code>/spend <1h 30m | -1h 5m></code> | Add or subtract spent time | -| `/remove_time_spent` | Remove time spent | -| `/target_branch <Branch Name>` | Set target branch for current merge request | -| `/award :emoji:` | Toggle award for :emoji: | -| `/board_move ~column` | Move issue to column on the board | diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 604c7d5cefb..54d4028a50a 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -21,7 +21,7 @@ - [Project users](add-user/add-user.md) - [Protected branches](../user/project/protected_branches.md) - [Protected tags](../user/project/protected_tags.md) -- [Slash commands](../user/project/slash_commands.md) +- [Quick Actions](../user/project/quick_actions.md) - [Sharing a project with a group](share_with_group.md) - [Share projects with other groups](share_projects_with_other_groups.md) - [Time tracking](time_tracking.md) diff --git a/doc/workflow/time_tracking.md b/doc/workflow/time_tracking.md index de12994c516..bfe87bb2ceb 100644 --- a/doc/workflow/time_tracking.md +++ b/doc/workflow/time_tracking.md @@ -21,13 +21,13 @@ below. ## How to enter data -Time Tracking uses two [slash commands] that GitLab introduced with this new +Time Tracking uses two [quick actions] that GitLab introduced with this new feature: `/spend` and `/estimate`. -Slash commands can be used in the body of an issue or a merge request, but also +Quick actions can be used in the body of an issue or a merge request, but also in a comment in both an issue or a merge request. -Below is an example of how you can use those new slash commands inside a comment. +Below is an example of how you can use those new quick actions inside a comment. ![Time tracking example in a comment](time-tracking/time-tracking-example.png) @@ -70,4 +70,4 @@ The following time units are available: Default conversion rates are 1w = 5d and 1d = 8h. [landing]: https://about.gitlab.com/features/time-tracking -[slash-commands]: ../user/project/slash_commands.md +[quick actions]: ../user/project/quick_actions.md diff --git a/lib/api/services.rb b/lib/api/services.rb index 47bd9940f77..7488f95a9b7 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -685,7 +685,7 @@ module API trigger_services.each do |service_slug, settings| helpers do - def chat_command_service(project, service_slug, params) + def slash_command_service(project, service_slug, params) project.services.active.where(template: false).find do |service| service.try(:token) == params[:token] && service.to_param == service_slug.underscore end @@ -710,7 +710,7 @@ module API # This is not accurate, but done to prevent leakage of the project names not_found!('Service') unless project - service = chat_command_service(project, service_slug, params) + service = slash_command_service(project, service_slug, params) result = service.try(:trigger, params) if result diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb index 118c6df6549..2d13d6fabfd 100644 --- a/lib/api/v3/services.rb +++ b/lib/api/v3/services.rb @@ -608,7 +608,7 @@ module API trigger_services.each do |service_slug, settings| helpers do - def chat_command_service(project, service_slug, params) + def slash_command_service(project, service_slug, params) project.services.active.where(template: false).find do |service| service.try(:token) == params[:token] && service.to_param == service_slug.underscore end @@ -633,7 +633,7 @@ module API # This is not accurate, but done to prevent leakage of the project names not_found!('Service') unless project - service = chat_command_service(project, service_slug, params) + service = slash_command_service(project, service_slug, params) result = service.try(:trigger, params) if result diff --git a/lib/gitlab/chat_commands/base_command.rb b/lib/gitlab/chat_commands/base_command.rb deleted file mode 100644 index 25da8474e95..00000000000 --- a/lib/gitlab/chat_commands/base_command.rb +++ /dev/null @@ -1,47 +0,0 @@ -module Gitlab - module ChatCommands - class BaseCommand - QUERY_LIMIT = 5 - - def self.match(_text) - raise NotImplementedError - end - - def self.help_message - raise NotImplementedError - end - - def self.available?(_project) - raise NotImplementedError - end - - def self.allowed?(_user, _ability) - true - end - - def self.can?(object, action, subject) - Ability.allowed?(object, action, subject) - end - - def execute(_) - raise NotImplementedError - end - - def collection - raise NotImplementedError - end - - attr_accessor :project, :current_user, :params - - def initialize(project, user, params = {}) - @project, @current_user, @params = project, user, params.dup - end - - private - - def find_by_iid(iid) - collection.find_by(iid: iid) - end - end - end -end diff --git a/lib/gitlab/chat_commands/command.rb b/lib/gitlab/chat_commands/command.rb deleted file mode 100644 index 3e0c30c33b7..00000000000 --- a/lib/gitlab/chat_commands/command.rb +++ /dev/null @@ -1,44 +0,0 @@ -module Gitlab - module ChatCommands - class Command < BaseCommand - COMMANDS = [ - Gitlab::ChatCommands::IssueShow, - Gitlab::ChatCommands::IssueNew, - Gitlab::ChatCommands::IssueSearch, - Gitlab::ChatCommands::Deploy - ].freeze - - def execute - command, match = match_command - - if command - if command.allowed?(project, current_user) - command.new(project, current_user, params).execute(match) - else - Gitlab::ChatCommands::Presenters::Access.new.access_denied - end - else - Gitlab::ChatCommands::Help.new(project, current_user, params).execute(available_commands, params[:text]) - end - end - - def match_command - match = nil - service = - available_commands.find do |klass| - match = klass.match(params[:text]) - end - - [service, match] - end - - private - - def available_commands - COMMANDS.select do |klass| - klass.available?(project) - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/deploy.rb b/lib/gitlab/chat_commands/deploy.rb deleted file mode 100644 index 458d90f84e8..00000000000 --- a/lib/gitlab/chat_commands/deploy.rb +++ /dev/null @@ -1,50 +0,0 @@ -module Gitlab - module ChatCommands - class Deploy < BaseCommand - def self.match(text) - /\Adeploy\s+(?<from>\S+.*)\s+to+\s+(?<to>\S+.*)\z/.match(text) - end - - def self.help_message - 'deploy <environment> to <target-environment>' - end - - def self.available?(project) - project.builds_enabled? - end - - def self.allowed?(project, user) - can?(user, :create_deployment, project) - end - - def execute(match) - from = match[:from] - to = match[:to] - - actions = find_actions(from, to) - - if actions.none? - Gitlab::ChatCommands::Presenters::Deploy.new(nil).no_actions - elsif actions.one? - action = play!(from, to, actions.first) - Gitlab::ChatCommands::Presenters::Deploy.new(action).present(from, to) - else - Gitlab::ChatCommands::Presenters::Deploy.new(actions).too_many_actions - end - end - - private - - def play!(from, to, action) - action.play(current_user) - end - - def find_actions(from, to) - environment = project.environments.find_by(name: from) - return [] unless environment - - environment.actions_for(to).select(&:starts_environment?) - end - end - end -end diff --git a/lib/gitlab/chat_commands/help.rb b/lib/gitlab/chat_commands/help.rb deleted file mode 100644 index 6c0e4d304a4..00000000000 --- a/lib/gitlab/chat_commands/help.rb +++ /dev/null @@ -1,28 +0,0 @@ -module Gitlab - module ChatCommands - class Help < BaseCommand - # This class has to be used last, as it always matches. It has to match - # because other commands were not triggered and we want to show the help - # command - def self.match(_text) - true - end - - def self.help_message - 'help' - end - - def self.allowed?(_project, _user) - true - end - - def execute(commands, text) - Gitlab::ChatCommands::Presenters::Help.new(commands).present(trigger, text) - end - - def trigger - params[:command] - end - end - end -end diff --git a/lib/gitlab/chat_commands/issue_command.rb b/lib/gitlab/chat_commands/issue_command.rb deleted file mode 100644 index 84de3e44c70..00000000000 --- a/lib/gitlab/chat_commands/issue_command.rb +++ /dev/null @@ -1,13 +0,0 @@ -module Gitlab - module ChatCommands - class IssueCommand < BaseCommand - def self.available?(project) - project.issues_enabled? && project.default_issues_tracker? - end - - def collection - IssuesFinder.new(current_user, project_id: project.id).execute - end - end - end -end diff --git a/lib/gitlab/chat_commands/issue_new.rb b/lib/gitlab/chat_commands/issue_new.rb deleted file mode 100644 index 016054ecd46..00000000000 --- a/lib/gitlab/chat_commands/issue_new.rb +++ /dev/null @@ -1,42 +0,0 @@ -module Gitlab - module ChatCommands - class IssueNew < IssueCommand - def self.match(text) - # we can not match \n with the dot by passing the m modifier as than - # the title and description are not seperated - /\Aissue\s+(new|create)\s+(?<title>[^\n]*)\n*(?<description>(.|\n)*)/.match(text) - end - - def self.help_message - 'issue new <title> *`⇧ Shift`*+*`↵ Enter`* <description>' - end - - def self.allowed?(project, user) - can?(user, :create_issue, project) - end - - def execute(match) - title = match[:title] - description = match[:description].to_s.rstrip - - issue = create_issue(title: title, description: description) - - if issue.persisted? - presenter(issue).present - else - presenter(issue).display_errors - end - end - - private - - def create_issue(title:, description:) - Issues::CreateService.new(project, current_user, title: title, description: description).execute - end - - def presenter(issue) - Gitlab::ChatCommands::Presenters::IssueNew.new(issue) - end - end - end -end diff --git a/lib/gitlab/chat_commands/issue_search.rb b/lib/gitlab/chat_commands/issue_search.rb deleted file mode 100644 index 3491b53093e..00000000000 --- a/lib/gitlab/chat_commands/issue_search.rb +++ /dev/null @@ -1,23 +0,0 @@ -module Gitlab - module ChatCommands - class IssueSearch < IssueCommand - def self.match(text) - /\Aissue\s+search\s+(?<query>.*)/.match(text) - end - - def self.help_message - "issue search <your query>" - end - - def execute(match) - issues = collection.search(match[:query]).limit(QUERY_LIMIT) - - if issues.present? - Presenters::IssueSearch.new(issues).present - else - Presenters::Access.new(issues).not_found - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/issue_show.rb b/lib/gitlab/chat_commands/issue_show.rb deleted file mode 100644 index d6013f4d10c..00000000000 --- a/lib/gitlab/chat_commands/issue_show.rb +++ /dev/null @@ -1,23 +0,0 @@ -module Gitlab - module ChatCommands - class IssueShow < IssueCommand - def self.match(text) - /\Aissue\s+show\s+#{Issue.reference_prefix}?(?<iid>\d+)/.match(text) - end - - def self.help_message - "issue show <id>" - end - - def execute(match) - issue = find_by_iid(match[:iid]) - - if issue - Gitlab::ChatCommands::Presenters::IssueShow.new(issue).present - else - Gitlab::ChatCommands::Presenters::Access.new.not_found - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/presenters/access.rb b/lib/gitlab/chat_commands/presenters/access.rb deleted file mode 100644 index 92f4fa17f78..00000000000 --- a/lib/gitlab/chat_commands/presenters/access.rb +++ /dev/null @@ -1,40 +0,0 @@ -module Gitlab - module ChatCommands - module Presenters - class Access < Presenters::Base - def access_denied - ephemeral_response(text: "Whoops! This action is not allowed. This incident will be [reported](https://xkcd.com/838/).") - end - - def not_found - ephemeral_response(text: "404 not found! GitLab couldn't find what you were looking for! :boom:") - end - - def authorize - message = - if @resource - ":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{@resource})." - else - ":sweat_smile: Couldn't identify you, nor can I autorize you!" - end - - ephemeral_response(text: message) - end - - def unknown_command(commands) - ephemeral_response(text: help_message(trigger)) - end - - private - - def help_message(trigger) - header_with_list("Command not found, these are the commands you can use", full_commands(trigger)) - end - - def full_commands(trigger) - @resource.map { |command| "#{trigger} #{command.help_message}" } - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/presenters/base.rb b/lib/gitlab/chat_commands/presenters/base.rb deleted file mode 100644 index 2700a5a2ad5..00000000000 --- a/lib/gitlab/chat_commands/presenters/base.rb +++ /dev/null @@ -1,77 +0,0 @@ -module Gitlab - module ChatCommands - module Presenters - class Base - include Gitlab::Routing.url_helpers - - def initialize(resource = nil) - @resource = resource - end - - def display_errors - message = header_with_list("The action was not successful, because:", @resource.errors.full_messages) - - ephemeral_response(text: message) - end - - private - - def header_with_list(header, items) - message = [header] - - items.each do |item| - message << "- #{item}" - end - - message.join("\n") - end - - def ephemeral_response(message) - response = { - response_type: :ephemeral, - status: 200 - }.merge(message) - - format_response(response) - end - - def in_channel_response(message) - response = { - response_type: :in_channel, - status: 200 - }.merge(message) - - format_response(response) - end - - def format_response(response) - response[:text] = format(response[:text]) if response.has_key?(:text) - - if response.has_key?(:attachments) - response[:attachments].each do |attachment| - attachment[:pretext] = format(attachment[:pretext]) if attachment[:pretext] - attachment[:text] = format(attachment[:text]) if attachment[:text] - end - end - - response - end - - # Convert Markdown to slacks format - def format(string) - Slack::Notifier::LinkFormatter.format(string) - end - - def resource_url - url_for( - [ - @resource.project.namespace.becomes(Namespace), - @resource.project, - @resource - ] - ) - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/presenters/deploy.rb b/lib/gitlab/chat_commands/presenters/deploy.rb deleted file mode 100644 index 863d0bf99ca..00000000000 --- a/lib/gitlab/chat_commands/presenters/deploy.rb +++ /dev/null @@ -1,21 +0,0 @@ -module Gitlab - module ChatCommands - module Presenters - class Deploy < Presenters::Base - def present(from, to) - message = "Deployment started from #{from} to #{to}. [Follow its progress](#{resource_url})." - - in_channel_response(text: message) - end - - def no_actions - ephemeral_response(text: "No action found to be executed") - end - - def too_many_actions - ephemeral_response(text: "Too many actions defined") - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/presenters/help.rb b/lib/gitlab/chat_commands/presenters/help.rb deleted file mode 100644 index cd47b7f4c6a..00000000000 --- a/lib/gitlab/chat_commands/presenters/help.rb +++ /dev/null @@ -1,27 +0,0 @@ -module Gitlab - module ChatCommands - module Presenters - class Help < Presenters::Base - def present(trigger, text) - ephemeral_response(text: help_message(trigger, text)) - end - - private - - def help_message(trigger, text) - return "No commands available :thinking_face:" unless @resource.present? - - if text.start_with?('help') - header_with_list("Available commands", full_commands(trigger)) - else - header_with_list("Unknown command, these commands are available", full_commands(trigger)) - end - end - - def full_commands(trigger) - @resource.map { |command| "#{trigger} #{command.help_message}" } - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/presenters/issue_base.rb b/lib/gitlab/chat_commands/presenters/issue_base.rb deleted file mode 100644 index 25bc82994ba..00000000000 --- a/lib/gitlab/chat_commands/presenters/issue_base.rb +++ /dev/null @@ -1,43 +0,0 @@ -module Gitlab - module ChatCommands - module Presenters - module IssueBase - def color(issuable) - issuable.open? ? '#38ae67' : '#d22852' - end - - def status_text(issuable) - issuable.open? ? 'Open' : 'Closed' - end - - def project - @resource.project - end - - def author - @resource.author - end - - def fields - [ - { - title: "Assignee", - value: @resource.assignees.any? ? @resource.assignees.first.name : "_None_", - short: true - }, - { - title: "Milestone", - value: @resource.milestone ? @resource.milestone.title : "_None_", - short: true - }, - { - title: "Labels", - value: @resource.labels.any? ? @resource.label_names.join(', ') : "_None_", - short: true - } - ] - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/presenters/issue_new.rb b/lib/gitlab/chat_commands/presenters/issue_new.rb deleted file mode 100644 index 3674ba25641..00000000000 --- a/lib/gitlab/chat_commands/presenters/issue_new.rb +++ /dev/null @@ -1,50 +0,0 @@ -module Gitlab - module ChatCommands - module Presenters - class IssueNew < Presenters::Base - include Presenters::IssueBase - - def present - in_channel_response(new_issue) - end - - private - - def new_issue - { - attachments: [ - { - title: "#{@resource.title} · #{@resource.to_reference}", - title_link: resource_url, - author_name: author.name, - author_icon: author.avatar_url, - fallback: "New issue #{@resource.to_reference}: #{@resource.title}", - pretext: pretext, - color: color(@resource), - fields: fields, - mrkdwn_in: [ - :title, - :pretext, - :text, - :fields - ] - } - ] - } - end - - def pretext - "I created an issue on #{author_profile_link}'s behalf: **#{@resource.to_reference}** in #{project_link}" - end - - def project_link - "[#{project.name_with_namespace}](#{project.web_url})" - end - - def author_profile_link - "[#{author.to_reference}](#{url_for(author)})" - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/presenters/issue_search.rb b/lib/gitlab/chat_commands/presenters/issue_search.rb deleted file mode 100644 index 73788cf9662..00000000000 --- a/lib/gitlab/chat_commands/presenters/issue_search.rb +++ /dev/null @@ -1,47 +0,0 @@ -module Gitlab - module ChatCommands - module Presenters - class IssueSearch < Presenters::Base - include Presenters::IssueBase - - def present - text = if @resource.count >= 5 - "Here are the first 5 issues I found:" - elsif @resource.one? - "Here is the only issue I found:" - else - "Here are the #{@resource.count} issues I found:" - end - - ephemeral_response(text: text, attachments: attachments) - end - - private - - def attachments - @resource.map do |issue| - url = "[#{issue.to_reference}](#{url_for([namespace, project, issue])})" - - { - color: color(issue), - fallback: "#{issue.to_reference} #{issue.title}", - text: "#{url} · #{issue.title} (#{status_text(issue)})", - - mrkdwn_in: [ - :text - ] - } - end - end - - def project - @project ||= @resource.first.project - end - - def namespace - @namespace ||= project.namespace.becomes(Namespace) - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/presenters/issue_show.rb b/lib/gitlab/chat_commands/presenters/issue_show.rb deleted file mode 100644 index bd784ad241e..00000000000 --- a/lib/gitlab/chat_commands/presenters/issue_show.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Gitlab - module ChatCommands - module Presenters - class IssueShow < Presenters::Base - include Presenters::IssueBase - - def present - if @resource.confidential? - ephemeral_response(show_issue) - else - in_channel_response(show_issue) - end - end - - private - - def show_issue - { - attachments: [ - { - title: "#{@resource.title} · #{@resource.to_reference}", - title_link: resource_url, - author_name: author.name, - author_icon: author.avatar_url, - fallback: "Issue #{@resource.to_reference}: #{@resource.title}", - pretext: pretext, - text: text, - color: color(@resource), - fields: fields, - mrkdwn_in: [ - :pretext, - :text, - :fields - ] - } - ] - } - end - - def text - message = "**#{status_text(@resource)}**" - - if @resource.upvotes.zero? && @resource.downvotes.zero? && @resource.user_notes_count.zero? - return message - end - - message << " · " - message << ":+1: #{@resource.upvotes} " unless @resource.upvotes.zero? - message << ":-1: #{@resource.downvotes} " unless @resource.downvotes.zero? - message << ":speech_balloon: #{@resource.user_notes_count}" unless @resource.user_notes_count.zero? - - message - end - - def pretext - "Issue *#{@resource.to_reference}* from #{project.name_with_namespace}" - end - end - end - end -end diff --git a/lib/gitlab/chat_commands/result.rb b/lib/gitlab/chat_commands/result.rb deleted file mode 100644 index 324d7ef43a3..00000000000 --- a/lib/gitlab/chat_commands/result.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Gitlab - module ChatCommands - Result = Struct.new(:type, :message) - end -end diff --git a/lib/gitlab/quick_actions/command_definition.rb b/lib/gitlab/quick_actions/command_definition.rb new file mode 100644 index 00000000000..1e2c3379da1 --- /dev/null +++ b/lib/gitlab/quick_actions/command_definition.rb @@ -0,0 +1,83 @@ +module Gitlab + module QuickActions + class CommandDefinition + attr_accessor :name, :aliases, :description, :explanation, :params, + :condition_block, :parse_params_block, :action_block + + def initialize(name, attributes = {}) + @name = name + + @aliases = attributes[:aliases] || [] + @description = attributes[:description] || '' + @explanation = attributes[:explanation] || '' + @params = attributes[:params] || [] + @condition_block = attributes[:condition_block] + @parse_params_block = attributes[:parse_params_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 explain(context, opts, arg) + return unless available?(opts) + + if explanation.respond_to?(:call) + execute_block(explanation, context, arg) + else + explanation + end + end + + def execute(context, opts, arg) + return if noop? || !available?(opts) + + execute_block(action_block, context, arg) + 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 + + private + + def execute_block(block, context, arg) + if arg.present? + parsed = parse_params(arg, context) + context.instance_exec(parsed, &block) + elsif block.arity == 0 + context.instance_exec(&block) + end + end + + def parse_params(arg, context) + return arg unless parse_params_block + + context.instance_exec(arg, &parse_params_block) + end + end + end +end diff --git a/lib/gitlab/quick_actions/dsl.rb b/lib/gitlab/quick_actions/dsl.rb new file mode 100644 index 00000000000..14b3525747c --- /dev/null +++ b/lib/gitlab/quick_actions/dsl.rb @@ -0,0 +1,140 @@ +module Gitlab + module QuickActions + 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 quick action. + # 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 quick action. + # 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 give an explanation of what the command will do when + # executed. This explanation is shown when rendering the Markdown + # preview. + # + # Example: + # + # explanation do |arguments| + # "Adds label(s) #{arguments.join(' ')}" + # end + # command :command_key do |arguments| + # # Awesome code block + # end + def explanation(text = '', &block) + @explanation = block_given? ? block : text + 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 + + # Allows to perform initial parsing of parameters. The result is passed + # both to `command` and `explanation` blocks, instead of the raw + # parameters. + # It accepts a block that will be evaluated with the context given to + # `CommandDefintion#to_h`. + # + # Example: + # + # parse_params do |raw| + # raw.strip + # end + # command :command_key do |parsed| + # # Awesome code block + # end + def parse_params(&block) + @parse_params_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, + explanation: @explanation, + params: @params, + condition_block: @condition_block, + parse_params_block: @parse_params_block, + action_block: block + ) + + self.command_definitions << definition + + definition.all_names.each do |name| + self.command_definitions_by_name[name] = definition + end + + @description = nil + @explanation = nil + @params = nil + @condition_block = nil + @parse_params_block = nil + end + + def definition_by_name(name) + command_definitions_by_name[name.to_sym] + end + end + end + end +end diff --git a/lib/gitlab/quick_actions/extractor.rb b/lib/gitlab/quick_actions/extractor.rb new file mode 100644 index 00000000000..09576be7156 --- /dev/null +++ b/lib/gitlab/quick_actions/extractor.rb @@ -0,0 +1,122 @@ +module Gitlab + module QuickActions + # This class takes an array of commands that should be extracted from a + # given text. + # + # ``` + # extractor = Gitlab::QuickActions::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::QuickActions::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/lib/gitlab/slash_commands/base_command.rb b/lib/gitlab/slash_commands/base_command.rb new file mode 100644 index 00000000000..cc3c9a50555 --- /dev/null +++ b/lib/gitlab/slash_commands/base_command.rb @@ -0,0 +1,47 @@ +module Gitlab + module SlashCommands + class BaseCommand + QUERY_LIMIT = 5 + + def self.match(_text) + raise NotImplementedError + end + + def self.help_message + raise NotImplementedError + end + + def self.available?(_project) + raise NotImplementedError + end + + def self.allowed?(_user, _ability) + true + end + + def self.can?(object, action, subject) + Ability.allowed?(object, action, subject) + end + + def execute(_) + raise NotImplementedError + end + + def collection + raise NotImplementedError + end + + attr_accessor :project, :current_user, :params + + def initialize(project, user, params = {}) + @project, @current_user, @params = project, user, params.dup + end + + private + + def find_by_iid(iid) + collection.find_by(iid: iid) + end + end + end +end diff --git a/lib/gitlab/slash_commands/command.rb b/lib/gitlab/slash_commands/command.rb new file mode 100644 index 00000000000..a78408b0519 --- /dev/null +++ b/lib/gitlab/slash_commands/command.rb @@ -0,0 +1,44 @@ +module Gitlab + module SlashCommands + class Command < BaseCommand + COMMANDS = [ + Gitlab::SlashCommands::IssueShow, + Gitlab::SlashCommands::IssueNew, + Gitlab::SlashCommands::IssueSearch, + Gitlab::SlashCommands::Deploy + ].freeze + + def execute + command, match = match_command + + if command + if command.allowed?(project, current_user) + command.new(project, current_user, params).execute(match) + else + Gitlab::SlashCommands::Presenters::Access.new.access_denied + end + else + Gitlab::SlashCommands::Help.new(project, current_user, params).execute(available_commands, params[:text]) + end + end + + def match_command + match = nil + service = + available_commands.find do |klass| + match = klass.match(params[:text]) + end + + [service, match] + end + + private + + def available_commands + COMMANDS.select do |klass| + klass.available?(project) + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/command_definition.rb b/lib/gitlab/slash_commands/command_definition.rb deleted file mode 100644 index 12a385f90fd..00000000000 --- a/lib/gitlab/slash_commands/command_definition.rb +++ /dev/null @@ -1,83 +0,0 @@ -module Gitlab - module SlashCommands - class CommandDefinition - attr_accessor :name, :aliases, :description, :explanation, :params, - :condition_block, :parse_params_block, :action_block - - def initialize(name, attributes = {}) - @name = name - - @aliases = attributes[:aliases] || [] - @description = attributes[:description] || '' - @explanation = attributes[:explanation] || '' - @params = attributes[:params] || [] - @condition_block = attributes[:condition_block] - @parse_params_block = attributes[:parse_params_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 explain(context, opts, arg) - return unless available?(opts) - - if explanation.respond_to?(:call) - execute_block(explanation, context, arg) - else - explanation - end - end - - def execute(context, opts, arg) - return if noop? || !available?(opts) - - execute_block(action_block, context, arg) - 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 - - private - - def execute_block(block, context, arg) - if arg.present? - parsed = parse_params(arg, context) - context.instance_exec(parsed, &block) - elsif block.arity == 0 - context.instance_exec(&block) - end - end - - def parse_params(arg, context) - return arg unless parse_params_block - - context.instance_exec(arg, &parse_params_block) - end - end - end -end diff --git a/lib/gitlab/slash_commands/deploy.rb b/lib/gitlab/slash_commands/deploy.rb new file mode 100644 index 00000000000..e71eb15d604 --- /dev/null +++ b/lib/gitlab/slash_commands/deploy.rb @@ -0,0 +1,50 @@ +module Gitlab + module SlashCommands + class Deploy < BaseCommand + def self.match(text) + /\Adeploy\s+(?<from>\S+.*)\s+to+\s+(?<to>\S+.*)\z/.match(text) + end + + def self.help_message + 'deploy <environment> to <target-environment>' + end + + def self.available?(project) + project.builds_enabled? + end + + def self.allowed?(project, user) + can?(user, :create_deployment, project) + end + + def execute(match) + from = match[:from] + to = match[:to] + + actions = find_actions(from, to) + + if actions.none? + Gitlab::SlashCommands::Presenters::Deploy.new(nil).no_actions + elsif actions.one? + action = play!(from, to, actions.first) + Gitlab::SlashCommands::Presenters::Deploy.new(action).present(from, to) + else + Gitlab::SlashCommands::Presenters::Deploy.new(actions).too_many_actions + end + end + + private + + def play!(from, to, action) + action.play(current_user) + end + + def find_actions(from, to) + environment = project.environments.find_by(name: from) + return [] unless environment + + environment.actions_for(to).select(&:starts_environment?) + end + end + end +end diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb deleted file mode 100644 index 614bafbe1b2..00000000000 --- a/lib/gitlab/slash_commands/dsl.rb +++ /dev/null @@ -1,140 +0,0 @@ -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 give an explanation of what the command will do when - # executed. This explanation is shown when rendering the Markdown - # preview. - # - # Example: - # - # explanation do |arguments| - # "Adds label(s) #{arguments.join(' ')}" - # end - # command :command_key do |arguments| - # # Awesome code block - # end - def explanation(text = '', &block) - @explanation = block_given? ? block : text - 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 - - # Allows to perform initial parsing of parameters. The result is passed - # both to `command` and `explanation` blocks, instead of the raw - # parameters. - # It accepts a block that will be evaluated with the context given to - # `CommandDefintion#to_h`. - # - # Example: - # - # parse_params do |raw| - # raw.strip - # end - # command :command_key do |parsed| - # # Awesome code block - # end - def parse_params(&block) - @parse_params_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, - explanation: @explanation, - params: @params, - condition_block: @condition_block, - parse_params_block: @parse_params_block, - action_block: block - ) - - self.command_definitions << definition - - definition.all_names.each do |name| - self.command_definitions_by_name[name] = definition - end - - @description = nil - @explanation = nil - @params = nil - @condition_block = nil - @parse_params_block = nil - end - - def definition_by_name(name) - command_definitions_by_name[name.to_sym] - end - end - end - end -end diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb deleted file mode 100644 index 6dbb467d70d..00000000000 --- a/lib/gitlab/slash_commands/extractor.rb +++ /dev/null @@ -1,122 +0,0 @@ -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/lib/gitlab/slash_commands/help.rb b/lib/gitlab/slash_commands/help.rb new file mode 100644 index 00000000000..81f3707e03e --- /dev/null +++ b/lib/gitlab/slash_commands/help.rb @@ -0,0 +1,28 @@ +module Gitlab + module SlashCommands + class Help < BaseCommand + # This class has to be used last, as it always matches. It has to match + # because other commands were not triggered and we want to show the help + # command + def self.match(_text) + true + end + + def self.help_message + 'help' + end + + def self.allowed?(_project, _user) + true + end + + def execute(commands, text) + Gitlab::SlashCommands::Presenters::Help.new(commands).present(trigger, text) + end + + def trigger + params[:command] + end + end + end +end diff --git a/lib/gitlab/slash_commands/issue_command.rb b/lib/gitlab/slash_commands/issue_command.rb new file mode 100644 index 00000000000..87ea19b8806 --- /dev/null +++ b/lib/gitlab/slash_commands/issue_command.rb @@ -0,0 +1,13 @@ +module Gitlab + module SlashCommands + class IssueCommand < BaseCommand + def self.available?(project) + project.issues_enabled? && project.default_issues_tracker? + end + + def collection + IssuesFinder.new(current_user, project_id: project.id).execute + end + end + end +end diff --git a/lib/gitlab/slash_commands/issue_new.rb b/lib/gitlab/slash_commands/issue_new.rb new file mode 100644 index 00000000000..25f965e843d --- /dev/null +++ b/lib/gitlab/slash_commands/issue_new.rb @@ -0,0 +1,42 @@ +module Gitlab + module SlashCommands + class IssueNew < IssueCommand + def self.match(text) + # we can not match \n with the dot by passing the m modifier as than + # the title and description are not seperated + /\Aissue\s+(new|create)\s+(?<title>[^\n]*)\n*(?<description>(.|\n)*)/.match(text) + end + + def self.help_message + 'issue new <title> *`⇧ Shift`*+*`↵ Enter`* <description>' + end + + def self.allowed?(project, user) + can?(user, :create_issue, project) + end + + def execute(match) + title = match[:title] + description = match[:description].to_s.rstrip + + issue = create_issue(title: title, description: description) + + if issue.persisted? + presenter(issue).present + else + presenter(issue).display_errors + end + end + + private + + def create_issue(title:, description:) + Issues::CreateService.new(project, current_user, title: title, description: description).execute + end + + def presenter(issue) + Gitlab::SlashCommands::Presenters::IssueNew.new(issue) + end + end + end +end diff --git a/lib/gitlab/slash_commands/issue_search.rb b/lib/gitlab/slash_commands/issue_search.rb new file mode 100644 index 00000000000..acba84b54b4 --- /dev/null +++ b/lib/gitlab/slash_commands/issue_search.rb @@ -0,0 +1,23 @@ +module Gitlab + module SlashCommands + class IssueSearch < IssueCommand + def self.match(text) + /\Aissue\s+search\s+(?<query>.*)/.match(text) + end + + def self.help_message + "issue search <your query>" + end + + def execute(match) + issues = collection.search(match[:query]).limit(QUERY_LIMIT) + + if issues.present? + Presenters::IssueSearch.new(issues).present + else + Presenters::Access.new(issues).not_found + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/issue_show.rb b/lib/gitlab/slash_commands/issue_show.rb new file mode 100644 index 00000000000..ffa5184e5cb --- /dev/null +++ b/lib/gitlab/slash_commands/issue_show.rb @@ -0,0 +1,23 @@ +module Gitlab + module SlashCommands + class IssueShow < IssueCommand + def self.match(text) + /\Aissue\s+show\s+#{Issue.reference_prefix}?(?<iid>\d+)/.match(text) + end + + def self.help_message + "issue show <id>" + end + + def execute(match) + issue = find_by_iid(match[:iid]) + + if issue + Gitlab::SlashCommands::Presenters::IssueShow.new(issue).present + else + Gitlab::SlashCommands::Presenters::Access.new.not_found + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/access.rb b/lib/gitlab/slash_commands/presenters/access.rb new file mode 100644 index 00000000000..1a817eb735b --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/access.rb @@ -0,0 +1,40 @@ +module Gitlab + module SlashCommands + module Presenters + class Access < Presenters::Base + def access_denied + ephemeral_response(text: "Whoops! This action is not allowed. This incident will be [reported](https://xkcd.com/838/).") + end + + def not_found + ephemeral_response(text: "404 not found! GitLab couldn't find what you were looking for! :boom:") + end + + def authorize + message = + if @resource + ":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{@resource})." + else + ":sweat_smile: Couldn't identify you, nor can I autorize you!" + end + + ephemeral_response(text: message) + end + + def unknown_command(commands) + ephemeral_response(text: help_message(trigger)) + end + + private + + def help_message(trigger) + header_with_list("Command not found, these are the commands you can use", full_commands(trigger)) + end + + def full_commands(trigger) + @resource.map { |command| "#{trigger} #{command.help_message}" } + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/base.rb b/lib/gitlab/slash_commands/presenters/base.rb new file mode 100644 index 00000000000..ac23406119f --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/base.rb @@ -0,0 +1,77 @@ +module Gitlab + module SlashCommands + module Presenters + class Base + include Gitlab::Routing.url_helpers + + def initialize(resource = nil) + @resource = resource + end + + def display_errors + message = header_with_list("The action was not successful, because:", @resource.errors.full_messages) + + ephemeral_response(text: message) + end + + private + + def header_with_list(header, items) + message = [header] + + items.each do |item| + message << "- #{item}" + end + + message.join("\n") + end + + def ephemeral_response(message) + response = { + response_type: :ephemeral, + status: 200 + }.merge(message) + + format_response(response) + end + + def in_channel_response(message) + response = { + response_type: :in_channel, + status: 200 + }.merge(message) + + format_response(response) + end + + def format_response(response) + response[:text] = format(response[:text]) if response.has_key?(:text) + + if response.has_key?(:attachments) + response[:attachments].each do |attachment| + attachment[:pretext] = format(attachment[:pretext]) if attachment[:pretext] + attachment[:text] = format(attachment[:text]) if attachment[:text] + end + end + + response + end + + # Convert Markdown to slacks format + def format(string) + Slack::Notifier::LinkFormatter.format(string) + end + + def resource_url + url_for( + [ + @resource.project.namespace.becomes(Namespace), + @resource.project, + @resource + ] + ) + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/deploy.rb b/lib/gitlab/slash_commands/presenters/deploy.rb new file mode 100644 index 00000000000..b8dc77bd37b --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/deploy.rb @@ -0,0 +1,21 @@ +module Gitlab + module SlashCommands + module Presenters + class Deploy < Presenters::Base + def present(from, to) + message = "Deployment started from #{from} to #{to}. [Follow its progress](#{resource_url})." + + in_channel_response(text: message) + end + + def no_actions + ephemeral_response(text: "No action found to be executed") + end + + def too_many_actions + ephemeral_response(text: "Too many actions defined") + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/help.rb b/lib/gitlab/slash_commands/presenters/help.rb new file mode 100644 index 00000000000..ea611a4d629 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/help.rb @@ -0,0 +1,27 @@ +module Gitlab + module SlashCommands + module Presenters + class Help < Presenters::Base + def present(trigger, text) + ephemeral_response(text: help_message(trigger, text)) + end + + private + + def help_message(trigger, text) + return "No commands available :thinking_face:" unless @resource.present? + + if text.start_with?('help') + header_with_list("Available commands", full_commands(trigger)) + else + header_with_list("Unknown command, these commands are available", full_commands(trigger)) + end + end + + def full_commands(trigger) + @resource.map { |command| "#{trigger} #{command.help_message}" } + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/issue_base.rb b/lib/gitlab/slash_commands/presenters/issue_base.rb new file mode 100644 index 00000000000..341f2aabdd0 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/issue_base.rb @@ -0,0 +1,43 @@ +module Gitlab + module SlashCommands + module Presenters + module IssueBase + def color(issuable) + issuable.open? ? '#38ae67' : '#d22852' + end + + def status_text(issuable) + issuable.open? ? 'Open' : 'Closed' + end + + def project + @resource.project + end + + def author + @resource.author + end + + def fields + [ + { + title: "Assignee", + value: @resource.assignees.any? ? @resource.assignees.first.name : "_None_", + short: true + }, + { + title: "Milestone", + value: @resource.milestone ? @resource.milestone.title : "_None_", + short: true + }, + { + title: "Labels", + value: @resource.labels.any? ? @resource.label_names.join(', ') : "_None_", + short: true + } + ] + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/issue_new.rb b/lib/gitlab/slash_commands/presenters/issue_new.rb new file mode 100644 index 00000000000..86490a39cc1 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/issue_new.rb @@ -0,0 +1,50 @@ +module Gitlab + module SlashCommands + module Presenters + class IssueNew < Presenters::Base + include Presenters::IssueBase + + def present + in_channel_response(new_issue) + end + + private + + def new_issue + { + attachments: [ + { + title: "#{@resource.title} · #{@resource.to_reference}", + title_link: resource_url, + author_name: author.name, + author_icon: author.avatar_url, + fallback: "New issue #{@resource.to_reference}: #{@resource.title}", + pretext: pretext, + color: color(@resource), + fields: fields, + mrkdwn_in: [ + :title, + :pretext, + :text, + :fields + ] + } + ] + } + end + + def pretext + "I created an issue on #{author_profile_link}'s behalf: **#{@resource.to_reference}** in #{project_link}" + end + + def project_link + "[#{project.name_with_namespace}](#{project.web_url})" + end + + def author_profile_link + "[#{author.to_reference}](#{url_for(author)})" + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/issue_search.rb b/lib/gitlab/slash_commands/presenters/issue_search.rb new file mode 100644 index 00000000000..4e27d668685 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/issue_search.rb @@ -0,0 +1,47 @@ +module Gitlab + module SlashCommands + module Presenters + class IssueSearch < Presenters::Base + include Presenters::IssueBase + + def present + text = if @resource.count >= 5 + "Here are the first 5 issues I found:" + elsif @resource.one? + "Here is the only issue I found:" + else + "Here are the #{@resource.count} issues I found:" + end + + ephemeral_response(text: text, attachments: attachments) + end + + private + + def attachments + @resource.map do |issue| + url = "[#{issue.to_reference}](#{url_for([namespace, project, issue])})" + + { + color: color(issue), + fallback: "#{issue.to_reference} #{issue.title}", + text: "#{url} · #{issue.title} (#{status_text(issue)})", + + mrkdwn_in: [ + :text + ] + } + end + end + + def project + @project ||= @resource.first.project + end + + def namespace + @namespace ||= project.namespace.becomes(Namespace) + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/presenters/issue_show.rb b/lib/gitlab/slash_commands/presenters/issue_show.rb new file mode 100644 index 00000000000..c99316df667 --- /dev/null +++ b/lib/gitlab/slash_commands/presenters/issue_show.rb @@ -0,0 +1,61 @@ +module Gitlab + module SlashCommands + module Presenters + class IssueShow < Presenters::Base + include Presenters::IssueBase + + def present + if @resource.confidential? + ephemeral_response(show_issue) + else + in_channel_response(show_issue) + end + end + + private + + def show_issue + { + attachments: [ + { + title: "#{@resource.title} · #{@resource.to_reference}", + title_link: resource_url, + author_name: author.name, + author_icon: author.avatar_url, + fallback: "Issue #{@resource.to_reference}: #{@resource.title}", + pretext: pretext, + text: text, + color: color(@resource), + fields: fields, + mrkdwn_in: [ + :pretext, + :text, + :fields + ] + } + ] + } + end + + def text + message = "**#{status_text(@resource)}**" + + if @resource.upvotes.zero? && @resource.downvotes.zero? && @resource.user_notes_count.zero? + return message + end + + message << " · " + message << ":+1: #{@resource.upvotes} " unless @resource.upvotes.zero? + message << ":-1: #{@resource.downvotes} " unless @resource.downvotes.zero? + message << ":speech_balloon: #{@resource.user_notes_count}" unless @resource.user_notes_count.zero? + + message + end + + def pretext + "Issue *#{@resource.to_reference}* from #{project.name_with_namespace}" + end + end + end + end +end diff --git a/lib/gitlab/slash_commands/result.rb b/lib/gitlab/slash_commands/result.rb new file mode 100644 index 00000000000..7021b4b01b2 --- /dev/null +++ b/lib/gitlab/slash_commands/result.rb @@ -0,0 +1,5 @@ +module Gitlab + module SlashCommands + Result = Struct.new(:type, :message) + end +end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index a38ae2eb990..ae13832adff 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -691,7 +691,7 @@ describe Projects::IssuesController do end end - context 'when description has slash commands' do + context 'when description has quick actions' do before do sign_in(user) end diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 81ae54c7a10..2809db65ed4 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -81,13 +81,13 @@ describe 'Awards Emoji', feature: true do end end - context 'execute /award slash command' do + context 'execute /award quick action' do it 'toggles the emoji award on noteable', js: true do - execute_slash_command('/award :100:') + execute_quick_action('/award :100:') expect(find(noteable_award_counter)).to have_text("1") - execute_slash_command('/award :100:') + execute_quick_action('/award :100:') expect(page).not_to have_selector(noteable_award_counter) end @@ -105,7 +105,7 @@ describe 'Awards Emoji', feature: true do end end - def execute_slash_command(cmd) + def execute_quick_action(cmd) within('.js-main-target-form') do fill_in 'note[note]', with: cmd click_button 'Comment' diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index 350473437a8..de1c2b514a5 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -208,7 +208,7 @@ feature 'GFM autocomplete', feature: true, js: true do expect(page).not_to have_selector('.atwho-view') end - it 'triggers autocomplete after selecting a slash command' do + it 'triggers autocomplete after selecting a quick action' do note = find('#note_note') page.within '.timeline-content-form' do note.native.send_keys('') diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index d14c319707c..4398c2dce01 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -1,9 +1,9 @@ require 'rails_helper' -feature 'Issues > User uses slash commands', feature: true, js: true do - include SlashCommandsHelpers +feature 'Issues > User uses quick actions', feature: true, js: true do + include QuickActionsHelpers - it_behaves_like 'issuable record that supports slash commands in its description and notes', :issue do + it_behaves_like 'issuable record that supports quick actions in its description and notes', :issue do let(:issuable) { create(:issue, project: project) } 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 index 0e64a3e1a4b..82c7a750248 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -1,14 +1,14 @@ require 'rails_helper' -feature 'Merge Requests > User uses slash commands', feature: true, js: true do - include SlashCommandsHelpers +feature 'Merge Requests > User uses quick actions', feature: true, js: true do + include QuickActionsHelpers 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 + it_behaves_like 'issuable record that supports quick actions 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', target_branch: 'master' } } } end diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 17aa70ff3f1..5606aa553ac 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -459,46 +459,46 @@ import '~/notes'; }); }); - describe('hasSlashCommands', () => { + describe('hasQuickActions', () => { beforeEach(() => { this.notes = new Notes('', []); }); - it('should return true when comment begins with a slash command', () => { + it('should return true when comment begins with a quick action', () => { const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; - const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + const hasQuickActions = this.notes.hasQuickActions(sampleComment); - expect(hasSlashCommands).toBeTruthy(); + expect(hasQuickActions).toBeTruthy(); }); - it('should return false when comment does NOT begin with a slash command', () => { + it('should return false when comment does NOT begin with a quick action', () => { const sampleComment = 'Hey, /unassign Merging this'; - const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + const hasQuickActions = this.notes.hasQuickActions(sampleComment); - expect(hasSlashCommands).toBeFalsy(); + expect(hasQuickActions).toBeFalsy(); }); - it('should return false when comment does NOT have any slash commands', () => { + it('should return false when comment does NOT have any quick actions', () => { const sampleComment = 'Looking good, Awesome!'; - const hasSlashCommands = this.notes.hasSlashCommands(sampleComment); + const hasQuickActions = this.notes.hasQuickActions(sampleComment); - expect(hasSlashCommands).toBeFalsy(); + expect(hasQuickActions).toBeFalsy(); }); }); - describe('stripSlashCommands', () => { - it('should strip slash commands from the comment which begins with a slash command', () => { + describe('stripQuickActions', () => { + it('should strip quick actions from the comment which begins with a quick action', () => { this.notes = new Notes(); const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; - const stripedComment = this.notes.stripSlashCommands(sampleComment); + const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe(''); }); - it('should strip slash commands from the comment but leaves plain comment if it is present', () => { + it('should strip quick actions from the comment but leaves plain comment if it is present', () => { this.notes = new Notes(); const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this'; - const stripedComment = this.notes.stripSlashCommands(sampleComment); + const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe('Merging this'); }); @@ -506,7 +506,7 @@ import '~/notes'; it('should NOT strip string that has slashes within', () => { this.notes = new Notes(); const sampleComment = 'http://127.0.0.1:3000/root/gitlab-shell/issues/1'; - const stripedComment = this.notes.stripSlashCommands(sampleComment); + const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe(sampleComment); }); diff --git a/spec/lib/gitlab/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb deleted file mode 100644 index 13e6953147b..00000000000 --- a/spec/lib/gitlab/chat_commands/command_spec.rb +++ /dev/null @@ -1,111 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::Command, service: true do - let(:project) { create(:empty_project) } - let(:user) { create(:user) } - - describe '#execute' do - subject do - described_class.new(project, user, params).execute - end - - context 'when no command is available' do - let(:params) { { text: 'issue show 1' } } - let(:project) { create(:empty_project, has_external_issue_tracker: true) } - - it 'displays 404 messages' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to start_with('404 not found') - end - end - - context 'when an unknown command is triggered' do - let(:params) { { command: '/gitlab', text: "unknown command 123" } } - - it 'displays the help message' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to start_with('Unknown command') - expect(subject[:text]).to match('/gitlab issue show') - end - end - - context 'the user can not create an issue' do - let(:params) { { text: "issue create my new issue" } } - - it 'rejects the actions' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to start_with('Whoops! This action is not allowed') - end - end - - context 'when trying to do deployment' do - let(:params) { { text: 'deploy staging to production' } } - let!(:build) { create(:ci_build, pipeline: pipeline) } - let!(:pipeline) { create(:ci_pipeline, project: project) } - let!(:staging) { create(:environment, name: 'staging', project: project) } - let!(:deployment) { create(:deployment, environment: staging, deployable: build) } - - let!(:manual) do - create(:ci_build, :manual, pipeline: pipeline, - name: 'first', - environment: 'production') - end - - context 'and user can not create deployment' do - it 'returns action' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to start_with('Whoops! This action is not allowed') - end - end - - context 'and user has deployment permission' do - before do - build.project.add_developer(user) - - create(:protected_branch, :developers_can_merge, - name: build.ref, project: project) - end - - it 'returns action' do - expect(subject[:text]).to include('Deployment started from staging to production') - expect(subject[:response_type]).to be(:in_channel) - end - - context 'when duplicate action exists' do - let!(:manual2) do - create(:ci_build, :manual, pipeline: pipeline, - name: 'second', - environment: 'production') - end - - it 'returns error' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to include('Too many actions defined') - end - end - end - end - end - - describe '#match_command' do - subject { described_class.new(project, user, params).match_command.first } - - context 'IssueShow is triggered' do - let(:params) { { text: 'issue show 123' } } - - it { is_expected.to eq(Gitlab::ChatCommands::IssueShow) } - end - - context 'IssueCreate is triggered' do - let(:params) { { text: 'issue create my title' } } - - it { is_expected.to eq(Gitlab::ChatCommands::IssueNew) } - end - - context 'IssueSearch is triggered' do - let(:params) { { text: 'issue search my query' } } - - it { is_expected.to eq(Gitlab::ChatCommands::IssueSearch) } - end - end -end diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb deleted file mode 100644 index 46dbdeae37c..00000000000 --- a/spec/lib/gitlab/chat_commands/deploy_spec.rb +++ /dev/null @@ -1,90 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::Deploy, service: true do - describe '#execute' do - let(:project) { create(:empty_project) } - let(:user) { create(:user) } - let(:regex_match) { described_class.match('deploy staging to production') } - - before do - # Make it possible to trigger protected manual actions for developers. - # - project.add_developer(user) - - create(:protected_branch, :developers_can_merge, - name: 'master', project: project) - end - - subject do - described_class.new(project, user).execute(regex_match) - end - - context 'if no environment is defined' do - it 'does not execute an action' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to eq("No action found to be executed") - end - end - - context 'with environment' do - let!(:staging) { create(:environment, name: 'staging', project: project) } - let!(:pipeline) { create(:ci_pipeline, project: project) } - let!(:build) { create(:ci_build, pipeline: pipeline) } - let!(:deployment) { create(:deployment, environment: staging, deployable: build) } - - context 'without actions' do - it 'does not execute an action' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to eq("No action found to be executed") - end - end - - context 'with action' do - let!(:manual1) do - create(:ci_build, :manual, pipeline: pipeline, - name: 'first', - environment: 'production') - end - - it 'returns success result' do - expect(subject[:response_type]).to be(:in_channel) - expect(subject[:text]).to start_with('Deployment started from staging to production') - end - - context 'when duplicate action exists' do - let!(:manual2) do - create(:ci_build, :manual, pipeline: pipeline, - name: 'second', - environment: 'production') - end - - it 'returns error' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to eq('Too many actions defined') - end - end - - context 'when teardown action exists' do - let!(:teardown) do - create(:ci_build, :manual, :teardown_environment, - pipeline: pipeline, name: 'teardown', environment: 'production') - end - - it 'returns the success message' do - expect(subject[:response_type]).to be(:in_channel) - expect(subject[:text]).to start_with('Deployment started from staging to production') - end - end - end - end - end - - describe 'self.match' do - it 'matches the environment' do - match = described_class.match('deploy staging to production') - - expect(match[:from]).to eq('staging') - expect(match[:to]).to eq('production') - end - end -end diff --git a/spec/lib/gitlab/chat_commands/issue_new_spec.rb b/spec/lib/gitlab/chat_commands/issue_new_spec.rb deleted file mode 100644 index 84c22328064..00000000000 --- a/spec/lib/gitlab/chat_commands/issue_new_spec.rb +++ /dev/null @@ -1,78 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::IssueNew, service: true do - describe '#execute' do - let(:project) { create(:empty_project) } - let(:user) { create(:user) } - let(:regex_match) { described_class.match("issue create bird is the word") } - - before do - project.team << [user, :master] - end - - subject do - described_class.new(project, user).execute(regex_match) - end - - context 'without description' do - it 'creates the issue' do - expect { subject }.to change { project.issues.count }.by(1) - - expect(subject[:response_type]).to be(:in_channel) - end - end - - context 'with description' do - let(:description) { "Surfin bird" } - let(:regex_match) { described_class.match("issue create bird is the word\n#{description}") } - - it 'creates the issue with description' do - subject - - expect(Issue.last.description).to eq(description) - end - end - - context "with more newlines between the title and the description" do - let(:description) { "Surfin bird" } - let(:regex_match) { described_class.match("issue create bird is the word\n\n#{description}\n") } - - it 'creates the issue' do - expect { subject }.to change { project.issues.count }.by(1) - end - end - - context 'issue cannot be created' do - let!(:issue) { create(:issue, project: project, title: 'bird is the word') } - let(:regex_match) { described_class.match("issue create #{'a' * 512}}") } - - it 'displays the errors' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to match("- Title is too long") - end - end - end - - describe '.match' do - it 'matches the title without description' do - match = described_class.match("issue create my title") - - expect(match[:title]).to eq('my title') - expect(match[:description]).to eq("") - end - - it 'matches the title with description' do - match = described_class.match("issue create my title\n\ndescription") - - expect(match[:title]).to eq('my title') - expect(match[:description]).to eq('description') - end - - it 'matches the alias new' do - match = described_class.match("issue new my title") - - expect(match).not_to be_nil - expect(match[:title]).to eq('my title') - end - end -end diff --git a/spec/lib/gitlab/chat_commands/issue_search_spec.rb b/spec/lib/gitlab/chat_commands/issue_search_spec.rb deleted file mode 100644 index 551ccb79a58..00000000000 --- a/spec/lib/gitlab/chat_commands/issue_search_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::IssueSearch, service: true do - describe '#execute' do - let!(:issue) { create(:issue, project: project, title: 'find me') } - let!(:confidential) { create(:issue, :confidential, project: project, title: 'mepmep find') } - let(:project) { create(:empty_project) } - let(:user) { issue.author } - let(:regex_match) { described_class.match("issue search find") } - - subject do - described_class.new(project, user).execute(regex_match) - end - - context 'when the user has no access' do - it 'only returns the open issues' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to match("not found") - end - end - - context 'the user has access' do - before do - project.team << [user, :master] - end - - it 'returns all results' do - expect(subject).to have_key(:attachments) - expect(subject[:text]).to eq("Here are the 2 issues I found:") - end - end - - context 'without hits on the query' do - it 'returns an empty collection' do - expect(subject[:text]).to match("not found") - end - end - end - - describe 'self.match' do - let(:query) { "my search keywords" } - it 'matches the query' do - match = described_class.match("issue search #{query}") - - expect(match[:query]).to eq(query) - end - end -end diff --git a/spec/lib/gitlab/chat_commands/issue_show_spec.rb b/spec/lib/gitlab/chat_commands/issue_show_spec.rb deleted file mode 100644 index 1f20d0a44ce..00000000000 --- a/spec/lib/gitlab/chat_commands/issue_show_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::IssueShow, service: true do - describe '#execute' do - let(:issue) { create(:issue, project: project) } - let(:project) { create(:empty_project) } - let(:user) { issue.author } - let(:regex_match) { described_class.match("issue show #{issue.iid}") } - - before do - project.team << [user, :master] - end - - subject do - described_class.new(project, user).execute(regex_match) - end - - context 'the issue exists' do - let(:title) { subject[:attachments].first[:title] } - - it 'returns the issue' do - expect(subject[:response_type]).to be(:in_channel) - expect(title).to start_with(issue.title) - end - - context 'when its reference is given' do - let(:regex_match) { described_class.match("issue show #{issue.to_reference}") } - - it 'shows the issue' do - expect(subject[:response_type]).to be(:in_channel) - expect(title).to start_with(issue.title) - end - end - end - - context 'the issue does not exist' do - let(:regex_match) { described_class.match("issue show 2343242") } - - it "returns not found" do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to match("not found") - end - end - end - - describe '.match' do - it 'matches the iid' do - match = described_class.match("issue show 123") - - expect(match[:iid]).to eq("123") - end - - it 'accepts a reference' do - match = described_class.match("issue show #{Issue.reference_prefix}123") - - expect(match[:iid]).to eq("123") - end - end -end diff --git a/spec/lib/gitlab/chat_commands/presenters/access_spec.rb b/spec/lib/gitlab/chat_commands/presenters/access_spec.rb deleted file mode 100644 index ae41d75ab0c..00000000000 --- a/spec/lib/gitlab/chat_commands/presenters/access_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::Presenters::Access do - describe '#access_denied' do - subject { described_class.new.access_denied } - - it { is_expected.to be_a(Hash) } - - it 'displays an error message' do - expect(subject[:text]).to match("is not allowed") - expect(subject[:response_type]).to be(:ephemeral) - end - end - - describe '#not_found' do - subject { described_class.new.not_found } - - it { is_expected.to be_a(Hash) } - - it 'tells the user the resource was not found' do - expect(subject[:text]).to match("not found!") - expect(subject[:response_type]).to be(:ephemeral) - end - end - - describe '#authorize' do - context 'with an authorization URL' do - subject { described_class.new('http://authorize.me').authorize } - - it { is_expected.to be_a(Hash) } - - it 'tells the user to authorize' do - expect(subject[:text]).to match("connect your GitLab account") - expect(subject[:response_type]).to be(:ephemeral) - end - end - - context 'without authorization url' do - subject { described_class.new.authorize } - - it { is_expected.to be_a(Hash) } - - it 'tells the user to authorize' do - expect(subject[:text]).to match("Couldn't identify you") - expect(subject[:response_type]).to be(:ephemeral) - end - end - end -end diff --git a/spec/lib/gitlab/chat_commands/presenters/deploy_spec.rb b/spec/lib/gitlab/chat_commands/presenters/deploy_spec.rb deleted file mode 100644 index dc2dd300072..00000000000 --- a/spec/lib/gitlab/chat_commands/presenters/deploy_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::Presenters::Deploy do - let(:build) { create(:ci_build) } - - describe '#present' do - subject { described_class.new(build).present('staging', 'prod') } - - it { is_expected.to have_key(:text) } - it { is_expected.to have_key(:response_type) } - it { is_expected.to have_key(:status) } - it { is_expected.not_to have_key(:attachments) } - - it 'messages the channel of the deploy' do - expect(subject[:response_type]).to be(:in_channel) - expect(subject[:text]).to start_with("Deployment started from staging to prod") - end - end - - describe '#no_actions' do - subject { described_class.new(nil).no_actions } - - it { is_expected.to have_key(:text) } - it { is_expected.to have_key(:response_type) } - it { is_expected.to have_key(:status) } - it { is_expected.not_to have_key(:attachments) } - - it 'tells the user there is no action' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to eq("No action found to be executed") - end - end - - describe '#too_many_actions' do - subject { described_class.new([]).too_many_actions } - - it { is_expected.to have_key(:text) } - it { is_expected.to have_key(:response_type) } - it { is_expected.to have_key(:status) } - it { is_expected.not_to have_key(:attachments) } - - it 'tells the user there is no action' do - expect(subject[:response_type]).to be(:ephemeral) - expect(subject[:text]).to eq("Too many actions defined") - end - end -end diff --git a/spec/lib/gitlab/chat_commands/presenters/issue_new_spec.rb b/spec/lib/gitlab/chat_commands/presenters/issue_new_spec.rb deleted file mode 100644 index 17fcdbc2452..00000000000 --- a/spec/lib/gitlab/chat_commands/presenters/issue_new_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::Presenters::IssueNew do - let(:project) { create(:empty_project) } - let(:issue) { create(:issue, project: project) } - let(:attachment) { subject[:attachments].first } - - subject { described_class.new(issue).present } - - it { is_expected.to be_a(Hash) } - - it 'shows the issue' do - expect(subject[:response_type]).to be(:in_channel) - expect(subject).to have_key(:attachments) - expect(attachment[:title]).to start_with(issue.title) - end -end diff --git a/spec/lib/gitlab/chat_commands/presenters/issue_search_spec.rb b/spec/lib/gitlab/chat_commands/presenters/issue_search_spec.rb deleted file mode 100644 index ec6d3e34a96..00000000000 --- a/spec/lib/gitlab/chat_commands/presenters/issue_search_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::Presenters::IssueSearch do - let(:project) { create(:empty_project) } - let(:message) { subject[:text] } - - before { create_list(:issue, 2, project: project) } - - subject { described_class.new(project.issues).present } - - it 'formats the message correct' do - is_expected.to have_key(:text) - is_expected.to have_key(:status) - is_expected.to have_key(:response_type) - is_expected.to have_key(:attachments) - end - - it 'shows a list of results' do - expect(subject[:response_type]).to be(:ephemeral) - - expect(message).to start_with("Here are the 2 issues I found") - end -end diff --git a/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb b/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb deleted file mode 100644 index 3916fc704a4..00000000000 --- a/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'spec_helper' - -describe Gitlab::ChatCommands::Presenters::IssueShow do - let(:project) { create(:empty_project) } - let(:issue) { create(:issue, project: project) } - let(:attachment) { subject[:attachments].first } - - subject { described_class.new(issue).present } - - it { is_expected.to be_a(Hash) } - - it 'shows the issue' do - expect(subject[:response_type]).to be(:in_channel) - expect(subject).to have_key(:attachments) - expect(attachment[:title]).to start_with(issue.title) - end - - context 'with upvotes' do - before do - create(:award_emoji, :upvote, awardable: issue) - end - - it 'shows the upvote count' do - expect(subject[:response_type]).to be(:in_channel) - expect(attachment[:text]).to start_with("**Open** · :+1: 1") - end - end - - context 'with labels' do - let(:label) { create(:label, project: project, title: 'mep') } - let(:label1) { create(:label, project: project, title: 'mop') } - - before do - issue.labels << [label, label1] - end - - it 'shows the labels' do - labels = attachment[:fields].find { |f| f[:title] == 'Labels' } - - expect(labels[:value]).to eq("mep, mop") - end - end - - context 'confidential issue' do - let(:issue) { create(:issue, project: project) } - - it 'shows an ephemeral response' do - expect(subject[:response_type]).to be(:in_channel) - expect(attachment[:text]).to start_with("**Open**") - end - end -end 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 3f79eaf7afb..cd0309e248d 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -91,7 +91,7 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do end end - context 'when the note contains slash commands' do + context 'when the note contains quick actions' do let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") } context 'and current user cannot update noteable' do diff --git a/spec/lib/gitlab/quick_actions/command_definition_spec.rb b/spec/lib/gitlab/quick_actions/command_definition_spec.rb new file mode 100644 index 00000000000..f44a562dc63 --- /dev/null +++ b/spec/lib/gitlab/quick_actions/command_definition_spec.rb @@ -0,0 +1,225 @@ +require 'spec_helper' + +describe Gitlab::QuickActions::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 + + context 'when the command defines parse_params block' do + before do + subject.parse_params_block = ->(raw) { raw.strip } + subject.action_block = ->(parsed) { self.received_arg = parsed } + end + + it 'executes the command passing the parsed param' do + subject.execute(context, {}, 'something ') + + expect(context.received_arg).to eq('something') + end + end + end + end + end + + describe '#explain' do + context 'when the command is not available' do + before do + subject.condition_block = proc { false } + subject.explanation = 'Explanation' + end + + it 'returns nil' do + result = subject.explain({}, {}, nil) + + expect(result).to be_nil + end + end + + context 'when the explanation is a static string' do + before do + subject.explanation = 'Explanation' + end + + it 'returns this static string' do + result = subject.explain({}, {}, nil) + + expect(result).to eq 'Explanation' + end + end + + context 'when the explanation is dynamic' do + before do + subject.explanation = proc { |arg| "Dynamic #{arg}" } + end + + it 'invokes the proc' do + result = subject.explain({}, {}, 'explanation') + + expect(result).to eq 'Dynamic explanation' + end + end + end +end diff --git a/spec/lib/gitlab/quick_actions/dsl_spec.rb b/spec/lib/gitlab/quick_actions/dsl_spec.rb new file mode 100644 index 00000000000..a4bb3f911d7 --- /dev/null +++ b/spec/lib/gitlab/quick_actions/dsl_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +describe Gitlab::QuickActions::Dsl do + before :all do + DummyClass = Struct.new(:project) do + include Gitlab::QuickActions::Dsl # rubocop:disable RSpec/DescribedClass + + desc 'A command with no args' + command :no_args, :none do + "Hello World!" + end + + params 'The first argument' + explanation 'Static explanation' + command :explanation_with_aliases, :once, :first do |arg| + arg + end + + desc do + "A dynamic description for #{noteable.upcase}" + end + params 'The first argument', 'The second argument' + command :dynamic_description do |args| + args.split + end + + command :cc + + explanation do |arg| + "Action does something with #{arg}" + end + condition do + project == 'foo' + end + command :cond_action do |arg| + arg + end + + parse_params do |raw_arg| + raw_arg.strip + end + command :with_params_parsing do |parsed| + parsed + end + end + end + + describe '.command_definitions' do + it 'returns an array with commands definitions' do + no_args_def, explanation_with_aliases_def, dynamic_description_def, + cc_def, cond_action_def, with_params_parsing_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.explanation).to eq('') + 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(no_args_def.parse_params_block).to be_nil + + expect(explanation_with_aliases_def.name).to eq(:explanation_with_aliases) + expect(explanation_with_aliases_def.aliases).to eq([:once, :first]) + expect(explanation_with_aliases_def.description).to eq('') + expect(explanation_with_aliases_def.explanation).to eq('Static explanation') + expect(explanation_with_aliases_def.params).to eq(['The first argument']) + expect(explanation_with_aliases_def.condition_block).to be_nil + expect(explanation_with_aliases_def.action_block).to be_a_kind_of(Proc) + expect(explanation_with_aliases_def.parse_params_block).to be_nil + + expect(dynamic_description_def.name).to eq(:dynamic_description) + expect(dynamic_description_def.aliases).to eq([]) + expect(dynamic_description_def.to_h(noteable: 'issue')[:description]).to eq('A dynamic description for ISSUE') + expect(dynamic_description_def.explanation).to eq('') + expect(dynamic_description_def.params).to eq(['The first argument', 'The second argument']) + expect(dynamic_description_def.condition_block).to be_nil + expect(dynamic_description_def.action_block).to be_a_kind_of(Proc) + expect(dynamic_description_def.parse_params_block).to be_nil + + expect(cc_def.name).to eq(:cc) + expect(cc_def.aliases).to eq([]) + expect(cc_def.description).to eq('') + expect(cc_def.explanation).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(cc_def.parse_params_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.explanation).to be_a_kind_of(Proc) + 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) + expect(cond_action_def.parse_params_block).to be_nil + + expect(with_params_parsing_def.name).to eq(:with_params_parsing) + expect(with_params_parsing_def.aliases).to eq([]) + expect(with_params_parsing_def.description).to eq('') + expect(with_params_parsing_def.explanation).to eq('') + expect(with_params_parsing_def.params).to eq([]) + expect(with_params_parsing_def.condition_block).to be_nil + expect(with_params_parsing_def.action_block).to be_a_kind_of(Proc) + expect(with_params_parsing_def.parse_params_block).to be_a_kind_of(Proc) + end + end +end diff --git a/spec/lib/gitlab/quick_actions/extractor_spec.rb b/spec/lib/gitlab/quick_actions/extractor_spec.rb new file mode 100644 index 00000000000..9d32938e155 --- /dev/null +++ b/spec/lib/gitlab/quick_actions/extractor_spec.rb @@ -0,0 +1,223 @@ +require 'spec_helper' + +describe Gitlab::QuickActions::Extractor do + let(:definitions) do + Class.new do + include Gitlab::QuickActions::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 + + it 'allows slash in command arguments' do + msg = "/assign @joe / @jane\nworld" + msg, commands = extractor.extract_commands(msg) + + expect(commands).to eq [['assign', '@joe / @jane']] + expect(msg).to eq '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/lib/gitlab/slash_commands/command_definition_spec.rb b/spec/lib/gitlab/slash_commands/command_definition_spec.rb deleted file mode 100644 index 5b9173d3d3f..00000000000 --- a/spec/lib/gitlab/slash_commands/command_definition_spec.rb +++ /dev/null @@ -1,225 +0,0 @@ -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 - - context 'when the command defines parse_params block' do - before do - subject.parse_params_block = ->(raw) { raw.strip } - subject.action_block = ->(parsed) { self.received_arg = parsed } - end - - it 'executes the command passing the parsed param' do - subject.execute(context, {}, 'something ') - - expect(context.received_arg).to eq('something') - end - end - end - end - end - - describe '#explain' do - context 'when the command is not available' do - before do - subject.condition_block = proc { false } - subject.explanation = 'Explanation' - end - - it 'returns nil' do - result = subject.explain({}, {}, nil) - - expect(result).to be_nil - end - end - - context 'when the explanation is a static string' do - before do - subject.explanation = 'Explanation' - end - - it 'returns this static string' do - result = subject.explain({}, {}, nil) - - expect(result).to eq 'Explanation' - end - end - - context 'when the explanation is dynamic' do - before do - subject.explanation = proc { |arg| "Dynamic #{arg}" } - end - - it 'invokes the proc' do - result = subject.explain({}, {}, 'explanation') - - expect(result).to eq 'Dynamic explanation' - end - end - end -end diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb new file mode 100644 index 00000000000..28d7f9858c3 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/command_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Command, service: true do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + describe '#execute' do + subject do + described_class.new(project, user, params).execute + end + + context 'when no command is available' do + let(:params) { { text: 'issue show 1' } } + let(:project) { create(:empty_project, has_external_issue_tracker: true) } + + it 'displays 404 messages' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('404 not found') + end + end + + context 'when an unknown command is triggered' do + let(:params) { { command: '/gitlab', text: "unknown command 123" } } + + it 'displays the help message' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('Unknown command') + expect(subject[:text]).to match('/gitlab issue show') + end + end + + context 'the user can not create an issue' do + let(:params) { { text: "issue create my new issue" } } + + it 'rejects the actions' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('Whoops! This action is not allowed') + end + end + + context 'when trying to do deployment' do + let(:params) { { text: 'deploy staging to production' } } + let!(:build) { create(:ci_build, pipeline: pipeline) } + let!(:pipeline) { create(:ci_pipeline, project: project) } + let!(:staging) { create(:environment, name: 'staging', project: project) } + let!(:deployment) { create(:deployment, environment: staging, deployable: build) } + + let!(:manual) do + create(:ci_build, :manual, pipeline: pipeline, + name: 'first', + environment: 'production') + end + + context 'and user can not create deployment' do + it 'returns action' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to start_with('Whoops! This action is not allowed') + end + end + + context 'and user has deployment permission' do + before do + build.project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) + end + + it 'returns action' do + expect(subject[:text]).to include('Deployment started from staging to production') + expect(subject[:response_type]).to be(:in_channel) + end + + context 'when duplicate action exists' do + let!(:manual2) do + create(:ci_build, :manual, pipeline: pipeline, + name: 'second', + environment: 'production') + end + + it 'returns error' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to include('Too many actions defined') + end + end + end + end + end + + describe '#match_command' do + subject { described_class.new(project, user, params).match_command.first } + + context 'IssueShow is triggered' do + let(:params) { { text: 'issue show 123' } } + + it { is_expected.to eq(Gitlab::SlashCommands::IssueShow) } + end + + context 'IssueCreate is triggered' do + let(:params) { { text: 'issue create my title' } } + + it { is_expected.to eq(Gitlab::SlashCommands::IssueNew) } + end + + context 'IssueSearch is triggered' do + let(:params) { { text: 'issue search my query' } } + + it { is_expected.to eq(Gitlab::SlashCommands::IssueSearch) } + end + end +end diff --git a/spec/lib/gitlab/slash_commands/deploy_spec.rb b/spec/lib/gitlab/slash_commands/deploy_spec.rb new file mode 100644 index 00000000000..d919f7260db --- /dev/null +++ b/spec/lib/gitlab/slash_commands/deploy_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Deploy, service: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:regex_match) { described_class.match('deploy staging to production') } + + before do + # Make it possible to trigger protected manual actions for developers. + # + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) + end + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'if no environment is defined' do + it 'does not execute an action' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to eq("No action found to be executed") + end + end + + context 'with environment' do + let!(:staging) { create(:environment, name: 'staging', project: project) } + let!(:pipeline) { create(:ci_pipeline, project: project) } + let!(:build) { create(:ci_build, pipeline: pipeline) } + let!(:deployment) { create(:deployment, environment: staging, deployable: build) } + + context 'without actions' do + it 'does not execute an action' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to eq("No action found to be executed") + end + end + + context 'with action' do + let!(:manual1) do + create(:ci_build, :manual, pipeline: pipeline, + name: 'first', + environment: 'production') + end + + it 'returns success result' do + expect(subject[:response_type]).to be(:in_channel) + expect(subject[:text]).to start_with('Deployment started from staging to production') + end + + context 'when duplicate action exists' do + let!(:manual2) do + create(:ci_build, :manual, pipeline: pipeline, + name: 'second', + environment: 'production') + end + + it 'returns error' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to eq('Too many actions defined') + end + end + + context 'when teardown action exists' do + let!(:teardown) do + create(:ci_build, :manual, :teardown_environment, + pipeline: pipeline, name: 'teardown', environment: 'production') + end + + it 'returns the success message' do + expect(subject[:response_type]).to be(:in_channel) + expect(subject[:text]).to start_with('Deployment started from staging to production') + end + end + end + end + end + + describe 'self.match' do + it 'matches the environment' do + match = described_class.match('deploy staging to production') + + expect(match[:from]).to eq('staging') + expect(match[:to]).to eq('production') + end + end +end diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb deleted file mode 100644 index 33b49a5ddf9..00000000000 --- a/spec/lib/gitlab/slash_commands/dsl_spec.rb +++ /dev/null @@ -1,109 +0,0 @@ -require 'spec_helper' - -describe Gitlab::SlashCommands::Dsl do - before :all do - DummyClass = Struct.new(:project) do - include Gitlab::SlashCommands::Dsl # rubocop:disable RSpec/DescribedClass - - desc 'A command with no args' - command :no_args, :none do - "Hello World!" - end - - params 'The first argument' - explanation 'Static explanation' - command :explanation_with_aliases, :once, :first do |arg| - arg - end - - desc do - "A dynamic description for #{noteable.upcase}" - end - params 'The first argument', 'The second argument' - command :dynamic_description do |args| - args.split - end - - command :cc - - explanation do |arg| - "Action does something with #{arg}" - end - condition do - project == 'foo' - end - command :cond_action do |arg| - arg - end - - parse_params do |raw_arg| - raw_arg.strip - end - command :with_params_parsing do |parsed| - parsed - end - end - end - - describe '.command_definitions' do - it 'returns an array with commands definitions' do - no_args_def, explanation_with_aliases_def, dynamic_description_def, - cc_def, cond_action_def, with_params_parsing_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.explanation).to eq('') - 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(no_args_def.parse_params_block).to be_nil - - expect(explanation_with_aliases_def.name).to eq(:explanation_with_aliases) - expect(explanation_with_aliases_def.aliases).to eq([:once, :first]) - expect(explanation_with_aliases_def.description).to eq('') - expect(explanation_with_aliases_def.explanation).to eq('Static explanation') - expect(explanation_with_aliases_def.params).to eq(['The first argument']) - expect(explanation_with_aliases_def.condition_block).to be_nil - expect(explanation_with_aliases_def.action_block).to be_a_kind_of(Proc) - expect(explanation_with_aliases_def.parse_params_block).to be_nil - - expect(dynamic_description_def.name).to eq(:dynamic_description) - expect(dynamic_description_def.aliases).to eq([]) - expect(dynamic_description_def.to_h(noteable: 'issue')[:description]).to eq('A dynamic description for ISSUE') - expect(dynamic_description_def.explanation).to eq('') - expect(dynamic_description_def.params).to eq(['The first argument', 'The second argument']) - expect(dynamic_description_def.condition_block).to be_nil - expect(dynamic_description_def.action_block).to be_a_kind_of(Proc) - expect(dynamic_description_def.parse_params_block).to be_nil - - expect(cc_def.name).to eq(:cc) - expect(cc_def.aliases).to eq([]) - expect(cc_def.description).to eq('') - expect(cc_def.explanation).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(cc_def.parse_params_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.explanation).to be_a_kind_of(Proc) - 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) - expect(cond_action_def.parse_params_block).to be_nil - - expect(with_params_parsing_def.name).to eq(:with_params_parsing) - expect(with_params_parsing_def.aliases).to eq([]) - expect(with_params_parsing_def.description).to eq('') - expect(with_params_parsing_def.explanation).to eq('') - expect(with_params_parsing_def.params).to eq([]) - expect(with_params_parsing_def.condition_block).to be_nil - expect(with_params_parsing_def.action_block).to be_a_kind_of(Proc) - expect(with_params_parsing_def.parse_params_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 deleted file mode 100644 index d7f77486b3e..00000000000 --- a/spec/lib/gitlab/slash_commands/extractor_spec.rb +++ /dev/null @@ -1,223 +0,0 @@ -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 - - it 'allows slash in command arguments' do - msg = "/assign @joe / @jane\nworld" - msg, commands = extractor.extract_commands(msg) - - expect(commands).to eq [['assign', '@joe / @jane']] - expect(msg).to eq '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/lib/gitlab/slash_commands/issue_new_spec.rb b/spec/lib/gitlab/slash_commands/issue_new_spec.rb new file mode 100644 index 00000000000..4de50d4a8bb --- /dev/null +++ b/spec/lib/gitlab/slash_commands/issue_new_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::IssueNew, service: true do + describe '#execute' do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:regex_match) { described_class.match("issue create bird is the word") } + + before do + project.team << [user, :master] + end + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'without description' do + it 'creates the issue' do + expect { subject }.to change { project.issues.count }.by(1) + + expect(subject[:response_type]).to be(:in_channel) + end + end + + context 'with description' do + let(:description) { "Surfin bird" } + let(:regex_match) { described_class.match("issue create bird is the word\n#{description}") } + + it 'creates the issue with description' do + subject + + expect(Issue.last.description).to eq(description) + end + end + + context "with more newlines between the title and the description" do + let(:description) { "Surfin bird" } + let(:regex_match) { described_class.match("issue create bird is the word\n\n#{description}\n") } + + it 'creates the issue' do + expect { subject }.to change { project.issues.count }.by(1) + end + end + + context 'issue cannot be created' do + let!(:issue) { create(:issue, project: project, title: 'bird is the word') } + let(:regex_match) { described_class.match("issue create #{'a' * 512}}") } + + it 'displays the errors' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to match("- Title is too long") + end + end + end + + describe '.match' do + it 'matches the title without description' do + match = described_class.match("issue create my title") + + expect(match[:title]).to eq('my title') + expect(match[:description]).to eq("") + end + + it 'matches the title with description' do + match = described_class.match("issue create my title\n\ndescription") + + expect(match[:title]).to eq('my title') + expect(match[:description]).to eq('description') + end + + it 'matches the alias new' do + match = described_class.match("issue new my title") + + expect(match).not_to be_nil + expect(match[:title]).to eq('my title') + end + end +end diff --git a/spec/lib/gitlab/slash_commands/issue_search_spec.rb b/spec/lib/gitlab/slash_commands/issue_search_spec.rb new file mode 100644 index 00000000000..06fff0afc50 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/issue_search_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::IssueSearch, service: true do + describe '#execute' do + let!(:issue) { create(:issue, project: project, title: 'find me') } + let!(:confidential) { create(:issue, :confidential, project: project, title: 'mepmep find') } + let(:project) { create(:empty_project) } + let(:user) { issue.author } + let(:regex_match) { described_class.match("issue search find") } + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'when the user has no access' do + it 'only returns the open issues' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to match("not found") + end + end + + context 'the user has access' do + before do + project.team << [user, :master] + end + + it 'returns all results' do + expect(subject).to have_key(:attachments) + expect(subject[:text]).to eq("Here are the 2 issues I found:") + end + end + + context 'without hits on the query' do + it 'returns an empty collection' do + expect(subject[:text]).to match("not found") + end + end + end + + describe 'self.match' do + let(:query) { "my search keywords" } + it 'matches the query' do + match = described_class.match("issue search #{query}") + + expect(match[:query]).to eq(query) + end + end +end diff --git a/spec/lib/gitlab/slash_commands/issue_show_spec.rb b/spec/lib/gitlab/slash_commands/issue_show_spec.rb new file mode 100644 index 00000000000..1899f664ccd --- /dev/null +++ b/spec/lib/gitlab/slash_commands/issue_show_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::IssueShow, service: true do + describe '#execute' do + let(:issue) { create(:issue, project: project) } + let(:project) { create(:empty_project) } + let(:user) { issue.author } + let(:regex_match) { described_class.match("issue show #{issue.iid}") } + + before do + project.team << [user, :master] + end + + subject do + described_class.new(project, user).execute(regex_match) + end + + context 'the issue exists' do + let(:title) { subject[:attachments].first[:title] } + + it 'returns the issue' do + expect(subject[:response_type]).to be(:in_channel) + expect(title).to start_with(issue.title) + end + + context 'when its reference is given' do + let(:regex_match) { described_class.match("issue show #{issue.to_reference}") } + + it 'shows the issue' do + expect(subject[:response_type]).to be(:in_channel) + expect(title).to start_with(issue.title) + end + end + end + + context 'the issue does not exist' do + let(:regex_match) { described_class.match("issue show 2343242") } + + it "returns not found" do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to match("not found") + end + end + end + + describe '.match' do + it 'matches the iid' do + match = described_class.match("issue show 123") + + expect(match[:iid]).to eq("123") + end + + it 'accepts a reference' do + match = described_class.match("issue show #{Issue.reference_prefix}123") + + expect(match[:iid]).to eq("123") + end + end +end diff --git a/spec/lib/gitlab/slash_commands/presenters/access_spec.rb b/spec/lib/gitlab/slash_commands/presenters/access_spec.rb new file mode 100644 index 00000000000..ef3d217f7be --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/access_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Presenters::Access do + describe '#access_denied' do + subject { described_class.new.access_denied } + + it { is_expected.to be_a(Hash) } + + it 'displays an error message' do + expect(subject[:text]).to match("is not allowed") + expect(subject[:response_type]).to be(:ephemeral) + end + end + + describe '#not_found' do + subject { described_class.new.not_found } + + it { is_expected.to be_a(Hash) } + + it 'tells the user the resource was not found' do + expect(subject[:text]).to match("not found!") + expect(subject[:response_type]).to be(:ephemeral) + end + end + + describe '#authorize' do + context 'with an authorization URL' do + subject { described_class.new('http://authorize.me').authorize } + + it { is_expected.to be_a(Hash) } + + it 'tells the user to authorize' do + expect(subject[:text]).to match("connect your GitLab account") + expect(subject[:response_type]).to be(:ephemeral) + end + end + + context 'without authorization url' do + subject { described_class.new.authorize } + + it { is_expected.to be_a(Hash) } + + it 'tells the user to authorize' do + expect(subject[:text]).to match("Couldn't identify you") + expect(subject[:response_type]).to be(:ephemeral) + end + end + end +end diff --git a/spec/lib/gitlab/slash_commands/presenters/deploy_spec.rb b/spec/lib/gitlab/slash_commands/presenters/deploy_spec.rb new file mode 100644 index 00000000000..dee3c77db27 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/deploy_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Presenters::Deploy do + let(:build) { create(:ci_build) } + + describe '#present' do + subject { described_class.new(build).present('staging', 'prod') } + + it { is_expected.to have_key(:text) } + it { is_expected.to have_key(:response_type) } + it { is_expected.to have_key(:status) } + it { is_expected.not_to have_key(:attachments) } + + it 'messages the channel of the deploy' do + expect(subject[:response_type]).to be(:in_channel) + expect(subject[:text]).to start_with("Deployment started from staging to prod") + end + end + + describe '#no_actions' do + subject { described_class.new(nil).no_actions } + + it { is_expected.to have_key(:text) } + it { is_expected.to have_key(:response_type) } + it { is_expected.to have_key(:status) } + it { is_expected.not_to have_key(:attachments) } + + it 'tells the user there is no action' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to eq("No action found to be executed") + end + end + + describe '#too_many_actions' do + subject { described_class.new([]).too_many_actions } + + it { is_expected.to have_key(:text) } + it { is_expected.to have_key(:response_type) } + it { is_expected.to have_key(:status) } + it { is_expected.not_to have_key(:attachments) } + + it 'tells the user there is no action' do + expect(subject[:response_type]).to be(:ephemeral) + expect(subject[:text]).to eq("Too many actions defined") + end + end +end diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_new_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_new_spec.rb new file mode 100644 index 00000000000..7f81ebb47db --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/issue_new_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Presenters::IssueNew do + let(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } + let(:attachment) { subject[:attachments].first } + + subject { described_class.new(issue).present } + + it { is_expected.to be_a(Hash) } + + it 'shows the issue' do + expect(subject[:response_type]).to be(:in_channel) + expect(subject).to have_key(:attachments) + expect(attachment[:title]).to start_with(issue.title) + end +end diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_search_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_search_spec.rb new file mode 100644 index 00000000000..e078d7145ec --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/issue_search_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Presenters::IssueSearch do + let(:project) { create(:empty_project) } + let(:message) { subject[:text] } + + before { create_list(:issue, 2, project: project) } + + subject { described_class.new(project.issues).present } + + it 'formats the message correct' do + is_expected.to have_key(:text) + is_expected.to have_key(:status) + is_expected.to have_key(:response_type) + is_expected.to have_key(:attachments) + end + + it 'shows a list of results' do + expect(subject[:response_type]).to be(:ephemeral) + + expect(message).to start_with("Here are the 2 issues I found") + end +end diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb new file mode 100644 index 00000000000..2a6ed860737 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Presenters::IssueShow do + let(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } + let(:attachment) { subject[:attachments].first } + + subject { described_class.new(issue).present } + + it { is_expected.to be_a(Hash) } + + it 'shows the issue' do + expect(subject[:response_type]).to be(:in_channel) + expect(subject).to have_key(:attachments) + expect(attachment[:title]).to start_with(issue.title) + end + + context 'with upvotes' do + before do + create(:award_emoji, :upvote, awardable: issue) + end + + it 'shows the upvote count' do + expect(subject[:response_type]).to be(:in_channel) + expect(attachment[:text]).to start_with("**Open** · :+1: 1") + end + end + + context 'with labels' do + let(:label) { create(:label, project: project, title: 'mep') } + let(:label1) { create(:label, project: project, title: 'mop') } + + before do + issue.labels << [label, label1] + end + + it 'shows the labels' do + labels = attachment[:fields].find { |f| f[:title] == 'Labels' } + + expect(labels[:value]).to eq("mep, mop") + end + end + + context 'confidential issue' do + let(:issue) { create(:issue, project: project) } + + it 'shows an ephemeral response' do + expect(subject[:response_type]).to be(:in_channel) + expect(attachment[:text]).to start_with("**Open**") + end + end +end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 712470d6bf5..3da28842e08 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1389,7 +1389,7 @@ describe MergeRequest, models: true do end end - describe '#mergeable_with_slash_command?' do + describe '#mergeable_with_quick_action?' do def create_pipeline(status) pipeline = create(:ci_pipeline_with_one_job, project: project, @@ -1413,21 +1413,21 @@ describe MergeRequest, models: true do context 'when autocomplete_precheck is set to true' do it 'is mergeable by developer' do - expect(merge_request.mergeable_with_slash_command?(developer, autocomplete_precheck: true)).to be_truthy + expect(merge_request.mergeable_with_quick_action?(developer, autocomplete_precheck: true)).to be_truthy end it 'is not mergeable by normal user' do - expect(merge_request.mergeable_with_slash_command?(user, autocomplete_precheck: true)).to be_falsey + expect(merge_request.mergeable_with_quick_action?(user, autocomplete_precheck: true)).to be_falsey end end context 'when autocomplete_precheck is set to false' do it 'is mergeable by developer' do - expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy + expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_truthy end it 'is not mergeable by normal user' do - expect(merge_request.mergeable_with_slash_command?(user, last_diff_sha: mr_sha)).to be_falsey + expect(merge_request.mergeable_with_quick_action?(user, last_diff_sha: mr_sha)).to be_falsey end context 'closed MR' do @@ -1436,7 +1436,7 @@ describe MergeRequest, models: true do end it 'is not mergeable' do - expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey + expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_falsey end end @@ -1446,19 +1446,19 @@ describe MergeRequest, models: true do end it 'is not mergeable' do - expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey + expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_falsey end end context 'sha differs from the MR diff_head_sha' do it 'is not mergeable' do - expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: 'some other sha')).to be_falsey + expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: 'some other sha')).to be_falsey end end context 'sha is not provided' do it 'is not mergeable' do - expect(merge_request.mergeable_with_slash_command?(developer)).to be_falsey + expect(merge_request.mergeable_with_quick_action?(developer)).to be_falsey end end @@ -1468,7 +1468,7 @@ describe MergeRequest, models: true do end it 'is mergeable' do - expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy + expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_truthy end end @@ -1478,7 +1478,7 @@ describe MergeRequest, models: true do end it 'is not mergeable' do - expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey + expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_falsey end end @@ -1488,7 +1488,7 @@ describe MergeRequest, models: true do end it 'is mergeable' do - expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy + expect(merge_request.mergeable_with_quick_action?(developer, last_diff_sha: mr_sha)).to be_truthy end end end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index dab1a3469f7..5726df57f4c 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -204,9 +204,9 @@ describe Issues::CreateService, services: true do end end - it_behaves_like 'new issuable record that supports slash commands' + it_behaves_like 'new issuable record that supports quick actions' - context 'Slash commands' do + context 'Quick actions' do context 'with assignee and milestone in params and command' do let(:opts) do { diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 2963f62cc7d..fbe977ca5d0 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -108,7 +108,7 @@ describe MergeRequests::CreateService, services: true do end end - it_behaves_like 'new issuable record that supports slash commands' do + it_behaves_like 'new issuable record that supports quick actions' do let(:default_params) do { source_branch: 'feature', @@ -117,7 +117,7 @@ describe MergeRequests::CreateService, services: true do end end - context 'Slash commands' do + context 'Quick actions' do context 'with assignee and milestone in params and command' do let(:merge_request) { described_class.new(project, user, opts).execute } let(:milestone) { create(:milestone, project: project) } diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb new file mode 100644 index 00000000000..b5bce531b73 --- /dev/null +++ b/spec/services/notes/quick_actions_service_spec.rb @@ -0,0 +1,250 @@ +require 'spec_helper' + +describe Notes::QuickActionsService, 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) } + + before { project.team << [assignee, :master] } + end + + shared_examples 'note on noteable that does not support quick actions' 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 quick actions' 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.assignees).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 + + describe '/spend' do + let(:note_text) { '/spend 1h' } + + it 'updates the spent time on the noteable' do + content, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(content).to eq '' + expect(note.noteable.time_spent).to eq(3600) + 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.assignees).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 '.noteable_update_service' do + include_context 'note on noteable' + + it 'returns Issues::UpdateService for a note on an issue' do + note = create(:note_on_issue, project: project) + + expect(described_class.noteable_update_service(note)).to eq(Issues::UpdateService) + end + + it 'returns Issues::UpdateService for a note on a merge request' do + note = create(:note_on_merge_request, project: project) + + expect(described_class.noteable_update_service(note)).to eq(MergeRequests::UpdateService) + end + + it 'returns nil for a note on a commit' do + note = create(:note_on_commit, project: project) + + expect(described_class.noteable_update_service(note)).to be_nil + end + end + + describe '.supported?' do + include_context 'note on noteable' + + let(:note) { create(:note_on_issue, project: project) } + + context 'with no current_user' do + it 'returns false' do + expect(described_class.supported?(note, nil)).to be_falsy + end + end + + context 'when current_user cannot update the noteable' do + it 'returns false' do + user = create(:user) + + expect(described_class.supported?(note, user)).to be_falsy + end + end + + context 'when current_user can update the noteable' do + it 'returns true' do + expect(described_class.supported?(note, master)).to be_truthy + end + + context 'with a note on a commit' do + let(:note) { create(:note_on_commit, project: project) } + + it 'returns false' do + expect(described_class.supported?(note, nil)).to be_falsy + end + end + end + end + + describe '#supported?' do + include_context 'note on noteable' + + it 'delegates to the class method' do + service = described_class.new(project, master) + note = create(:note_on_issue, project: project) + + expect(described_class).to receive(:supported?).with(note, master) + + service.supported?(note) + end + end + + describe '#execute' do + let(:service) { described_class.new(project, master) } + + it_behaves_like 'note on noteable that supports quick actions' do + let(:note) { build(:note_on_issue, project: project) } + end + + it_behaves_like 'note on noteable that supports quick actions' do + let(:note) { build(:note_on_merge_request, project: project) } + end + + it_behaves_like 'note on noteable that does not support quick actions' do + let(:note) { build(:note_on_commit, project: project) } + end + end + + context 'CE restriction for issue assignees' do + describe '/assign' do + let(:project) { create(:empty_project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:assignee) { create(:user) } + let(:master) { create(:user) } + let(:service) { described_class.new(project, master) } + let(:note) { create(:note_on_issue, note: note_text, project: project) } + + let(:note_text) do + %(/assign @#{assignee.username} @#{master.username}\n") + end + + before do + project.team << [master, :master] + project.team << [assignee, :master] + end + + it 'adds only one assignee from the list' do + _, command_params = service.extract_commands(note) + service.execute(command_params, note) + + expect(note.noteable.assignees.count).to eq(1) + end + end + end +end diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb deleted file mode 100644 index c9954dc3603..00000000000 --- a/spec/services/notes/slash_commands_service_spec.rb +++ /dev/null @@ -1,250 +0,0 @@ -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) } - - before { project.team << [assignee, :master] } - 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.assignees).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 - - describe '/spend' do - let(:note_text) { '/spend 1h' } - - it 'updates the spent time on the noteable' do - content, command_params = service.extract_commands(note) - service.execute(command_params, note) - - expect(content).to eq '' - expect(note.noteable.time_spent).to eq(3600) - 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.assignees).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 '.noteable_update_service' do - include_context 'note on noteable' - - it 'returns Issues::UpdateService for a note on an issue' do - note = create(:note_on_issue, project: project) - - expect(described_class.noteable_update_service(note)).to eq(Issues::UpdateService) - end - - it 'returns Issues::UpdateService for a note on a merge request' do - note = create(:note_on_merge_request, project: project) - - expect(described_class.noteable_update_service(note)).to eq(MergeRequests::UpdateService) - end - - it 'returns nil for a note on a commit' do - note = create(:note_on_commit, project: project) - - expect(described_class.noteable_update_service(note)).to be_nil - end - end - - describe '.supported?' do - include_context 'note on noteable' - - let(:note) { create(:note_on_issue, project: project) } - - context 'with no current_user' do - it 'returns false' do - expect(described_class.supported?(note, nil)).to be_falsy - end - end - - context 'when current_user cannot update the noteable' do - it 'returns false' do - user = create(:user) - - expect(described_class.supported?(note, user)).to be_falsy - end - end - - context 'when current_user can update the noteable' do - it 'returns true' do - expect(described_class.supported?(note, master)).to be_truthy - end - - context 'with a note on a commit' do - let(:note) { create(:note_on_commit, project: project) } - - it 'returns false' do - expect(described_class.supported?(note, nil)).to be_falsy - end - end - end - end - - describe '#supported?' do - include_context 'note on noteable' - - it 'delegates to the class method' do - service = described_class.new(project, master) - note = create(:note_on_issue, project: project) - - expect(described_class).to receive(:supported?).with(note, master) - - service.supported?(note) - 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 - - context 'CE restriction for issue assignees' do - describe '/assign' do - let(:project) { create(:empty_project) } - let(:master) { create(:user).tap { |u| project.team << [u, :master] } } - let(:assignee) { create(:user) } - let(:master) { create(:user) } - let(:service) { described_class.new(project, master) } - let(:note) { create(:note_on_issue, note: note_text, project: project) } - - let(:note_text) do - %(/assign @#{assignee.username} @#{master.username}\n") - end - - before do - project.team << [master, :master] - project.team << [assignee, :master] - end - - it 'adds only one assignee from the list' do - _, command_params = service.extract_commands(note) - service.execute(command_params, note) - - expect(note.noteable.assignees.count).to eq(1) - end - end - end -end diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb index b2fb5c91313..4fd9cb23ae1 100644 --- a/spec/services/preview_markdown_service_spec.rb +++ b/spec/services/preview_markdown_service_spec.rb @@ -19,24 +19,24 @@ describe PreviewMarkdownService do end end - context 'new note with slash commands' do + context 'new note with quick actions' do let(:issue) { create(:issue, project: project) } let(:params) do { text: "Please do it\n/assign #{user.to_reference}", - slash_commands_target_type: 'Issue', - slash_commands_target_id: issue.id + quick_actions_target_type: 'Issue', + quick_actions_target_id: issue.id } end let(:service) { described_class.new(project, user, params) } - it 'removes slash commands from text' do + it 'removes quick actions from text' do result = service.execute expect(result[:text]).to eq 'Please do it' end - it 'explains slash commands effect' do + it 'explains quick actions effect' do result = service.execute expect(result[:commands]).to eq "Assigns #{user.to_reference}." @@ -47,21 +47,21 @@ describe PreviewMarkdownService do let(:params) do { text: "My work\n/estimate 2y", - slash_commands_target_type: 'MergeRequest' + quick_actions_target_type: 'MergeRequest' } end let(:service) { described_class.new(project, user, params) } - it 'removes slash commands from text' do + it 'removes quick actions from text' do result = service.execute expect(result[:text]).to eq 'My work' end - it 'explains slash commands effect' do + it 'explains quick actions effect' do result = service.execute expect(result[:commands]).to eq 'Sets time estimate to 2y.' - end + end end end diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb new file mode 100644 index 00000000000..92f72ae4f8c --- /dev/null +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -0,0 +1,1036 @@ +require 'spec_helper' + +describe QuickActions::InterpretService, services: true do + let(:project) { create(:empty_project, :public) } + let(:developer) { create(:user) } + let(:developer2) { 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') } + let(:note) { build(:note, commit_id: merge_request.diff_head_sha) } + + before do + project.team << [developer, :developer] + end + + describe '#execute' do + let(:service) { described_class.new(project, developer) } + 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 '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 'multiple label command' do + it 'fetches label ids and populates add_label_ids if content contains multiple /label' do + bug # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [inprogress.id, bug.id]) + end + end + + shared_examples 'multiple label with same argument' do + it 'prevents duplicate label ids and populates add_label_ids if content contains multiple /label' do + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [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 'multiple unlabel command' do + it 'fetches label ids and populates remove_label_ids if content contains mutiple /unlabel' do + issuable.update!(label_ids: [inprogress.id, bug.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(remove_label_ids: [inprogress.id, bug.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, developer) + _, 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(developer, project) + _, 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 'wip command' do + it 'returns wip_event: "wip" if content contains /wip' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(wip_event: 'wip') + end + end + + shared_examples 'unwip command' do + it 'returns wip_event: "unwip" if content contains /wip' do + issuable.update!(title: issuable.wip_title) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(wip_event: 'unwip') + end + end + + shared_examples 'estimate command' do + it 'populates time_estimate: 3600 if content contains /estimate 1h' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(time_estimate: 3600) + end + end + + shared_examples 'spend command' do + it 'populates spend_time: 3600 if content contains /spend 1h' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(spend_time: { duration: 3600, user: developer }) + end + end + + shared_examples 'spend command with negative time' do + it 'populates spend_time: -1800 if content contains /spend -30m' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(spend_time: { duration: -1800, user: developer }) + end + end + + shared_examples 'remove_estimate command' do + it 'populates time_estimate: 0 if content contains /remove_estimate' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(time_estimate: 0) + end + end + + shared_examples 'remove_time_spent command' do + it 'populates spend_time: :reset if content contains /remove_time_spent' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(spend_time: { duration: :reset, user: developer }) + 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 + + shared_examples 'merge command' do + let(:project) { create(:project, :repository) } + + it 'runs merge command if content contains /merge' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(merge: merge_request.diff_head_sha) + end + end + + shared_examples 'award command' do + it 'toggle award 100 emoji if content containts /award :100:' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(emoji_award: "100") + 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 + + context 'merge command' do + let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: merge_request.diff_head_sha }) } + + it_behaves_like 'merge command' do + let(:content) { '/merge' } + let(:issuable) { merge_request } + end + + context 'can not be merged when logged user does not have permissions' do + let(:service) { described_class.new(project, create(:user)) } + + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { merge_request } + end + end + + context 'can not be merged when sha does not match' do + let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: 'othersha' }) } + + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { merge_request } + end + end + + context 'when sha is missing' do + let(:project) { create(:project, :repository) } + let(:service) { described_class.new(project, developer, {}) } + + it 'precheck passes and returns merge command' do + _, updates = service.execute('/merge', merge_request) + + expect(updates).to eq(merge: nil) + end + end + + context 'issue can not be merged' do + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { issue } + end + end + + context 'non persisted merge request cant be merged' do + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { build(:merge_request) } + end + end + + context 'not persisted merge request can not be merged' do + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { build(:merge_request, source_project: project) } + end + end + 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 + + context 'assign command' do + let(:content) { "/assign @#{developer.username}" } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: [developer.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end + end + + context 'assign command with multiple assignees' do + let(:content) { "/assign @#{developer.username} @#{developer2.username}" } + + before{ project.team << [developer2, :developer] } + + context 'Issue' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issue) + + expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id]) + end + end + + context 'Merge Request' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: developer.id) + end + end + 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 + + context 'unassign command' do + let(:content) { '/unassign' } + + context 'Issue' do + it 'populates assignee_ids: [] if content contains /unassign' do + issue.update(assignee_ids: [developer.id]) + _, updates = service.execute(content, issue) + + expect(updates).to eq(assignee_ids: []) + end + end + + context 'Merge Request' do + it 'populates assignee_id: nil if content contains /unassign' do + merge_request.update(assignee_id: developer.id) + _, updates = service.execute(content, merge_request) + + expect(updates).to eq(assignee_id: nil) + end + end + 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 'multiple label command' do + let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{bug.title}) } + let(:issuable) { issue } + end + + it_behaves_like 'multiple label with same argument' do + let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) } + let(:issuable) { issue } + 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 'multiple unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}" \n/unlabel ~#{bug.title}) } + let(:issuable) { issue } + 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 'wip command' do + let(:content) { '/wip' } + let(:issuable) { merge_request } + end + + it_behaves_like 'unwip command' do + let(:content) { '/wip' } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_due_date' } + let(:issuable) { merge_request } + end + + it_behaves_like 'estimate command' do + let(:content) { '/estimate 1h' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/estimate' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/estimate abc' } + let(:issuable) { issue } + end + + it_behaves_like 'spend command' do + let(:content) { '/spend 1h' } + let(:issuable) { issue } + end + + it_behaves_like 'spend command with negative time' do + let(:content) { '/spend -30m' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/spend' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/spend abc' } + let(:issuable) { issue } + end + + it_behaves_like 'remove_estimate command' do + let(:content) { '/remove_estimate' } + let(:issuable) { issue } + end + + it_behaves_like 'remove_time_spent command' do + let(:content) { '/remove_time_spent' } + let(:issuable) { issue } + end + + context 'when current_user cannot :admin_issue' do + let(:visitor) { create(:user) } + let(:issue) { create(:issue, project: project, author: visitor) } + let(:service) { described_class.new(project, visitor) } + + it_behaves_like 'empty command' do + let(:content) { "/assign @#{developer.username}" } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/unassign' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_milestone' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/due tomorrow' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_due_date' } + let(:issuable) { issue } + end + end + + context '/award command' do + it_behaves_like 'award command' do + let(:content) { '/award :100:' } + let(:issuable) { issue } + end + + it_behaves_like 'award command' do + let(:content) { '/award :100:' } + let(:issuable) { merge_request } + end + + context 'ignores command with no argument' do + it_behaves_like 'empty command' do + let(:content) { '/award' } + let(:issuable) { issue } + end + end + + context 'ignores non-existing / invalid emojis' do + it_behaves_like 'empty command' do + let(:content) { '/award noop' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/award :lorem_ipsum:' } + let(:issuable) { issue } + end + end + end + + context '/target_branch command' do + let(:non_empty_project) { create(:project, :repository) } + let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) } + let(:service) { described_class.new(non_empty_project, developer)} + + it 'updates target_branch if /target_branch command is executed' do + _, updates = service.execute('/target_branch merge-test', merge_request) + + expect(updates).to eq(target_branch: 'merge-test') + end + + it 'handles blanks around param' do + _, updates = service.execute('/target_branch merge-test ', merge_request) + + expect(updates).to eq(target_branch: 'merge-test') + end + + context 'ignores command with no argument' do + it_behaves_like 'empty command' do + let(:content) { '/target_branch' } + let(:issuable) { another_merge_request } + end + end + + context 'ignores non-existing target branch' do + it_behaves_like 'empty command' do + let(:content) { '/target_branch totally_non_existing_branch' } + let(:issuable) { another_merge_request } + end + end + end + + context '/board_move command' do + let(:todo) { create(:label, project: project, title: 'To Do') } + let(:inreview) { create(:label, project: project, title: 'In Review') } + let(:content) { %{/board_move ~"#{inreview.title}"} } + + let!(:board) { create(:board, project: project) } + let!(:todo_list) { create(:list, board: board, label: todo) } + let!(:inreview_list) { create(:list, board: board, label: inreview) } + let!(:inprogress_list) { create(:list, board: board, label: inprogress) } + + it 'populates remove_label_ids for all current board columns' do + issue.update!(label_ids: [todo.id, inprogress.id]) + + _, updates = service.execute(content, issue) + + expect(updates[:remove_label_ids]).to match_array([todo.id, inprogress.id]) + end + + it 'populates add_label_ids with the id of the given label' do + _, updates = service.execute(content, issue) + + expect(updates[:add_label_ids]).to eq([inreview.id]) + end + + it 'does not include the given label id in remove_label_ids' do + issue.update!(label_ids: [todo.id, inreview.id]) + + _, updates = service.execute(content, issue) + + expect(updates[:remove_label_ids]).to match_array([todo.id]) + end + + it 'does not remove label ids that are not lists on the board' do + issue.update!(label_ids: [todo.id, bug.id]) + + _, updates = service.execute(content, issue) + + expect(updates[:remove_label_ids]).to match_array([todo.id]) + end + + context 'if the project has multiple boards' do + let(:issuable) { issue } + before { create(:board, project: project) } + it_behaves_like 'empty command' + end + + context 'if the given label does not exist' do + let(:issuable) { issue } + let(:content) { '/board_move ~"Fake Label"' } + it_behaves_like 'empty command' + end + + context 'if multiple labels are given' do + let(:issuable) { issue } + let(:content) { %{/board_move ~"#{inreview.title}" ~"#{todo.title}"} } + it_behaves_like 'empty command' + end + + context 'if the given label is not a list on the board' do + let(:issuable) { issue } + let(:content) { %{/board_move ~"#{bug.title}"} } + it_behaves_like 'empty command' + end + + context 'if issuable is not an Issue' do + let(:issuable) { merge_request } + it_behaves_like 'empty command' + end + end + end + + describe '#explain' do + let(:service) { described_class.new(project, developer) } + let(:merge_request) { create(:merge_request, source_project: project) } + + describe 'close command' do + let(:content) { '/close' } + + it 'includes issuable name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Closes this issue.']) + end + end + + describe 'reopen command' do + let(:content) { '/reopen' } + let(:merge_request) { create(:merge_request, :closed, source_project: project) } + + it 'includes issuable name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Reopens this merge request.']) + end + end + + describe 'title command' do + let(:content) { '/title This is new title' } + + it 'includes new title' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Changes the title to "This is new title".']) + end + end + + describe 'assign command' do + let(:content) { "/assign @#{developer.username} do it!" } + + it 'includes only the user reference' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(["Assigns @#{developer.username}."]) + end + end + + describe 'unassign command' do + let(:content) { '/unassign' } + let(:issue) { create(:issue, project: project, assignees: [developer]) } + + it 'includes current assignee reference' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Removes assignee @#{developer.username}."]) + end + end + + describe 'milestone command' do + let(:content) { '/milestone %wrong-milestone' } + let!(:milestone) { create(:milestone, project: project, title: '9.10') } + + it 'is empty when milestone reference is wrong' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq([]) + end + end + + describe 'remove milestone command' do + let(:content) { '/remove_milestone' } + let(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) } + + it 'includes current milestone name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Removes %"9.10" milestone.']) + end + end + + describe 'label command' do + let(:content) { '/label ~missing' } + let!(:label) { create(:label, project: project) } + + it 'is empty when there are no correct labels' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq([]) + end + end + + describe 'unlabel command' do + let(:content) { '/unlabel' } + + it 'says all labels if no parameter provided' do + merge_request.update!(label_ids: [bug.id]) + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Removes all labels.']) + end + end + + describe 'relabel command' do + let(:content) { '/relabel Bug' } + let!(:bug) { create(:label, project: project, title: 'Bug') } + let(:feature) { create(:label, project: project, title: 'Feature') } + + it 'includes label name' do + issue.update!(label_ids: [feature.id]) + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Replaces all labels with ~#{bug.id} label."]) + end + end + + describe 'subscribe command' do + let(:content) { '/subscribe' } + + it 'includes issuable name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Subscribes to this issue.']) + end + end + + describe 'unsubscribe command' do + let(:content) { '/unsubscribe' } + + it 'includes issuable name' do + merge_request.subscribe(developer, project) + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Unsubscribes from this merge request.']) + end + end + + describe 'due command' do + let(:content) { '/due April 1st 2016' } + + it 'includes the date' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Sets the due date to Apr 1, 2016.']) + end + end + + describe 'wip command' do + let(:content) { '/wip' } + + it 'includes the new status' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Marks this merge request as Work In Progress.']) + end + end + + describe 'award command' do + let(:content) { '/award :confetti_ball: ' } + + it 'includes the emoji' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Toggles :confetti_ball: emoji award.']) + end + end + + describe 'estimate command' do + let(:content) { '/estimate 79d' } + + it 'includes the formatted duration' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Sets time estimate to 3mo 3w 4d.']) + end + end + + describe 'spend command' do + let(:content) { '/spend -120m' } + + it 'includes the formatted duration and proper verb' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(['Substracts 2h spent time.']) + end + end + + describe 'target branch command' do + let(:content) { '/target_branch my-feature ' } + + it 'includes the branch name' do + _, explanations = service.explain(content, merge_request) + + expect(explanations).to eq(['Sets target branch to my-feature.']) + end + end + + describe 'board move command' do + let(:content) { '/board_move ~bug' } + let!(:bug) { create(:label, project: project, title: 'bug') } + let!(:board) { create(:board, project: project) } + + it 'includes the label name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Moves issue to ~#{bug.id} column in the board."]) + end + end + end +end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb deleted file mode 100644 index e5e400ee281..00000000000 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ /dev/null @@ -1,1036 +0,0 @@ -require 'spec_helper' - -describe SlashCommands::InterpretService, services: true do - let(:project) { create(:empty_project, :public) } - let(:developer) { create(:user) } - let(:developer2) { 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') } - let(:note) { build(:note, commit_id: merge_request.diff_head_sha) } - - before do - project.team << [developer, :developer] - end - - describe '#execute' do - let(:service) { described_class.new(project, developer) } - 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 '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 'multiple label command' do - it 'fetches label ids and populates add_label_ids if content contains multiple /label' do - bug # populate the label - inprogress # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(add_label_ids: [inprogress.id, bug.id]) - end - end - - shared_examples 'multiple label with same argument' do - it 'prevents duplicate label ids and populates add_label_ids if content contains multiple /label' do - inprogress # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(add_label_ids: [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 'multiple unlabel command' do - it 'fetches label ids and populates remove_label_ids if content contains mutiple /unlabel' do - issuable.update!(label_ids: [inprogress.id, bug.id]) # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(remove_label_ids: [inprogress.id, bug.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, developer) - _, 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(developer, project) - _, 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 'wip command' do - it 'returns wip_event: "wip" if content contains /wip' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(wip_event: 'wip') - end - end - - shared_examples 'unwip command' do - it 'returns wip_event: "unwip" if content contains /wip' do - issuable.update!(title: issuable.wip_title) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(wip_event: 'unwip') - end - end - - shared_examples 'estimate command' do - it 'populates time_estimate: 3600 if content contains /estimate 1h' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(time_estimate: 3600) - end - end - - shared_examples 'spend command' do - it 'populates spend_time: 3600 if content contains /spend 1h' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(spend_time: { duration: 3600, user: developer }) - end - end - - shared_examples 'spend command with negative time' do - it 'populates spend_time: -1800 if content contains /spend -30m' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(spend_time: { duration: -1800, user: developer }) - end - end - - shared_examples 'remove_estimate command' do - it 'populates time_estimate: 0 if content contains /remove_estimate' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(time_estimate: 0) - end - end - - shared_examples 'remove_time_spent command' do - it 'populates spend_time: :reset if content contains /remove_time_spent' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(spend_time: { duration: :reset, user: developer }) - 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 - - shared_examples 'merge command' do - let(:project) { create(:project, :repository) } - - it 'runs merge command if content contains /merge' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(merge: merge_request.diff_head_sha) - end - end - - shared_examples 'award command' do - it 'toggle award 100 emoji if content containts /award :100:' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(emoji_award: "100") - 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 - - context 'merge command' do - let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: merge_request.diff_head_sha }) } - - it_behaves_like 'merge command' do - let(:content) { '/merge' } - let(:issuable) { merge_request } - end - - context 'can not be merged when logged user does not have permissions' do - let(:service) { described_class.new(project, create(:user)) } - - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { merge_request } - end - end - - context 'can not be merged when sha does not match' do - let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: 'othersha' }) } - - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { merge_request } - end - end - - context 'when sha is missing' do - let(:project) { create(:project, :repository) } - let(:service) { described_class.new(project, developer, {}) } - - it 'precheck passes and returns merge command' do - _, updates = service.execute('/merge', merge_request) - - expect(updates).to eq(merge: nil) - end - end - - context 'issue can not be merged' do - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { issue } - end - end - - context 'non persisted merge request cant be merged' do - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { build(:merge_request) } - end - end - - context 'not persisted merge request can not be merged' do - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { build(:merge_request, source_project: project) } - end - end - 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 - - context 'assign command' do - let(:content) { "/assign @#{developer.username}" } - - context 'Issue' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, issue) - - expect(updates).to eq(assignee_ids: [developer.id]) - end - end - - context 'Merge Request' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, merge_request) - - expect(updates).to eq(assignee_id: developer.id) - end - end - end - - context 'assign command with multiple assignees' do - let(:content) { "/assign @#{developer.username} @#{developer2.username}" } - - before{ project.team << [developer2, :developer] } - - context 'Issue' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, issue) - - expect(updates[:assignee_ids]).to match_array([developer.id, developer2.id]) - end - end - - context 'Merge Request' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, merge_request) - - expect(updates).to eq(assignee_id: developer.id) - end - end - 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 - - context 'unassign command' do - let(:content) { '/unassign' } - - context 'Issue' do - it 'populates assignee_ids: [] if content contains /unassign' do - issue.update(assignee_ids: [developer.id]) - _, updates = service.execute(content, issue) - - expect(updates).to eq(assignee_ids: []) - end - end - - context 'Merge Request' do - it 'populates assignee_id: nil if content contains /unassign' do - merge_request.update(assignee_id: developer.id) - _, updates = service.execute(content, merge_request) - - expect(updates).to eq(assignee_id: nil) - end - end - 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 'multiple label command' do - let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{bug.title}) } - let(:issuable) { issue } - end - - it_behaves_like 'multiple label with same argument' do - let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) } - let(:issuable) { issue } - 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 'multiple unlabel command' do - let(:content) { %(/unlabel ~"#{inprogress.title}" \n/unlabel ~#{bug.title}) } - let(:issuable) { issue } - 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 'wip command' do - let(:content) { '/wip' } - let(:issuable) { merge_request } - end - - it_behaves_like 'unwip command' do - let(:content) { '/wip' } - let(:issuable) { merge_request } - end - - it_behaves_like 'empty command' do - let(:content) { '/remove_due_date' } - let(:issuable) { merge_request } - end - - it_behaves_like 'estimate command' do - let(:content) { '/estimate 1h' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/estimate' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/estimate abc' } - let(:issuable) { issue } - end - - it_behaves_like 'spend command' do - let(:content) { '/spend 1h' } - let(:issuable) { issue } - end - - it_behaves_like 'spend command with negative time' do - let(:content) { '/spend -30m' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/spend' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/spend abc' } - let(:issuable) { issue } - end - - it_behaves_like 'remove_estimate command' do - let(:content) { '/remove_estimate' } - let(:issuable) { issue } - end - - it_behaves_like 'remove_time_spent command' do - let(:content) { '/remove_time_spent' } - let(:issuable) { issue } - end - - context 'when current_user cannot :admin_issue' do - let(:visitor) { create(:user) } - let(:issue) { create(:issue, project: project, author: visitor) } - let(:service) { described_class.new(project, visitor) } - - it_behaves_like 'empty command' do - let(:content) { "/assign @#{developer.username}" } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/unassign' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { "/milestone %#{milestone.title}" } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/remove_milestone' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { %(/unlabel ~"#{inprogress.title}") } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { %(/relabel ~"#{inprogress.title}") } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/due tomorrow' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/remove_due_date' } - let(:issuable) { issue } - end - end - - context '/award command' do - it_behaves_like 'award command' do - let(:content) { '/award :100:' } - let(:issuable) { issue } - end - - it_behaves_like 'award command' do - let(:content) { '/award :100:' } - let(:issuable) { merge_request } - end - - context 'ignores command with no argument' do - it_behaves_like 'empty command' do - let(:content) { '/award' } - let(:issuable) { issue } - end - end - - context 'ignores non-existing / invalid emojis' do - it_behaves_like 'empty command' do - let(:content) { '/award noop' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/award :lorem_ipsum:' } - let(:issuable) { issue } - end - end - end - - context '/target_branch command' do - let(:non_empty_project) { create(:project, :repository) } - let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) } - let(:service) { described_class.new(non_empty_project, developer)} - - it 'updates target_branch if /target_branch command is executed' do - _, updates = service.execute('/target_branch merge-test', merge_request) - - expect(updates).to eq(target_branch: 'merge-test') - end - - it 'handles blanks around param' do - _, updates = service.execute('/target_branch merge-test ', merge_request) - - expect(updates).to eq(target_branch: 'merge-test') - end - - context 'ignores command with no argument' do - it_behaves_like 'empty command' do - let(:content) { '/target_branch' } - let(:issuable) { another_merge_request } - end - end - - context 'ignores non-existing target branch' do - it_behaves_like 'empty command' do - let(:content) { '/target_branch totally_non_existing_branch' } - let(:issuable) { another_merge_request } - end - end - end - - context '/board_move command' do - let(:todo) { create(:label, project: project, title: 'To Do') } - let(:inreview) { create(:label, project: project, title: 'In Review') } - let(:content) { %{/board_move ~"#{inreview.title}"} } - - let!(:board) { create(:board, project: project) } - let!(:todo_list) { create(:list, board: board, label: todo) } - let!(:inreview_list) { create(:list, board: board, label: inreview) } - let!(:inprogress_list) { create(:list, board: board, label: inprogress) } - - it 'populates remove_label_ids for all current board columns' do - issue.update!(label_ids: [todo.id, inprogress.id]) - - _, updates = service.execute(content, issue) - - expect(updates[:remove_label_ids]).to match_array([todo.id, inprogress.id]) - end - - it 'populates add_label_ids with the id of the given label' do - _, updates = service.execute(content, issue) - - expect(updates[:add_label_ids]).to eq([inreview.id]) - end - - it 'does not include the given label id in remove_label_ids' do - issue.update!(label_ids: [todo.id, inreview.id]) - - _, updates = service.execute(content, issue) - - expect(updates[:remove_label_ids]).to match_array([todo.id]) - end - - it 'does not remove label ids that are not lists on the board' do - issue.update!(label_ids: [todo.id, bug.id]) - - _, updates = service.execute(content, issue) - - expect(updates[:remove_label_ids]).to match_array([todo.id]) - end - - context 'if the project has multiple boards' do - let(:issuable) { issue } - before { create(:board, project: project) } - it_behaves_like 'empty command' - end - - context 'if the given label does not exist' do - let(:issuable) { issue } - let(:content) { '/board_move ~"Fake Label"' } - it_behaves_like 'empty command' - end - - context 'if multiple labels are given' do - let(:issuable) { issue } - let(:content) { %{/board_move ~"#{inreview.title}" ~"#{todo.title}"} } - it_behaves_like 'empty command' - end - - context 'if the given label is not a list on the board' do - let(:issuable) { issue } - let(:content) { %{/board_move ~"#{bug.title}"} } - it_behaves_like 'empty command' - end - - context 'if issuable is not an Issue' do - let(:issuable) { merge_request } - it_behaves_like 'empty command' - end - end - end - - describe '#explain' do - let(:service) { described_class.new(project, developer) } - let(:merge_request) { create(:merge_request, source_project: project) } - - describe 'close command' do - let(:content) { '/close' } - - it 'includes issuable name' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(['Closes this issue.']) - end - end - - describe 'reopen command' do - let(:content) { '/reopen' } - let(:merge_request) { create(:merge_request, :closed, source_project: project) } - - it 'includes issuable name' do - _, explanations = service.explain(content, merge_request) - - expect(explanations).to eq(['Reopens this merge request.']) - end - end - - describe 'title command' do - let(:content) { '/title This is new title' } - - it 'includes new title' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(['Changes the title to "This is new title".']) - end - end - - describe 'assign command' do - let(:content) { "/assign @#{developer.username} do it!" } - - it 'includes only the user reference' do - _, explanations = service.explain(content, merge_request) - - expect(explanations).to eq(["Assigns @#{developer.username}."]) - end - end - - describe 'unassign command' do - let(:content) { '/unassign' } - let(:issue) { create(:issue, project: project, assignees: [developer]) } - - it 'includes current assignee reference' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(["Removes assignee @#{developer.username}."]) - end - end - - describe 'milestone command' do - let(:content) { '/milestone %wrong-milestone' } - let!(:milestone) { create(:milestone, project: project, title: '9.10') } - - it 'is empty when milestone reference is wrong' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq([]) - end - end - - describe 'remove milestone command' do - let(:content) { '/remove_milestone' } - let(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) } - - it 'includes current milestone name' do - _, explanations = service.explain(content, merge_request) - - expect(explanations).to eq(['Removes %"9.10" milestone.']) - end - end - - describe 'label command' do - let(:content) { '/label ~missing' } - let!(:label) { create(:label, project: project) } - - it 'is empty when there are no correct labels' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq([]) - end - end - - describe 'unlabel command' do - let(:content) { '/unlabel' } - - it 'says all labels if no parameter provided' do - merge_request.update!(label_ids: [bug.id]) - _, explanations = service.explain(content, merge_request) - - expect(explanations).to eq(['Removes all labels.']) - end - end - - describe 'relabel command' do - let(:content) { '/relabel Bug' } - let!(:bug) { create(:label, project: project, title: 'Bug') } - let(:feature) { create(:label, project: project, title: 'Feature') } - - it 'includes label name' do - issue.update!(label_ids: [feature.id]) - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(["Replaces all labels with ~#{bug.id} label."]) - end - end - - describe 'subscribe command' do - let(:content) { '/subscribe' } - - it 'includes issuable name' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(['Subscribes to this issue.']) - end - end - - describe 'unsubscribe command' do - let(:content) { '/unsubscribe' } - - it 'includes issuable name' do - merge_request.subscribe(developer, project) - _, explanations = service.explain(content, merge_request) - - expect(explanations).to eq(['Unsubscribes from this merge request.']) - end - end - - describe 'due command' do - let(:content) { '/due April 1st 2016' } - - it 'includes the date' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(['Sets the due date to Apr 1, 2016.']) - end - end - - describe 'wip command' do - let(:content) { '/wip' } - - it 'includes the new status' do - _, explanations = service.explain(content, merge_request) - - expect(explanations).to eq(['Marks this merge request as Work In Progress.']) - end - end - - describe 'award command' do - let(:content) { '/award :confetti_ball: ' } - - it 'includes the emoji' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(['Toggles :confetti_ball: emoji award.']) - end - end - - describe 'estimate command' do - let(:content) { '/estimate 79d' } - - it 'includes the formatted duration' do - _, explanations = service.explain(content, merge_request) - - expect(explanations).to eq(['Sets time estimate to 3mo 3w 4d.']) - end - end - - describe 'spend command' do - let(:content) { '/spend -120m' } - - it 'includes the formatted duration and proper verb' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(['Substracts 2h spent time.']) - end - end - - describe 'target branch command' do - let(:content) { '/target_branch my-feature ' } - - it 'includes the branch name' do - _, explanations = service.explain(content, merge_request) - - expect(explanations).to eq(['Sets target branch to my-feature.']) - end - end - - describe 'board move command' do - let(:content) { '/board_move ~bug' } - let!(:bug) { create(:label, project: project, title: 'bug') } - let!(:board) { create(:board, project: project) } - - it 'includes the label name' do - _, explanations = service.explain(content, issue) - - expect(explanations).to eq(["Moves issue to ~#{bug.id} column in the board."]) - end - end - end -end diff --git a/spec/support/chat_slash_commands_shared_examples.rb b/spec/support/chat_slash_commands_shared_examples.rb index 4dfa29849ee..978b0b9cc30 100644 --- a/spec/support/chat_slash_commands_shared_examples.rb +++ b/spec/support/chat_slash_commands_shared_examples.rb @@ -87,7 +87,7 @@ RSpec.shared_examples 'chat slash commands service' do end it 'triggers the command' do - expect_any_instance_of(Gitlab::ChatCommands::Command).to receive(:execute) + expect_any_instance_of(Gitlab::SlashCommands::Command).to receive(:execute) subject.trigger(params) end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index fa82dc5e9f9..db39e8dfd89 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -1,8 +1,8 @@ # 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| - include SlashCommandsHelpers +shared_examples 'issuable record that supports quick actions in its description and notes' do |issuable_type| + include QuickActionsHelpers let(:master) { create(:user) } let(:assignee) { create(:user, username: 'bob') } @@ -260,7 +260,7 @@ shared_examples 'issuable record that supports slash commands in its description end describe "preview of note on #{issuable_type}" do - it 'removes slash commands from note and explains them' do + it 'removes quick actions from note and explains them' do visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) page.within('.js-main-target-form') do diff --git a/spec/support/quick_actions_helpers.rb b/spec/support/quick_actions_helpers.rb new file mode 100644 index 00000000000..d2aaae7518f --- /dev/null +++ b/spec/support/quick_actions_helpers.rb @@ -0,0 +1,10 @@ +module QuickActionsHelpers + def write_note(text) + Sidekiq::Testing.fake! do + page.within('.js-main-target-form') do + fill_in 'note[note]', with: text + find('.js-comment-submit-button').trigger('click') + end + end + end +end diff --git a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb index 1dd3663b944..5243768090e 100644 --- a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb +++ b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb @@ -1,7 +1,7 @@ # 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 +shared_examples 'new issuable record that supports quick actions' do let!(:project) { create(:project, :repository) } let(:user) { create(:user).tap { |u| project.team << [u, :master] } } let(:assignee) { create(:user) } diff --git a/spec/support/slash_commands_helpers.rb b/spec/support/slash_commands_helpers.rb deleted file mode 100644 index 4bfe481115f..00000000000 --- a/spec/support/slash_commands_helpers.rb +++ /dev/null @@ -1,10 +0,0 @@ -module SlashCommandsHelpers - def write_note(text) - Sidekiq::Testing.fake! do - page.within('.js-main-target-form') do - fill_in 'note[note]', with: text - find('.js-comment-submit-button').trigger('click') - end - end - end -end diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index b407b8097d2..0fa74f911f6 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -54,7 +54,7 @@ shared_examples 'issuable time tracker' do it 'shows the help state when icon is clicked' do page.within '.time-tracking-component-wrap' do find('.help-button').click - expect(page).to have_content 'Track time with slash commands' + expect(page).to have_content 'Track time with quick actions' expect(page).to have_content 'Learn more' end end @@ -64,7 +64,7 @@ shared_examples 'issuable time tracker' do find('.help-button').click find('.close-help-button').click - expect(page).not_to have_content 'Track time with slash commands' + expect(page).not_to have_content 'Track time with quick actions' expect(page).not_to have_content 'Learn more' end end @@ -78,8 +78,8 @@ shared_examples 'issuable time tracker' do end end -def submit_time(slash_command) - fill_in 'note[note]', with: slash_command +def submit_time(quick_action) + fill_in 'note[note]', with: quick_action find('.js-comment-submit-button').trigger('click') wait_for_requests end diff --git a/spec/views/shared/notes/_form.html.haml_spec.rb b/spec/views/shared/notes/_form.html.haml_spec.rb index d7d0a5bf56a..cae6bee2776 100644 --- a/spec/views/shared/notes/_form.html.haml_spec.rb +++ b/spec/views/shared/notes/_form.html.haml_spec.rb @@ -20,8 +20,8 @@ describe 'shared/notes/_form' do context "with a note on #{noteable}" do let(:note) { build(:"note_on_#{noteable}", project: project) } - it 'says that markdown and slash commands are supported' do - expect(rendered).to have_content('Markdown and slash commands are supported') + it 'says that markdown and quick actions are supported' do + expect(rendered).to have_content('Markdown and quick actions are supported') end end end @@ -29,7 +29,7 @@ describe 'shared/notes/_form' do context 'with a note on a commit' do let(:note) { build(:note_on_commit, project: project) } - it 'says that only markdown is supported, not slash commands' do + it 'says that only markdown is supported, not quick actions' do expect(rendered).to have_content('Markdown is supported') end end -- cgit v1.2.1