diff --git a/CHANGELOG b/CHANGELOG
index 96965a20f69449ee7e1ce907fa82c6e7a9403867..63ee7b91dcbe022fb9acb02214214e8f375b6126 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -53,6 +53,7 @@ v 8.11.0 (unreleased)
   - Update version_sorter and use new interface for faster tag sorting
   - Optimize checking if a user has read access to a list of issues !5370
   - Store all DB secrets in secrets.yml, under descriptive names !5274
+  - Support slash commands in issue and merge request descriptions as well as comments. !5021
   - Nokogiri's various parsing methods are now instrumented
   - Add simple identifier to public SSH keys (muteor)
   - Admin page now references docs instead of a specific file !5600 (AnAverageHuman)
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js.es6
similarity index 86%
rename from app/assets/javascripts/gfm_auto_complete.js
rename to app/assets/javascripts/gfm_auto_complete.js.es6
index 2e5b15f4b77ea4c512c52ff4ee816c3fac861c60..21639c7c08431f56f9333702a10c339600837b9e 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js.es6
@@ -223,7 +223,7 @@
           }
         }
       });
-      return this.input.atwho({
+      this.input.atwho({
         at: '~',
         alias: 'labels',
         searchKey: 'search',
@@ -249,6 +249,41 @@
           }
         }
       });
+      return this.input.atwho({
+        at: '/',
+        alias: 'commands',
+        displayTpl: function(value) {
+          var tpl = '<li>/${name}';
+          if (value.aliases.length > 0) {
+            tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
+          }
+          if (value.params.length > 0) {
+            tpl += ' <small><%- params.join(" ") %></small>';
+          }
+          if (value.description !== '') {
+            tpl += '<small class="description"><i><%- description %></i></small>';
+          }
+          tpl += '</li>';
+          return _.template(tpl)(value);
+        },
+        insertTpl: function(value) {
+          var tpl = "\n/${name} ";
+          var reference_prefix = null;
+          if (value.params.length > 0) {
+            reference_prefix = value.params[0][0];
+            if (/^[@%~]/.test(reference_prefix)) {
+              tpl += '<%- reference_prefix %>';
+            }
+          }
+          return _.template(tpl)({ reference_prefix: reference_prefix });
+        },
+        suffix: '',
+        callbacks: {
+          sorter: this.DefaultOptions.sorter,
+          filter: this.DefaultOptions.filter,
+          beforeInsert: this.DefaultOptions.beforeInsert
+        }
+      });
     },
     destroyAtWho: function() {
       return this.input.atwho('destroy');
@@ -265,6 +300,7 @@
       this.input.atwho('load', 'mergerequests', data.mergerequests);
       this.input.atwho('load', ':', data.emojis);
       this.input.atwho('load', '~', data.labels);
+      this.input.atwho('load', '/', data.commands);
       return $(':focus').trigger('keyup');
     }
   };
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 9ece474d9941ea4c8f64bcfe67d98c2da3993022..99bc1a640a81c42eaf9b3d1ba5b736e74853d52f 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -231,7 +231,12 @@
       var $notesList, votesBlock;
       if (!note.valid) {
         if (note.award) {
-          new Flash('You have already awarded this emoji!', 'alert');
+          new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline);
+        }
+        else {
+          if (note.errors.commands_only) {
+            new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
+          }
         }
         return;
       }
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 96565da1bc9ae788741cdaa94f4bf339e5cd015e..edea4ad00eb3085e6fa052cf2877e68634c82f12 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -147,3 +147,8 @@
     color: $gl-link-color;
   }
 }
+
+.atwho-view small.description {
+  float: right;
+  padding: 3px 5px;
+}
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 766b7e9cf2228e0c8c03027bc2c9df87a205dc2f..f2422729364d65d88def74639fa43d13e8ed4f3e 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -125,7 +125,7 @@ def note_json(note)
         id:     note.id,
         name:   note.name
       }
-    elsif note.valid?
+    elsif note.persisted?
       Banzai::NoteRenderer.render([note], @project, current_user)
 
       attrs = {
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 47efbd4a93902efe1be30b1e30e0a5f711dc8113..64d31e4a3a03073fcb4d4751baa7969f596a87f6 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -145,7 +145,8 @@ def autocomplete_sources
       milestones: autocomplete.milestones,
       mergerequests: autocomplete.merge_requests,
       labels: autocomplete.labels,
-      members: participants
+      members: participants,
+      commands: autocomplete.commands
     }
 
     respond_to do |format|
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index ff866c2faa502e219f8bb06603dfa72ddb997c60..fd859e134e5ac5b31ceed7f83d0e62f12cf8da11 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -17,7 +17,7 @@ class TodosFinder
 
   attr_accessor :current_user, :params
 
-  def initialize(current_user, params)
+  def initialize(current_user, params = {})
     @current_user = current_user
     @params = params
   end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 2d96efe1042c955fc984e0c895ae6ae9e84a6100..b365e19c4a8835a6bd3c2649f4ff7ded801f1461 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -69,14 +69,9 @@ def filter_milestone
   end
 
   def filter_labels
-    if params[:add_label_ids].present? || params[:remove_label_ids].present?
-      params.delete(:label_ids)
-
-      filter_labels_in_param(:add_label_ids)
-      filter_labels_in_param(:remove_label_ids)
-    else
-      filter_labels_in_param(:label_ids)
-    end
+    filter_labels_in_param(:add_label_ids)
+    filter_labels_in_param(:remove_label_ids)
+    filter_labels_in_param(:label_ids)
   end
 
   def filter_labels_in_param(key)
@@ -85,23 +80,65 @@ def filter_labels_in_param(key)
     params[key] = project.labels.where(id: params[key]).pluck(:id)
   end
 
-  def update_issuable(issuable, attributes)
+  def process_label_ids(attributes, base_label_ids: [], merge_all: false)
+    label_ids = attributes.delete(:label_ids) { [] }
+    add_label_ids = attributes.delete(:add_label_ids) { [] }
+    remove_label_ids = attributes.delete(:remove_label_ids) { [] }
+
+    new_label_ids = base_label_ids
+    new_label_ids |= label_ids if merge_all || (add_label_ids.empty? && remove_label_ids.empty?)
+    new_label_ids |= add_label_ids
+    new_label_ids -= remove_label_ids
+
+    new_label_ids
+  end
+
+  def merge_slash_commands_into_params!
+    command_params = SlashCommands::InterpretService.new(project, current_user).
+      execute(params[:description])
+
+    params.merge!(command_params)
+  end
+
+  def create_issuable(issuable, attributes)
     issuable.with_transaction_returning_status do
-      add_label_ids = attributes.delete(:add_label_ids)
-      remove_label_ids = attributes.delete(:remove_label_ids)
+      attributes.delete(:state_event)
+      params[:author] ||= current_user
+      label_ids = process_label_ids(attributes, merge_all: true)
+
+      issuable.assign_attributes(attributes)
+
+      if issuable.save
+        issuable.update_attributes(label_ids: label_ids)
+      end
+    end
+  end
 
-      issuable.label_ids |= add_label_ids if add_label_ids
-      issuable.label_ids -= remove_label_ids if remove_label_ids
+  def create(issuable)
+    merge_slash_commands_into_params!
+    filter_params
 
-      issuable.assign_attributes(attributes.merge(updated_by: current_user))
+    if params.present? && create_issuable(issuable, params)
+      handle_creation(issuable)
+      issuable.create_cross_references!(current_user)
+      execute_hooks(issuable)
+    end
+
+    issuable
+  end
 
