Add cancel_auto_merge merge request API endpoint
What does this MR do and why?
This adds POST /projects/:id/merge_requests/:merge_request_iid/cancel_auto_merge. It cancels an active auto-merge and answers 201 with the merge request, and it refuses with 409 and Can't cancel the automatic merge when the cancel does not go through.
cancel_merge_when_pipeline_succeeds promises the merge request and has never returned it. Its handler ends in AutoMergeService#cancel, so what Grape renders is the service result hash rather than the entity: a successful call answers 201 with {"status":"success"} instead of the merge request that its desc block and the API page both promise, and a refusal answers 201 with a body carrying message, status and http_status instead of a refusal status code.
!255239 (closed) set out to correct that in place. Doing so would change both the response body and the status code of an endpoint that has shipped for years, which is a breaking change twice over, so in the discussion there @phikai and @marc_shaw asked instead for a new endpoint carrying the documented contract, a deprecation of the old one, and a separate correction of the old one's documentation. This merge request is the new endpoint and its documentation; !255704 is the correction and the deprecation notice. Only the single route @marc_shaw asked for is added here: no add_to_* or other auto-merge endpoints, since every new endpoint is one this project maintains indefinitely.
The route keeps the same :merge_request_iid lookup, the same can_cancel_auto_merge? authorisation, the same cancel_merge_merge_request granular permission on the project boundary, and the same feature category as the endpoint it replaces, and it presents the merge request with the entity and options every other merge request endpoint uses. It declares urgency: :low, as every other route in this file that presents a full merge request already does.
Status codes, and why they are the merge request family's rather than the old page's
The old page documented 406 Not Acceptable for a refusal, and the endpoint never sent it: the service returns error("Can't cancel the automatic merge", 406), BaseServiceUtility#error builds a plain hash, and Grape applies its own 201 while the 406 arrives inside the body. So there is no deployed behaviour to preserve here and no caller expecting 406 — nobody has ever received one. Given a free choice, the codes follow this API rather than that page, as @marc_shaw asked:
- Success is
201. Every otherPOSTaction on a merge request (approve,unapprove,subscribe,todo) answers201, andrest/troubleshooting.mddefines201as the successfulPOSTthat returns the resource, which is exactly this endpoint. An earlier revision of this merge request used200on the precedent ofPOST /projects/:id/pipelines/:pipeline_id/cancel; the merge request family is the closer neighbour and this route is permanent once released. - A refusal is
409 Conflict, not406.406 Not Acceptableis content negotiation, no endpoint in this API sends it for a refusal about state, and the merge request family already answers409when the merge request is in the wrong state:rebase, immediately below this route in the same file, declares exactly that. The handler now maps the service result withconflict!(result[:message]). - A caller who is authenticated but not allowed gets
403, not401.doc/api/rest/troubleshooting.mddefines401as "the user isn't authenticated" and403as "the request isn't allowed", andrebasein this same file already answersforbidden!. The deprecated endpoint keeps401, because its behaviour is frozen by the agreement in !255239 (closed), so the two endpoints differing on this is intentional rather than an oversight. - The
descfailure list carries only what the route can produce:403,404and409. The405the old endpoint'sdescdeclares is not reachable from this handler.
One thing the 409 inherits from the service rather than from this route: AutoMerge::BaseService#clear_auto_merge rescues any StandardError raised while clearing the parameters and reports it as the same result, so a genuine internal failure arrives here as a refusal too. That conflation is the service's and predates this route; the documented reason names the common cause rather than claiming to be the only one.
Status codes in the documentation
The new section's table lists 201, 403 and 409, and no 404. That follows this page, where no status table has a 404 row and each one lists only the statuses specific to its endpoint, leaving the universal ones to rest/troubleshooting.md. 404 stays in the desc block, so it is still published in the OpenAPI document, which is how Merge a merge request on this page is already arranged.
One thing worth knowing about the existing specs
The specs for cancel_merge_when_pipeline_succeeds arm the auto-merge by calling AutoMergeService#execute(merge_request, STRATEGY_MERGE_WHEN_CHECKS_PASS), and on that fixture the call is a no-op. AutoMerge::MergeWhenChecksPassService#availability_details returns an error when the merge request is already mergeable and no pipeline is in progress, which is exactly the state of the factory's merge request, so execute returns :failed and auto_merge_enabled is never set. The old endpoint answers 201 whether or not anything was cancelled, so its single assertion cannot see this. The new endpoint does see it: with that setup every call to it lands on the refusal branch. The specs here therefore arm the auto-merge with the :merge_when_checks_pass factory trait, which is what the rest of this file already uses for the same purpose. !255704 now pins both bodies of the old endpoint for the same reason.
Generated files
config/routing/gitlab_routes.json, doc/auth/tokens/fine_grained_access_tokens_rest.md and doc/api/openapi/openapi_v3.yaml were regenerated with gitlab:cells:routes:generate, gitlab:permissions:routes:compile_docs and gitlab:openapi:v3:generate, and each picked up the new route and nothing else. gitlab:cells:routes:updated_check, gitlab:permissions:validate and gitlab:openapi:v3:check_docs all pass against the committed files.
On the cells-routes:router-in-sync job
This merge request adds one route, /api/:version/projects/:id/merge_requests/:merge_request_iid/cancel_auto_merge, so it changes config/routing/gitlab_routes.json, and the job compares that file with the HTTP Router's committed snapshot. An earlier revision of this description said the job's diff was made of routes this branch never touches. That was true of the pipeline of 2026-09-15 and stopped being true when the router's snapshot was refreshed from master on 2026-09-16 (gitlab-org/cells/http-router!1339 (merged)): every pipeline since has listed this route. The snapshot was reconciled with master again on 2026-09-23 (gitlab-org/cells/http-router!1349 (merged)), and the job became blocking on master the same day. On the pipeline after the rebase onto current master, it reports this route and one rename that is already on master and not yet in the router: /api/:version/projects/:id/jobs/:job_id/runtime_environment_key became /api/:version/jobs/:id/runtime_environment_key.
The route matches the router's existing /api/v4/projects/:PROJECT_ID_OR_ROUTE/* rule and claims the project, exactly as cancel_merge_when_pipeline_succeeds beside it does, so it needs a refreshed snapshot and no routing change. The paired refresh, gitlab-org/cells/http-router!1354 (merged), merged on 2026-09-24 and carries that rename as well, as gitlab-org/cells/http-router!1351 (merged) does. So the difference this merge request introduces is in the router now, and the job should pass on the next pipeline; the fork pipeline of 2026-09-24 ran before the refresh merged, which is why it still shows the job red.
Merging alongside !255704
This one should merge first. Both reviewers asked for the deprecation notice in !255704 to link to this merge request's #cancel-auto-merge section rather than name it, and that link can only be added once this section exists on master. !255704 therefore names the endpoint in code font today and gains the link when it is rebased after this merges.
The two merge requests conflict in two hunks of the documentation, both expected:
In doc/api/merge_requests.md, !255704 rewrites the section for the deprecated endpoint and this adds a new section immediately after it. The correct result is the deprecated section's closing example and http_status note first, then the new ## Cancel auto merge section.
In doc/api/merge_trains.md, both change the same line. It points readers at the endpoint for removing a merge request from a merge train, and the correct resolution is #cancel-auto-merge, which is what this merge request sets it to; !255704 only repoints it at the renamed deprecated anchor so that docs-lint links stays green on its own branch, where #cancel-auto-merge does not exist yet. Once this merges, !255704's own change to that file drops out at rebase.
The changes to lib/api/merge_requests.rb and to the generated OpenAPI document fall in disjoint parts of those files and merge cleanly.
References
- The discussion that asked for this endpoint: !255239 (closed)
- The documentation correction and deprecation of the endpoint it replaces: !255704
How to set up and validate locally
-
Set a merge request to merge automatically.
-
Cancel it through the new endpoint, and check that the response is
201with the merge request body andmerge_when_pipeline_succeedsfalse:curl --request POST \ --header "PRIVATE-TOKEN: <your_access_token>" \ --url "http://127.0.0.1:3000/api/v4/projects/<id>/merge_requests/<iid>/cancel_auto_merge" -
Repeat the same call, and check that the response is
409withCan't cancel the automatic merge. -
Repeat it as a user who can read the merge request but not merge it, and check that the response is
403.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.