diff --git a/changelogs/unreleased/9-0-api-changes.yml b/changelogs/unreleased/9-0-api-changes.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2f0f18872576a16204ee568e090f565f4e49ac73
--- /dev/null
+++ b/changelogs/unreleased/9-0-api-changes.yml
@@ -0,0 +1,4 @@
+---
+title: Remove deprecated MR and Issue endpoints and preserve V3 namespace
+merge_request: 8967
+author:
diff --git a/doc/api/issues.md b/doc/api/issues.md
index b276d1ad9182570825bf41eb225ad135fda610ca..7c0a444d4fac27f127c03397581c490e858d8e34 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -181,7 +181,6 @@ GET /projects/:id/issues?labels=foo,bar
 GET /projects/:id/issues?labels=foo,bar&state=opened
 GET /projects/:id/issues?milestone=1.0.0
 GET /projects/:id/issues?milestone=1.0.0&state=opened
-GET /projects/:id/issues?iid=42
 ```
 
 | Attribute | Type | Required | Description |
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index 7b005591545274dae11a1f2e7ea32b54c7f5c345..1cf7632d60cdff7951754ea449fbd897ad68b3d6 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -10,8 +10,7 @@ The pagination parameters `page` and `per_page` can be used to restrict the list
 GET /projects/:id/merge_requests
 GET /projects/:id/merge_requests?state=opened
 GET /projects/:id/merge_requests?state=all
-GET /projects/:id/merge_requests?iid=42
-GET /projects/:id/merge_requests?iid[]=42&iid[]=43
+GET /projects/:id/merge_requests?iids[]=42&iids[]=43
 ```
 
 Parameters:
diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md
index 01de1e59fcbbdbc78936da899b72e0b1c12d5b6a..9748aec17ad24e60cbf278fe5779fa3eca55c2d7 100644
--- a/doc/api/v3_to_v4.md
+++ b/doc/api/v3_to_v4.md
@@ -7,4 +7,7 @@ changes are in V4:
 ### Changes
 
 - Removed `/projects/:search` (use: `/projects?search=x`)
+- `iid` filter has been removed from `projects/:id/issues`
+- `projects/:id/merge_requests?iid[]=x&iid[]=y` array filter has been renamed to `iids`
+- Endpoints under `projects/merge_request/:id` have been removed (use: `projects/merge_requests/:id`)
 
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 090109d5e6fb8b98eaa822caa4a30315d502268d..1950d2791abee6988326b689a3545d98ecc3425b 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -5,6 +5,8 @@ module API
     version %w(v3 v4), using: :path
 
     version 'v3', using: :path do
+      mount ::API::V3::Issues
+      mount ::API::V3::MergeRequests
       mount ::API::V3::Projects
     end
 
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index fe016c1ec0a06d3aeb896040a53a64e435ea3772..90fca20d4fa9b4d700466620cb2d93f26f4dba45 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -15,8 +15,6 @@ module API
         labels = args.delete(:labels)
         args[:label_name] = labels if match_all_labels
 
-        args[:search] = "#{Issue.reference_prefix}#{args.delete(:iid)}" if args.key?(:iid)
-
         issues = IssuesFinder.new(current_user, args).execute.inc_notes_with_associations
 
         # TODO: Remove in 9.0  pass `label_name: args.delete(:labels)` to IssuesFinder
@@ -97,7 +95,6 @@ module API
       params do
         optional :state, type: String, values: %w[opened closed all], default: 'all',
                          desc: 'Return opened, closed, or all issues'
-        optional :iid, type: Integer, desc: 'Return the issue having the given `iid`'
         use :issues_params
       end
       get ":id/issues" do
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index 7ffb38e62daa3f761fd381244b0aa1da1be295f7..782147883c8d73adca478b221c082ad6c186d639 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -2,8 +2,6 @@ module API
   class MergeRequests < Grape::API
     include PaginationParams
 
-    DEPRECATION_MESSAGE = 'This endpoint is deprecated and will be removed in GitLab 9.0.'.freeze
-
     before { authenticate! }
 
     params do
@@ -46,14 +44,14 @@ module API
                             desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.'
         optional :sort, type: String, values: %w[asc desc], default: 'desc',
                         desc: 'Return merge requests sorted in `asc` or `desc` order.'
-        optional :iid, type: Array[Integer], desc: 'The IID of the merge requests'
+        optional :iids, type: Array[Integer], desc: 'The IID array of merge requests'
         use :pagination
       end
       get ":id/merge_requests" do
         authorize! :read_merge_request, user_project
 
         merge_requests = user_project.merge_requests.inc_notes_with_associations
-        merge_requests = filter_by_iid(merge_requests, params[:iid]) if params[:iid].present?
+        merge_requests = filter_by_iid(merge_requests, params[:iids]) if params[:iids].present?
 
         merge_requests =
           case params[:state]
@@ -104,177 +102,167 @@ module API
         merge_request.destroy
       end
 
-      # Routing "merge_request/:merge_request_id/..." is DEPRECATED and WILL BE REMOVED in version 9.0
-      # Use "merge_requests/:merge_request_id/..." instead.
-      #
       params do
         requires :merge_request_id, type: Integer, desc: 'The ID of a merge request'
       end
-      { ":id/merge_request/:merge_request_id" => :deprecated, ":id/merge_requests/:merge_request_id" => :ok }.each do |path, status|
-        desc 'Get a single merge request' do
-          if status == :deprecated
-            detail DEPRECATION_MESSAGE
-          end
-          success Entities::MergeRequest
-        end
-        get path do
-          merge_request = find_merge_request_with_access(params[:merge_request_id])
+      desc 'Get a single merge request' do
+        success Entities::MergeRequest
+      end
+      get ':id/merge_requests/:merge_request_id' do
+        merge_request = find_merge_request_with_access(params[:merge_request_id])
 
-          present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
-        end
+        present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
+      end
 
-        desc 'Get the commits of a merge request' do
-          success Entities::RepoCommit
-        end
-        get "#{path}/commits" do
-          merge_request = find_merge_request_with_access(params[:merge_request_id])
+      desc 'Get the commits of a merge request' do
+        success Entities::RepoCommit
+      end
+      get ':id/merge_requests/:merge_request_id/commits' do
+        merge_request = find_merge_request_with_access(params[:merge_request_id])
 
-          present merge_request.commits, with: Entities::RepoCommit
-        end
+        present merge_request.commits, with: Entities::RepoCommit
+      end
 
-        desc 'Show the merge request changes' do
-          success Entities::MergeRequestChanges
-        end
-        get "#{path}/changes" do
-          merge_request = find_merge_request_with_access(params[:merge_request_id])
+      desc 'Show the merge request changes' do
+        success Entities::MergeRequestChanges
+      end
+      get ':id/merge_requests/:merge_request_id/changes' do
+        merge_request = find_merge_request_with_access(params[:merge_request_id])
 
-          present merge_request, with: Entities::MergeRequestChanges, current_user: current_user
-        end
+        present merge_request, with: Entities::MergeRequestChanges, current_user: current_user
+      end
 
-        desc 'Update a merge request' do
-          success Entities::MergeRequest
-        end
-        params do
-          optional :title, type: String, allow_blank: false, desc: 'The title of the merge request'
-          optional :target_branch, type: String, allow_blank: false, desc: 'The target branch'
-          optional :state_event, type: String, values: %w[close reopen merge],
-                                 desc: 'Status of the merge request'
-          use :optional_params
-          at_least_one_of :title, :target_branch, :description, :assignee_id,
-                          :milestone_id, :labels, :state_event,
-                          :remove_source_branch
-        end
-        put path do
-          merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request)
+      desc 'Update a merge request' do
+        success Entities::MergeRequest
+      end
+      params do
+        optional :title, type: String, allow_blank: false, desc: 'The title of the merge request'
+        optional :target_branch, type: String, allow_blank: false, desc: 'The target branch'
+        optional :state_event, type: String, values: %w[close reopen merge],
+                               desc: 'Status of the merge request'
+        use :optional_params
+        at_least_one_of :title, :target_branch, :description, :assignee_id,
+                        :milestone_id, :labels, :state_event,
+                        :remove_source_branch
+      end
+      put ':id/merge_requests/:merge_request_id' do
+        merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request)
 
-          mr_params = declared_params(include_missing: false)
-          mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present?
+        mr_params = declared_params(include_missing: false)
+        mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present?
 
-          merge_request = ::MergeRequests::UpdateService.new(user_project, current_user, mr_params).execute(merge_request)
+        merge_request = ::MergeRequests::UpdateService.new(user_project, current_user, mr_params).execute(merge_request)
 
-          if merge_request.valid?
-            present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
-          else
-            handle_merge_request_errors! merge_request.errors
-          end
+        if merge_request.valid?
+          present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
+        else
+          handle_merge_request_errors! merge_request.errors
         end
+      end
 
-        desc 'Merge a merge request' do
-          success Entities::MergeRequest
-        end
-        params do
-          optional :merge_commit_message, type: String, desc: 'Custom merge commit message'
-          optional :should_remove_source_branch, type: Boolean,
-                                                 desc: 'When true, the source branch will be deleted if possible'
-          optional :merge_when_build_succeeds, type: Boolean,
-                                               desc: 'When true, this merge request will be merged when the pipeline succeeds'
-          optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch'
-        end
-        put "#{path}/merge" do
-          merge_request = find_project_merge_request(params[:merge_request_id])
+      desc 'Merge a merge request' do
+        success Entities::MergeRequest
+      end
+      params do
+        optional :merge_commit_message, type: String, desc: 'Custom merge commit message'
+        optional :should_remove_source_branch, type: Boolean,
+                                               desc: 'When true, the source branch will be deleted if possible'
+        optional :merge_when_build_succeeds, type: Boolean,
+                                             desc: 'When true, this merge request will be merged when the pipeline succeeds'
+        optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch'
+      end
+      put ':id/merge_requests/:merge_request_id/merge' do
+        merge_request = find_project_merge_request(params[:merge_request_id])
 
-          # Merge request can not be merged
-          # because user dont have permissions to push into target branch
-          unauthorized! unless merge_request.can_be_merged_by?(current_user)
+        # Merge request can not be merged
+        # because user dont have permissions to push into target branch
+        unauthorized! unless merge_request.can_be_merged_by?(current_user)
 
-          not_allowed! unless merge_request.mergeable_state?
+        not_allowed! unless merge_request.mergeable_state?
 
-          render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable?
+        render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable?
 
-          if params[:sha] && merge_request.diff_head_sha != params[:sha]
-            render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409)
-          end
+        if params[:sha] && merge_request.diff_head_sha != params[:sha]
+          render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409)
+        end
 
-          merge_params = {
-            commit_message: params[:merge_commit_message],
-            should_remove_source_branch: params[:should_remove_source_branch]
-          }
-
-          if params[:merge_when_build_succeeds] && merge_request.head_pipeline && merge_request.head_pipeline.active?
-            ::MergeRequests::MergeWhenPipelineSucceedsService
-              .new(merge_request.target_project, current_user, merge_params)
-              .execute(merge_request)
-          else
-            ::MergeRequests::MergeService
-              .new(merge_request.target_project, current_user, merge_params)
-              .execute(merge_request)
-          end
+        merge_params = {
+          commit_message: params[:merge_commit_message],
+          should_remove_source_branch: params[:should_remove_source_branch]
+        }
 
-          present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
+        if params[:merge_when_build_succeeds] && merge_request.head_pipeline && merge_request.head_pipeline.active?
+          ::MergeRequests::MergeWhenPipelineSucceedsService
+            .new(merge_request.target_project, current_user, merge_params)
+            .execute(merge_request)
+        else
+          ::MergeRequests::MergeService
+            .new(merge_request.target_project, current_user, merge_params)
+            .execute(merge_request)
         end
 
-        desc 'Cancel merge if "Merge When Pipeline Succeeds" is enabled' do
-          success Entities::MergeRequest
-        end
-        post "#{path}/cancel_merge_when_build_succeeds" do
-          merge_request = find_project_merge_request(params[:merge_request_id])
+        present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
+      end
 