-      issuable.save
+  def update_issuable(issuable, attributes)
+    issuable.with_transaction_returning_status do
+      attributes[:label_ids] = process_label_ids(attributes, base_label_ids: issuable.label_ids)
+
+      issuable.update(attributes.merge(updated_by: current_user))
     end
   end
 
   def update(issuable)
     change_state(issuable)
     change_subscription(issuable)
+    change_todo(issuable)
     filter_params
     old_labels = issuable.labels.to_a
 
@@ -134,6 +171,18 @@ def change_subscription(issuable)
     end
   end
 
+  def change_todo(issuable)
+    case params.delete(:todo_event)
+    when 'mark'
+      todo_service.mark_todo(issuable, current_user)
+    when 'done'
+      todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
+      if todo
+        todo_service.mark_todos_as_done([todo], current_user)
+      end
+    end
+  end
+
   def has_changes?(issuable, old_labels: [])
     valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
 
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 5e2de2ccf64528942cddf0482320ac5f5d7a2483..1b03d7f4c052baa65c32c86d6c71795facfd91ec 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -1,25 +1,19 @@
 module Issues
   class CreateService < Issues::BaseService
     def execute
-      filter_params
-      label_params = params.delete(:label_ids)
+      issue = project.issues.new
       request = params.delete(:request)
       api = params.delete(:api)
-      issue = project.issues.new(params)
-      issue.author = params[:author] || current_user
 
       issue.spam = spam_check_service.execute(request, api)
 
-      if issue.save
-        issue.update_attributes(label_ids: label_params)
-        notification_service.new_issue(issue, current_user)
-        todo_service.new_issue(issue, current_user)
-        event_service.open_issue(issue, current_user)
-        issue.create_cross_references!(current_user)
-        execute_hooks(issue, 'open')
-      end
+      create(issue)
+    end
 
-      issue
+    def handle_creation(issuable)
+      event_service.open_issue(issuable, current_user)
+      notification_service.new_issue(issuable, current_user)
+      todo_service.new_issue(issuable, current_user)
     end
 
     private
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 96a25330af100447755e284febaa6ffdf5028ad4..0b592cd5620333eee26d004aa2842f9790c0c9bb 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -7,26 +7,19 @@ def execute
       source_project = @project
       @project = Project.find(params[:target_project_id]) if params[:target_project_id]
 
-      filter_params
-      label_params = params.delete(:label_ids)
-      force_remove_source_branch = params.delete(:force_remove_source_branch)
+      params[:target_project_id] ||= source_project.id
 
-      merge_request = MergeRequest.new(params)
+      merge_request = MergeRequest.new
       merge_request.source_project = source_project
-      merge_request.target_project ||= source_project
-      merge_request.author = current_user
-      merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
+      merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
 
-      if merge_request.save
-        merge_request.update_attributes(label_ids: label_params)
-        event_service.open_mr(merge_request, current_user)
-        notification_service.new_merge_request(merge_request, current_user)
-        todo_service.new_merge_request(merge_request, current_user)
-        merge_request.create_cross_references!(current_user)
-        execute_hooks(merge_request)
-      end
+      create(merge_request)
+    end
 
-      merge_request
+    def handle_creation(issuable)
+      event_service.open_mr(issuable, current_user)
+      notification_service.new_merge_request(issuable, current_user)
+      todo_service.new_merge_request(issuable, current_user)
     end
   end
 end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 18971bd0be3eec0572bbf2b23b8a9a5570f52012..d7531a5d63bc2357e239f6d3f0366068ff74ae04 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -11,13 +11,61 @@ def execute
         return noteable.create_award_emoji(note.award_emoji_name, current_user)
       end
 
+      # We execute commands (extracted from `params[:note]`) on the noteable
+      # **before** we save the note because if the note consists of commands
+      # only, there is no need be create a note!
+      commands_executed = execute_slash_commands!(note)
+
       if note.save
         # Finish the harder work in the background
         NewNoteWorker.perform_in(2.seconds, note.id, params)
-        TodoService.new.new_note(note, current_user)
+        todo_service.new_note(note, current_user)
+      end
+
+      if commands_executed && note.note.blank?
+        note.errors.add(:commands_only, 'Your commands are being executed.')
       end
 
       note
     end
+
+    private
+
+    def execute_slash_commands!(note)
+      noteable_update_service = noteable_update_service(note.noteable_type)
+      return unless noteable_update_service
+
+      command_params = SlashCommands::InterpretService.new(project, current_user).
+        execute(note.note)
+
+      commands = execute_or_filter_commands(command_params, note)
+
+      if commands.any?
+        noteable_update_service.new(project, current_user, commands).execute(note.noteable)
+      end
+    end
+
+    def execute_or_filter_commands(commands, note)
+      final_commands = commands.reduce({}) do |memo, (command_key, command_value)|
+        if command_key != :due_date || note.noteable.respond_to?(:due_date)
+          memo[command_key] = command_value
+        end
+
+        memo
+      end
+
+      final_commands
+    end
+
+    def noteable_update_service(noteable_type)
+      case noteable_type
+      when 'Issue'
+        Issues::UpdateService
+      when 'MergeRequest'
+        MergeRequests::UpdateService
+      else
+        nil
+      end
+    end
   end
 end
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 23b6668e0d1c9de43a4fec73ddd5c8486ab56458..e943d2ffbcb0fbbc1c9d1534afe8f9ee9b01d343 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -15,5 +15,9 @@ def merge_requests
     def labels
       @project.labels.select([:title, :color])
     end
+
+    def commands
+      SlashCommands::InterpretService.command_definitions
+    end
   end
 end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2c92a4f7de5d81b7d1192f7ecade4eff1c0ff0fb
