1# frozen_string_literal: true 2 3module API 4 class MergeRequests < ::API::Base 5 include PaginationParams 6 7 CONTEXT_COMMITS_POST_LIMIT = 20 8 9 before { authenticate_non_get! } 10 11 helpers Helpers::MergeRequestsHelpers 12 helpers Helpers::SSEHelpers 13 14 # These endpoints are defined in `TimeTrackingEndpoints` and is shared by 15 # API::Issues. In order to be able to define the feature category of these 16 # endpoints, we need to define them at the top-level by route. 17 feature_category :code_review, [ 18 '/projects/:id/merge_requests/:merge_request_iid/time_estimate', 19 '/projects/:id/merge_requests/:merge_request_iid/reset_time_estimate', 20 '/projects/:id/merge_requests/:merge_request_iid/add_spent_time', 21 '/projects/:id/merge_requests/:merge_request_iid/reset_spent_time', 22 '/projects/:id/merge_requests/:merge_request_iid/time_stats' 23 ] 24 25 # EE::API::MergeRequests would override the following helpers 26 helpers do 27 params :optional_params_ee do 28 end 29 30 params :optional_merge_requests_search_params do 31 end 32 end 33 34 def self.update_params_at_least_one_of 35 %i[ 36 assignee_id 37 assignee_ids 38 reviewer_ids 39 description 40 labels 41 add_labels 42 remove_labels 43 milestone_id 44 remove_source_branch 45 allow_collaboration 46 allow_maintainer_to_push 47 squash 48 target_branch 49 title 50 state_event 51 discussion_locked 52 ] 53 end 54 55 prepend_mod_with('API::MergeRequests') # rubocop: disable Cop/InjectEnterpriseEditionModule 56 57 helpers do 58 # rubocop: disable CodeReuse/ActiveRecord 59 def find_merge_requests(args = {}) 60 args = declared_params.merge(args) 61 args[:milestone_title] = args.delete(:milestone) 62 args[:not][:milestone_title] = args[:not]&.delete(:milestone) 63 args[:label_name] = args.delete(:labels) 64 args[:not][:label_name] = args[:not]&.delete(:labels) 65 args[:scope] = args[:scope].underscore if args[:scope] 66 67 merge_requests = MergeRequestsFinder.new(current_user, args).execute 68 .reorder(order_options_with_tie_breaker) 69 merge_requests = paginate(merge_requests) 70 .preload(:source_project, :target_project) 71 72 return merge_requests if args[:view] == 'simple' 73 74 merge_requests 75 .with_api_entity_associations 76 end 77 # rubocop: enable CodeReuse/ActiveRecord 78 79 def merge_request_pipelines_with_access 80 mr = find_merge_request_with_access(params[:merge_request_iid]) 81 ::Ci::PipelinesForMergeRequestFinder.new(mr, current_user).execute 82 end 83 84 def automatically_mergeable?(merge_when_pipeline_succeeds, merge_request) 85 pipeline_active = merge_request.head_pipeline_active? || merge_request.actual_head_pipeline_active? 86 merge_when_pipeline_succeeds && merge_request.mergeable_state?(skip_ci_check: true) && pipeline_active 87 end 88 89 def immediately_mergeable?(merge_when_pipeline_succeeds, merge_request) 90 if merge_when_pipeline_succeeds 91 merge_request.actual_head_pipeline_success? 92 else 93 merge_request.mergeable_state? 94 end 95 end 96 97 def serializer_options_for(merge_requests) 98 options = { with: Entities::MergeRequestBasic, current_user: current_user, with_labels_details: declared_params[:with_labels_details] } 99 100 if params[:view] == 'simple' 101 options[:with] = Entities::MergeRequestSimple 102 else 103 options[:skip_merge_status_recheck] = !declared_params[:with_merge_status_recheck] 104 end 105 106 options 107 end 108 109 def authorize_push_to_merge_request!(merge_request) 110 forbidden!('Source branch does not exist') unless 111 merge_request.source_branch_exists? 112 113 user_access = Gitlab::UserAccess.new( 114 current_user, 115 container: merge_request.source_project 116 ) 117 118 forbidden!('Cannot push to source branch') unless 119 user_access.can_push_to_branch?(merge_request.source_branch) 120 end 121 122 params :merge_requests_params do 123 use :merge_requests_base_params 124 use :optional_merge_requests_search_params 125 use :pagination 126 end 127 end 128 129 resource :merge_requests do 130 desc 'List merge requests' do 131 success Entities::MergeRequestBasic 132 end 133 params do 134 use :merge_requests_params 135 use :optional_scope_param 136 end 137 get feature_category: :code_review, urgency: :low do 138 authenticate! unless params[:scope] == 'all' 139 validate_anonymous_search_access! if params[:search].present? 140 merge_requests = find_merge_requests 141 142 present merge_requests, serializer_options_for(merge_requests) 143 end 144 end 145 146 params do 147 requires :id, type: String, desc: 'The ID of a group' 148 end 149 resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do 150 desc 'Get a list of group merge requests' do 151 success Entities::MergeRequestBasic 152 end 153 params do 154 use :merge_requests_params 155 optional :non_archived, type: Boolean, desc: 'Return merge requests from non archived projects', 156 default: true 157 end 158 get ":id/merge_requests", feature_category: :code_review, urgency: :low do 159 validate_anonymous_search_access! if declared_params[:search].present? 160 merge_requests = find_merge_requests(group_id: user_group.id, include_subgroups: true) 161 162 present merge_requests, serializer_options_for(merge_requests).merge(group: user_group) 163 end 164 end 165 166 params do 167 requires :id, type: String, desc: 'The ID of a project' 168 end 169 resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do 170 include TimeTrackingEndpoints 171 172 helpers do 173 params :optional_params do 174 optional :assignee_id, type: Integer, desc: 'The ID of a user to assign the merge request' 175 optional :assignee_ids, type: Array[Integer], coerce_with: ::API::Validations::Types::CommaSeparatedToIntegerArray.coerce, desc: 'Comma-separated list of assignee ids' 176 optional :reviewer_ids, type: Array[Integer], coerce_with: ::API::Validations::Types::CommaSeparatedToIntegerArray.coerce, desc: 'Comma-separated list of reviewer ids' 177 optional :description, type: String, desc: 'The description of the merge request' 178 optional :labels, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Comma-separated list of label names' 179 optional :add_labels, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Comma-separated list of label names' 180 optional :remove_labels, type: Array[String], coerce_with: Validations::Types::CommaSeparatedToArray.coerce, desc: 'Comma-separated list of label names' 181 optional :milestone_id, type: Integer, desc: 'The ID of a milestone to assign the merge request' 182 optional :remove_source_branch, type: Boolean, desc: 'Remove source branch when merging' 183 optional :allow_collaboration, type: Boolean, desc: 'Allow commits from members who can merge to the target branch' 184 optional :allow_maintainer_to_push, type: Boolean, as: :allow_collaboration, desc: '[deprecated] See allow_collaboration' 185 optional :squash, type: Grape::API::Boolean, desc: 'When true, the commits will be squashed into a single commit on merge' 186 187 use :optional_params_ee 188 end 189 end 190 191 desc 'List merge requests' do 192 success Entities::MergeRequestBasic 193 end 194 params do 195 use :merge_requests_params 196 optional :iids, type: Array[Integer], coerce_with: ::API::Validations::Types::CommaSeparatedToIntegerArray.coerce, desc: 'The IID array of merge requests' 197 end 198 get ":id/merge_requests", feature_category: :code_review, urgency: :low do 199 authorize! :read_merge_request, user_project 200 validate_anonymous_search_access! if declared_params[:search].present? 201 202 merge_requests = find_merge_requests(project_id: user_project.id) 203 204 options = serializer_options_for(merge_requests).merge(project: user_project) 205 options[:project] = user_project 206 207 if Feature.enabled?(:api_caching_merge_requests, user_project, type: :development, default_enabled: :yaml) 208 present_cached merge_requests, expires_in: 2.days, **options 209 else 210 present merge_requests, options 211 end 212 end 213 214 desc 'Create a merge request' do 215 success Entities::MergeRequest 216 end 217 params do 218 requires :title, type: String, desc: 'The title of the merge request' 219 requires :source_branch, type: String, desc: 'The source branch' 220 requires :target_branch, type: String, desc: 'The target branch' 221 optional :target_project_id, type: Integer, 222 desc: 'The target project of the merge request defaults to the :id of the project' 223 use :optional_params 224 end 225 post ":id/merge_requests", feature_category: :code_review, urgency: :low do 226 Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20770') 227 228 authorize! :create_merge_request_from, user_project 229 230 mr_params = declared_params(include_missing: false) 231 mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) 232 mr_params = convert_parameters_from_legacy_format(mr_params) 233 234 merge_request = ::MergeRequests::CreateService.new(project: user_project, current_user: current_user, params: mr_params).execute 235 236 handle_merge_request_errors!(merge_request) 237 238 Gitlab::UsageDataCounters::EditorUniqueCounter.track_sse_edit_action(author: current_user) if request_from_sse?(user_project) 239 240 present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project 241 end 242 243 desc 'Delete a merge request' 244 params do 245 requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' 246 end 247 delete ":id/merge_requests/:merge_request_iid", feature_category: :code_review, urgency: :low do 248 merge_request = find_project_merge_request(params[:merge_request_iid]) 249 250 authorize!(:destroy_merge_request, merge_request) 251 252 destroy_conditionally!(merge_request) do |merge_request| 253 Issuable::DestroyService.new(project: user_project, current_user: current_user).execute(merge_request) 254 end 255 end 256 257 params do 258 requires :merge_request_iid, type: Integer, desc: 'The IID of a merge request' 259 optional :render_html, type: Boolean, desc: 'Returns the description and title rendered HTML' 260 optional :include_diverged_commits_count, type: Boolean, desc: 'Returns the commits count behind the target branch' 261 optional :include_rebase_in_progress, type: Boolean, desc: 'Returns whether a rebase operation is ongoing ' 262 end 263 desc 'Get a single merge request' do 264 success Entities::MergeRequest 265 end 266 get ':id/merge_requests/:merge_request_iid', feature_category: :code_review, urgency: :low do 267 merge_request = find_merge_request_with_access(params[:merge_request_iid]) 268 269 present merge_request, 270 with: Entities::MergeRequest, 271 current_user: current_user, 272 project: user_project, 273 render_html: params[:render_html], 274 include_first_contribution: true, 275 include_diverged_commits_count: params[:include_diverged_commits_count], 276 include_rebase_in_progress: params[:include_rebase_in_progress] 277 end 278 279 desc 'Get the participants of a merge request' do 280 success Entities::UserBasic 281 end 282 get ':id/merge_requests/:merge_request_iid/participants', feature_category: :code_review, urgency: :low do 283 merge_request = find_merge_request_with_access(params[:merge_request_iid]) 284 285 participants = ::Kaminari.paginate_array(merge_request.visible_participants(current_user)) 286 287 present paginate(participants), with: Entities::UserBasic 288 end 289 290 desc 'Get the commits of a merge request' do 291 success Entities::Commit 292 end 293 get ':id/merge_requests/:merge_request_iid/commits', feature_category: :code_review, urgency: :low do 294 merge_request = find_merge_request_with_access(params[:merge_request_iid]) 295 296 commits = 297 paginate(merge_request.merge_request_diff.merge_request_diff_commits) 298 .map { |commit| Commit.from_hash(commit.to_hash, merge_request.project) } 299 300 present commits, with: Entities::Commit 301 end 302 303 desc 'Get the context commits of a merge request' do 304 success Entities::Commit 305 end 306 get ':id/merge_requests/:merge_request_iid/context_commits', feature_category: :code_review, urgency: :high do 307 merge_request = find_merge_request_with_access(params[:merge_request_iid]) 308 project = merge_request.project 309 310 not_found! unless project.context_commits_enabled? 311 312 context_commits = 313 paginate(merge_request.merge_request_context_commits).map(&:to_commit) 314 315 present context_commits, with: Entities::CommitWithLink, type: :full, request: merge_request 316 end 317 318 params do 319 requires :commits, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, allow_blank: false, desc: 'List of context commits sha' 320 end 321 desc 'create context commits of merge request' do 322 success Entities::Commit 323 end 324 post ':id/merge_requests/:merge_request_iid/context_commits', feature_category: :code_review do 325 commit_ids = params[:commits] 326 327 if commit_ids.size > CONTEXT_COMMITS_POST_LIMIT 328 render_api_error!("Context commits array size should not be more than #{CONTEXT_COMMITS_POST_LIMIT}", 400) 329 end 330 331 merge_request = find_merge_request_with_access(params[:merge_request_iid]) 332 project = merge_request.project 333 334 not_found! unless project.context_commits_enabled? 335 336 authorize!(:update_merge_request, merge_request) 337 338 project = merge_request.target_project 339 result = ::MergeRequests::AddContextService.new(project: project, current_user: current_user, params: { merge_request: merge_request, commits: commit_ids }).execute 340 341 if result.instance_of?(Array) 342 present result, with: Entities::Commit 343 else 344 render_api_error!(result[:message], result[:http_status]) 345 end 346 end 347 348 params do 349 requires :commits, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, allow_blank: false, desc: 'List of context commits sha' 350 end 351 desc 'remove context commits of merge request' 352 delete ':id/merge_requests/:merge_request_iid/context_commits', feature_category: :code_review do 353 commit_ids = params[:commits] 354 merge_request = find_merge_request_with_access(params[:merge_request_iid]) 355 project = merge_request.project 356 357 not_found! unless project.context_commits_enabled? 358 359 authorize!(:destroy_merge_request, merge_request) 360 project = merge_request.target_project 361 commits = project.repository.commits_by(oids: commit_ids) 362 363 if commits.size != commit_ids.size 364 render_api_error!("One or more context commits' sha is not valid.", 400) 365 end 366 367 MergeRequestContextCommit.delete_bulk(merge_request, commits) 368 status 204 369 end 370 371 desc 'Show the merge request changes' do 372 success Entities::MergeRequestChanges 373 end 374 get ':id/merge_requests/:merge_request_iid/changes', feature_category: :code_review, urgency: :low do 375 merge_request = find_merge_request_with_access(params[:merge_request_iid]) 376 377 present merge_request, 378 with: Entities::MergeRequestChanges, 379 current_user: current_user, 380 project: user_project, 381 access_raw_diffs: to_boolean(params.fetch(:access_raw_diffs, false)) 382 end 383 384 desc 'Get the merge request pipelines' do 385 success Entities::Ci::PipelineBasic 386 end 387 get ':id/merge_requests/:merge_request_iid/pipelines', feature_category: :continuous_integration do 388 pipelines = merge_request_pipelines_with_access 389 390 present paginate(pipelines), with: Entities::Ci::PipelineBasic 391 end 392 393 desc 'Create a pipeline for merge request' do 394 success ::API::Entities::Ci::Pipeline 395 end 396 post ':id/merge_requests/:merge_request_iid/pipelines', feature_category: :continuous_integration do 397 pipeline = ::MergeRequests::CreatePipelineService 398 .new(project: user_project, current_user: current_user, params: { allow_duplicate: true }) 399 .execute(find_merge_request_with_access(params[:merge_request_iid])) 400 .payload 401 402 if pipeline.nil? 403 not_allowed! 404 elsif pipeline.persisted? 405 status :ok 406 present pipeline, with: ::API::Entities::Ci::Pipeline 407 else 408 render_validation_error!(pipeline) 409 end 410 end 411 412 desc 'Update a merge request' do 413 success Entities::MergeRequest 414 end 415 params do 416 optional :title, type: String, allow_blank: false, desc: 'The title of the merge request' 417 optional :target_branch, type: String, allow_blank: false, desc: 'The target branch' 418 optional :state_event, type: String, values: %w[close reopen], 419 desc: 'Status of the merge request' 420 optional :discussion_locked, type: Boolean, desc: 'Whether the MR discussion is locked' 421 422 use :optional_params 423 at_least_one_of(*::API::MergeRequests.update_params_at_least_one_of) 424 end 425 put ':id/merge_requests/:merge_request_iid', feature_category: :code_review, urgency: :low do 426 Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20772') 427 428 merge_request = find_merge_request_with_access(params.delete(:merge_request_iid), :update_merge_request) 429 430 mr_params = declared_params(include_missing: false) 431 mr_params[:force_remove_source_branch] = mr_params.delete(:remove_source_branch) if mr_params.has_key?(:remove_source_branch) 432 mr_params = convert_parameters_from_legacy_format(mr_params) 433 mr_params[:use_specialized_service] = true 434 435 merge_request = ::MergeRequests::UpdateService 436 .new(project: user_project, current_user: current_user, params: mr_params) 437 .execute(merge_request) 438 439 handle_merge_request_errors!(merge_request) 440 441 present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project 442 end 443 444 desc 'Merge a merge request' do 445 success Entities::MergeRequest 446 end 447 params do 448 optional :merge_commit_message, type: String, desc: 'Custom merge commit message' 449 optional :squash_commit_message, type: String, desc: 'Custom squash commit message' 450 optional :should_remove_source_branch, type: Boolean, 451 desc: 'When true, the source branch will be deleted if possible' 452 optional :merge_when_pipeline_succeeds, type: Boolean, 453 desc: 'When true, this merge request will be merged when the pipeline succeeds' 454 optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch' 455 optional :squash, type: Grape::API::Boolean, desc: 'When true, the commits will be squashed into a single commit on merge' 456 end 457 put ':id/merge_requests/:merge_request_iid/merge', feature_category: :code_review, urgency: :low do 458 Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/4796') 459 460 merge_request = find_project_merge_request(params[:merge_request_iid]) 461 462 # Merge request can not be merged 463 # because user dont have permissions to push into target branch 464 unauthorized! unless merge_request.can_be_merged_by?(current_user) 465 466 merge_when_pipeline_succeeds = to_boolean(params[:merge_when_pipeline_succeeds]) 467 automatically_mergeable = automatically_mergeable?(merge_when_pipeline_succeeds, merge_request) 468 immediately_mergeable = immediately_mergeable?(merge_when_pipeline_succeeds, merge_request) 469 470 not_allowed! if !immediately_mergeable && !automatically_mergeable 471 472 render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable?(skip_ci_check: automatically_mergeable) 473 474 check_sha_param!(params, merge_request) 475 476 merge_request.update(squash: params[:squash]) if params[:squash] 477 478 merge_params = HashWithIndifferentAccess.new( 479 commit_message: params[:merge_commit_message], 480 squash_commit_message: params[:squash_commit_message], 481 should_remove_source_branch: params[:should_remove_source_branch], 482 sha: params[:sha] || merge_request.diff_head_sha 483 ).compact 484 485 if immediately_mergeable 486 ::MergeRequests::MergeService 487 .new(project: merge_request.target_project, current_user: current_user, params: merge_params) 488 .execute(merge_request) 489 elsif automatically_mergeable 490 AutoMergeService.new(merge_request.target_project, current_user, merge_params) 491 .execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) 492 end 493 494 present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project 495 end 496 497 desc 'Returns the up to date merge-ref HEAD commit' 498 get ':id/merge_requests/:merge_request_iid/merge_ref', feature_category: :code_review do 499 merge_request = find_project_merge_request(params[:merge_request_iid]) 500 501 result = ::MergeRequests::MergeabilityCheckService.new(merge_request).execute(recheck: true) 502 503 if result.success? 504 present :commit_id, result.payload.dig(:merge_ref_head, :commit_id) 505 else 506 render_api_error!(result.message, 400) 507 end 508 end 509 510 desc 'Cancel merge if "Merge When Pipeline Succeeds" is enabled' do 511 success Entities::MergeRequest 512 end 513 post ':id/merge_requests/:merge_request_iid/cancel_merge_when_pipeline_succeeds', feature_category: :code_review do 514 merge_request = find_project_merge_request(params[:merge_request_iid]) 515 516 unauthorized! unless merge_request.can_cancel_auto_merge?(current_user) 517 518 AutoMergeService.new(merge_request.target_project, current_user).cancel(merge_request) 519 end 520 521 desc 'Rebase the merge request against its target branch' do 522 detail 'This feature was added in GitLab 11.6' 523 end 524 params do 525 optional :skip_ci, type: Boolean, desc: 'Do not create CI pipeline' 526 end 527 put ':id/merge_requests/:merge_request_iid/rebase', feature_category: :code_review, urgency: :low do 528 merge_request = find_project_merge_request(params[:merge_request_iid]) 529 530 authorize_push_to_merge_request!(merge_request) 531 532 merge_request.rebase_async(current_user.id, skip_ci: params[:skip_ci]) 533 534 status :accepted 535 present rebase_in_progress: merge_request.rebase_in_progress? 536 rescue ::MergeRequest::RebaseLockTimeout => e 537 render_api_error!(e.message, 409) 538 end 539 540 desc 'List issues that will be closed on merge' do 541 success Entities::MRNote 542 end 543 params do 544 use :pagination 545 end 546 get ':id/merge_requests/:merge_request_iid/closes_issues', feature_category: :code_review, urgency: :low do 547 merge_request = find_merge_request_with_access(params[:merge_request_iid]) 548 issues = ::Kaminari.paginate_array(merge_request.visible_closing_issues_for(current_user)) 549 issues = paginate(issues) 550 551 external_issues, internal_issues = issues.partition { |issue| issue.is_a?(ExternalIssue) } 552 553 data = Entities::IssueBasic.represent(internal_issues, current_user: current_user) 554 data += Entities::ExternalIssue.represent(external_issues, current_user: current_user) 555 556 data.as_json 557 end 558 end 559 end 560end 561