-          unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user)
+      desc 'Cancel merge if "Merge When Pipeline Succeeds" is enabled' do
+        success Entities::MergeRequest
+      end
+      post ':id/merge_requests/:merge_request_id/cancel_merge_when_build_succeeds' do
+        merge_request = find_project_merge_request(params[:merge_request_id])
 
-          ::MergeRequest::MergeWhenPipelineSucceedsService
-            .new(merge_request.target_project, current_user)
-            .cancel(merge_request)
-        end
+        unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user)
 
-        desc 'Get the comments of a merge request' do
-          detail 'Duplicate. DEPRECATED and WILL BE REMOVED in 9.0'
-          success Entities::MRNote
-        end
-        params do
-          use :pagination
-        end
-        get "#{path}/comments" do
-          merge_request = find_merge_request_with_access(params[:merge_request_id])
-          present paginate(merge_request.notes.fresh), with: Entities::MRNote
-        end
+        ::MergeRequest::MergeWhenPipelineSucceedsService
+          .new(merge_request.target_project, current_user)
+          .cancel(merge_request)
+      end
 
-        desc 'Post a comment to a merge request' do
-          detail 'Duplicate. DEPRECATED and WILL BE REMOVED in 9.0'
-          success Entities::MRNote
-        end
-        params do
-          requires :note, type: String, desc: 'The text of the comment'
-        end
-        post "#{path}/comments" do
-          merge_request = find_merge_request_with_access(params[:merge_request_id], :create_note)
+      desc 'Get the comments of a merge request' do
+        success Entities::MRNote
+      end
+      params do
+        use :pagination
+      end
+      get ':id/merge_requests/:merge_request_id/comments' do
+        merge_request = find_merge_request_with_access(params[:merge_request_id])
+        present paginate(merge_request.notes.fresh), with: Entities::MRNote
+      end
 
-          opts = {
-            note: params[:note],
-            noteable_type: 'MergeRequest',
-            noteable_id: merge_request.id
-          }
+      desc 'Post a comment to a merge request' do
+        success Entities::MRNote
+      end
+      params do
+        requires :note, type: String, desc: 'The text of the comment'
+      end
+      post ':id/merge_requests/:merge_request_id/comments' do
+        merge_request = find_merge_request_with_access(params[:merge_request_id], :create_note)
 
-          note = ::Notes::CreateService.new(user_project, current_user, opts).execute
+        opts = {
+          note: params[:note],
+          noteable_type: 'MergeRequest',
+          noteable_id: merge_request.id
+        }
 
-          if note.save
-            present note, with: Entities::MRNote
-          else
-            render_api_error!("Failed to save note #{note.errors.messages}", 400)
-          end
-        end
+        note = ::Notes::CreateService.new(user_project, current_user, opts).execute
 
-        desc 'List issues that will be closed on merge' do
-          success Entities::MRNote
-        end
-        params do
-          use :pagination
-        end
-        get "#{path}/closes_issues" do
-          merge_request = find_merge_request_with_access(params[:merge_request_id])
-          issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user))
-          present paginate(issues), with: issue_entity(user_project), current_user: current_user
+        if note.save
+          present note, with: Entities::MRNote
+        else
+          render_api_error!("Failed to save note #{note.errors.messages}", 400)
         end
       end
+
+      desc 'List issues that will be closed on merge' do
+        success Entities::MRNote
+      end
+      params do
+        use :pagination
+      end
+      get ':id/merge_requests/:merge_request_id/closes_issues' do
+        merge_request = find_merge_request_with_access(params[:merge_request_id])
+        issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user))
+        present paginate(issues), with: issue_entity(user_project), current_user: current_user
+      end
     end
   end
 end
diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb
new file mode 100644
index 0000000000000000000000000000000000000000..be3ecc29449515eb455aeca0e9837dba321bf433
--- /dev/null
+++ b/lib/api/v3/issues.rb
@@ -0,0 +1,231 @@
+module API
+  module V3
+    class Issues < Grape::API
+      include PaginationParams
+
+      before { authenticate! }
+
+      helpers do
+        def find_issues(args = {})
+          args = params.merge(args)
+
+          args.delete(:id)
+          args[:milestone_title] = args.delete(:milestone)
+
+          match_all_labels = args.delete(:match_all_labels)
+          labels = args.delete(:labels)
+          args[:label_name] = labels if match_all_labels
+
+          args[:search] = "#{Issue.reference_prefix}#{args.delete(:iid)}" if args.key?(:iid)
+
+          issues = IssuesFinder.new(current_user, args).execute.inc_notes_with_associations
+
+          if !match_all_labels && labels.present?
+            issues = issues.includes(:labels).where('labels.title' => labels.split(','))
+          end
+
+          issues.reorder(args[:order_by] => args[:sort])
+        end
+
+        params :issues_params do
+          optional :labels, type: String, desc: 'Comma-separated list of label names'
+          optional :milestone, type: String, desc: 'Milestone title'
+          optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at',
+                              desc: 'Return issues ordered by `created_at` or `updated_at` fields.'
+          optional :sort, type: String, values: %w[asc desc], default: 'desc',
+                          desc: 'Return issues sorted in `asc` or `desc` order.'
+          optional :milestone, type: String, desc: 'Return issues for a specific milestone'
+          use :pagination
+        end
+
+        params :issue_params do
+          optional :description, type: String, desc: 'The description of an issue'
+          optional :assignee_id, type: Integer, desc: 'The ID of a user to assign issue'
+          optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign issue'
+          optional :labels, type: String, desc: 'Comma-separated list of label names'
+          optional :due_date, type: String, desc: 'Date time string in the format YEAR-MONTH-DAY'
+          optional :confidential, type: Boolean, desc: 'Boolean parameter if the issue should be confidential'
+        end
+      end
+
+      resource :issues do
+        desc "Get currently authenticated user's issues" do
+          success Entities::Issue
+        end
+        params do
+          optional :state, type: String, values: %w[opened closed all], default: 'all',
+                           desc: 'Return opened, closed, or all issues'
+          use :issues_params
+        end
+        get do
+          issues = find_issues(scope: 'authored')
+
+          present paginate(issues), with: Entities::Issue, current_user: current_user
+        end
+      end
+
+      params do
+        requires :id, type: String, desc: 'The ID of a group'
+      end
+      resource :groups do
+        desc 'Get a list of group issues' do
+          success Entities::Issue
+        end
+        params do
+          optional :state, type: String, values: %w[opened closed all], default: 'opened',
+                           desc: 'Return opened, closed, or all issues'
+          use :issues_params
+        end
+        get ":id/issues" do
+          group = find_group!(params[:id])
+
+          issues = find_issues(group_id: group.id, state: params[:state] || 'opened', match_all_labels: true)
+
+          present paginate(issues), with: Entities::Issue, current_user: current_user
+        end
+      end
+
+      params do
+        requires :id, type: String, desc: 'The ID of a project'
+      end
+      resource :projects do
+        include TimeTrackingEndpoints
+
+        desc 'Get a list of project issues' do
+          detail 'iid filter is deprecated have been removed on V4'
+          success Entities::Issue
+        end
+        params do
+          optional :state, type: String, values: %w[opened closed all], default: 'all',
+                           desc: 'Return opened, closed, or all issues'
+          optional :iid, type: Integer, desc: 'Return the issue having the given `iid`'
+          use :issues_params
+        end
+        get ":id/issues" do
+          project = find_project(params[:id])
+
+          issues = find_issues(project_id: project.id)
+
+          present paginate(issues), with: Entities::Issue, current_user: current_user, project: user_project
+        end
+
+        desc 'Get a single project issue' do
+          success Entities::Issue
+        end
+        params do
+          requires :issue_id, type: Integer, desc: 'The ID of a project issue'
+        end
+        get ":id/issues/:issue_id" do
+          issue = find_project_issue(params[:issue_id])
+          present issue, with: Entities::Issue, current_user: current_user, project: user_project
+        end
+
+        desc 'Create a new project issue' do
+          success Entities::Issue
+        end
+        params do
+          requires :title, type: String, desc: 'The title of an issue'
+          optional :created_at, type: DateTime,
+                                desc: 'Date time when the issue was created. Available only for admins and project owners.'
+          optional :merge_request_for_resolving_discussions, type: Integer,
+                                                             desc: 'The IID of a merge request for which to resolve discussions'
+          use :issue_params
+        end
+        post ':id/issues' do
+          # Setting created_at time only allowed for admins and project owners
+          unless current_user.admin? || user_project.owner == current_user
+            params.delete(:created_at)
+          end
+
+          issue_params = declared_params(include_missing: false)
+
+          if merge_request_iid = params[:merge_request_for_resolving_discussions]
+            issue_params[:merge_request_for_resolving_discussions] = MergeRequestsFinder.new(current_user, project_id: user_project.id).
+              execute.
+              find_by(iid: merge_request_iid)
+          end
+
+          issue = ::Issues::CreateService.new(user_project,
+                                              current_user,
+                                              issue_params.merge(request: request, api: true)).execute
+          if issue.spam?
+            render_api_error!({ error: 'Spam detected' }, 400)
+          end
+
+          if issue.valid?
+            present issue, with: Entities::Issue, current_user: current_user, project: user_project
+          else
+            render_validation_error!(issue)
+          end
+        end
+
+        desc 'Update an existing issue' do
+          success Entities::Issue
+        end
+        params do
+          requires :issue_id, type: Integer, desc: 'The ID of a project issue'
+          optional :title, type: String, desc: 'The title of an issue'
+          optional :updated_at, type: DateTime,
+                                desc: 'Date time when the issue was updated. Available only for admins and project owners.'
+          optional :state_event, type: String, values: %w[reopen close], desc: 'State of the issue'
+          use :issue_params
+          at_least_one_of :title, :description, :assignee_id, :milestone_id,
+                          :labels, :created_at, :due_date, :confidential, :state_event
+        end
+        put ':id/issues/:issue_id' do
+          issue = user_project.issues.find(params.delete(:issue_id))
+          authorize! :update_issue, issue
+
+          # Setting created_at time only allowed for admins and project owners
+          unless current_user.admin? || user_project.owner == current_user
+            params.delete(:updated_at)
+          end
+
+          issue = ::Issues::UpdateService.new(user_project,
+                                              current_user,
+                                              declared_params(include_missing: false)).execute(issue)
+
+          if issue.valid?
+            present issue, with: Entities::Issue, current_user: current_user, project: user_project
+          else
+            render_validation_error!(issue)
+          end
+        end
+
+        desc 'Move an existing issue' do
+          success Entities::Issue
+        end
+        params do
+          requires :issue_id, type: Integer, desc: 'The ID of a project issue'
+          requires :to_project_id, type: Integer, desc: 'The ID of the new project'
+        end
+        post ':id/issues/:issue_id/move' do
+          issue = user_project.issues.find_by(id: params[:issue_id])
+          not_found!('Issue') unless issue
+
+          new_project = Project.find_by(id: params[:to_project_id])
+          not_found!('Project') unless new_project
+
+          begin
+            issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project)
+            present issue, with: Entities::Issue, current_user: current_user, project: user_project
+          rescue ::Issues::MoveService::MoveError => error
+            render_api_error!(error.message, 400)
+          end
+        end
+
+        desc 'Delete a project issue'
+        params do
+          requires :issue_id, type: Integer, desc: 'The ID of a project issue'
+        end
+        delete ":id/issues/:issue_id" do
+          issue = user_project.issues.find_by(id: params[:issue_id])
+          not_found!('Issue') unless issue
+
+          authorize!(:destroy_issue, issue)
+          issue.destroy
+        end
+      end
+    end
+  end
+end
diff --git a/lib/api/v3/merge_requests.rb b/lib/api/v3/merge_requests.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1af70cf58cc8153f15456e5e8c16c6904a1b47a8
--- /dev/null
+++ b/lib/api/v3/merge_requests.rb
@@ -0,0 +1,280 @@
+module API
+  module V3
+    class MergeRequests < Grape::API
+      include PaginationParams
+
+      DEPRECATION_MESSAGE = 'This endpoint is deprecated and has been removed on V4'.freeze
+
+      before { authenticate! }
+
+      params do
+        requires :id, type: String, desc: 'The ID of a project'
+      end
+      resource :projects do
+        include TimeTrackingEndpoints
+
+        helpers do
+          def handle_merge_request_errors!(errors)
+            if errors[:project_access].any?
+              error!(errors[:project_access], 422)
+            elsif errors[:branch_conflict].any?
+              error!(errors[:branch_conflict], 422)
+            elsif errors[:validate_fork].any?
+              error!(errors[:validate_fork], 422)
+            elsif errors[:validate_branches].any?
+              conflict!(errors[:validate_branches])
+            end
+
+            render_api_error!(errors, 400)
+          end
+
+          params :optional_params do
+            optional :description, type: String, desc: 'The description of the merge request'
+            optional :assignee_id, type: Integer, desc: 'The ID of a user to assign the merge request'
+            optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign the merge request'
+            optional :labels, type: String, desc: 'Comma-separated list of label names'
+            optional :remove_source_branch, type: Boolean, desc: 'Remove source branch when merging'
+          end
+        end
+
+        desc 'List merge requests' do
+          detail 'iid filter is deprecated have been removed on V4'
+          success Entities::MergeRequest
+        end
+        params do
+          optional :state, type: String, values: %w[opened closed merged all], default: 'all',
+                           desc: 'Return opened, closed, merged, or all merge requests'
+          optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at',
+                              desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.'
+          optional :sort, type: String, values: %w[asc desc], default: 'desc',
+                          desc: 'Return merge requests sorted in `asc` or `desc` order.'
+          optional :iid, type: Array[Integer], desc: 'The IID of the merge requests'
+          use :pagination
+        end
+        get ":id/merge_requests" do
+          authorize! :read_merge_request, user_project
+
+          merge_requests = user_project.merge_requests.inc_notes_with_associations
+          merge_requests = filter_by_iid(merge_requests, params[:iid]) if params[:iid].present?
+
+          merge_requests =
+            case params[:state]
+            when 'opened' then merge_requests.opened
+            when 'closed' then merge_requests.closed
+            when 'merged' then merge_requests.merged
+            else merge_requests
+            end
+
+          merge_requests = merge_requests.reorder(params[:order_by] => params[:sort])
+          present paginate(merge_requests), with: Entities::MergeRequest, current_user: current_user, project: user_project
+        end
+
+        desc 'Create a merge request' do
+          success Entities::MergeRequest
+        end
+        params do
+          requires :title, type: String, desc: 'The title of the merge request'
+          requires :source_branch, type: String, desc: 'The source branch'
+          requires :target_branch, type: String, desc: 'The target branch'
+          optional :target_project_id, type: Integer,
+                                       desc: 'The target project of the merge request defaults to the :id of the project'
+          use :optional_params
+        end
+        post ":id/merge_requests" do
+          authorize! :create_merge_request, user_project
+
+          mr_params = declared_params(include_missing: false)
+          mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present?
+
+          merge_request = ::MergeRequests::CreateService.new(user_project, current_user, mr_params).execute
+
+          if merge_request.valid?
+            present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
+          else
+            handle_merge_request_errors! merge_request.errors
+          end
+        end
+
+        desc 'Delete a merge request'
+        params do
+          requires :merge_request_id, type: Integer, desc: 'The ID of a merge request'
+        end
+        delete ":id/merge_requests/:merge_request_id" do
+          merge_request = find_project_merge_request(params[:merge_request_id])
+
+          authorize!(:destroy_merge_request, merge_request)
+          merge_request.destroy
+        end
+
+        params do
+          requires :merge_request_id, type: Integer, desc: 'The ID of a merge request'
+        end
+        { ":id/merge_request/:merge_request_id" => :deprecated, ":id/merge_requests/:merge_request_id" => :ok }.each do |path, status|
+          desc 'Get a single merge request' do
+            if status == :deprecated
+              detail DEPRECATION_MESSAGE
+            end
+            success Entities::MergeRequest
+          end
+          get path do
+            merge_request = find_merge_request_with_access(params[:merge_request_id])
+
+            present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
+          end
+
+          desc 'Get the commits of a merge request' do
+            success Entities::RepoCommit
+          end
+          get "#{path}/commits" do
+            merge_request = find_merge_request_with_access(params[:merge_request_id])
+
+            present merge_request.commits, with: Entities::RepoCommit
+          end
+
+          desc 'Show the merge request changes' do
+            success Entities::MergeRequestChanges
+          end
+          get "#{path}/changes" do
+            merge_request = find_merge_request_with_access(params[:merge_request_id])
+
+            present merge_request, with: Entities::MergeRequestChanges, current_user: current_user
+          end
+
+          desc 'Update a merge request' do
+            success Entities::MergeRequest
+          end
+          params do
+            optional :title, type: String, allow_blank: false, desc: 'The title of the merge request'
+            optional :target_branch, type: String, allow_blank: false, desc: 'The target branch'
+            optional :state_event, type: String, values: %w[close reopen merge],
+                                   desc: 'Status of the merge request'
+            use :optional_params
+            at_least_one_of :title, :target_branch, :description, :assignee_id,
+                            :milestone_id, :labels, :state_event,
+                            :remove_source_branch
+          end
+          put path do
+            merge_request = find_merge_request_with_access(params.delete(:merge_request_id), :update_merge_request)
+
+            mr_params = declared_params(include_missing: false)
+            mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params[:remove_source_branch].present?
+
+            merge_request = ::MergeRequests::UpdateService.new(user_project, current_user, mr_params).execute(merge_request)
+
+            if merge_request.valid?
+              present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
+            else
+              handle_merge_request_errors! merge_request.errors
+            end
+          end
+
+          desc 'Merge a merge request' do
+            success Entities::MergeRequest
+          end
+          params do
+            optional :merge_commit_message, type: String, desc: 'Custom merge commit message'
+            optional :should_remove_source_branch, type: Boolean,
+                                                   desc: 'When true, the source branch will be deleted if possible'
+            optional :merge_when_build_succeeds, type: Boolean,
+                                                 desc: 'When true, this merge request will be merged when the pipeline succeeds'
+            optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch'
+          end
+          put "#{path}/merge" do
+            merge_request = find_project_merge_request(params[:merge_request_id])
+
+            # Merge request can not be merged
+            # because user dont have permissions to push into target branch
+            unauthorized! unless merge_request.can_be_merged_by?(current_user)
+
+            not_allowed! unless merge_request.mergeable_state?
+
+            render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable?
+
+            if params[:sha] && merge_request.diff_head_sha != params[:sha]
+              render_api_error!("SHA does not match HEAD of source branch: #{merge_request.diff_head_sha}", 409)
+            end
+
+            merge_params = {
+              commit_message: params[:merge_commit_message],
+              should_remove_source_branch: params[:should_remove_source_branch]
+            }
+
+            if params[:merge_when_build_succeeds] && merge_request.head_pipeline && merge_request.head_pipeline.active?
+              ::MergeRequests::MergeWhenPipelineSucceedsService
+                .new(merge_request.target_project, current_user, merge_params)
+                .execute(merge_request)
+            else
+              ::MergeRequests::MergeService
+                .new(merge_request.target_project, current_user, merge_params)
+                .execute(merge_request)
+            end
+
+            present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
+          end
+
+          desc 'Cancel merge if "Merge When Pipeline Succeeds" is enabled' do
+            success Entities::MergeRequest
+          end
+          post "#{path}/cancel_merge_when_build_succeeds" do
+            merge_request = find_project_merge_request(params[:merge_request_id])
+
+            unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user)
+
+            ::MergeRequest::MergeWhenPipelineSucceedsService
+              .new(merge_request.target_project, current_user)
+              .cancel(merge_request)
+          end
+
+          desc 'Get the comments of a merge request' do
+            detail 'Duplicate. DEPRECATED and HAS BEEN REMOVED in V4'
+            success Entities::MRNote
+          end
+          params do
+            use :pagination
+          end
+          get "#{path}/comments" do
+            merge_request = find_merge_request_with_access(params[:merge_request_id])
+            present paginate(merge_request.notes.fresh), with: Entities::MRNote
+          end
+
+          desc 'Post a comment to a merge request' do
+            detail 'Duplicate. DEPRECATED and HAS BEEN REMOVED in V4'
+            success Entities::MRNote
+          end
+          params do
+            requires :note, type: String, desc: 'The text of the comment'
+          end
+          post "#{path}/comments" do
+            merge_request = find_merge_request_with_access(params[:merge_request_id], :create_note)
+
+            opts = {
+              note: params[:note],
+              noteable_type: 'MergeRequest',
+              noteable_id: merge_request.id
+            }
+
+            note = ::Notes::CreateService.new(user_project, current_user, opts).execute
+
+            if note.save
+              present note, with: Entities::MRNote
+            else
+              render_api_error!("Failed to save note #{note.errors.messages}", 400)
+            end
+          end
+
+          desc 'List issues that will be closed on merge' do
+            success Entities::MRNote
+          end
+          params do
+            use :pagination
+          end
+          get "#{path}/closes_issues" do
+            merge_request = find_merge_request_with_access(params[:merge_request_id])
+            issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user))
+            present paginate(issues), with: issue_entity(user_project), current_user: current_user
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index 62f1b8d7ca2a7894a33d7ef4c263b70c0528afd4..863da19f294934f3798a482b9e40bfe6ca8d1a81 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -612,23 +612,6 @@ describe API::Issues, api: true  do
       expect(json_response['iid']).to eq(issue.iid)
     end
 
-    it 'returns a project issue by iid' do
-      get api("/projects/#{project.id}/issues?iid=#{issue.iid}", user)
-
-      expect(response.status).to eq 200
-      expect(json_response.length).to eq 1
-      expect(json_response.first['title']).to eq issue.title
-      expect(json_response.first['id']).to eq issue.id
-      expect(json_response.first['iid']).to eq issue.iid
-    end
-
-    it 'returns an empty array for an unknown project issue iid' do
-      get api("/projects/#{project.id}/issues?iid=#{issue.iid + 10}", user)
-
-      expect(response.status).to eq 200
-      expect(json_response.length).to eq 0
-    end
-
     it "returns 404 if issue id not found" do
       get api("/projects/#{project.id}/issues/54321", user)
       expect(response).to have_http_status(404)
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 21a2c583aa85eaa72998a1d364e588a0319bf6bc..ff10e79e4174fb286ad696041686330e0a0c1acc 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -73,6 +73,16 @@ describe API::MergeRequests, api: true  do
         expect(json_response.first['title']).to eq(merge_request_merged.title)
       end
 
+      it 'returns merge_request by "iids" array' do
+        get api("/projects/#{project.id}/merge_requests", user), iids: [merge_request.iid, merge_request_closed.iid]
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(2)
+        expect(json_response.first['title']).to eq merge_request_closed.title
+        expect(json_response.first['id']).to eq merge_request_closed.id
+      end
+
       context "with ordering" do
         before do
           @mr_later = mr_with_later_created_and_updated_at_time
@@ -159,24 +169,6 @@ describe API::MergeRequests, api: true  do
       expect(json_response['force_close_merge_request']).to be_falsy
     end
 
-    it 'returns merge_request by iid' do
-      url = "/projects/#{project.id}/merge_requests?iid=#{merge_request.iid}"
-      get api(url, user)
-      expect(response.status).to eq 200
-      expect(json_response.first['title']).to eq merge_request.title
-      expect(json_response.first['id']).to eq merge_request.id
-    end
-
-    it 'returns merge_request by iid array' do
-      get api("/projects/#{project.id}/merge_requests", user), iid: [merge_request.iid, merge_request_closed.iid]
-
-      expect(response).to have_http_status(200)
-      expect(json_response).to be_an Array
-      expect(json_response.length).to eq(2)
-      expect(json_response.first['title']).to eq merge_request_closed.title
-      expect(json_response.first['id']).to eq merge_request_closed.id
-    end
-
     it "returns a 404 error if merge_request_id not found" do
       get api("/projects/#{project.id}/merge_requests/999", user)
       expect(response).to have_http_status(404)
diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..33a127de98a7635be7fa1b01fd4df0cb621ff3fc
--- /dev/null
+++ b/spec/requests/api/v3/issues_spec.rb
@@ -0,0 +1,1259 @@
+require 'spec_helper'
+
+describe API::V3::Issues, api: true  do
+  include ApiHelpers
+  include EmailHelpers
+
+  let(:user)        { create(:user) }
+  let(:user2)       { create(:user) }
+  let(:non_member)  { create(:user) }
+  let(:guest)       { create(:user) }
+  let(:author)      { create(:author) }
+  let(:assignee)    { create(:assignee) }
+  let(:admin)       { create(:user, :admin) }
+  let!(:project)    { create(:empty_project, :public, creator_id: user.id, namespace: user.namespace ) }
+  let!(:closed_issue) do
+    create :closed_issue,
+           author: user,
+           assignee: user,
+           project: project,
+           state: :closed,
+           milestone: milestone,
+           created_at: generate(:issue_created_at),
+           updated_at: 3.hours.ago
+  end
+  let!(:confidential_issue) do
+    create :issue,
+           :confidential,
+           project: project,
+           author: author,
+           assignee: assignee,
+           created_at: generate(:issue_created_at),
+           updated_at: 2.hours.ago
+  end
+  let!(:issue) do
+    create :issue,
+           author: user,
+           assignee: user,
+           project: project,
+           milestone: milestone,
+           created_at: generate(:issue_created_at),
+           updated_at: 1.hour.ago
+  end
+  let!(:label) do
+    create(:label, title: 'label', color: '#FFAABB', project: project)
+  end
+  let!(:label_link) { create(:label_link, label: label, target: issue) }
+  let!(:milestone) { create(:milestone, title: '1.0.0', project: project) }
+  let!(:empty_milestone) do
+    create(:milestone, title: '2.0.0', project: project)
+  end
+  let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) }
+
+  let(:no_milestone_title) { URI.escape(Milestone::None.title) }
+
+  before do
+    project.team << [user, :reporter]
+    project.team << [guest, :guest]
+  end
+
+  describe "GET /issues" do
+    context "when unauthenticated" do
+      it "returns authentication error" do
+        get v3_api("/issues")
+
+        expect(response).to have_http_status(401)
+      end
+    end
+
+    context "when authenticated" do
+      it "returns an array of issues" do
+        get v3_api("/issues", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.first['title']).to eq(issue.title)
+        expect(json_response.last).to have_key('web_url')
+      end
+
+      it 'returns an array of closed issues' do
+        get v3_api('/issues?state=closed', user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['id']).to eq(closed_issue.id)
+      end
+
+      it 'returns an array of opened issues' do
+        get v3_api('/issues?state=opened', user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['id']).to eq(issue.id)
+      end
+
+      it 'returns an array of all issues' do
+        get v3_api('/issues?state=all', user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(2)
+        expect(json_response.first['id']).to eq(issue.id)
+        expect(json_response.second['id']).to eq(closed_issue.id)
+      end
+
+      it 'returns an array of labeled issues' do
+        get v3_api("/issues?labels=#{label.title}", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['labels']).to eq([label.title])
+      end
+
+      it 'returns an array of labeled issues when at least one label matches' do
+        get v3_api("/issues?labels=#{label.title},foo,bar", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['labels']).to eq([label.title])
+      end
+
+      it 'returns an empty array if no issue matches labels' do
+        get v3_api('/issues?labels=foo,bar', user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(0)
+      end
+
+      it 'returns an array of labeled issues matching given state' do
+        get v3_api("/issues?labels=#{label.title}&state=opened", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['labels']).to eq([label.title])
+        expect(json_response.first['state']).to eq('opened')
+      end
+
+      it 'returns an empty array if no issue matches labels and state filters' do
+        get v3_api("/issues?labels=#{label.title}&state=closed", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(0)
+      end
+
+      it 'returns an empty array if no issue matches milestone' do
+        get v3_api("/issues?milestone=#{empty_milestone.title}", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(0)
+      end
+
+      it 'returns an empty array if milestone does not exist' do
+        get v3_api("/issues?milestone=foo", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(0)
+      end
+
+      it 'returns an array of issues in given milestone' do
+        get v3_api("/issues?milestone=#{milestone.title}", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(2)
+        expect(json_response.first['id']).to eq(issue.id)
+        expect(json_response.second['id']).to eq(closed_issue.id)
+      end
+
+      it 'returns an array of issues matching state in milestone' do
+        get v3_api("/issues?milestone=#{milestone.title}",  user),
+          '&state=closed'
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['id']).to eq(closed_issue.id)
+      end
+
+      it 'returns an array of issues with no milestone' do
+        get v3_api("/issues?milestone=#{no_milestone_title}", author)
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['id']).to eq(confidential_issue.id)
+      end
+
+      it 'sorts by created_at descending by default' do
+        get v3_api('/issues', user)
+
+        response_dates = json_response.map { |issue| issue['created_at'] }
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(response_dates).to eq(response_dates.sort.reverse)
+      end
+
+      it 'sorts ascending when requested' do
+        get v3_api('/issues?sort=asc', user)
+
+        response_dates = json_response.map { |issue| issue['created_at'] }
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(response_dates).to eq(response_dates.sort)
+      end
+
+      it 'sorts by updated_at descending when requested' do
+        get v3_api('/issues?order_by=updated_at', user)
+
+        response_dates = json_response.map { |issue| issue['updated_at'] }
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(response_dates).to eq(response_dates.sort.reverse)
+      end
+
+      it 'sorts by updated_at ascending when requested' do
+        get v3_api('/issues?order_by=updated_at&sort=asc', user)
+
+        response_dates = json_response.map { |issue| issue['updated_at'] }
+
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(response_dates).to eq(response_dates.sort)
+      end
+    end
+  end
+
+  describe "GET /groups/:id/issues" do
+    let!(:group)            { create(:group) }
+    let!(:group_project)    { create(:empty_project, :public, creator_id: user.id, namespace: group) }
+    let!(:group_closed_issue) do
+      create :closed_issue,
+             author: user,
+             assignee: user,
+             project: group_project,
+             state: :closed,
+             milestone: group_milestone,
+             updated_at: 3.hours.ago
+    end
+    let!(:group_confidential_issue) do
+      create :issue,
+             :confidential,
+             project: group_project,
+             author: author,
+             assignee: assignee,
+             updated_at: 2.hours.ago
+    end
+    let!(:group_issue) do
+      create :issue,
+             author: user,
+             assignee: user,
+             project: group_project,
+             milestone: group_milestone,
+             updated_at: 1.hour.ago
+    end
+    let!(:group_label) do
+      create(:label, title: 'group_lbl', color: '#FFAABB', project: group_project)
+    end
+    let!(:group_label_link) { create(:label_link, label: group_label, target: group_issue) }
+    let!(:group_milestone) { create(:milestone, title: '3.0.0', project: group_project) }
+    let!(:group_empty_milestone) do
+      create(:milestone, title: '4.0.0', project: group_project)
+    end
+    let!(:group_note) { create(:note_on_issue, author: user, project: group_project, noteable: group_issue) }
+
+    before do
+      group_project.team << [user, :reporter]
+    end
+    let(:base_url) { "/groups/#{group.id}/issues" }
+
+    it 'returns group issues without confidential issues for non project members' do
+      get v3_api(base_url, non_member)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['title']).to eq(group_issue.title)
+    end
+
+    it 'returns group confidential issues for author' do
+      get v3_api(base_url, author)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+    end
+
+    it 'returns group confidential issues for assignee' do
+      get v3_api(base_url, assignee)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+    end
+
+    it 'returns group issues with confidential issues for project members' do
+      get v3_api(base_url, user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+    end
+
+    it 'returns group confidential issues for admin' do
+      get v3_api(base_url, admin)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+    end
+
+    it 'returns an array of labeled group issues' do
+      get v3_api("#{base_url}?labels=#{group_label.title}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['labels']).to eq([group_label.title])
+    end
+
+    it 'returns an array of labeled group issues where all labels match' do
+      get v3_api("#{base_url}?labels=#{group_label.title},foo,bar", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(0)
+    end
+
+    it 'returns an empty array if no group issue matches labels' do
+      get v3_api("#{base_url}?labels=foo,bar", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(0)
+    end
+
+    it 'returns an empty array if no issue matches milestone' do
+      get v3_api("#{base_url}?milestone=#{group_empty_milestone.title}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(0)
+    end
+
+    it 'returns an empty array if milestone does not exist' do
+      get v3_api("#{base_url}?milestone=foo", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(0)
+    end
+
+    it 'returns an array of issues in given milestone' do
+      get v3_api("#{base_url}?milestone=#{group_milestone.title}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['id']).to eq(group_issue.id)
+    end
+
+    it 'returns an array of issues matching state in milestone' do
+      get v3_api("#{base_url}?milestone=#{group_milestone.title}", user),
+        '&state=closed'
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['id']).to eq(group_closed_issue.id)
+    end
+
+    it 'returns an array of issues with no milestone' do
+      get v3_api("#{base_url}?milestone=#{no_milestone_title}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['id']).to eq(group_confidential_issue.id)
+    end
+
+    it 'sorts by created_at descending by default' do
+      get v3_api(base_url, user)
+
+      response_dates = json_response.map { |issue| issue['created_at'] }
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(response_dates).to eq(response_dates.sort.reverse)
+    end
+
+    it 'sorts ascending when requested' do
+      get v3_api("#{base_url}?sort=asc", user)
+
+      response_dates = json_response.map { |issue| issue['created_at'] }
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(response_dates).to eq(response_dates.sort)
+    end
+
+    it 'sorts by updated_at descending when requested' do
+      get v3_api("#{base_url}?order_by=updated_at", user)
+
+      response_dates = json_response.map { |issue| issue['updated_at'] }
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(response_dates).to eq(response_dates.sort.reverse)
+    end
+
+    it 'sorts by updated_at ascending when requested' do
+      get v3_api("#{base_url}?order_by=updated_at&sort=asc", user)
+
+      response_dates = json_response.map { |issue| issue['updated_at'] }
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(response_dates).to eq(response_dates.sort)
+    end
+  end
+
+  describe "GET /projects/:id/issues" do
+    let(:base_url) { "/projects/#{project.id}" }
+
+    it "returns 404 on private projects for other users" do
+      private_project = create(:empty_project, :private)
+      create(:issue, project: private_project)
+
+      get v3_api("/projects/#{private_project.id}/issues", non_member)
+
+      expect(response).to have_http_status(404)
+    end
+
+    it 'returns no issues when user has access to project but not issues' do
+      restricted_project = create(:empty_project, :public, issues_access_level: ProjectFeature::PRIVATE)
+      create(:issue, project: restricted_project)
+
+      get v3_api("/projects/#{restricted_project.id}/issues", non_member)
+
+      expect(json_response).to eq([])
+    end
+
+    it 'returns project issues without confidential issues for non project members' do
+      get v3_api("#{base_url}/issues", non_member)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+      expect(json_response.first['title']).to eq(issue.title)
+    end
+
+    it 'returns project issues without confidential issues for project members with guest role' do
+      get v3_api("#{base_url}/issues", guest)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+      expect(json_response.first['title']).to eq(issue.title)
+    end
+
+    it 'returns project confidential issues for author' do
+      get v3_api("#{base_url}/issues", author)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(3)
+      expect(json_response.first['title']).to eq(issue.title)
+    end
+
+    it 'returns project confidential issues for assignee' do
+      get v3_api("#{base_url}/issues", assignee)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(3)
+      expect(json_response.first['title']).to eq(issue.title)
+    end
+
+    it 'returns project issues with confidential issues for project members' do
+      get v3_api("#{base_url}/issues", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(3)
+      expect(json_response.first['title']).to eq(issue.title)
+    end
+
+    it 'returns project confidential issues for admin' do
+      get v3_api("#{base_url}/issues", admin)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(3)
+      expect(json_response.first['title']).to eq(issue.title)
+    end
+
+    it 'returns an array of labeled project issues' do
+      get v3_api("#{base_url}/issues?labels=#{label.title}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['labels']).to eq([label.title])
+    end
+
+    it 'returns an array of labeled project issues where all labels match' do
+      get v3_api("#{base_url}/issues?labels=#{label.title},foo,bar", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['labels']).to eq([label.title])
+    end
+
+    it 'returns an empty array if no project issue matches labels' do
+      get v3_api("#{base_url}/issues?labels=foo,bar", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(0)
+    end
+
+    it 'returns an empty array if no issue matches milestone' do
+      get v3_api("#{base_url}/issues?milestone=#{empty_milestone.title}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(0)
+    end
+
+    it 'returns an empty array if milestone does not exist' do
+      get v3_api("#{base_url}/issues?milestone=foo", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(0)
+    end
+
+    it 'returns an array of issues in given milestone' do
+      get v3_api("#{base_url}/issues?milestone=#{milestone.title}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+      expect(json_response.first['id']).to eq(issue.id)
+      expect(json_response.second['id']).to eq(closed_issue.id)
+    end
+
+    it 'returns an array of issues matching state in milestone' do
+      get v3_api("#{base_url}/issues?milestone=#{milestone.title}", user),
+        '&state=closed'
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['id']).to eq(closed_issue.id)
+    end
+
+    it 'returns an array of issues with no milestone' do
+      get v3_api("#{base_url}/issues?milestone=#{no_milestone_title}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['id']).to eq(confidential_issue.id)
+    end
+
+    it 'sorts by created_at descending by default' do
+      get v3_api("#{base_url}/issues", user)
+
+      response_dates = json_response.map { |issue| issue['created_at'] }
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(response_dates).to eq(response_dates.sort.reverse)
+    end
+
+    it 'sorts ascending when requested' do
+      get v3_api("#{base_url}/issues?sort=asc", user)
+
+      response_dates = json_response.map { |issue| issue['created_at'] }
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(response_dates).to eq(response_dates.sort)
+    end
+
+    it 'sorts by updated_at descending when requested' do
+      get v3_api("#{base_url}/issues?order_by=updated_at", user)
+
+      response_dates = json_response.map { |issue| issue['updated_at'] }
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(response_dates).to eq(response_dates.sort.reverse)
+    end
+
+    it 'sorts by updated_at ascending when requested' do
+      get v3_api("#{base_url}/issues?order_by=updated_at&sort=asc", user)
+
+      response_dates = json_response.map { |issue| issue['updated_at'] }
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(response_dates).to eq(response_dates.sort)
+    end
+  end
+
+  describe "GET /projects/:id/issues/:issue_id" do
+    it 'exposes known attributes' do
+      get v3_api("/projects/#{project.id}/issues/#{issue.id}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response['id']).to eq(issue.id)
+      expect(json_response['iid']).to eq(issue.iid)
+      expect(json_response['project_id']).to eq(issue.project.id)
+      expect(json_response['title']).to eq(issue.title)
+      expect(json_response['description']).to eq(issue.description)
+      expect(json_response['state']).to eq(issue.state)
+      expect(json_response['created_at']).to be_present
+      expect(json_response['updated_at']).to be_present
+      expect(json_response['labels']).to eq(issue.label_names)
+      expect(json_response['milestone']).to be_a Hash
+      expect(json_response['assignee']).to be_a Hash
+      expect(json_response['author']).to be_a Hash
+      expect(json_response['confidential']).to be_falsy
+    end
+
+    it "returns a project issue by id" do
+      get v3_api("/projects/#{project.id}/issues/#{issue.id}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response['title']).to eq(issue.title)
+      expect(json_response['iid']).to eq(issue.iid)
+    end
+
+    it 'returns a project issue by iid' do
+      get v3_api("/projects/#{project.id}/issues?iid=#{issue.iid}", user)
+
+      expect(response.status).to eq 200
+      expect(json_response.length).to eq 1
+      expect(json_response.first['title']).to eq issue.title
+      expect(json_response.first['id']).to eq issue.id
+      expect(json_response.first['iid']).to eq issue.iid
+    end
+
+    it 'returns an empty array for an unknown project issue iid' do
+      get v3_api("/projects/#{project.id}/issues?iid=#{issue.iid + 10}", user)
+
+      expect(response.status).to eq 200
+      expect(json_response.length).to eq 0
+    end
+
+    it "returns 404 if issue id not found" do
+      get v3_api("/projects/#{project.id}/issues/54321", user)
+
+      expect(response).to have_http_status(404)
+    end
+
+    context 'confidential issues' do
+      it "returns 404 for non project members" do
+        get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member)
+
+        expect(response).to have_http_status(404)
+      end
+
+      it "returns 404 for project members with guest role" do
+        get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest)
+
+        expect(response).to have_http_status(404)
+      end
+
+      it "returns confidential issue for project members" do
+        get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", user)
+
+        expect(response).to have_http_status(200)
+        expect(json_response['title']).to eq(confidential_issue.title)
+        expect(json_response['iid']).to eq(confidential_issue.iid)
+      end
+
+      it "returns confidential issue for author" do
+        get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", author)
+
+        expect(response).to have_http_status(200)
+        expect(json_response['title']).to eq(confidential_issue.title)
+        expect(json_response['iid']).to eq(confidential_issue.iid)
+      end
+
+      it "returns confidential issue for assignee" do
+        get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", assignee)
+
+        expect(response).to have_http_status(200)
+        expect(json_response['title']).to eq(confidential_issue.title)
+        expect(json_response['iid']).to eq(confidential_issue.iid)
+      end
+
+      it "returns confidential issue for admin" do
+        get v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin)
+
+        expect(response).to have_http_status(200)
+        expect(json_response['title']).to eq(confidential_issue.title)
+        expect(json_response['iid']).to eq(confidential_issue.iid)
+      end
+    end
+  end
+
+  describe "POST /projects/:id/issues" do
+    it 'creates a new project issue' do
+      post v3_api("/projects/#{project.id}/issues", user),
+        title: 'new issue', labels: 'label, label2'
+
+      expect(response).to have_http_status(201)
+      expect(json_response['title']).to eq('new issue')
+      expect(json_response['description']).to be_nil
+      expect(json_response['labels']).to eq(['label', 'label2'])
+      expect(json_response['confidential']).to be_falsy
+    end
+
+    it 'creates a new confidential project issue' do
+      post v3_api("/projects/#{project.id}/issues", user),
+        title: 'new issue', confidential: true
+
+      expect(response).to have_http_status(201)
+      expect(json_response['title']).to eq('new issue')
+      expect(json_response['confidential']).to be_truthy
+    end
+
+    it 'creates a new confidential project issue with a different param' do
+      post v3_api("/projects/#{project.id}/issues", user),
+        title: 'new issue', confidential: 'y'
+
+      expect(response).to have_http_status(201)
+      expect(json_response['title']).to eq('new issue')
+      expect(json_response['confidential']).to be_truthy
+    end
+
+    it 'creates a public issue when confidential param is false' do
+      post v3_api("/projects/#{project.id}/issues", user),
+        title: 'new issue', confidential: false
+
+      expect(response).to have_http_status(201)
+      expect(json_response['title']).to eq('new issue')
+      expect(json_response['confidential']).to be_falsy
+    end
+
+    it 'creates a public issue when confidential param is invalid' do
+      post v3_api("/projects/#{project.id}/issues", user),
+        title: 'new issue', confidential: 'foo'
+
+      expect(response).to have_http_status(400)
+      expect(json_response['error']).to eq('confidential is invalid')
+    end
+
+    it "sends notifications for subscribers of newly added labels" do
+      label = project.labels.first
+      label.toggle_subscription(user2, project)
+
+      perform_enqueued_jobs do
+        post v3_api("/projects/#{project.id}/issues", user),
+          title: 'new issue', labels: label.title
+      end
+
+      should_email(user2)
+    end
+
+    it "returns a 400 bad request if title not given" do
+      post v3_api("/projects/#{project.id}/issues", user), labels: 'label, label2'
+
+      expect(response).to have_http_status(400)
+    end
+
+    it 'allows special label names' do
+      post v3_api("/projects/#{project.id}/issues", user),
+           title: 'new issue',
+           labels: 'label, label?, label&foo, ?, &'
+
+      expect(response.status).to eq(201)
+      expect(json_response['labels']).to include 'label'
+      expect(json_response['labels']).to include 'label?'
+      expect(json_response['labels']).to include 'label&foo'
+      expect(json_response['labels']).to include '?'
+      expect(json_response['labels']).to include '&'
+    end
+
+    it 'returns 400 if title is too long' do
+      post v3_api("/projects/#{project.id}/issues", user),
+           title: 'g' * 256
+
+      expect(response).to have_http_status(400)
+      expect(json_response['message']['title']).to eq([
+        'is too long (maximum is 255 characters)'
+      ])
+    end
+
+    context 'resolving issues in a merge request' do
+      let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
+      let(:merge_request) { discussion.noteable }
+      let(:project) { merge_request.source_project }
+      before do
+        project.team << [user, :master]
+        post v3_api("/projects/#{project.id}/issues", user),
+             title: 'New Issue',
+             merge_request_for_resolving_discussions: merge_request.iid
+      end
+
+      it 'creates a new project issue' do
+        expect(response).to have_http_status(:created)
+      end
+
+      it 'resolves the discussions in a merge request' do
+        discussion.first_note.reload
+
+        expect(discussion.resolved?).to be(true)
+      end
+
+      it 'assigns a description to the issue mentioning the merge request' do
+        expect(json_response['description']).to include(merge_request.to_reference)
+      end
+    end
+
+    context 'with due date' do
+      it 'creates a new project issue' do
+        due_date = 2.weeks.from_now.strftime('%Y-%m-%d')
+
+        post v3_api("/projects/#{project.id}/issues", user),
+          title: 'new issue', due_date: due_date
+
+        expect(response).to have_http_status(201)
+        expect(json_response['title']).to eq('new issue')
+        expect(json_response['description']).to be_nil
+        expect(json_response['due_date']).to eq(due_date)
+      end
+    end
+
+    context 'when an admin or owner makes the request' do
+      it 'accepts the creation date to be set' do
+        creation_time = 2.weeks.ago
+        post v3_api("/projects/#{project.id}/issues", user),
+          title: 'new issue', labels: 'label, label2', created_at: creation_time
+
+        expect(response).to have_http_status(201)
+        expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time)
+      end
+    end
+
+    context 'the user can only read the issue' do
+      it 'cannot create new labels' do
+        expect do
+          post v3_api("/projects/#{project.id}/issues", non_member), title: 'new issue', labels: 'label, label2'
+        end.not_to change { project.labels.count }
+      end
+    end
+  end
+
+  describe 'POST /projects/:id/issues with spam filtering' do
+    before do
+      allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
+      allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true)
+    end
+
+    let(:params) do
+      {
+        title: 'new issue',
+        description: 'content here',
+        labels: 'label, label2'
+      }
+    end
+
+    it "does not create a new project issue" do
+      expect { post v3_api("/projects/#{project.id}/issues", user), params }.not_to change(Issue, :count)
+
+      expect(response).to have_http_status(400)
+      expect(json_response['message']).to eq({ "error" => "Spam detected" })
+
+      spam_logs = SpamLog.all
+
+      expect(spam_logs.count).to eq(1)
+      expect(spam_logs[0].title).to eq('new issue')
+      expect(spam_logs[0].description).to eq('content here')
+      expect(spam_logs[0].user).to eq(user)
+      expect(spam_logs[0].noteable_type).to eq('Issue')
+    end
+  end
+
+  describe "PUT /projects/:id/issues/:issue_id to update only title" do
+    it "updates a project issue" do
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+        title: 'updated title'
+
+      expect(response).to have_http_status(200)
+      expect(json_response['title']).to eq('updated title')
+    end
+
+    it "returns 404 error if issue id not found" do
+      put v3_api("/projects/#{project.id}/issues/44444", user),
+        title: 'updated title'
+
+      expect(response).to have_http_status(404)
+    end
+
+    it 'allows special label names' do
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+          title: 'updated title',
+          labels: 'label, label?, label&foo, ?, &'
+
+      expect(response.status).to eq(200)
+      expect(json_response['labels']).to include 'label'
+      expect(json_response['labels']).to include 'label?'
+      expect(json_response['labels']).to include 'label&foo'
+      expect(json_response['labels']).to include '?'
+      expect(json_response['labels']).to include '&'
+    end
+
+    context 'confidential issues' do
+      it "returns 403 for non project members" do
+        put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member),
+          title: 'updated title'
+
+        expect(response).to have_http_status(403)
+      end
+
+      it "returns 403 for project members with guest role" do
+        put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest),
+          title: 'updated title'
+
+        expect(response).to have_http_status(403)
+      end
+
+      it "updates a confidential issue for project members" do
+        put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", user),
+          title: 'updated title'
+
+        expect(response).to have_http_status(200)
+        expect(json_response['title']).to eq('updated title')
+      end
+
+      it "updates a confidential issue for author" do
+        put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", author),
+          title: 'updated title'
+
+        expect(response).to have_http_status(200)
+        expect(json_response['title']).to eq('updated title')
+      end
+
+      it "updates a confidential issue for admin" do
+        put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin),
+          title: 'updated title'
+
+        expect(response).to have_http_status(200)
+        expect(json_response['title']).to eq('updated title')
+      end
+
+      it 'sets an issue to confidential' do
+        put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+          confidential: true
+
+        expect(response).to have_http_status(200)
+        expect(json_response['confidential']).to be_truthy
+      end
+
+      it 'makes a confidential issue public' do
+        put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", user),
+          confidential: false
+
+        expect(response).to have_http_status(200)
+        expect(json_response['confidential']).to be_falsy
+      end
+
+      it 'does not update a confidential issue with wrong confidential flag' do
+        put v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}", user),
+          confidential: 'foo'
+
+        expect(response).to have_http_status(400)
+        expect(json_response['error']).to eq('confidential is invalid')
+      end
+    end
+  end
+
+  describe 'PUT /projects/:id/issues/:issue_id to update labels' do
+    let!(:label) { create(:label, title: 'dummy', project: project) }
+    let!(:label_link) { create(:label_link, label: label, target: issue) }
+
+    it 'does not update labels if not present' do
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+          title: 'updated title'
+
+      expect(response).to have_http_status(200)
+      expect(json_response['labels']).to eq([label.title])
+    end
+
+    it "sends notifications for subscribers of newly added labels when issue is updated" do
+      label = create(:label, title: 'foo', color: '#FFAABB', project: project)
+      label.toggle_subscription(user2, project)
+
+      perform_enqueued_jobs do
+        put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+          title: 'updated title', labels: label.title
+      end
+
+      should_email(user2)
+    end
+
+    it 'removes all labels' do
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), labels: ''
+
+      expect(response).to have_http_status(200)
+      expect(json_response['labels']).to eq([])
+    end
+
+    it 'updates labels' do
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+          labels: 'foo,bar'
+
+      expect(response).to have_http_status(200)
+      expect(json_response['labels']).to include 'foo'
+      expect(json_response['labels']).to include 'bar'
+    end
+
+    it 'allows special label names' do
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+          labels: 'label:foo, label-bar,label_bar,label/bar,label?bar,label&bar,?,&'
+
+      expect(response.status).to eq(200)
+      expect(json_response['labels']).to include 'label:foo'
+      expect(json_response['labels']).to include 'label-bar'
+      expect(json_response['labels']).to include 'label_bar'
+      expect(json_response['labels']).to include 'label/bar'
+      expect(json_response['labels']).to include 'label?bar'
+      expect(json_response['labels']).to include 'label&bar'
+      expect(json_response['labels']).to include '?'
+      expect(json_response['labels']).to include '&'
+    end
+
+    it 'returns 400 if title is too long' do
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+          title: 'g' * 256
+
+      expect(response).to have_http_status(400)
+      expect(json_response['message']['title']).to eq([
+        'is too long (maximum is 255 characters)'
+      ])
+    end
+  end
+
+  describe "PUT /projects/:id/issues/:issue_id to update state and label" do
+    it "updates a project issue" do
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+        labels: 'label2', state_event: "close"
+
+      expect(response).to have_http_status(200)
+      expect(json_response['labels']).to include 'label2'
+      expect(json_response['state']).to eq "closed"
+    end
+
+    it 'reopens a project isssue' do
+      put v3_api("/projects/#{project.id}/issues/#{closed_issue.id}", user), state_event: 'reopen'
+
+      expect(response).to have_http_status(200)
+      expect(json_response['state']).to eq 'reopened'
+    end
+
+    context 'when an admin or owner makes the request' do
+      it 'accepts the update date to be set' do
+        update_time = 2.weeks.ago
+        put v3_api("/projects/#{project.id}/issues/#{issue.id}", user),
+          labels: 'label3', state_event: 'close', updated_at: update_time
+
+        expect(response).to have_http_status(200)
+        expect(json_response['labels']).to include 'label3'
+        expect(Time.parse(json_response['updated_at'])).to be_like_time(update_time)
+      end
+    end
+  end
+
+  describe 'PUT /projects/:id/issues/:issue_id to update due date' do
+    it 'creates a new project issue' do
+      due_date = 2.weeks.from_now.strftime('%Y-%m-%d')
+
+      put v3_api("/projects/#{project.id}/issues/#{issue.id}", user), due_date: due_date
+
+      expect(response).to have_http_status(200)
+      expect(json_response['due_date']).to eq(due_date)
+    end
+  end
+
+  describe "DELETE /projects/:id/issues/:issue_id" do
+    it "rejects a non member from deleting an issue" do
+      delete v3_api("/projects/#{project.id}/issues/#{issue.id}", non_member)
+
+      expect(response).to have_http_status(403)
+    end
+
+    it "rejects a developer from deleting an issue" do
+      delete v3_api("/projects/#{project.id}/issues/#{issue.id}", author)
+
+      expect(response).to have_http_status(403)
+    end
+
+    context "when the user is project owner" do
+      let(:owner)     { create(:user) }
+      let(:project)   { create(:empty_project, namespace: owner.namespace) }
+
+      it "deletes the issue if an admin requests it" do
+        delete v3_api("/projects/#{project.id}/issues/#{issue.id}", owner)
+
+        expect(response).to have_http_status(200)
+        expect(json_response['state']).to eq 'opened'
+      end
+    end
+
+    context 'when issue does not exist' do
+      it 'returns 404 when trying to move an issue' do
+        delete v3_api("/projects/#{project.id}/issues/123", user)
+
+        expect(response).to have_http_status(404)
+      end
+    end
+  end
+
+  describe '/projects/:id/issues/:issue_id/move' do
+    let!(:target_project) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace ) }
+    let!(:target_project2) { create(:empty_project, creator_id: non_member.id, namespace: non_member.namespace ) }
+
+    it 'moves an issue' do
+      post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user),
+               to_project_id: target_project.id
+
+      expect(response).to have_http_status(201)
+      expect(json_response['project_id']).to eq(target_project.id)
+    end
+
+    context 'when source and target projects are the same' do
+      it 'returns 400 when trying to move an issue' do
+        post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user),
+                 to_project_id: project.id
+
+        expect(response).to have_http_status(400)
+        expect(json_response['message']).to eq('Cannot move issue to project it originates from!')
+      end
+    end
+
+    context 'when the user does not have the permission to move issues' do
+      it 'returns 400 when trying to move an issue' do
+        post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user),
+                 to_project_id: target_project2.id
+
+        expect(response).to have_http_status(400)
+        expect(json_response['message']).to eq('Cannot move issue due to insufficient permissions!')
+      end
+    end
+
+    it 'moves the issue to another namespace if I am admin' do
+      post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", admin),
+               to_project_id: target_project2.id
+
+      expect(response).to have_http_status(201)
+      expect(json_response['project_id']).to eq(target_project2.id)
+    end
+
+    context 'when issue does not exist' do
+      it 'returns 404 when trying to move an issue' do
+        post v3_api("/projects/#{project.id}/issues/123/move", user),
+                 to_project_id: target_project.id
+
+        expect(response).to have_http_status(404)
+        expect(json_response['message']).to eq('404 Issue Not Found')
+      end
+    end
+
+    context 'when source project does not exist' do
+      it 'returns 404 when trying to move an issue' do
+        post v3_api("/projects/123/issues/#{issue.id}/move", user),
+                 to_project_id: target_project.id
+
+        expect(response).to have_http_status(404)
+        expect(json_response['message']).to eq('404 Project Not Found')
+      end
+    end
+
+    context 'when target project does not exist' do
+      it 'returns 404 when trying to move an issue' do
+        post v3_api("/projects/#{project.id}/issues/#{issue.id}/move", user),
+                 to_project_id: 123
+
+        expect(response).to have_http_status(404)
+      end
+    end
+  end
+
+  describe 'POST :id/issues/:issue_id/subscription' do
+    it 'subscribes to an issue' do
+      post v3_api("/projects/#{project.id}/issues/#{issue.id}/subscription", user2)
+
+      expect(response).to have_http_status(201)
+      expect(json_response['subscribed']).to eq(true)
+    end
+
+    it 'returns 304 if already subscribed' do
+      post v3_api("/projects/#{project.id}/issues/#{issue.id}/subscription", user)
+
+      expect(response).to have_http_status(304)
+    end
+
+    it 'returns 404 if the issue is not found' do
+      post v3_api("/projects/#{project.id}/issues/123/subscription", user)
+
+      expect(response).to have_http_status(404)
+    end
+
+    it 'returns 404 if the issue is confidential' do
+      post v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}/subscription", non_member)
+
+      expect(response).to have_http_status(404)
+    end
+  end
+
+  describe 'DELETE :id/issues/:issue_id/subscription' do
+    it 'unsubscribes from an issue' do
+      delete v3_api("/projects/#{project.id}/issues/#{issue.id}/subscription", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response['subscribed']).to eq(false)
+    end
+
+    it 'returns 304 if not subscribed' do
+      delete v3_api("/projects/#{project.id}/issues/#{issue.id}/subscription", user2)
+
+      expect(response).to have_http_status(304)
+    end
+
+    it 'returns 404 if the issue is not found' do
+      delete v3_api("/projects/#{project.id}/issues/123/subscription", user)
+
+      expect(response).to have_http_status(404)
+    end
+
+    it 'returns 404 if the issue is confidential' do
+      delete v3_api("/projects/#{project.id}/issues/#{confidential_issue.id}/subscription", non_member)
+
+      expect(response).to have_http_status(404)
+    end
+  end
+
+  describe 'time tracking endpoints' do
+    let(:issuable) { issue }
+
+    include_examples 'time tracking endpoints', 'issue'
+  end
+end
diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b94e1ef4cedf383a7254681a950a9aabf085d8ef
--- /dev/null
+++ b/spec/requests/api/v3/merge_requests_spec.rb
@@ -0,0 +1,726 @@
+require "spec_helper"
+
+describe API::MergeRequests, api: true  do
+  include ApiHelpers
+  let(:base_time)   { Time.now }
+  let(:user)        { create(:user) }
+  let(:admin)       { create(:user, :admin) }
+  let(:non_member)  { create(:user) }
+  let!(:project)    { create(:project, :public, :repository, creator: user, namespace: user.namespace) }
+  let!(:merge_request) { create(:merge_request, :simple, author: user, assignee: user, source_project: project, title: "Test", created_at: base_time) }
+  let!(:merge_request_closed) { create(:merge_request, state: "closed", author: user, assignee: user, source_project: project, title: "Closed test", created_at: base_time + 1.second) }
+  let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignee: user, source_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') }
+  let(:milestone)   { create(:milestone, title: '1.0.0', project: project) }
+
+  before do
+    project.team << [user, :reporter]
+  end
+
+  describe "GET /projects/:id/merge_requests" do
+    context "when unauthenticated" do
+      it "returns authentication error" do
+        get v3_api("/projects/#{project.id}/merge_requests")
+        expect(response).to have_http_status(401)
+      end
+    end
+
+    context "when authenticated" do
+      it "returns an array of all merge_requests" do
+        get v3_api("/projects/#{project.id}/merge_requests", user)
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(3)
+        expect(json_response.last['title']).to eq(merge_request.title)
+        expect(json_response.last).to have_key('web_url')
+        expect(json_response.last['sha']).to eq(merge_request.diff_head_sha)
+        expect(json_response.last['merge_commit_sha']).to be_nil
+        expect(json_response.last['merge_commit_sha']).to eq(merge_request.merge_commit_sha)
+        expect(json_response.first['title']).to eq(merge_request_merged.title)
+        expect(json_response.first['sha']).to eq(merge_request_merged.diff_head_sha)
+        expect(json_response.first['merge_commit_sha']).not_to be_nil
+        expect(json_response.first['merge_commit_sha']).to eq(merge_request_merged.merge_commit_sha)
+      end
+
+      it "returns an array of all merge_requests" do
+        get v3_api("/projects/#{project.id}/merge_requests?state", user)
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(3)
+        expect(json_response.last['title']).to eq(merge_request.title)
+      end
+
+      it "returns an array of open merge_requests" do
+        get v3_api("/projects/#{project.id}/merge_requests?state=opened", user)
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.last['title']).to eq(merge_request.title)
+      end
+
+      it "returns an array of closed merge_requests" do
+        get v3_api("/projects/#{project.id}/merge_requests?state=closed", user)
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['title']).to eq(merge_request_closed.title)
+      end
+
+      it "returns an array of merged merge_requests" do
+        get v3_api("/projects/#{project.id}/merge_requests?state=merged", user)
+        expect(response).to have_http_status(200)
+        expect(json_response).to be_an Array
+        expect(json_response.length).to eq(1)
+        expect(json_response.first['title']).to eq(merge_request_merged.title)
+      end
+
+      context "with ordering" do
+        before do
+          @mr_later = mr_with_later_created_and_updated_at_time
+          @mr_earlier = mr_with_earlier_created_and_updated_at_time
+        end
+
+        it "returns an array of merge_requests in ascending order" do
+          get v3_api("/projects/#{project.id}/merge_requests?sort=asc", user)
+          expect(response).to have_http_status(200)
+          expect(json_response).to be_an Array
+          expect(json_response.length).to eq(3)
+          response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+          expect(response_dates).to eq(response_dates.sort)
+        end
+
+        it "returns an array of merge_requests in descending order" do
+          get v3_api("/projects/#{project.id}/merge_requests?sort=desc", user)
+          expect(response).to have_http_status(200)
+          expect(json_response).to be_an Array
+          expect(json_response.length).to eq(3)
+          response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+          expect(response_dates).to eq(response_dates.sort.reverse)
+        end
+
+        it "returns an array of merge_requests ordered by updated_at" do
+          get v3_api("/projects/#{project.id}/merge_requests?order_by=updated_at", user)
+          expect(response).to have_http_status(200)
+          expect(json_response).to be_an Array
+          expect(json_response.length).to eq(3)
+          response_dates = json_response.map{ |merge_request| merge_request['updated_at'] }
+          expect(response_dates).to eq(response_dates.sort.reverse)
+        end
+
+        it "returns an array of merge_requests ordered by created_at" do
+          get v3_api("/projects/#{project.id}/merge_requests?order_by=created_at&sort=asc", user)
+          expect(response).to have_http_status(200)
+          expect(json_response).to be_an Array
+          expect(json_response.length).to eq(3)
+          response_dates = json_response.map{ |merge_request| merge_request['created_at'] }
+          expect(response_dates).to eq(response_dates.sort)
+        end
+      end
+    end
+  end
+
+  describe "GET /projects/:id/merge_requests/:merge_request_id" do
+    it 'exposes known attributes' do
+      get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response['id']).to eq(merge_request.id)
+      expect(json_response['iid']).to eq(merge_request.iid)
+      expect(json_response['project_id']).to eq(merge_request.project.id)
+      expect(json_response['title']).to eq(merge_request.title)
+      expect(json_response['description']).to eq(merge_request.description)
+      expect(json_response['state']).to eq(merge_request.state)
+      expect(json_response['created_at']).to be_present
+      expect(json_response['updated_at']).to be_present
+      expect(json_response['labels']).to eq(merge_request.label_names)
+      expect(json_response['milestone']).to be_nil
+      expect(json_response['assignee']).to be_a Hash
+      expect(json_response['author']).to be_a Hash
+      expect(json_response['target_branch']).to eq(merge_request.target_branch)
+      expect(json_response['source_branch']).to eq(merge_request.source_branch)
+      expect(json_response['upvotes']).to eq(0)
+      expect(json_response['downvotes']).to eq(0)
+      expect(json_response['source_project_id']).to eq(merge_request.source_project.id)
+      expect(json_response['target_project_id']).to eq(merge_request.target_project.id)
+      expect(json_response['work_in_progress']).to be_falsy
+      expect(json_response['merge_when_build_succeeds']).to be_falsy
+      expect(json_response['merge_status']).to eq('can_be_merged')
+      expect(json_response['should_close_merge_request']).to be_falsy
+      expect(json_response['force_close_merge_request']).to be_falsy
+    end
+
+    it "returns merge_request" do
+      get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user)
+      expect(response).to have_http_status(200)
+      expect(json_response['title']).to eq(merge_request.title)
+      expect(json_response['iid']).to eq(merge_request.iid)
+      expect(json_response['work_in_progress']).to eq(false)
+      expect(json_response['merge_status']).to eq('can_be_merged')
+      expect(json_response['should_close_merge_request']).to be_falsy
+      expect(json_response['force_close_merge_request']).to be_falsy
+    end
+
+    it 'returns merge_request by iid' do
+      url = "/projects/#{project.id}/merge_requests?iid=#{merge_request.iid}"
+      get v3_api(url, user)
+      expect(response.status).to eq 200
+      expect(json_response.first['title']).to eq merge_request.title
+      expect(json_response.first['id']).to eq merge_request.id
+    end
+
+    it 'returns merge_request by iid array' do
+      get v3_api("/projects/#{project.id}/merge_requests", user), iid: [merge_request.iid, merge_request_closed.iid]
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+      expect(json_response.first['title']).to eq merge_request_closed.title
+      expect(json_response.first['id']).to eq merge_request_closed.id
+    end
+
+    it "returns a 404 error if merge_request_id not found" do
+      get v3_api("/projects/#{project.id}/merge_requests/999", user)
+      expect(response).to have_http_status(404)
+    end
+
+    context 'Work in Progress' do
+      let!(:merge_request_wip) { create(:merge_request, author: user, assignee: user, source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) }
+
+      it "returns merge_request" do
+        get v3_api("/projects/#{project.id}/merge_requests/#{merge_request_wip.id}", user)
+        expect(response).to have_http_status(200)
+        expect(json_response['work_in_progress']).to eq(true)
+      end
+    end
+  end
+
+  describe 'GET /projects/:id/merge_requests/:merge_request_id/commits' do
+    it 'returns a 200 when merge request is valid' do
+      get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/commits", user)
+      commit = merge_request.commits.first
+
+      expect(response.status).to eq 200
+      expect(json_response.size).to eq(merge_request.commits.size)
+      expect(json_response.first['id']).to eq(commit.id)
+      expect(json_response.first['title']).to eq(commit.title)
+    end
+
+    it 'returns a 404 when merge_request_id not found' do
+      get v3_api("/projects/#{project.id}/merge_requests/999/commits", user)
+      expect(response).to have_http_status(404)
+    end
+  end
+
+  describe 'GET /projects/:id/merge_requests/:merge_request_id/changes' do
+    it 'returns the change information of the merge_request' do
+      get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/changes", user)
+      expect(response.status).to eq 200
+      expect(json_response['changes'].size).to eq(merge_request.diffs.size)
+    end
+
+    it 'returns a 404 when merge_request_id not found' do
+      get v3_api("/projects/#{project.id}/merge_requests/999/changes", user)
+      expect(response).to have_http_status(404)
+    end
+  end
+
+  describe "POST /projects/:id/merge_requests" do
+    context 'between branches projects' do
+      it "returns merge_request" do
+        post v3_api("/projects/#{project.id}/merge_requests", user),
+             title: 'Test merge_request',
+             source_branch: 'feature_conflict',
+             target_branch: 'master',
+             author: user,
+             labels: 'label, label2',
+             milestone_id: milestone.id,
+             remove_source_branch: true
+
+        expect(response).to have_http_status(201)
+        expect(json_response['title']).to eq('Test merge_request')
+        expect(json_response['labels']).to eq(['label', 'label2'])
+        expect(json_response['milestone']['id']).to eq(milestone.id)
+        expect(json_response['force_remove_source_branch']).to be_truthy
+      end
+
+      it "returns 422 when source_branch equals target_branch" do
+        post v3_api("/projects/#{project.id}/merge_requests", user),
+        title: "Test merge_request", source_branch: "master", target_branch: "master", author: user
+        expect(response).to have_http_status(422)
+      end
+
+      it "returns 400 when source_branch is missing" do
+        post v3_api("/projects/#{project.id}/merge_requests", user),
+        title: "Test merge_request", target_branch: "master", author: user
+        expect(response).to have_http_status(400)
+      end
+
+      it "returns 400 when target_branch is missing" do
+        post v3_api("/projects/#{project.id}/merge_requests", user),
+        title: "Test merge_request", source_branch: "markdown", author: user
+        expect(response).to have_http_status(400)
+      end
+
+      it "returns 400 when title is missing" do
+        post v3_api("/projects/#{project.id}/merge_requests", user),
+        target_branch: 'master', source_branch: 'markdown'
+        expect(response).to have_http_status(400)
+      end
+
+      it 'allows special label names' do
+        post v3_api("/projects/#{project.id}/merge_requests", user),
+             title: 'Test merge_request',
+             source_branch: 'markdown',
+             target_branch: 'master',
+             author: user,
+             labels: 'label, label?, label&foo, ?, &'
+        expect(response.status).to eq(201)
+        expect(json_response['labels']).to include 'label'
+        expect(json_response['labels']).to include 'label?'
+        expect(json_response['labels']).to include 'label&foo'
+        expect(json_response['labels']).to include '?'
+        expect(json_response['labels']).to include '&'
+      end
+
+      context 'with existing MR' do
+        before do
+          post v3_api("/projects/#{project.id}/merge_requests", user),
+               title: 'Test merge_request',
+               source_branch: 'feature_conflict',
+               target_branch: 'master',
+               author: user
+          @mr = MergeRequest.all.last
+        end
+
+        it 'returns 409 when MR already exists for source/target' do
+          expect do
+            post v3_api("/projects/#{project.id}/merge_requests", user),
+                 title: 'New test merge_request',
+                 source_branch: 'feature_conflict',
+                 target_branch: 'master',
+                 author: user
+          end.to change { MergeRequest.count }.by(0)
+          expect(response).to have_http_status(409)
+        end
+      end
+    end
+
+    context 'forked projects' do
+      let!(:user2) { create(:user) }
+      let!(:fork_project) { create(:empty_project, forked_from_project: project,  namespace: user2.namespace, creator_id: user2.id) }
+      let!(:unrelated_project) { create(:empty_project,  namespace: create(:user).namespace, creator_id: user2.id) }
+
+      before :each do |each|
+        fork_project.team << [user2, :reporter]
+      end
+
+      it "returns merge_request" do
+        post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
+          title: 'Test merge_request', source_branch: "feature_conflict", target_branch: "master",
+          author: user2, target_project_id: project.id, description: 'Test description for Test merge_request'
+        expect(response).to have_http_status(201)
+        expect(json_response['title']).to eq('Test merge_request')
+        expect(json_response['description']).to eq('Test description for Test merge_request')
+      end
+
+      it "does not return 422 when source_branch equals target_branch" do
+        expect(project.id).not_to eq(fork_project.id)
+        expect(fork_project.forked?).to be_truthy
+        expect(fork_project.forked_from_project).to eq(project)
+        post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
+        title: 'Test merge_request', source_branch: "master", target_branch: "master", author: user2, target_project_id: project.id
+        expect(response).to have_http_status(201)
+        expect(json_response['title']).to eq('Test merge_request')
+      end
+
+      it "returns 400 when source_branch is missing" do
+        post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
+        title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id
+        expect(response).to have_http_status(400)
+      end
+
+      it "returns 400 when target_branch is missing" do
+        post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
+        title: 'Test merge_request', target_branch: "master", author: user2, target_project_id: project.id
+        expect(response).to have_http_status(400)
+      end
+
+      it "returns 400 when title is missing" do
+        post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
+        target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: project.id
+        expect(response).to have_http_status(400)
+      end
+
+      context 'when target_branch is specified' do
+        it 'returns 422 if not a forked project' do
+          post v3_api("/projects/#{project.id}/merge_requests", user),
+               title: 'Test merge_request',
+               target_branch: 'master',
+               source_branch: 'markdown',
+               author: user,
+               target_project_id: fork_project.id
+          expect(response).to have_http_status(422)
+        end
+
+        it 'returns 422 if targeting a different fork' do
+          post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
+               title: 'Test merge_request',
+               target_branch: 'master',
+               source_branch: 'markdown',
+               author: user2,
+               target_project_id: unrelated_project.id
+          expect(response).to have_http_status(422)
+        end
+      end
+
+      it "returns 201 when target_branch is specified and for the same project" do
+        post v3_api("/projects/#{fork_project.id}/merge_requests", user2),
+        title: 'Test merge_request', target_branch: 'master', source_branch: 'markdown', author: user2, target_project_id: fork_project.id
+        expect(response).to have_http_status(201)
+      end
+    end
+  end
+
+  describe "DELETE /projects/:id/merge_requests/:merge_request_id" do
+    context "when the user is developer" do
+      let(:developer) { create(:user) }
+
+      before do
+        project.team << [developer, :developer]
+      end
+
+      it "denies the deletion of the merge request" do
+        delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", developer)
+        expect(response).to have_http_status(403)
+      end
+    end
+
+    context "when the user is project owner" do
+      it "destroys the merge request owners can destroy" do
+        delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user)
+
+        expect(response).to have_http_status(200)
+      end
+    end
+  end
+
+  describe "PUT /projects/:id/merge_requests/:merge_request_id/merge" do
+    let(:pipeline) { create(:ci_pipeline_without_jobs) }
+
+    it "returns merge_request in case of success" do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user)
+
+      expect(response).to have_http_status(200)
+    end
+
+    it "returns 406 if branch can't be merged" do
+      allow_any_instance_of(MergeRequest).
+        to receive(:can_be_merged?).and_return(false)
+
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user)
+
+      expect(response).to have_http_status(406)
+      expect(json_response['message']).to eq('Branch cannot be merged')
+    end
+
+    it "returns 405 if merge_request is not open" do
+      merge_request.close
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user)
+      expect(response).to have_http_status(405)
+      expect(json_response['message']).to eq('405 Method Not Allowed')
+    end
+
+    it "returns 405 if merge_request is a work in progress" do
+      merge_request.update_attribute(:title, "WIP: #{merge_request.title}")
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user)
+      expect(response).to have_http_status(405)
+      expect(json_response['message']).to eq('405 Method Not Allowed')
+    end
+
+    it 'returns 405 if the build failed for a merge request that requires success' do
+      allow_any_instance_of(MergeRequest).to receive(:mergeable_ci_state?).and_return(false)
+
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user)
+
+      expect(response).to have_http_status(405)
+      expect(json_response['message']).to eq('405 Method Not Allowed')
+    end
+
+    it "returns 401 if user has no permissions to merge" do
+      user2 = create(:user)
+      project.team << [user2, :reporter]
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user2)
+      expect(response).to have_http_status(401)
+      expect(json_response['message']).to eq('401 Unauthorized')
+    end
+
+    it "returns 409 if the SHA parameter doesn't match" do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), sha: merge_request.diff_head_sha.reverse
+
+      expect(response).to have_http_status(409)
+      expect(json_response['message']).to start_with('SHA does not match HEAD of source branch')
+    end
+
+    it "succeeds if the SHA parameter matches" do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), sha: merge_request.diff_head_sha
+
+      expect(response).to have_http_status(200)
+    end
+
+    it "enables merge when pipeline succeeds if the pipeline is active" do
+      allow_any_instance_of(MergeRequest).to receive(:head_pipeline).and_return(pipeline)
+      allow(pipeline).to receive(:active?).and_return(true)
+
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), merge_when_build_succeeds: true
+
+      expect(response).to have_http_status(200)
+      expect(json_response['title']).to eq('Test')
+      expect(json_response['merge_when_build_succeeds']).to eq(true)
+    end
+  end
+
+  describe "PUT /projects/:id/merge_requests/:merge_request_id" do
+    context "to close a MR" do
+      it "returns merge_request" do
+        put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: "close"
+
+        expect(response).to have_http_status(200)
+        expect(json_response['state']).to eq('closed')
+      end
+    end
+
+    it "updates title and returns merge_request" do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), title: "New title"
+      expect(response).to have_http_status(200)
+      expect(json_response['title']).to eq('New title')
+    end
+
+    it "updates description and returns merge_request" do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), description: "New description"
+      expect(response).to have_http_status(200)
+      expect(json_response['description']).to eq('New description')
+    end
+
+    it "updates milestone_id and returns merge_request" do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), milestone_id: milestone.id
+      expect(response).to have_http_status(200)
+      expect(json_response['milestone']['id']).to eq(milestone.id)
+    end
+
+    it "returns merge_request with renamed target_branch" do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), target_branch: "wiki"
+      expect(response).to have_http_status(200)
+      expect(json_response['target_branch']).to eq('wiki')
+    end
+
+    it "returns merge_request that removes the source branch" do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), remove_source_branch: true
+
+      expect(response).to have_http_status(200)
+      expect(json_response['force_remove_source_branch']).to be_truthy
+    end
+
+    it 'allows special label names' do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user),
+        title: 'new issue',
+        labels: 'label, label?, label&foo, ?, &'
+
+      expect(response.status).to eq(200)
+      expect(json_response['labels']).to include 'label'
+      expect(json_response['labels']).to include 'label?'
+      expect(json_response['labels']).to include 'label&foo'
+      expect(json_response['labels']).to include '?'
+      expect(json_response['labels']).to include '&'
+    end
+
+    it 'does not update state when title is empty' do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: 'close', title: nil
+
+      merge_request.reload
+      expect(response).to have_http_status(400)
+      expect(merge_request.state).to eq('opened')
+    end
+
+    it 'does not update state when target_branch is empty' do
+      put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: 'close', target_branch: nil
+
+      merge_request.reload
+      expect(response).to have_http_status(400)
+      expect(merge_request.state).to eq('opened')
+    end
+  end
+
+  describe "POST /projects/:id/merge_requests/:merge_request_id/comments" do
+    it "returns comment" do
+      original_count = merge_request.notes.size
+
+      post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user), note: "My comment"
+
+      expect(response).to have_http_status(201)
+      expect(json_response['note']).to eq('My comment')
+      expect(json_response['author']['name']).to eq(user.name)
+      expect(json_response['author']['username']).to eq(user.username)
+      expect(merge_request.reload.notes.size).to eq(original_count + 1)
+    end
+
+    it "returns 400 if note is missing" do
+      post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user)
+      expect(response).to have_http_status(400)
+    end
+
+    it "returns 404 if note is attached to non existent merge request" do
+      post v3_api("/projects/#{project.id}/merge_requests/404/comments", user),
+        note: 'My comment'
+      expect(response).to have_http_status(404)
+    end
+  end
+
+  describe "GET :id/merge_requests/:merge_request_id/comments" do
+    let!(:note)  { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") }
+    let!(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") }
+
+    it "returns merge_request comments ordered by created_at" do
+      get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user)
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(2)
+      expect(json_response.first['note']).to eq("a comment on a MR")
+      expect(json_response.first['author']['id']).to eq(user.id)
+      expect(json_response.last['note']).to eq("another comment on a MR")
+    end
+
+    it "returns a 404 error if merge_request_id not found" do
+      get v3_api("/projects/#{project.id}/merge_requests/999/comments", user)
+      expect(response).to have_http_status(404)
+    end
+  end
+
+  describe 'GET :id/merge_requests/:merge_request_id/closes_issues' do
+    it 'returns the issue that will be closed on merge' do
+      issue = create(:issue, project: project)
+      mr = merge_request.tap do |mr|
+        mr.update_attribute(:description, "Closes #{issue.to_reference(mr.project)}")
+      end
+
+      get v3_api("/projects/#{project.id}/merge_requests/#{mr.id}/closes_issues", user)
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['id']).to eq(issue.id)
+    end
+
+    it 'returns an empty array when there are no issues to be closed' do
+      get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", user)
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(0)
+    end
+
+    it 'handles external issues' do
+      jira_project = create(:jira_project, :public, name: 'JIR_EXT1')
+      issue = ExternalIssue.new("#{jira_project.name}-123", jira_project)
+      merge_request = create(:merge_request, :simple, author: user, assignee: user, source_project: jira_project)
+      merge_request.update_attribute(:description, "Closes #{issue.to_reference(jira_project)}")
+
+      get v3_api("/projects/#{jira_project.id}/merge_requests/#{merge_request.id}/closes_issues", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response).to be_an Array
+      expect(json_response.length).to eq(1)
+      expect(json_response.first['title']).to eq(issue.title)
+      expect(json_response.first['id']).to eq(issue.id)
+    end
+
+    it 'returns 403 if the user has no access to the merge request' do
+      project = create(:empty_project, :private)
+      merge_request = create(:merge_request, :simple, source_project: project)
+      guest = create(:user)
+      project.team << [guest, :guest]
+
+      get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", guest)
+
+      expect(response).to have_http_status(403)
+    end
+  end
+
+  describe 'POST :id/merge_requests/:merge_request_id/subscription' do
+    it 'subscribes to a merge request' do
+      post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", admin)
+
+      expect(response).to have_http_status(201)
+      expect(json_response['subscribed']).to eq(true)
+    end
+
+    it 'returns 304 if already subscribed' do
+      post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", user)
+
+      expect(response).to have_http_status(304)
+    end
+
+    it 'returns 404 if the merge request is not found' do
+      post v3_api("/projects/#{project.id}/merge_requests/123/subscription", user)
+
+      expect(response).to have_http_status(404)
+    end
+
+    it 'returns 403 if user has no access to read code' do
+      guest = create(:user)
+      project.team << [guest, :guest]
+
+      post v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", guest)
+
+      expect(response).to have_http_status(403)
+    end
+  end
+
+  describe 'DELETE :id/merge_requests/:merge_request_id/subscription' do
+    it 'unsubscribes from a merge request' do
+      delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", user)
+
+      expect(response).to have_http_status(200)
+      expect(json_response['subscribed']).to eq(false)
+    end
+
+    it 'returns 304 if not subscribed' do
+      delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", admin)
+
+      expect(response).to have_http_status(304)
+    end
+
+    it 'returns 404 if the merge request is not found' do
+      post v3_api("/projects/#{project.id}/merge_requests/123/subscription", user)
+
+      expect(response).to have_http_status(404)
+    end
+
+    it 'returns 403 if user has no access to read code' do
+      guest = create(:user)
+      project.team << [guest, :guest]
+
+      delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscription", guest)
+
+      expect(response).to have_http_status(403)
+    end
+  end
+
+  describe 'Time tracking' do
+    let(:issuable) { merge_request }
+
+    include_examples 'time tracking endpoints', 'merge_request'
+  end
+
+  def mr_with_later_created_and_updated_at_time
+    merge_request
+    merge_request.created_at += 1.hour
+    merge_request.updated_at += 30.minutes
+    merge_request.save
+    merge_request
+  end
+
+  def mr_with_earlier_created_and_updated_at_time
+    merge_request_closed
+    merge_request_closed.created_at -= 1.hour
+    merge_request_closed.updated_at -= 30.minutes
+    merge_request_closed.save
+    merge_request_closed
+  end
+end