--- /dev/null
+++ b/app/services/slash_commands/interpret_service.rb
@@ -0,0 +1,133 @@
+module SlashCommands
+  class InterpretService < BaseService
+    include Gitlab::SlashCommands::Dsl
+
+    # Takes a text and interpret the commands that are extracted from it.
+    # Returns a hash of changes to be applied to a record.
+    def execute(content)
+      @updates = {}
+
+      commands = extractor.extract_commands!(content)
+      commands.each do |command|
+        __send__(*command)
+      end
+
+      @updates
+    end
+
+    private
+
+    def extractor
+      @extractor ||= Gitlab::SlashCommands::Extractor.new(self.class.command_names)
+    end
+
+    desc 'Close this issue or merge request'
+    command :close do
+      @updates[:state_event] = 'close'
+    end
+
+    desc 'Reopen this issue or merge request'
+    command :open, :reopen do
+      @updates[:state_event] = 'reopen'
+    end
+
+    desc 'Reassign'
+    params '@user'
+    command :assign, :reassign do |assignee_param|
+      user = extract_references(assignee_param, :user).first
+      return unless user
+
+      @updates[:assignee_id] = user.id
+    end
+
+    desc 'Remove assignee'
+    command :unassign, :remove_assignee do
+      @updates[:assignee_id] = nil
+    end
+
+    desc 'Change milestone'
+    params '%"milestone"'
+    command :milestone do |milestone_param|
+      milestone = extract_references(milestone_param, :milestone).first
+      return unless milestone
+
+      @updates[:milestone_id] = milestone.id
+    end
+
+    desc 'Remove milestone'
+    command :clear_milestone, :remove_milestone do
+      @updates[:milestone_id] = nil
+    end
+
+    desc 'Add label(s)'
+    params '~label1 ~"label 2"'
+    command :label, :labels do |labels_param|
+      label_ids = find_label_ids(labels_param)
+      return if label_ids.empty?
+
+      @updates[:add_label_ids] = label_ids
+    end
+
+    desc 'Remove label(s)'
+    params '~label1 ~"label 2"'
+    command :unlabel, :remove_label, :remove_labels do |labels_param|
+      label_ids = find_label_ids(labels_param)
+      return if label_ids.empty?
+
+      @updates[:remove_label_ids] = label_ids
+    end
+
+    desc 'Remove all labels'
+    command :clear_labels, :clear_label do
+      @updates[:label_ids] = []
+    end
+
+    desc 'Add a todo'
+    command :todo do
+      @updates[:todo_event] = 'mark'
+    end
+
+    desc 'Mark todo as done'
+    command :done do
+      @updates[:todo_event] = 'done'
+    end
+
+    desc 'Subscribe'
+    command :subscribe do
+      @updates[:subscription_event] = 'subscribe'
+    end
+
+    desc 'Unsubscribe'
+    command :unsubscribe do
+      @updates[:subscription_event] = 'unsubscribe'
+    end
+
+    desc 'Set a due date'
+    params '<YYYY-MM-DD> | <N days>'
+    command :due_date do |due_date_param|
+      due_date = begin
+        Time.now + ChronicDuration.parse(due_date_param)
+      rescue ChronicDuration::DurationParseError
+        Date.parse(due_date_param) rescue nil
+      end
+
+      @updates[:due_date] = due_date if due_date
+    end
+
+    desc 'Remove due date'
+    command :clear_due_date do
+      @updates[:due_date] = nil
+    end
+
+    def find_label_ids(labels_param)
+      extract_references(labels_param, :label).map(&:id)
+    end
+
+    def extract_references(cmd_arg, type)
+      ext = Gitlab::ReferenceExtractor.new(project, current_user)
+      ext.analyze(cmd_arg, author: current_user)
+
+      ext.references(type)
+    end
+  end
+end
diff --git a/doc/workflow/README.md b/doc/workflow/README.md
index 49dec613716562bb6108952fcd3aaaad5d2ab27d..17c04377b4c75b175c8fe16713b15b32b938f675 100644
--- a/doc/workflow/README.md
+++ b/doc/workflow/README.md
@@ -6,6 +6,7 @@
 - [GitLab Flow](gitlab_flow.md)
 - [Groups](groups.md)
 - [Keyboard shortcuts](shortcuts.md)
+- [Slash commands](slash_commands.md)
 - [File finder](file_finder.md)
 - [Labels](../user/project/labels.md)
 - [Notification emails](notifications.md)
diff --git a/doc/workflow/slash_commands.md b/doc/workflow/slash_commands.md
new file mode 100644
index 0000000000000000000000000000000000000000..3bfc66309baf06dee69f6af1553d6b9f1cb12061
--- /dev/null
+++ b/doc/workflow/slash_commands.md
@@ -0,0 +1,28 @@
+# GitLab slash commands
+
+Slash commands are textual shortcuts for common actions on issues or merge
+requests that are usually done by clicking buttons or dropdowns in GitLab's UI.
+You can enter these commands while creating a new issue or merge request, and
+in comments. Each command should be on a separate line in order to be properly
+detected and executed.
+
+Here is a list of all of the available commands and descriptions about what they
+do.
+
+| Command                    | Aliases             | Action       |
+|:---------------------------|:--------------------|:-------------|
+| `/close`                   | None                | Close the issue or merge request |
+| `/open`                    | `/reopen`           | Reopen the issue or merge request |
+| `/assign @username`        | `/reassign`         | Reassign |
+| `/unassign`                | `/remove_assignee`  | Remove assignee |
+| `/milestone %milestone`    | None                | Change milestone |
+| `/clear_milestone`         | `/remove_milestone` | Remove milestone |
+| `/label ~foo ~"bar baz"`   | `/labels`           | Add label(s) |
+| `/unlabel ~foo ~"bar baz"` | `/remove_label`, `remove_labels` | Remove label(s) |
+| `/clear_labels`            | `/clear_label`      | Clear all labels |
+| `/todo`                    | None                | Add a todo |
+| `/done`                    | None                | Mark todo as done |
+| `/subscribe`               | None                | Subscribe |
+| `/unsubscribe`             | None                | Unsubscribe |
+| `/due_date`                | None                | Set a due date |
+| `/clear_due_date`          | None                | Remove due date |
diff --git a/lib/gitlab/email/handler/base_handler.rb b/lib/gitlab/email/handler/base_handler.rb
index b7ed11cb6389e06cdbeccd81a4b0134534d5636e..7cccf465334f5ceacfc68b2809ca704e73ac7aab 100644
--- a/lib/gitlab/email/handler/base_handler.rb
+++ b/lib/gitlab/email/handler/base_handler.rb
@@ -45,6 +45,7 @@ def add_attachments(reply)
 
         def verify_record!(record:, invalid_exception:, record_name:)
           return if record.persisted?
+          return if record.errors.key?(:commands_only)
 
           error_title = "The #{record_name} could not be created for the following reasons:"
 
diff --git a/lib/gitlab/slash_commands/dsl.rb b/lib/gitlab/slash_commands/dsl.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3ded4109f2e780eaf0a6b54911b7f49801331c0c
--- /dev/null
+++ b/lib/gitlab/slash_commands/dsl.rb
@@ -0,0 +1,76 @@
+module Gitlab
+  module SlashCommands
+    module Dsl
+      extend ActiveSupport::Concern
+
+      included do
+        @command_definitions = []
+      end
+
+      module ClassMethods
+        def command_definitions
+          @command_definitions
+        end
+
+        def command_names
+          command_definitions.flat_map do |command_definition|
+            [command_definition[:name], command_definition[:aliases]].flatten
+          end
+        end
+
+        # Allows to give a description to the next slash command
+        def desc(text)
+          @description = text
+        end
+
+        # Allows to define params for the next slash command
+        def params(*params)
+          @params = params
+        end
+
+        # Registers a new command which is recognizeable
+        # from body of email or comment.
+        # Example:
+        #
+        #   command :command_key do |arguments|
+        #     # Awesome code block
+        #   end
+        #
+        def command(*command_names, &block)
+          command_name, *aliases = command_names
+          proxy_method_name = "__#{command_name}__"
+
+          # This proxy method is needed because calling `return` from inside a
+          # block/proc, causes a `return` from the enclosing method or lambda,
+          # otherwise a LocalJumpError error is raised.
+          define_method(proxy_method_name, &block)
+
+          define_method(command_name) do |*args|
+            proxy_method = method(proxy_method_name)
+
+            if proxy_method.arity == -1 || proxy_method.arity == args.size
+              instance_exec(*args, &proxy_method)
+            end
+          end
+
+          private command_name
+          aliases.each do |alias_command|
+            alias_method alias_command, command_name
+            private alias_command
+          end
+
+          command_definition = {
+            name: command_name,
+            aliases: aliases,
+            description: @description || '',
+            params: @params || []
+          }
+          @command_definitions << command_definition
+
+          @description = nil
+          @params = nil
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/slash_commands/extractor.rb b/lib/gitlab/slash_commands/extractor.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1a854b81aca1bc748caf1dece886c4ae17611afe
--- /dev/null
+++ b/lib/gitlab/slash_commands/extractor.rb
@@ -0,0 +1,59 @@
+module Gitlab
+  module SlashCommands
+    # This class takes an array of commands that should be extracted from a
+    # given text.
+    #
+    # ```
+    # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels])
+    # ```
+    class Extractor
+      attr_reader :command_names
+
+      def initialize(command_names)
+        @command_names = command_names
+      end
+
+      # Extracts commands from content and return an array of commands.
+      # The array looks like the following:
+      # [
+      #   ['command1'],
+      #   ['command3', 'arg1 arg2'],
+      # ]
+      # The command and the arguments are stripped.
+      # The original command text is removed from the given `content`.
+      #
+      # Usage:
+      # ```
+      # extractor = Gitlab::SlashCommands::Extractor.new([:open, :assign, :labels])
+      # msg = %(hello\n/labels ~foo ~"bar baz"\nworld)
+      # commands = extractor.extract_commands! #=> [['labels', '~foo ~"bar baz"']]
+      # msg #=> "hello\nworld"
+      # ```
+      def extract_commands!(content)
+        return [] unless content
+
+        commands = []
+
+        content.gsub!(commands_regex) do
+          commands << [$1, $2].flatten.reject(&:blank?)
+          ''
+        end
+
+        commands
+      end
+
+      private
+
+      # Builds a regular expression to match known commands.
+      # First match group captures the command name and
+      # second match group captures its arguments.
+      #
+      # It looks something like:
+      #
+      #   /^\/(?<cmd>close|reopen|...)(?:( |$))(?<args>[^\/\n]*)(?:\n|$)/
+      def commands_regex
+        /^\/(?<cmd>#{command_names.join('|')})(?:( |$))(?<args>[^\/\n]*)(?:\n|$)/
+      end
+    end
+  end
+end
diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..47c4ce306e9f04fcd3395cab9282e74fcf1f2538
--- /dev/null
+++ b/spec/features/issues/user_uses_slash_commands_spec.rb
@@ -0,0 +1,59 @@
+require 'rails_helper'
+
+feature 'Issues > User uses slash commands', feature: true, js: true do
+  include WaitForAjax
+
+  it_behaves_like 'issuable record that supports slash commands in its description and notes', :issue do
+    let(:issuable) { create(:issue, project: project) }
+  end
+
+  describe 'issue-only commands' do
+    let(:user) { create(:user) }
+    let(:project) { create(:project, :public) }
+
+    before do
+      project.team << [user, :master]
+      login_with(user)
+      visit namespace_project_issue_path(project.namespace, project, issue)
+    end
+
+    describe 'adding a due date from note' do
+      let(:issue) { create(:issue, project: project) }
+
+      it 'does not create a note, and sets the due date accordingly' do
+        page.within('.js-main-target-form') do
+          fill_in 'note[note]', with: "/due_date 2016-08-28"
+          click_button 'Comment'
+        end
+
+        expect(page).not_to have_content '/due_date 2016-08-28'
+        expect(page).to have_content 'Your commands are being executed.'
+
+        issue.reload
+
+        expect(issue.due_date).to eq Date.new(2016, 8, 28)
+      end
+    end
+
+    describe 'removing a due date from note' do
+      let(:issue) { create(:issue, project: project, due_date: Date.new(2016, 8, 28)) }
+
+      it 'does not create a note, and removes the due date accordingly' do
+        expect(issue.due_date).to eq Date.new(2016, 8, 28)
+
+        page.within('.js-main-target-form') do
+          fill_in 'note[note]', with: "/clear_due_date"
+          click_button 'Comment'
+        end
+
+        expect(page).not_to have_content '/clear_due_date'
+        expect(page).to have_content 'Your commands are being executed.'
+
+        issue.reload
+
+        expect(issue.due_date).to be_nil
+      end
+    end
+  end
+
+end
diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..890648f38602b917a07c6ba51bec121bba414b7f
--- /dev/null
+++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb
@@ -0,0 +1,59 @@
+require 'rails_helper'
+
+feature 'Merge Requests > User uses slash commands', feature: true, js: true do
+  include WaitForAjax
+
+  let(:user) { create(:user) }
+  let(:project) { create(:project, :public) }
+  let(:merge_request) { create(:merge_request, source_project: project) }
+  let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
+
+  it_behaves_like 'issuable record that supports slash commands in its description and notes', :merge_request do
+    let(:issuable) { create(:merge_request, source_project: project) }
+    let(:new_url_opts) { { merge_request: { source_branch: 'feature' } } }
+  end
+
+  describe 'adding a due date from note' do
+    before do
+      project.team << [user, :master]
+      login_with(user)
+      visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+    end
+
+    it 'does not recognize the command nor create a note' do
+      page.within('.js-main-target-form') do
+        fill_in 'note[note]', with: "/due_date 2016-08-28"
+        click_button 'Comment'
+      end
+
+      expect(page).not_to have_content '/due_date 2016-08-28'
+    end
+  end
+
+  # Postponed because of high complexity
+  xdescribe 'merging from note' do
+    before do
+      project.team << [user, :master]
+      login_with(user)
+      visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+    end
+
+    it 'creates a note without the commands and interpret the commands accordingly' do
+      page.within('.js-main-target-form') do
+        fill_in 'note[note]', with: "Let's merge this!\n/merge\n/milestone %ASAP"
+        click_button 'Comment'
+      end
+
+      expect(page).to have_content("Let's merge this!")
+      expect(page).not_to have_content('/merge')
+      expect(page).not_to have_content('/milestone %ASAP')
+
+      merge_request.reload
+      note = merge_request.notes.user.first
+
+      expect(note.note).to eq "Let's merge this!\r\n"
+      expect(merge_request).to be_merged
+      expect(merge_request.milestone).to eq milestone
+    end
+  end
+end
diff --git a/spec/fixtures/emails/commands_only_reply.eml b/spec/fixtures/emails/commands_only_reply.eml
new file mode 100644
index 0000000000000000000000000000000000000000..b64d851a79c7addd50f1704e48e6da63da5040a2
--- /dev/null
+++ b/spec/fixtures/emails/commands_only_reply.eml
@@ -0,0 +1,40 @@
+Return-Path: <jake@adventuretime.ooo>
+Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
+Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
+Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
+Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
+Date: Thu, 13 Jun 2013 17:03:48 -0400
+From: Jake the Dog <jake@adventuretime.ooo>
+To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo
+Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
+In-Reply-To: <issue_1@localhost>
+References: <issue_1@localhost> <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>
+Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'
+Mime-Version: 1.0
+Content-Type: text/plain;
+ charset=ISO-8859-1
+Content-Transfer-Encoding: 7bit
+X-Sieve: CMU Sieve 2.2
+X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
+ 13 Jun 2013 14:03:48 -0700 (PDT)
+X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
+
+/close
+/unsubscribe
+
+
+On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta
+<reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo> wrote:
+>
+>
+>
+> eviltrout posted in 'Adventure Time Sux' on Discourse Meta:
+>
+> ---
+> hey guys everyone knows adventure time sucks!
+>
+> ---
+> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3
+>
+> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences).
+>
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 a2119b0dadfb1619434cc6ffe1c60d33593c3a53..e2339c5e103792fb0156e9b9b637c6d344294494 100644
--- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb
@@ -60,6 +60,15 @@
     it "raises an InvalidNoteError" do
       expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
     end
+
+    context 'because the note was commands only' do
+      let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") }
+
+      it 'raises a CommandsOnlyNoteError' do
+        expect { receiver.execute }.not_to raise_error
+      end
+
+    end
   end
 
   context "when the reply is blank" do
diff --git a/spec/lib/gitlab/slash_commands/dsl_spec.rb b/spec/lib/gitlab/slash_commands/dsl_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f8abb35674dcac10d76e3f0b27a034f5e2938a9b
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/dsl_spec.rb
@@ -0,0 +1,110 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Dsl do
+  before :all do
+    DummyClass = Class.new do
+      include Gitlab::SlashCommands::Dsl
+
+      desc 'A command with no args'
+      command :no_args, :none do
+        "Hello World!"
+      end
+
+      desc 'A command returning a value'
+      command :returning do
+        return 42
+      end
+
+      params 'The first argument'
+      command :one_arg, :once, :first do |arg1|
+        arg1
+      end
+
+      desc 'A command with two args'
+      params 'The first argument', 'The second argument'
+      command :two_args do |arg1, arg2|
+        [arg1, arg2]
+      end
+
+      command :wildcard do |*args|
+        args
+      end
+    end
+  end
+  let(:dummy) { DummyClass.new }
+
+  describe '.command_definitions' do
+    it 'returns an array with commands definitions' do
+      expected = [
+        { name: :no_args, aliases: [:none], description: 'A command with no args', params: [] },
+        { name: :returning, aliases: [], description: 'A command returning a value', params: [] },
+        { name: :one_arg, aliases: [:once, :first], description: '', params: ['The first argument'] },
+        { name: :two_args, aliases: [], description: 'A command with two args', params: ['The first argument', 'The second argument'] },
+        { name: :wildcard, aliases: [], description: '', params: [] }
+      ]
+
+      expect(DummyClass.command_definitions).to eq expected
+    end
+  end
+
+  describe '.command_names' do
+    it 'returns an array with commands definitions' do
+      expect(DummyClass.command_names).to eq [
+        :no_args, :none, :returning, :one_arg,
+        :once, :first, :two_args, :wildcard
+      ]
+    end
+  end
+
+  describe 'command with no args' do
+    context 'called with no args' do
+      it 'succeeds' do
+        expect(dummy.__send__(:no_args)).to eq 'Hello World!'
+      end
+    end
+  end
+
+  describe 'command with an explicit return' do
+    context 'called with no args' do
+      it 'succeeds' do
+        expect(dummy.__send__(:returning)).to eq 42
+      end
+    end
+  end
+
+  describe 'command with one arg' do
+    context 'called with one arg' do
+      it 'succeeds' do
+        expect(dummy.__send__(:one_arg, 42)).to eq 42
+      end
+    end
+  end
+
+  describe 'command with two args' do
+    context 'called with two args' do
+      it 'succeeds' do
+        expect(dummy.__send__(:two_args, 42, 'foo')).to eq [42, 'foo']
+      end
+    end
+  end
+
+  describe 'command with wildcard' do
+    context 'called with no args' do
+      it 'succeeds' do
+        expect(dummy.__send__(:wildcard)).to eq []
+      end
+    end
+
+    context 'called with one arg' do
+      it 'succeeds' do
+        expect(dummy.__send__(:wildcard, 42)).to eq [42]
+      end
+    end
+
+    context 'called with two args' do
+      it 'succeeds' do
+        expect(dummy.__send__(:wildcard, 42, 'foo')).to eq [42, 'foo']
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/slash_commands/extractor_spec.rb b/spec/lib/gitlab/slash_commands/extractor_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fd1b30052ed96d47a7a2c4c36d06e7c6296e62aa
--- /dev/null
+++ b/spec/lib/gitlab/slash_commands/extractor_spec.rb
@@ -0,0 +1,177 @@
+require 'spec_helper'
+
+describe Gitlab::SlashCommands::Extractor do
+  let(:extractor) { described_class.new([:open, :assign, :labels, :power]) }
+
+  shared_examples 'command with no argument' do
+    it 'extracts command' do
+      commands = extractor.extract_commands!(original_msg)
+
+      expect(commands).to eq [['open']]
+      expect(original_msg).to eq final_msg
+    end
+  end
+
+  shared_examples 'command with a single argument' do
+    it 'extracts command' do
+      commands = extractor.extract_commands!(original_msg)
+
+      expect(commands).to eq [['assign', '@joe']]
+      expect(original_msg).to eq final_msg
+    end
+  end
+
+  shared_examples 'command with multiple arguments' do
+    it 'extracts command' do
+      commands = extractor.extract_commands!(original_msg)
+
+      expect(commands).to eq [['labels', '~foo ~"bar baz" label']]
+      expect(original_msg).to eq final_msg
+    end
+  end
+
+  describe '#extract_commands!' do
+    describe 'command with no argument' do
+      context 'at the start of content' do
+        it_behaves_like 'command with no argument' do
+          let(:original_msg) { "/open\nworld" }
+          let(:final_msg) { "world" }
+        end
+      end
+
+      context 'in the middle of content' do
+        it_behaves_like 'command with no argument' do
+          let(:original_msg) { "hello\n/open\nworld" }
+          let(:final_msg) { "hello\nworld" }
+        end
+      end
+
+      context 'in the middle of a line' do
+        it 'does not extract command' do
+          msg = "hello\nworld /open"
+          commands = extractor.extract_commands!(msg)
+
+          expect(commands).to be_empty
+          expect(msg).to eq "hello\nworld /open"
+        end
+      end
+
+      context 'at the end of content' do
+        it_behaves_like 'command with no argument' do
+          let(:original_msg) { "hello\n/open" }
+          let(:final_msg) { "hello\n" }
+        end
+      end
+    end
+
+    describe 'command with a single argument' do
+      context 'at the start of content' do
+        it_behaves_like 'command with a single argument' do
+          let(:original_msg) { "/assign @joe\nworld" }
+          let(:final_msg) { "world" }
+        end
+      end
+
+      context 'in the middle of content' do
+        it_behaves_like 'command with a single argument' do
+          let(:original_msg) { "hello\n/assign @joe\nworld" }
+          let(:final_msg) { "hello\nworld" }
+        end
+      end
+
+      context 'in the middle of a line' do
+        it 'does not extract command' do
+          msg = "hello\nworld /assign @joe"
+          commands = extractor.extract_commands!(msg)
+
+          expect(commands).to be_empty
+          expect(msg).to eq "hello\nworld /assign @joe"
+        end
+      end
+
+      context 'at the end of content' do
+        it_behaves_like 'command with a single argument' do
+          let(:original_msg) { "hello\n/assign @joe" }
+          let(:final_msg) { "hello\n" }
+        end
+      end
+
+      context 'when argument is not separated with a space' do
+        it 'does not extract command' do
+          msg = "hello\n/assign@joe\nworld"
+          commands = extractor.extract_commands!(msg)
+
+          expect(commands).to be_empty
+          expect(msg).to eq "hello\n/assign@joe\nworld"
+        end
+      end
+    end
+
+    describe 'command with multiple arguments' do
+      context 'at the start of content' do
+        it_behaves_like 'command with multiple arguments' do
+          let(:original_msg) { %(/labels ~foo ~"bar baz" label\nworld) }
+          let(:final_msg) { "world" }
+        end
+      end
+
+      context 'in the middle of content' do
+        it_behaves_like 'command with multiple arguments' do
+          let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label\nworld) }
+          let(:final_msg) { "hello\nworld" }
+        end
+      end
+
+      context 'in the middle of a line' do
+        it 'does not extract command' do
+          msg = %(hello\nworld /labels ~foo ~"bar baz" label)
+          commands = extractor.extract_commands!(msg)
+
+          expect(commands).to be_empty
+          expect(msg).to eq %(hello\nworld /labels ~foo ~"bar baz" label)
+        end
+      end
+
+      context 'at the end of content' do
+        it_behaves_like 'command with multiple arguments' do
+          let(:original_msg) { %(hello\n/labels ~foo ~"bar baz" label) }
+          let(:final_msg) { "hello\n" }
+        end
+      end
+
+      context 'when argument is not separated with a space' do
+        it 'does not extract command' do
+          msg = %(hello\n/labels~foo ~"bar baz" label\nworld)
+          commands = extractor.extract_commands!(msg)
+
+          expect(commands).to be_empty
+          expect(msg).to eq %(hello\n/labels~foo ~"bar baz" label\nworld)
+        end
+      end
+    end
+
+    it 'extracts command with multiple arguments and various prefixes' do
+      msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld)
+      commands = extractor.extract_commands!(msg)
+
+      expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']]
+      expect(msg).to eq "hello\nworld"
+    end
+
+    it 'extracts multiple commands' do
+      msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/open)
+      commands = extractor.extract_commands!(msg)
+
+      expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2" label'], ['open']]
+      expect(msg).to eq "hello\nworld\n"
+    end
+
+    it 'does not alter original content if no command is found' do
+      msg = 'Fixes #123'
+      commands = extractor.extract_commands!(msg)
+
+      expect(commands).to be_empty
+      expect(msg).to eq 'Fixes #123'
+    end
+  end
+end
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 1ee9f3aae4dcb17aaf754121e33e75c4381df444..fcc3c0a00bd08e946d7409e5f26930e115c1cbd1 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -73,5 +73,7 @@
         end
       end
     end
+
+    it_behaves_like 'new issuable record that supports slash commands'
   end
 end
diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb
index b84a580967ad92c95ea0499aa3645e9033e197cf..c1e4f8bd96b019b8bb09c6b9445d2cc390ec9d54 100644
--- a/spec/services/merge_requests/create_service_spec.rb
+++ b/spec/services/merge_requests/create_service_spec.rb
@@ -17,7 +17,7 @@
         }
       end
 
-      let(:service) { MergeRequests::CreateService.new(project, user, opts) }
+      let(:service) { described_class.new(project, user, opts) }
 
       before do
         project.team << [user, :master]
@@ -74,5 +74,14 @@
         end
       end
     end
+
+    it_behaves_like 'new issuable record that supports slash commands' do
+      let(:default_params) do
+        {
+          source_branch: 'feature',
+          target_branch: 'master'
+        }
+      end
+    end
   end
 end
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 32753e84b314c7e7391e54ea9fc9846c79ca6c46..36ca7d2bce8e25e5a9b09d7f116c276225657a7a 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -4,22 +4,31 @@
   let(:project) { create(:empty_project) }
   let(:issue) { create(:issue, project: project) }
   let(:user) { create(:user) }
+  let(:opts) do
+    { note: 'Awesome comment', noteable_type: 'Issue', noteable_id: issue.id }
+  end
 
   describe '#execute' do
     context "valid params" do
       before do
         project.team << [user, :master]
-        opts = {
-          note: 'Awesome comment',
-          noteable_type: 'Issue',
-          noteable_id: issue.id
-        }
-
         @note = Notes::CreateService.new(project, user, opts).execute
       end
 
       it { expect(@note).to be_valid }
-      it { expect(@note.note).to eq('Awesome comment') }
+      it { expect(@note.note).to eq(opts[:note]) }
+
+      it_behaves_like 'note on noteable that supports slash commands' do
+        let(:noteable) { create(:issue, project: project) }
+      end
+
+      it_behaves_like 'note on noteable that supports slash commands' do
+        let(:noteable) { create(:merge_request, source_project: project) }
+      end
+
+      it_behaves_like 'note on noteable that does not support slash commands' do
+        let(:noteable) { create(:commit, project: project) }
+      end
     end
   end
 
diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fa0f65495ce92b3d6c1baa2f9ace01e78d939761
--- /dev/null
+++ b/spec/services/slash_commands/interpret_service_spec.rb
@@ -0,0 +1,217 @@
+require 'spec_helper'
+
+describe SlashCommands::InterpretService, services: true do
+  let(:project) { create(:project) }
+  let(:user) { create(:user) }
+  let(:issue) { create(:issue, project: project) }
+  let(:milestone) { create(:milestone, project: project, title: '9.10') }
+  let(:inprogress) { create(:label, project: project, title: 'In Progress') }
+  let(:bug) { create(:label, project: project, title: 'Bug') }
+
+  describe '#command_names' do
+    subject { described_class.command_names }
+
+    it 'returns the known commands' do
+      is_expected.to match_array([
+        :open, :reopen,
+        :close,
+        :assign, :reassign,
+        :unassign, :remove_assignee,
+        :milestone,
+        :remove_milestone,
+        :clear_milestone,
+        :labels, :label,
+        :unlabel, :remove_labels, :remove_label,
+        :clear_labels, :clear_label,
+        :todo,
+        :done,
+        :subscribe,
+        :unsubscribe,
+        :due_date,
+        :clear_due_date
+      ])
+    end
+  end
+
+  describe '#execute' do
+    let(:service) { described_class.new(project, user) }
+
+    shared_examples 'open command' do
+      it 'returns state_event: "open" if content contains /open' do
+        changes = service.execute(content)
+
+        expect(changes).to eq(state_event: 'reopen')
+      end
+    end
+
+    shared_examples 'close command' do
+      it 'returns state_event: "close" if content contains /open' do
+        changes = service.execute(content)
+
+        expect(changes).to eq(state_event: 'close')
+      end
+    end
+
+    shared_examples 'assign command' do
+      it 'fetches assignee and populates assignee_id if content contains /assign' do
+        changes = service.execute(content)
+
+        expect(changes).to eq(assignee_id: user.id)
+      end
+    end
+
+    shared_examples 'milestone command' do
+      it 'fetches milestone and populates milestone_id if content contains /milestone' do
+        changes = service.execute(content)
+
+        expect(changes).to eq(milestone_id: milestone.id)
+      end
+    end
+
+    shared_examples 'label command' do
+      it 'fetches label ids and populates add_label_ids if content contains /label' do
+        changes = service.execute(content)
+
+        expect(changes).to eq(add_label_ids: [bug.id, inprogress.id])
+      end
+    end
+
+    shared_examples 'remove_labels command' do
+      it 'fetches label ids and populates remove_label_ids if content contains /label' do
+        changes = service.execute(content)
+
+        expect(changes).to eq(remove_label_ids: [inprogress.id])
+      end
+    end
+
+    shared_examples 'clear_labels command' do
+      it 'populates label_ids: [] if content contains /clear_labels' do
+        changes = service.execute(content)
+
+        expect(changes).to eq(label_ids: [])
+      end
+    end
+
+    shared_examples 'command returning no changes' do
+      it 'returns an empty hash if content contains /open' do
+        changes = service.execute(content)
+
+        expect(changes).to be_empty
+      end
+    end
+
+    it_behaves_like 'open command' do
+      let(:content) { '/open' }
+    end
+
+    it_behaves_like 'open command' do
+      let(:content) { '/reopen' }
+    end
+
+    it_behaves_like 'close command' do
+      let(:content) { '/close' }
+    end
+
+    it_behaves_like 'assign command' do
+      let(:content) { "/assign @#{user.username}" }
+    end
+
+    it 'does not populate assignee_id if content contains /assign with an unknown user' do
+      changes = service.execute('/assign joe')
+
+      expect(changes).to be_empty
+    end
+
+    it 'does not populate assignee_id if content contains /assign without user' do
+      changes = service.execute('/assign')
+
+      expect(changes).to be_empty
+    end
+
+    it 'populates assignee_id: nil if content contains /unassign' do
+      changes = service.execute('/unassign')
+
+      expect(changes).to eq(assignee_id: nil)
+    end
+
+    it_behaves_like 'milestone command' do
+      let(:content) { "/milestone %#{milestone.title}" }
+    end
+
+    it 'populates milestone_id: nil if content contains /clear_milestone' do
+      changes = service.execute('/clear_milestone')
+
+      expect(changes).to eq(milestone_id: nil)
+    end
+
+    it_behaves_like 'label command' do
+      let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
+    end
+
+    it_behaves_like 'label command' do
+      let(:content) { %(/labels ~"#{inprogress.title}" ~#{bug.title} ~unknown) }
+    end
+
+    it_behaves_like 'remove_labels command' do
+      let(:content) { %(/unlabel ~"#{inprogress.title}") }
+    end
+
+    it_behaves_like 'remove_labels command' do
+      let(:content) { %(/remove_labels ~"#{inprogress.title}") }
+    end
+
+    it_behaves_like 'remove_labels command' do
+      let(:content) { %(/remove_label ~"#{inprogress.title}") }
+    end
+
+    it_behaves_like 'clear_labels command' do
+      let(:content) { '/clear_labels' }
+    end
+
+    it_behaves_like 'clear_labels command' do
+      let(:content) { '/clear_label' }
+    end
+
+    it 'populates todo: :mark if content contains /todo' do
+      changes = service.execute('/todo')
+
+      expect(changes).to eq(todo_event: 'mark')
+    end
+
+    it 'populates todo: :done if content contains /done' do
+      changes = service.execute('/done')
+
+      expect(changes).to eq(todo_event: 'done')
+    end
+
+    it 'populates subscription: :subscribe if content contains /subscribe' do
+      changes = service.execute('/subscribe')
+
+      expect(changes).to eq(subscription_event: 'subscribe')
+    end
+
+    it 'populates subscription: :unsubscribe if content contains /unsubscribe' do
+      changes = service.execute('/unsubscribe')
+
+      expect(changes).to eq(subscription_event: 'unsubscribe')
+    end
+
+    it 'populates due_date: Time.now.tomorrow if content contains /due_date 2016-08-28' do
+      changes = service.execute('/due_date 2016-08-28')
+
+      expect(changes).to eq(due_date: Date.new(2016, 8, 28))
+    end
+
+    it 'populates due_date: Time.now.tomorrow if content contains /due_date foo' do
+      changes = service.execute('/due_date foo')
+
+      expect(changes).to be_empty
+    end
+
+    it 'populates due_date: nil if content contains /clear_due_date' do
+      changes = service.execute('/clear_due_date')
+
+      expect(changes).to eq(due_date: nil)
+    end
+  end
+end
diff --git a/spec/support/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/issuable_create_service_slash_commands_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bd0201c866f47eab0c7c41a36413d3583faa37b3
--- /dev/null
+++ b/spec/support/issuable_create_service_slash_commands_shared_examples.rb
@@ -0,0 +1,83 @@
+# Specifications for behavior common to all objects with executable attributes.
+# It can take a `default_params`.
+
+shared_examples 'new issuable record that supports slash commands' do
+  let!(:project) { create(:project) }
+  let(:user) { create(:user).tap { |u| project.team << [u, :master] } }
+  let(:assignee) { create(:user) }
+  let!(:milestone) { create(:milestone, project: project) }
+  let!(:labels) { create_list(:label, 3, project: project) }
+  let(:base_params) { { title: FFaker::Lorem.sentence(3) } }
+  let(:params) { base_params.merge(defined?(default_params) ? default_params : {}).merge(example_params) }
+  let(:issuable) { described_class.new(project, user, params).execute }
+
+  context 'with labels in command only' do
+    let(:example_params) do
+      {
+        description: "/label ~#{labels.first.name} ~#{labels.second.name}\n/remove_label ~#{labels.third.name}"
+      }
+    end
+
+    it 'attaches labels to issuable' do
+      expect(issuable).to be_persisted
+      expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id])
+    end
+  end
+
+  context 'with labels in params and command' do
+    let(:example_params) do
+      {
+        label_ids: [labels.second.id],
+        description: "/label ~#{labels.first.name}\n/remove_label ~#{labels.third.name}"
+      }
+    end
+
+    it 'attaches all labels to issuable' do
+      expect(issuable).to be_persisted
+      expect(issuable.label_ids).to match_array([labels.first.id, labels.second.id])
+    end
+  end
+
+  context 'with assignee and milestone in command only' do
+    let(:example_params) do
+      {
+        description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
+      }
+    end
+
+    it 'assigns and sets milestone to issuable' do
+      expect(issuable).to be_persisted
+      expect(issuable.assignee).to eq(assignee)
+      expect(issuable.milestone).to eq(milestone)
+    end
+  end
+
+  context 'with assignee and milestone in params and command' do
+    let(:example_params) do
+      {
+        assignee: build_stubbed(:user),
+        milestone_id: double(:milestone),
+        description: %(/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
+      }
+    end
+
+    it 'assigns and sets milestone to issuable from command' do
+      expect(issuable).to be_persisted
+      expect(issuable.assignee).to eq(assignee)
+      expect(issuable.milestone).to eq(milestone)
+    end
+  end
+
+  describe '/close' do
+    let(:example_params) do
+      {
+        description: '/close'
+      }
+    end
+
+    it 'returns an open issue' do
+      expect(issuable).to be_persisted
+      expect(issuable).to be_open
+    end
+  end
+end
diff --git a/spec/support/issuable_slash_commands_shared_examples.rb b/spec/support/issuable_slash_commands_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0c8bd69add6e1205bba09557dca1abe0ebf00eff
--- /dev/null
+++ b/spec/support/issuable_slash_commands_shared_examples.rb
@@ -0,0 +1,170 @@
+# Specifications for behavior common to all objects with executable attributes.
+# It takes a `issuable_type`, and expect an `issuable`.
+
+shared_examples 'issuable record that supports slash commands in its description and notes' do |issuable_type|
+  let(:user) { create(:user) }
+  let(:assignee) { create(:user, username: 'bob') }
+  let(:project) { create(:project, :public) }
+  let!(:milestone) { create(:milestone, project: project, title: 'ASAP') }
+  let!(:label_bug) { create(:label, project: project, title: 'bug') }
+  let!(:label_feature) { create(:label, project: project, title: 'feature') }
+  let(:new_url_opts) { {} }
+
+  before do
+    project.team << [user, :master]
+    project.team << [assignee, :developer]
+    login_with(user)
+  end
+
+  describe "new #{issuable_type}" do
+    context 'with commands in the description' do
+      it "creates the #{issuable_type} and interpret commands accordingly" do
+        visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts)
+        fill_in "#{issuable_type}_title", with: 'bug 345'
+        fill_in "#{issuable_type}_description", with: "bug description\n/label ~bug\n/milestone %\"ASAP\""
+        click_button "Submit #{issuable_type}".humanize
+
+        issuable = project.public_send(issuable_type.to_s.pluralize).first
+
+        expect(issuable.description).to eq "bug description\r\n"
+        expect(issuable.labels).to eq [label_bug]
+        expect(issuable.milestone).to eq milestone
+        expect(page).to have_content 'bug 345'
+        expect(page).to have_content 'bug description'
+      end
+    end
+  end
+
+  describe "note on #{issuable_type}" do
+    before do
+      visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
+    end
+
+    context 'with a note containing commands' do
+      it 'creates a note without the commands and interpret the commands accordingly' do
+        page.within('.js-main-target-form') do
+          fill_in 'note[note]', with: "Awesome!\n/assign @bob\n/label ~bug\n/milestone %\"ASAP\""
+          click_button 'Comment'
+        end
+
+        expect(page).to have_content 'Awesome!'
+        expect(page).not_to have_content '/assign @bob'
+        expect(page).not_to have_content '/label ~bug'
+        expect(page).not_to have_content '/milestone %"ASAP"'
+
+        issuable.reload
+        note = issuable.notes.user.first
+
+        expect(note.note).to eq "Awesome!\r\n"
+        expect(issuable.assignee).to eq assignee
+        expect(issuable.labels).to eq [label_bug]
+        expect(issuable.milestone).to eq milestone
+      end
+    end
+
+    context 'with a note containing only commands' do
+      it 'does not create a note but interpret the commands accordingly' do
+        page.within('.js-main-target-form') do
+          fill_in 'note[note]', with: "/assign @bob\n/label ~bug\n/milestone %\"ASAP\""
+          click_button 'Comment'
+        end
+
+        expect(page).not_to have_content '/assign @bob'
+        expect(page).not_to have_content '/label ~bug'
+        expect(page).not_to have_content '/milestone %"ASAP"'
+        expect(page).to have_content 'Your commands are being executed.'
+
+        issuable.reload
+
+        expect(issuable.notes.user).to be_empty
+        expect(issuable.assignee).to eq assignee
+        expect(issuable.labels).to eq [label_bug]
+        expect(issuable.milestone).to eq milestone
+      end
+    end
+
+    context "with a note marking the #{issuable_type} as todo" do
+      it "creates a new todo for the #{issuable_type}" do
+        page.within('.js-main-target-form') do
+          fill_in 'note[note]', with: "/todo"
+          click_button 'Comment'
+        end
+
+        expect(page).not_to have_content '/todo'
+        expect(page).to have_content 'Your commands are being executed.'
+
+        todos = TodosFinder.new(user).execute
+        todo = todos.first
+
+        expect(todos.size).to eq 1
+        expect(todo).to be_pending
+        expect(todo.target).to eq issuable
+        expect(todo.author).to eq user
+        expect(todo.user).to eq user
+      end
+    end
+
+    context "with a note marking the #{issuable_type} as done" do
+      before do
+        TodoService.new.mark_todo(issuable, user)
+      end
+
+      it "creates a new todo for the #{issuable_type}" do
+        todos = TodosFinder.new(user).execute
+        todo = todos.first
+
+        expect(todos.size).to eq 1
+        expect(todos.first).to be_pending
+        expect(todo.target).to eq issuable
+        expect(todo.author).to eq user
+        expect(todo.user).to eq user
+
+        page.within('.js-main-target-form') do
+          fill_in 'note[note]', with: "/done"
+          click_button 'Comment'
+        end
+
+        expect(page).not_to have_content '/done'
+        expect(page).to have_content 'Your commands are being executed.'
+
+        expect(todo.reload).to be_done
+      end
+    end
+
+    context "with a note subscribing to the #{issuable_type}" do
+      it "creates a new todo for the #{issuable_type}" do
+        expect(issuable.subscribed?(user)).to be_falsy
+
+        page.within('.js-main-target-form') do
+          fill_in 'note[note]', with: "/subscribe"
+          click_button 'Comment'
+        end
+
+        expect(page).not_to have_content '/subscribe'
+        expect(page).to have_content 'Your commands are being executed.'
+
+        expect(issuable.subscribed?(user)).to be_truthy
+      end
+    end
+
+    context "with a note unsubscribing to the #{issuable_type} as done" do
+      before do
+        issuable.subscribe(user)
+      end
+
+      it "creates a new todo for the #{issuable_type}" do
+        expect(issuable.subscribed?(user)).to be_truthy
+
+        page.within('.js-main-target-form') do
+          fill_in 'note[note]', with: "/unsubscribe"
+          click_button 'Comment'
+        end
+
+        expect(page).not_to have_content '/unsubscribe'
+        expect(page).to have_content 'Your commands are being executed.'
+
+        expect(issuable.subscribed?(user)).to be_falsy
+      end
+    end
+  end
+end
diff --git a/spec/support/note_create_service_slash_commands_shared_examples.rb b/spec/support/note_create_service_slash_commands_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3f7ad8b2f912dd9b3aa3b09a6cae41ce05e9f464
--- /dev/null
+++ b/spec/support/note_create_service_slash_commands_shared_examples.rb
@@ -0,0 +1,116 @@
+# Specifications for behavior common to all note objects with executable attributes.
+# It expects a `noteable` object for which the note is posted.
+
+shared_context 'note on noteable' do
+  let!(:project) { create(:project) }
+  let(:user) { create(:user).tap { |u| project.team << [u, :master] } }
+  let(:assignee) { create(:user) }
+  let(:base_params) { { noteable: noteable } }
+  let(:params) { base_params.merge(example_params) }
+  let(:note) { described_class.new(project, user, params).execute }
+end
+
+shared_examples 'note on noteable that does not support slash commands' do
+  include_context 'note on noteable'
+
+  let(:params) { { commit_id: noteable.id, noteable_type: 'Commit' }.merge(example_params) }
+
+  describe 'note with only command' do
+    describe '/close, /label, /assign & /milestone' do
+      let(:note_text) { %(/close\n/assign @#{assignee.username}") }
+      let(:example_params) { { note: note_text } }
+
+      it 'saves the note and does not alter the note text' do
+        expect(note).to be_persisted
+        expect(note.note).to eq note_text
+      end
+    end
+  end
+
+  describe 'note with command & text' do
+    describe '/close, /label, /assign & /milestone' do
+      let(:note_text) { %(HELLO\n/close\n/assign @#{assignee.username}\nWORLD) }
+      let(:example_params) { { note: note_text } }
+
+      it 'saves the note and does not alter the note text' do
+        expect(note).to be_persisted
+        expect(note.note).to eq note_text
+      end
+    end
+  end
+end
+
+shared_examples 'note on noteable that supports slash commands' do
+  include_context 'note on noteable'
+
+  let!(:milestone) { create(:milestone, project: project) }
+  let!(:labels) { create_pair(:label, project: project) }
+
+  describe 'note with only command' do
+    describe '/close, /label, /assign & /milestone' do
+      let(:example_params) do
+        {
+          note: %(/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}")
+        }
+      end
+
+      it 'closes noteable, sets labels, assigns, and sets milestone to noteable, and leave no note' do
+        expect(note).not_to be_persisted
+        expect(note.note).to eq ''
+        expect(noteable).to be_closed
+        expect(noteable.labels).to match_array(labels)
+        expect(noteable.assignee).to eq(assignee)
+        expect(noteable.milestone).to eq(milestone)
+      end
+    end
+
+    describe '/open' do
+      let(:noteable) { create(:issue, project: project, state: :closed) }
+      let(:example_params) do
+        {
+          note: '/open'
+        }
+      end
+
+      it 'opens the noteable, and leave no note' do
+        expect(note).not_to be_persisted
+        expect(note.note).to eq ''
+        expect(noteable).to be_open
+      end
+    end
+  end
+
+  describe 'note with command & text' do
+    describe '/close, /label, /assign & /milestone' do
+      let(:example_params) do
+        {
+          note: %(HELLO\n/close\n/label ~#{labels.first.name} ~#{labels.last.name}\n/assign @#{assignee.username}\n/milestone %"#{milestone.name}"\nWORLD)
+        }
+      end
+
+      it 'closes noteable, sets labels, assigns, and sets milestone to noteable' do
+        expect(note).to be_persisted
+        expect(note.note).to eq "HELLO\nWORLD"
+        expect(noteable).to be_closed
+        expect(noteable.labels).to match_array(labels)
+        expect(noteable.assignee).to eq(assignee)
+        expect(noteable.milestone).to eq(milestone)
+      end
+    end
+
+    describe '/open' do
+      let(:noteable) { create(:issue, project: project, state: :closed) }
+      let(:example_params) do
+        {
+          note: "HELLO\n/open\nWORLD"
+        }
+      end
+
+      it 'opens the noteable' do
+        expect(note).to be_persisted
+        expect(note.note).to eq "HELLO\nWORLD"
+        expect(noteable).to be_open
+      end
+    end
+  end
+end