From 7077c72618ddd6319242a98a2af3b523c7c9e20e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 27 Oct 2026 21:55:33 +0000 Subject: [PATCH] feat(api)!: answer failed merge guards with 409 ref_guard_failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #568 _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ Change-Id: Id3704634ec263d38bd926e07c6fb6533124ebf29 Signed-off-by: Thomas Kosiewski --- ...smatch-412-lost-race-merge-conflict-409.md | 2 +- docs/explanation/wire-compatibility.md | 25 +- docs/reference/http-api.md | 44 +- docs/upstream/openapi.yaml | 408 ++++++++++++++++-- internal/api/errors.go | 29 ++ internal/api/handlers_write_merge.go | 23 +- internal/api/problem_test.go | 100 ++++- internal/api/write_merge_source_pin_test.go | 52 ++- internal/api/write_merge_test.go | 40 +- internal/githttp/merge_e2e_test.go | 9 +- 10 files changed, 625 insertions(+), 107 deletions(-) diff --git a/docs/adr/022-cas-mismatch-412-lost-race-merge-conflict-409.md b/docs/adr/022-cas-mismatch-412-lost-race-merge-conflict-409.md index 055f2bc2f..506ce0979 100644 --- a/docs/adr/022-cas-mismatch-412-lost-race-merge-conflict-409.md +++ b/docs/adr/022-cas-mismatch-412-lost-race-merge-conflict-409.md @@ -6,7 +6,7 @@ > **Amended by [#532](https://github.com/coder/openstorage/issues/532) (merge only):** a > stale `expected_target_sha` on `POST .../merge` answers **409** with the problem body, -> code `conflict`, as upstream's merge contract documents (spec 2026-10-16), the same as a +> code `conflict` (`ref_guard_failed` since [#568](https://github.com/coder/openstorage/issues/568)), as upstream's merge contract documents (spec 2026-10-16), the same as a > source branch that no longer contains `expected_source_sha`. The guard still runs > before any merge work. Every other route keeps the 412 rule below. diff --git a/docs/explanation/wire-compatibility.md b/docs/explanation/wire-compatibility.md index 9589b300c..5e3e7f3e1 100644 --- a/docs/explanation/wire-compatibility.md +++ b/docs/explanation/wire-compatibility.md @@ -200,13 +200,14 @@ the [archived phase notes](../archive/phases/)): | `POST /repos` `base_repo` copy: default branch defaults to `main` when omitted | Without a top-level `default_branch`, a copy takes the source's default branch name (fork behavior) | A copy runs the fork implementation | | `POST /repos` is idempotent on the name: `200` for any existing name unless its upstream differs (spec 2026-10-07) | A finished repo answers `200` to a matching repeat create: a sync create with the same upstream and `forward_pushes`, or a plain, copy or fork create of a repo without an upstream. A different upstream is `409`, as upstream documents, and so is a create form that does not match the repo (sync onto a plain repo, plain onto a synced repo). Repos created before create tracking (on S3-WAL, every repo created by v0.18.0 or earlier) answer `409` to every repeat. While a copy or fork create is still running, plain, copy and fork creates of the name are `503` with `Retry-After`. A pending sync create is finished by a matching sync create with `git:write` (`200`); other forms are `409` ([Repeat creates](../reference/http-api.md#repository-lifecycle-repowrite--orgread)). A name a deleted repo holds is `404` | A `200` names a repo only after its create finished, so it never names a repo that the failing create then deletes ([#388](https://github.com/coder/openstorage/issues/388)). A form mismatch is `409` because a `200` would hide that the request was not applied. Older repos store no create state (#335 for S3-WAL) | | `GET`/`POST /api/repos` (bare collection routes) | `/api/v1/repos` only; named-repo aliases exist for repo-scoped routes, not org-scoped collections | The alias layer covers the styles SDKs use | -| RFC 9457 problem-details error bodies (spec). Since upstream's 2026-09-24 spec update, `ProblemDetails` requires a snake_case `code` and tells clients to branch on `code`, not on `detail` | The plain `{"error":""}` envelope carries problem-details fields next to `error`: `type` (`about:blank`), `title`, `status`, `detail` (same string as `error`), `code` ([#349](https://github.com/coder/openstorage/issues/349)), and `instance`, the request path without the query ([#352](https://github.com/coder/openstorage/issues/352)). `code` is upstream's generic per-status code, except for eight of upstream's specific codes: `repository_not_found`, `insufficient_scope`, `repo_name_mismatch`, `branch_not_found`, `ref_not_found`, `credential_not_found`, `credential_exists`, and `repository_deleted` ([#352](https://github.com/coder/openstorage/issues/352)). A 400 for a request body carries the optional `invalid_params` list (`ValidationProblemDetails`, spec 2026-10-08, [#396](https://github.com/coder/openstorage/issues/396)) when open.storage can name the invalid fields: body decode failures (wrong JSON type, and unknown, duplicate or `null` members of strictly decoded bodies) and single-field checks (required fields, allowed values, ranges, name formats). Not emitted: upstream's other specific codes (such as `route_not_found`), `invalid_params` for failures that cannot name one field, and the `application/problem+json` content type. The [streaming protocols](streaming-commit-protocols.md) `{"result":{"success":false,…}}` envelope carries no problem-details fields. Failures use one of these two shapes, by route and failure class. Do not assume one shape per route family: branch on the status code, or check both shapes. Per-route contracts are in [HTTP API](../reference/http-api.md) and [streaming protocols](streaming-commit-protocols.md) | Recorded deviations ([phase-3 notes](../archive/phases/12-phase3-implementation-notes.md), [ADR-26](../adr/026-result-envelope-statuses-499-is-not-emittable.md)): all three SDKs branch on status codes. At the 2026-09-25 audit this held for SDK `1.17.1` (stable) and `1.18.0-beta.0` (beta): no SDK reads `code`, `detail`, or any problem-details field other than the legacy `error` string. The beta Go and TypeScript clients additionally accept `application/problem+json` error bodies; the content type stays `application/json` because the `1.17.1` Go and TypeScript clients parse error JSON only for that type. Upstream says to treat an unknown code as the generic code for its status, so a client that follows that guidance handles every `code` open.storage sends; a client that expects one of the specific codes open.storage does not emit sees the generic one instead (for example `not_found` for `route_not_found`). `status` is a JSON number, as in upstream's bodies: a client that decoded the old `{"error"}` body into a string-only map (such as Go's `map[string]string`) must switch to a struct or `map[string]any`. At the 2026-10-08 audit, no SDK (`1.17.1`, `1.18.0-beta.1`) reads `invalid_params`; the member is optional, so a client that ignores it sees no change | +| RFC 9457 problem-details error bodies (spec). Since upstream's 2026-09-24 spec update, `ProblemDetails` requires a snake_case `code` and tells clients to branch on `code`, not on `detail` | The plain `{"error":""}` envelope carries problem-details fields next to `error`: `type` (`about:blank`), `title`, `status`, `detail` (same string as `error`), `code` ([#349](https://github.com/coder/openstorage/issues/349)), and `instance`, the request path without the query ([#352](https://github.com/coder/openstorage/issues/352)). `code` is upstream's generic per-status code, except for nine of upstream's specific codes: `repository_not_found`, `insufficient_scope`, `repo_name_mismatch`, `branch_not_found`, `ref_not_found`, `credential_not_found`, `credential_exists`, and `repository_deleted` ([#352](https://github.com/coder/openstorage/issues/352)), and `ref_guard_failed` with its `guard` and `current_sha` members on a failed merge guard ([#568](https://github.com/coder/openstorage/issues/568)). A 400 for a request body carries the optional `invalid_params` list (`ValidationProblemDetails`, spec 2026-10-08, [#396](https://github.com/coder/openstorage/issues/396)) when open.storage can name the invalid fields: body decode failures (wrong JSON type, and unknown, duplicate or `null` members of strictly decoded bodies) and single-field checks (required fields, allowed values, ranges, name formats). Not emitted: upstream's other specific codes (such as `route_not_found`), `invalid_params` for failures that cannot name one field, and the `application/problem+json` content type. The [streaming protocols](streaming-commit-protocols.md) `{"result":{"success":false,…}}` envelope carries no problem-details fields. Failures use one of these two shapes, by route and failure class. Do not assume one shape per route family: branch on the status code, or check both shapes. Per-route contracts are in [HTTP API](../reference/http-api.md) and [streaming protocols](streaming-commit-protocols.md) | Recorded deviations ([phase-3 notes](../archive/phases/12-phase3-implementation-notes.md), [ADR-26](../adr/026-result-envelope-statuses-499-is-not-emittable.md)): all three SDKs branch on status codes. At the 2026-09-25 audit this held for SDK `1.17.1` (stable) and `1.18.0-beta.0` (beta): no SDK reads `code`, `detail`, or any problem-details field other than the legacy `error` string. The beta Go and TypeScript clients additionally accept `application/problem+json` error bodies; the content type stays `application/json` because the `1.17.1` Go and TypeScript clients parse error JSON only for that type. Upstream says to treat an unknown code as the generic code for its status, so a client that follows that guidance handles every `code` open.storage sends; a client that expects one of the specific codes open.storage does not emit sees the generic one instead (for example `not_found` for `route_not_found`). `status` is a JSON number, as in upstream's bodies: a client that decoded the old `{"error"}` body into a string-only map (such as Go's `map[string]string`) must switch to a struct or `map[string]any`. At the 2026-10-08 audit, no SDK (`1.17.1`, `1.18.0-beta.1`) reads `invalid_params`; the member is optional, so a client that ignores it sees no change | | `201` on write endpoints (spec) | `commit-pack` / `diff-commit` / `restore-commit` and `POST`/`DELETE .../notes` return `200`. The path-style `POST .../notes/append` returns `201` | Recorded deviation ([phase-3 notes](../archive/phases/12-phase3-implementation-notes.md)): the SDKs accept both; schema-validating clients should expect `200` on those legacy routes and `201` on `notes/append` | | `restore-commit`: `412` when the branch already points at the target, the tree would not change, or the target is not an ancestor of the tip (spec) | Enforced for `base_ref`. The deprecated `target_commit_sha` form keeps restoring without these checks | The SDKs send `target_commit_sha`; adding the checks there would change what SDK callers see | | `DELETE .../branches` naming the default branch: `409` (spec) | `409` for the `target_branch` query parameter form. `403` for the deprecated body form | The SDKs send the body form; changing its status would change what SDK callers see | -| Stale `expected_notes_ref_sha` on a note write: `409` (spec, for `POST .../notes/append` and the `DELETE .../notes` query parameter) | `409` for `POST .../notes/append` and the `DELETE` query parameter form. `412` `precondition_failed` for the legacy body forms (`POST .../notes` and the deprecated `DELETE` body) | The SDKs send the body forms and report `result.status`, so changing those would change what SDK callers see | -| Stale `expected_target_sha` or `expected_head_sha` on `commit-pack`: `409` (spec: "The branch moved or the requested update could not be applied cleanly") | `412` `precondition_failed` with a `result` object. A CAS lost at publish time is `409` | The Go SDK `1.17.1` builds its `RefUpdateError` from `result.status`, so changing the status would change what SDK callers see ([#533](https://github.com/coder/openstorage/issues/533)). Revisit when a stable SDK stops reading `result.status` here | -| Stale `expected_target_sha` or `expected_head_sha` on `diff-commit`: `409` (spec: "the target branch tip changed") | `412` `precondition_failed` with a `result` object. A CAS lost at publish time is `409` | Same reason as `commit-pack` ([#533](https://github.com/coder/openstorage/issues/533)) | +| Stale `expected_notes_ref_sha` on a note write: `409` `ref_guard_failed` with `guard` and `current_sha` next to the note members (spec 2026-10-27, for `POST .../notes`, `POST .../notes/append` and `DELETE .../notes`) | `409` with the note envelope (`result.status` `conflict`) and no problem members for `POST .../notes/append` and the `DELETE` query parameter form. `412` `precondition_failed` for the legacy body forms (`POST .../notes` and the deprecated `DELETE` body) | The SDKs send the body forms and report `result.status`, so changing those would change what SDK callers see | +| Stale `expected_target_sha` or `expected_head_sha` on `commit-pack`: `409` `ref_guard_failed` with `guard`, `current_sha` and the `result` object (spec 2026-10-27) | `412` `precondition_failed` with a `result` object. A CAS lost at publish time is `409` | The Go SDK `1.17.1` builds its `RefUpdateError` from `result.status`, so changing the status would change what SDK callers see ([#533](https://github.com/coder/openstorage/issues/533)). Revisit when a stable SDK stops reading `result.status` here | +| Stale `expected_target_sha` or `expected_head_sha` on `diff-commit`: `409` `ref_guard_failed` with `guard`, `current_sha` and the `result` object (spec 2026-10-27) | `412` `precondition_failed` with a `result` object. A CAS lost at publish time is `409` | Same reason as `commit-pack` ([#533](https://github.com/coder/openstorage/issues/533)) | +| Stale `expected_target_sha` or `expected_head_sha` on `restore-commit`: `409` `ref_guard_failed` with `guard`, `current_sha` and the `result` object (spec 2026-10-27) | `412` `precondition_failed` with a `result` object | Same reason as `commit-pack`: the Go SDK `1.17.1` also builds its restore `RefUpdateError` from the failure body's status ([#533](https://github.com/coder/openstorage/issues/533)) | | Credential-create response includes a summary (`created_at`, …) | `POST .../git-credentials` returns only `{"id": …}` | Recorded deviation ([phase-7 notes](../archive/phases/16-phase7-implementation-notes.md)); fetch the summary via the credential list if needed | | One ephemeral namespace across git and REST | Two separate ref spaces: git push to `/{repo}+ephemeral.git` writes a distinct `+ephemeral` storage key; REST `ephemeral=true` reads/writes `refs/namespaces/ephemeral/…` under the base repository. Ephemeral state does not cross surfaces | Recorded cross-surface split ([phase-4 notes](../archive/phases/13-phase4-implementation-notes.md)): REST writes follow the REST read surface, so API-created ephemeral refs stay visible to `GET .../branches?ephemeral=true` and merge promotion | | Implicit push forwarding: in upstream's GitHub App mode every `git push` to the default remote is proxied straight to GitHub, always on, with no REST trigger ([integrations guide](../upstream/guides/integrations.md)) | Explicit `POST /repos/push-upstream` (non-force by default, `force: true` opt-in) plus an opt-in per-repo `base_repo.forward_pushes` flag (default **off**) that schedules a forward after every won local push and mirrors back on success. A rejected non-fast-forward fails the run with per-ref reasons instead of rewriting upstream history. Ref deletions are not propagated in either direction (the inbound mirror deliberately avoids `--mirror` for the same reason). With `forward_pushes` off, a local push still triggers the inbound mirror, which force-resets local refs to upstream state ("upstream wins") | Recorded deviation ([ADR-44](../adr/044-outbound-push-forwarding-rides-a-separate-push-claim-lane.md), issue #90): implicit always-on forwarding invites silent divergence and needs GitHub App infrastructure this backend does not have. The explicit trigger + opt-in flag keep the direction observable (`repo.push_upstream.*` events) and non-destructive by default | @@ -237,12 +238,16 @@ Upstream evolves continuously, so drift is tracked in the open: - **Vendored snapshot:** [`docs/upstream/`](../upstream) holds verbatim copies of the official OpenAPI spec ([`openapi.yaml`](../upstream/openapi.yaml)) and the doc pages the implementation was built against. The current `openapi.yaml` is - as fetched on 2026-10-23 (upstream - `Last-Modified` 2026-10-22 21:43:10 GMT), SHA-256 - `10f3e009d55fc521ff38c88b3231433317545cc3fbaac7d021cf8b8184f8e356`. On that day - [the cluster OpenAPI](https://api.pierre.code.storage/api/openapi.yaml) served the - same bytes, so it lists the merge `expected_source_sha` guard too. A spec that the - cluster serves does not prove how the cluster behaves. + as fetched on 2026-10-27 (upstream + `Last-Modified` 2026-10-27 21:25:57 GMT), SHA-256 + `c494c01b2299053d8e6591a4aa794e65d2e38859407da6d320322d9f0d0119d0`. It adds the + `ref_guard_failed` code ([#568](https://github.com/coder/openstorage/issues/568)). + At 21:47 UTC that day + [the cluster OpenAPI](https://api.pierre.code.storage/api/openapi.yaml) still served the + previous snapshot (`10f3e009d55fc521ff38c88b3231433317545cc3fbaac7d021cf8b8184f8e356`), + so the change is documented in the docs only. A spec that the cluster serves does not + prove how the cluster behaves either. The docs spec changed again at 21:45:58 GMT. + [#570](https://github.com/coder/openstorage/issues/570) tracks that revision. - **Recurring diff:** the live upstream spec and docs are periodically re-fetched and diffed against the snapshot and the implemented surface. Every new, changed, or deprecated upstream endpoint becomes a GitHub issue labeled diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index c35cf7a03..2c26732bb 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -33,9 +33,10 @@ are in [`../upstream/openapi.yaml`](../upstream/openapi.yaml). `repo_name_mismatch` (400: the create body's `repo_name` differs from the token's `repo` claim), `branch_not_found` (404), `ref_not_found` (404: a ref, tag, or revision does not resolve), `credential_not_found` (404), `credential_exists` (409), - and `repository_deleted` (409: `DELETE /api/v1/repos/delete` on a deleted repository; + `repository_deleted` (409: `DELETE /api/v1/repos/delete` on a deleted repository; `DELETE /api/repos/{repo_name}` answers that case with 404 `repository_not_found`, - because its name lookup skips deleted repositories). open.storage + because its name lookup skips deleted repositories), and `ref_guard_failed` (409: a + failed merge guard, with `guard` and `current_sha`, [details](#merge-guards)). open.storage emits no other specific codes. As upstream advises, treat an unknown code as the generic code for its status. Branch on the status code or `code`, not on the message. @@ -59,8 +60,8 @@ are in [`../upstream/openapi.yaml`](../upstream/openapi.yaml). ([streaming protocols](../explanation/streaming-commit-protocols.md)), even on 4xx/5xx statuses `[400,401,403,404,408,409,412,422,429,499,500,502,503,504]`. One exception: a merge `409` for a failed guard (a stale `expected_target_sha`, or a source branch - that no longer contains `expected_source_sha`) uses the error body above, as upstream - documents ([details](#merge-guards)). + that no longer contains `expected_source_sha`) uses the error body above with code + `ref_guard_failed`, as upstream documents ([details](#merge-guards)). - **Throttling**: every `429` (the gateway [rate limiter](configuration.md#rate-limiting) and transient busy/backpressure answers) carries: - `Retry-After` (delay-seconds), which takes precedence over the `RateLimit` window; @@ -277,15 +278,27 @@ Each page runs one `git rev-list --topo-order` walk for `order`, as one more git | `POST /repos/branches/create` | git:write | `{target_branch?, base_ref?\|base_branch?(deprecated), preferred_base?, base_is_ephemeral?, target_is_ephemeral?, force?}` → `{message, target_branch, target_is_ephemeral, commit_sha?}` | | `DELETE /repos/branches` | git:write | Query `target_branch`, `ephemeral?`. The deprecated body `{target_branch (deprecated alias name), ephemeral?}` still works; a query parameter that is present wins over its body field, even when empty → `{target_branch, name (deprecated mirror), message, ephemeral}`. Refuses default branch (persistent): 409 for the query form, 403 for the body form | | `GET /repos/branches/diff` | git:read | **Deprecated upstream** (both path styles); still implemented. Use `GET /repos/diff` ([migration](#migrating-from-branchesdiff)). `branch` (req), `base?` (defaults to the repo default branch), `ephemeral?`, `ephemeral_base?`, `path` (repeatable) → `{branch, base, merge_base_sha, stats: {files, additions, deletions, changes}, files: [FileDiff], filtered_files}`; `merge_base_sha` is empty when the histories are unrelated (direct `base..branch` diff) | -| `POST /repos/merge` | git:write | `{source_ref (bare branch name or refs/heads form; deprecated alias source_branch), target_branch, strategy: merge\|ff_only\|ff_prefer, source_is_ephemeral?, target_is_ephemeral?, expected_target_sha?, expected_source_sha?, commit_message?, author?, committer?, allow_unrelated_histories?, squash?}` → `{result: merge_commit\|fast_forward\|no_op\|squash, commit_sha, tree_sha, source: {ref, branch (deprecated mirror), ephemeral, sha}, target: {branch, ephemeral, old_sha, new_sha}, merge_base_sha?, promoted_commits}`. Conflict → 409 (+`conflict_paths`, `merge_base_sha`). A failed `expected_target_sha` or `expected_source_sha` guard → 409 error body, `code: conflict` ([details](#merge-guards)) | +| `POST /repos/merge` | git:write | `{source_ref (bare branch name or refs/heads form; deprecated alias source_branch), target_branch, strategy: merge\|ff_only\|ff_prefer, source_is_ephemeral?, target_is_ephemeral?, expected_target_sha?, expected_source_sha?, commit_message?, author?, committer?, allow_unrelated_histories?, squash?}` → `{result: merge_commit\|fast_forward\|no_op\|squash, commit_sha, tree_sha, source: {ref, branch (deprecated mirror), ephemeral, sha}, target: {branch, ephemeral, old_sha, new_sha}, merge_base_sha?, promoted_commits}`. Conflict → 409 (+`conflict_paths`, `merge_base_sha`). A failed `expected_target_sha` or `expected_source_sha` guard → 409 error body, `code: ref_guard_failed` ([details](#merge-guards)) | | `GET /repos/merge/preview` | git:read | `source_branch, target_branch, source_is_ephemeral?, target_is_ephemeral?` (deprecated aliases `source_ephemeral`/`target_ephemeral`), `include_content?` → `{status: clean\|conflicted, result, source_branch, target_branch, source_tip_sha, target_tip_sha, merge_base_sha?, conflict_paths, conflicts: [{path, result\|base\|ours\|theirs: {oid?, content?, truncated, binary}}], filtered_conflicts: [{path, reason}]}`. Read-only | #### Merge guards +A failed guard answers `409` with the error body before any merge work, and nothing +changes. The body has `code: ref_guard_failed` and two more members, as upstream's +`RefGuardProblemDetails`: + +- `guard`: the request field of the guard that failed, `expected_target_sha` or + `expected_source_sha`. +- `current_sha`: the tip of the guarded branch when the server checked the guard, or + 40 zeros when the source branch does not exist. Send it as the guard of the next + attempt. + +Before [#568](https://github.com/coder/openstorage/issues/568), the code was +`conflict`, without `guard` and `current_sha`. + `expected_target_sha` (a full 40-hex commit SHA) requires the target branch to still -point at that commit. If it does not, the merge answers `409` with the error body -(`code: conflict`, `detail` "expected_target_sha … does not match current tip …") -before any merge work, and nothing changes. v0.18.0 and earlier answered `412` +point at that commit. If it does not, the guard fails (`detail` "expected_target_sha … +does not match current tip …"). v0.18.0 and earlier answered `412` `precondition_failed` with a `result` object ([#532](https://github.com/coder/openstorage/issues/532)). @@ -295,17 +308,18 @@ commit instead of the current tip of `source_ref`. Use it to merge exactly the moved on since. - The source branch must still contain the commit: the commit is the tip or one of its - ancestors. Otherwise the merge answers `409` with the error body (`code: conflict`, - `detail` "source branch moved: the source branch does not contain commit …") before - any merge work, and nothing changes. + ancestors. Otherwise the guard fails (`detail` "source branch moved: the source + branch does not contain commit …"). +- A source branch that does not exist fails the guard too, with `current_sha` 40 + zeros. Without `expected_source_sha`, a missing source branch is `404`. - `source.sha` in the response is the pinned commit. - An explicit `null`, `""` or any value that is not a full SHA is `400`. -- `expected_target_sha` is checked first. When both guards fail, the `detail` names the - target. +- `expected_target_sha` is checked first, after both branches are looked up. When both + guards fail, the body names the target. A missing target branch is `404`. This follows the [public OpenAPI](https://code.storage/docs/openapi.yaml) of -2026-10-16. On 2026-10-22 the cluster OpenAPI served the same spec bytes as the docs, -so it lists the field too +2026-10-16 (the field) and 2026-10-27 (`ref_guard_failed`). On 2026-10-27 the cluster +OpenAPI still served the older spec ([snapshot provenance](../explanation/wire-compatibility.md#how-drift-is-tracked)). #### Migrating from `branches/diff` diff --git a/docs/upstream/openapi.yaml b/docs/upstream/openapi.yaml index 14483446d..8ce23d885 100644 --- a/docs/upstream/openapi.yaml +++ b/docs/upstream/openapi.yaml @@ -287,6 +287,18 @@ components: - committer_email - date type: object + CommitWriteRefGuardProblemDetails: + allOf: + - $ref: "#/components/schemas/RefGuardProblemDetails" + - properties: + result: + $ref: "#/components/schemas/RefUpdateResult" + description: Final branch update outcome. + required: + - result + type: object + description: Problem details of a commit write whose `expected_target_sha` guard failed. The body keeps `result`, and `commit` is null. + type: object CommitWriteResponse: additionalProperties: false description: Result of applying an NDJSON commit pack, including the resulting commit and branch update. @@ -1121,6 +1133,48 @@ components: - ref - sha type: object + NotesWriteRefGuardProblemDetails: + allOf: + - $ref: "#/components/schemas/RefGuardProblemDetails" + - properties: + base_commit: + description: Previous notes ref commit SHA. + examples: + - 0c8b5bb9d550d58fcfd651a4fdce8024ef7f1492 + type: string + new_ref_sha: + description: New notes ref SHA after the operation. + examples: + - 3a1d9f7d6b6d482c970554e2a6ef980ecf4f1d55 + type: string + notes_ref: + description: The notes ref the operation updates. + examples: + - refs/notes/commits + type: string + result: + $ref: "#/components/schemas/NotesWriteResult" + description: Operation result with success, status, and optional message. + sha: + description: The commit SHA the note is attached to. + examples: + - b003fc78805954584e1ee364a4ad39d7c79e819a + type: string + target_ref: + deprecated: true + description: "Deprecated: use notes_ref. The notes ref the operation updates." + examples: + - refs/notes/commits + type: string + required: + - sha + - notes_ref + - target_ref + - new_ref_sha + - result + type: object + description: Problem details of a notes write whose `expected_notes_ref_sha` guard failed. The body keeps the members of the notes write body. + type: object NotesWriteResponse: additionalProperties: false description: Result of a notes write operation, including the resulting notes ref state. @@ -1285,6 +1339,28 @@ components: - status - code type: object + RefGuardProblemDetails: + allOf: + - $ref: "#/components/schemas/ProblemDetails" + - properties: + current_sha: + description: The commit of the guarded ref when the server checked the guard, or 40 zeros when the ref does not exist. Send it as the guard of the next attempt. A commit write to a new branch with `base_branch` compares the guard with the tip of `base_branch`, so `current_sha` is that tip. + examples: + - c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + type: string + guard: + description: The request field of the guard that failed. A request that sends a deprecated alias of the field gets the standard name. + enum: + - expected_target_sha + - expected_source_sha + - expected_notes_ref_sha + type: string + required: + - guard + - current_sha + type: object + description: Problem details of a failed ref guard, with the code `ref_guard_failed`. `guard` names the request field of the guard, and `current_sha` is the commit of the guarded ref. + type: object RefUpdateResult: additionalProperties: false description: Branch update outcome for an operation that writes a commit. @@ -1804,7 +1880,7 @@ components: $ref: "#/components/schemas/FilteredFile" type: array merge_base_sha: - description: Merge base SHA used for explicit baseSha three-dot comparisons. Empty for single-commit diffs. + description: "The merge base of base_ref and ref: the commit where the diff starts. Empty for a single-commit diff." examples: - a2d127e6a4d54bb7390de828a99e36411f0c84df type: string @@ -2709,6 +2785,18 @@ components: - default_branch - created_at type: object + RestoreCommitRefGuardProblemDetails: + allOf: + - $ref: "#/components/schemas/RefGuardProblemDetails" + - properties: + result: + $ref: "#/components/schemas/RefUpdateResult" + description: Branch update result. + required: + - result + type: object + description: Problem details of a restore whose `expected_target_sha` guard failed. The body keeps `result`, and `commit` is null. + type: object RestoredCommit: additionalProperties: false description: Commit object generated by a restore-commit operation. @@ -2954,6 +3042,7 @@ info: - `branch_not_found` (404): the branch does not exist. - `ref_not_found` (404): the ref or revision does not exist. - `merge_conflict` (409): the merge has file conflicts. + - `ref_guard_failed` (409): a ref guard such as `expected_target_sha` does not match the ref; `guard` names it and `current_sha` is the commit of the ref. - `credential_not_found` (404): the Git credential does not exist. - `credential_exists` (409): the repository already has a Git credential. - `github_app_not_configured` (412): the organization does not have exactly one GitHub App. @@ -5324,7 +5413,28 @@ paths: target_branch: main schema: $ref: "#/components/schemas/CommitWriteResponse" - description: The branch moved or the requested update could not be applied cleanly. + application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + code: ref_guard_failed + error: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + guard: expected_target_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + commit: null + result: + target_branch: main + branch: main + old_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + new_sha: "" + success: false + status: conflict + message: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + schema: + $ref: "#/components/schemas/CommitWriteRefGuardProblemDetails" + description: "The branch moved or the requested update could not be applied cleanly. When `expected_target_sha` is not the tip of the branch, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the tip of the branch, which `result.old_sha` repeats. The body keeps the members of the failure body, and `result.status` is `conflict`." "413": content: application/json: @@ -5589,7 +5699,7 @@ paths: name: sha schema: type: string - - description: Optional base branch, tag, or commit to diff against. When omitted, the diff is generated against the commit's parent. Provide `base_ref` or the deprecated `baseSha`. + - description: Optional base branch, tag, or commit to diff against. When omitted, the diff is generated against the commit's parent. When set, the diff starts at the merge base of `base_ref` and `ref`, as `git diff base_ref...ref` does, and `merge_base_sha` gives that commit. So the diff holds only the changes on `ref` after the two histories split. When `ref` is an ancestor of `base_ref`, the merge base is `ref`, and the diff is empty. Provide `base_ref` or the deprecated `baseSha`. example: a4d39d7c79e819ab003fc78805954584e1ee36 in: query name: base_ref @@ -5865,7 +5975,28 @@ paths: target_branch: main schema: $ref: "#/components/schemas/CommitWriteResponse" - description: The diff could not be applied cleanly or the target branch tip changed. + application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + code: ref_guard_failed + error: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + guard: expected_target_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + commit: null + result: + target_branch: main + branch: main + old_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + new_sha: "" + success: false + status: conflict + message: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + schema: + $ref: "#/components/schemas/CommitWriteRefGuardProblemDetails" + description: "The diff could not be applied cleanly or the target branch tip changed. When `expected_target_sha` is not the tip of the branch, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the tip of the branch, which `result.old_sha` repeats. The body keeps the members of the failure body, and `result.status` is `conflict`." "429": content: application/json: @@ -6505,12 +6636,11 @@ paths: name: cursor schema: type: string - - description: Maximum number of paths to return. Defaults to 1000. + - description: Maximum number of paths to return. A call with no `limit` and no `cursor` returns every path in one response, with `has_more` false. A `limit` of 0 is the same as no `limit`. A call with a `cursor` and no `limit` returns up to 1000 paths. example: 1000 in: query name: limit schema: - default: 1000 maximum: 5000 minimum: 0 type: integer @@ -6638,6 +6768,8 @@ paths: description: |- Return a recursive file tree together with per-file commit metadata in one request. Each file also includes a derived `type` field for symmetry with `/files`. + The server reads the last commit of each file under `path`, also when the call sends a `limit` or a `cursor`. When those files have more than 50,000 distinct last commits, the call fails with a 429 response, and a retry gets the same response. To list such a tree, send a narrower `path`. + Scope: `git:read` on the repository. operationId: repos-files-metadata-list parameters: @@ -6672,12 +6804,11 @@ paths: name: cursor schema: type: string - - description: Maximum number of files to return. Defaults to 200. + - description: Maximum number of files to return. A call with no `limit` and no `cursor` returns every file in one response, with `has_more` false. A `limit` of 0 is the same as no `limit`. A call with a `cursor` and no `limit` returns up to 200 files. example: 200 in: query name: limit schema: - default: 200 maximum: 1000 minimum: 0 type: integer @@ -7450,11 +7581,13 @@ paths: title: Conflict status: 409 detail: "source branch moved: the source branch does not contain commit 9eb378bdb5bf1944f6ba0bd5a2c4df3ce32f4df1" - code: conflict + code: ref_guard_failed error: "source branch moved: the source branch does not contain commit 9eb378bdb5bf1944f6ba0bd5a2c4df3ce32f4df1" + guard: expected_source_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 schema: $ref: "#/components/schemas/ProblemDetails" - description: "The merge could not be completed. An `application/json` body reports file-level conflicts, or an automatic stale-target retry that could not absorb concurrent target/repository movement. Inspect conflict_type to distinguish merge_conflict, target_moved, and repo_changed. A problem body reports any other conflict. A failed guard has the code `conflict`: the target no longer matches `expected_target_sha`, or the source branch no longer contains `expected_source_sha`." + description: "The merge could not be completed. An `application/json` body reports file-level conflicts, or an automatic stale-target retry that could not absorb concurrent target/repository movement. Inspect conflict_type to distinguish merge_conflict, target_moved, and repo_changed. A problem body reports any other conflict. A failed guard has the code `ref_guard_failed`: the target no longer matches `expected_target_sha`, or the source branch no longer contains `expected_source_sha`. Its body is a `RefGuardProblemDetails`: `guard` names the guard, and `current_sha` is the tip of the guarded branch, or 40 zeros when the source branch does not exist." "429": content: application/json: @@ -7780,9 +7913,26 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + code: ref_guard_failed + error: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + guard: expected_notes_ref_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + sha: b003fc78805954584e1ee364a4ad39d7c79e819a + notes_ref: refs/notes/commits + target_ref: refs/notes/commits + new_ref_sha: "" + result: + success: false + status: conflict + message: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/NotesWriteRefGuardProblemDetails" + description: "Conflict. When `expected_notes_ref_sha` is not the SHA of the notes ref, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the SHA of the notes ref, or 40 zeros when the notes ref does not exist. The body keeps the members of the failure body, and `result.status` is `conflict`." "429": content: application/json: @@ -8074,9 +8224,26 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + code: ref_guard_failed + error: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + guard: expected_notes_ref_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + sha: b003fc78805954584e1ee364a4ad39d7c79e819a + notes_ref: refs/notes/commits + target_ref: refs/notes/commits + new_ref_sha: "" + result: + success: false + status: conflict + message: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/NotesWriteRefGuardProblemDetails" + description: "Conflict. When `expected_notes_ref_sha` is not the SHA of the notes ref, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the SHA of the notes ref, or 40 zeros when the notes ref does not exist. The body keeps the members of the failure body, and `result.status` is `conflict`." "429": content: application/json: @@ -8229,9 +8396,26 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + code: ref_guard_failed + error: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + guard: expected_notes_ref_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + sha: b003fc78805954584e1ee364a4ad39d7c79e819a + notes_ref: refs/notes/commits + target_ref: refs/notes/commits + new_ref_sha: "" + result: + success: false + status: conflict + message: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/NotesWriteRefGuardProblemDetails" + description: "Conflict. When `expected_notes_ref_sha` is not the SHA of the notes ref, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the SHA of the notes ref, or 40 zeros when the notes ref does not exist. The body keeps the members of the failure body, and `result.status` is `conflict`." "429": content: application/json: @@ -8653,9 +8837,27 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + code: ref_guard_failed + error: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + guard: expected_target_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + commit: null + result: + target_branch: main + branch: main + old_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + new_sha: "" + success: false + status: conflict + message: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/RestoreCommitRefGuardProblemDetails" + description: "Conflict. When `expected_target_sha` is not the tip of the branch, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the tip of the branch, which `result.old_sha` repeats. The body keeps the members of the failure body, and `result.status` is `conflict`." "412": content: application/json: @@ -8810,9 +9012,27 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + code: ref_guard_failed + error: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + guard: expected_target_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + commit: null + result: + target_branch: main + branch: main + old_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + new_sha: "" + success: false + status: conflict + message: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/RestoreCommitRefGuardProblemDetails" + description: "Conflict. When `expected_target_sha` is not the tip of the branch, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the tip of the branch, which `result.old_sha` repeats. The body keeps the members of the failure body, and `result.status` is `conflict`." "412": content: application/json: @@ -11603,7 +11823,28 @@ paths: target_branch: main schema: $ref: "#/components/schemas/CommitWriteResponse" - description: The branch moved or the requested update could not be applied cleanly. + application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + code: ref_guard_failed + error: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + guard: expected_target_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + commit: null + result: + target_branch: main + branch: main + old_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + new_sha: "" + success: false + status: conflict + message: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + schema: + $ref: "#/components/schemas/CommitWriteRefGuardProblemDetails" + description: "The branch moved or the requested update could not be applied cleanly. When `expected_target_sha` is not the tip of the branch, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the tip of the branch, which `result.old_sha` repeats. The body keeps the members of the failure body, and `result.status` is `conflict`." "413": content: application/json: @@ -11996,7 +12237,7 @@ paths: name: sha schema: type: string - - description: Optional base branch, tag, or commit to diff against. When omitted, the diff is generated against the commit's parent. Provide `base_ref` or the deprecated `baseSha`. + - description: Optional base branch, tag, or commit to diff against. When omitted, the diff is generated against the commit's parent. When set, the diff starts at the merge base of `base_ref` and `ref`, as `git diff base_ref...ref` does, and `merge_base_sha` gives that commit. So the diff holds only the changes on `ref` after the two histories split. When `ref` is an ancestor of `base_ref`, the merge base is `ref`, and the diff is empty. Provide `base_ref` or the deprecated `baseSha`. example: a4d39d7c79e819ab003fc78805954584e1ee36 in: query name: base_ref @@ -12268,7 +12509,28 @@ paths: target_branch: main schema: $ref: "#/components/schemas/CommitWriteResponse" - description: The diff could not be applied cleanly or the target branch tip changed. + application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + code: ref_guard_failed + error: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + guard: expected_target_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + commit: null + result: + target_branch: main + branch: main + old_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + new_sha: "" + success: false + status: conflict + message: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + schema: + $ref: "#/components/schemas/CommitWriteRefGuardProblemDetails" + description: "The diff could not be applied cleanly or the target branch tip changed. When `expected_target_sha` is not the tip of the branch, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the tip of the branch, which `result.old_sha` repeats. The body keeps the members of the failure body, and `result.status` is `conflict`." "429": content: application/json: @@ -12899,12 +13161,11 @@ paths: name: cursor schema: type: string - - description: Maximum number of paths to return. Defaults to 1000. + - description: Maximum number of paths to return. A call with no `limit` and no `cursor` returns every path in one response, with `has_more` false. A `limit` of 0 is the same as no `limit`. A call with a `cursor` and no `limit` returns up to 1000 paths. example: 1000 in: query name: limit schema: - default: 1000 maximum: 5000 minimum: 0 type: integer @@ -13034,6 +13295,8 @@ paths: description: |- Return a recursive file tree together with per-file commit metadata in one request. Each file also includes a derived `type` field for symmetry with `/files`. + The server reads the last commit of each file under `path`, also when the call sends a `limit` or a `cursor`. When those files have more than 50,000 distinct last commits, the call fails with a 429 response, and a retry gets the same response. To list such a tree, send a narrower `path`. + Scope: `git:read` on the repository. Deprecated: Use `/api/repos/{repo_name}/files/metadata` instead. @@ -13063,12 +13326,11 @@ paths: name: cursor schema: type: string - - description: Maximum number of files to return. Defaults to 200. + - description: Maximum number of files to return. A call with no `limit` and no `cursor` returns every file in one response, with `has_more` false. A `limit` of 0 is the same as no `limit`. A call with a `cursor` and no `limit` returns up to 200 files. example: 200 in: query name: limit schema: - default: 200 maximum: 1000 minimum: 0 type: integer @@ -13820,11 +14082,13 @@ paths: title: Conflict status: 409 detail: "source branch moved: the source branch does not contain commit 9eb378bdb5bf1944f6ba0bd5a2c4df3ce32f4df1" - code: conflict + code: ref_guard_failed error: "source branch moved: the source branch does not contain commit 9eb378bdb5bf1944f6ba0bd5a2c4df3ce32f4df1" + guard: expected_source_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 schema: $ref: "#/components/schemas/ProblemDetails" - description: "The merge could not be completed. An `application/json` body reports file-level conflicts, or an automatic stale-target retry that could not absorb concurrent target/repository movement. Inspect conflict_type to distinguish merge_conflict, target_moved, and repo_changed. A problem body reports any other conflict. A failed guard has the code `conflict`: the target no longer matches `expected_target_sha`, or the source branch no longer contains `expected_source_sha`." + description: "The merge could not be completed. An `application/json` body reports file-level conflicts, or an automatic stale-target retry that could not absorb concurrent target/repository movement. Inspect conflict_type to distinguish merge_conflict, target_moved, and repo_changed. A problem body reports any other conflict. A failed guard has the code `ref_guard_failed`: the target no longer matches `expected_target_sha`, or the source branch no longer contains `expected_source_sha`. Its body is a `RefGuardProblemDetails`: `guard` names the guard, and `current_sha` is the tip of the guarded branch, or 40 zeros when the source branch does not exist." "429": content: application/json: @@ -14153,9 +14417,26 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + code: ref_guard_failed + error: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + guard: expected_notes_ref_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + sha: b003fc78805954584e1ee364a4ad39d7c79e819a + notes_ref: refs/notes/commits + target_ref: refs/notes/commits + new_ref_sha: "" + result: + success: false + status: conflict + message: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/NotesWriteRefGuardProblemDetails" + description: "Conflict. When `expected_notes_ref_sha` is not the SHA of the notes ref, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the SHA of the notes ref, or 40 zeros when the notes ref does not exist. The body keeps the members of the failure body, and `result.status` is `conflict`." "429": content: application/json: @@ -14443,9 +14724,26 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + code: ref_guard_failed + error: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" + guard: expected_notes_ref_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + sha: b003fc78805954584e1ee364a4ad39d7c79e819a + notes_ref: refs/notes/commits + target_ref: refs/notes/commits + new_ref_sha: "" + result: + success: false + status: conflict + message: "notes ref mismatch: got c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2, expected d34db33fd34db33fd34db33fd34db33fd34db33f" schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/NotesWriteRefGuardProblemDetails" + description: "Conflict. When `expected_notes_ref_sha` is not the SHA of the notes ref, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the SHA of the notes ref, or 40 zeros when the notes ref does not exist. The body keeps the members of the failure body, and `result.status` is `conflict`." "429": content: application/json: @@ -14852,9 +15150,27 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + code: ref_guard_failed + error: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + guard: expected_target_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + commit: null + result: + target_branch: main + branch: main + old_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + new_sha: "" + success: false + status: conflict + message: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/RestoreCommitRefGuardProblemDetails" + description: "Conflict. When `expected_target_sha` is not the tip of the branch, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the tip of the branch, which `result.old_sha` repeats. The body keeps the members of the failure body, and `result.status` is `conflict`." "412": content: application/json: @@ -15005,9 +15321,27 @@ paths: schema: $ref: "#/components/schemas/ProblemDetails" application/problem+json: + example: + type: about:blank + title: Conflict + status: 409 + detail: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + code: ref_guard_failed + error: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + guard: expected_target_sha + current_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + commit: null + result: + target_branch: main + branch: main + old_sha: c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 + new_sha: "" + success: false + status: conflict + message: base_ref (d34db33fd34db33fd34db33fd34db33fd34db33f) does not match current head c4f0fdfc41adab56630b34f5f4fd4e84a2c5b4d2 schema: - $ref: "#/components/schemas/ProblemDetails" - description: Conflict + $ref: "#/components/schemas/RestoreCommitRefGuardProblemDetails" + description: "Conflict. When `expected_target_sha` is not the tip of the branch, the body is `application/problem+json` with the code `ref_guard_failed`: `guard` names the guard, and `current_sha` is the tip of the branch, which `result.old_sha` repeats. The body keeps the members of the failure body, and `result.status` is `conflict`." "412": content: application/json: diff --git a/internal/api/errors.go b/internal/api/errors.go index de25799e9..e2f573a05 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -8,6 +8,8 @@ import ( "reflect" "slices" "strings" + + "github.com/coder/openstorage/internal/ref" ) // internalErrorMsg is the only 500 body clients ever see: raw error strings @@ -50,6 +52,12 @@ type errorEnvelope struct { // InvalidParams is upstream's ValidationProblemDetails member: only on // a 400 for a request body, only when the server can name the field. InvalidParams []invalidParam `json:"invalid_params,omitempty"` + // Guard and CurrentSHA are upstream's RefGuardProblemDetails members, + // set together and only on code ref_guard_failed: the request field of + // the failed guard, and the commit of the guarded ref (40 zeros when + // the ref does not exist). + Guard string `json:"guard,omitempty"` + CurrentSHA string `json:"current_sha,omitempty"` } // invalidParam is one item of upstream's `invalid_params` (InvalidParam): @@ -77,7 +85,24 @@ func writeProblem(w http.ResponseWriter, status int, code, msg string) { // writeProblemParams is writeProblem with `invalid_params`, which only a // 400 may carry. func writeProblemParams(w http.ResponseWriter, status int, code, msg string, params []invalidParam) { + writeProblemBody(w, status, code, msg, params, "", "") +} + +// writeRefGuardFailed writes the 409 ref_guard_failed problem body of a +// failed ref guard (upstream's RefGuardProblemDetails). guard is the +// standard name of the request field; currentSHA is the guarded ref's +// commit, or ref.ZeroSHA when the ref does not exist. +func writeRefGuardFailed(w http.ResponseWriter, guard, currentSHA, msg string) { + assertf(guard == "expected_target_sha" || guard == "expected_source_sha" || guard == "expected_notes_ref_sha", + "writeRefGuardFailed: unknown guard %q", guard) + assertf(ref.ValidSHA(currentSHA), "writeRefGuardFailed: current_sha %q is not a full sha", currentSHA) + writeProblemBody(w, http.StatusConflict, codeRefGuardFailed, msg, nil, guard, currentSHA) +} + +func writeProblemBody(w http.ResponseWriter, status int, code, msg string, params []invalidParam, guard, currentSHA string) { assertf(status >= 400 && status < 600, "writeProblem called with non-error status %d", status) + assertf((guard != "") == (code == codeRefGuardFailed) && (guard != "") == (currentSHA != ""), + "writeProblem: code %q with guard %q and current_sha %q", code, guard, currentSHA) assertf(len(params) == 0 || status == http.StatusBadRequest, "writeProblem: invalid_params on status %d", status) for _, p := range params { assertf(p.Name != "" && p.Reason != "", "writeProblem: incomplete invalid param %+v", p) @@ -104,6 +129,8 @@ func writeProblemParams(w http.ResponseWriter, status int, code, msg string, par Detail: msg, Instance: problemInstance(w), InvalidParams: params, + Guard: guard, + CurrentSHA: currentSHA, }) } @@ -238,6 +265,7 @@ const ( codeCredentialExists = "credential_exists" codeCredentialNotFound = "credential_not_found" codeInsufficientScope = "insufficient_scope" + codeRefGuardFailed = "ref_guard_failed" codeRefNotFound = "ref_not_found" codeRepoNameMismatch = "repo_name_mismatch" codeRepositoryDeleted = "repository_deleted" @@ -251,6 +279,7 @@ var specificProblemCodes = map[string]int{ codeCredentialExists: http.StatusConflict, codeCredentialNotFound: http.StatusNotFound, codeInsufficientScope: http.StatusForbidden, + codeRefGuardFailed: http.StatusConflict, codeRefNotFound: http.StatusNotFound, codeRepoNameMismatch: http.StatusBadRequest, codeRepositoryDeleted: http.StatusConflict, diff --git a/internal/api/handlers_write_merge.go b/internal/api/handlers_write_merge.go index fdd640e52..cca842f9b 100644 --- a/internal/api/handlers_write_merge.go +++ b/internal/api/handlers_write_merge.go @@ -512,7 +512,7 @@ func wmValidateMergeRequest(req *wmMergeRequest) error { // handleMerge implements POST /repos/merge (docs/reference/http-api.md): resolve both branch // tips from the ref authority, guard on expected_target_sha and -// expected_source_sha (409 problem bodies), decide +// expected_source_sha (409 ref_guard_failed problem bodies), decide // the outcome (no_op / fast_forward / merge_commit / squash), run // merge-tree in the scratch odb, enforce ref policies, and publish under // CAS. @@ -548,8 +548,11 @@ func (s *server) handleMerge(w http.ResponseWriter, r *http.Request) { } sourceRef := wcRefName(req.SourceBranch, req.SourceIsEphemeral) targetRef := wcRefName(req.TargetBranch, req.TargetIsEphemeral) - sourceSHA, ok := tips[sourceRef] - if !ok { + // With expected_source_sha, a missing source branch fails the source + // guard below (upstream: current_sha is 40 zeros), after the target + // checks. + sourceSHA, sourceExists := tips[sourceRef] + if !sourceExists && req.sourcePin == "" { wmFail(w, http.StatusNotFound, "not_found", "source branch "+req.SourceBranch+" not found", req.TargetBranch, "") return } @@ -558,13 +561,19 @@ func (s *server) handleMerge(w http.ResponseWriter, r *http.Request) { wmFail(w, http.StatusNotFound, "not_found", "target branch "+req.TargetBranch+" not found", req.TargetBranch, "") return } - // A stale guard is a 409 problem body (code conflict) before any merge - // work happens (ADR-22 as amended by #532: upstream's merge contract). + // A stale guard is a 409 ref_guard_failed problem body before any + // merge work happens (ADR-22 as amended by #532 and #568: upstream's + // merge contract). if req.ExpectedTargetSHA != "" && strings.ToLower(req.ExpectedTargetSHA) != targetSHA { - writeErr(w, http.StatusConflict, + writeRefGuardFailed(w, "expected_target_sha", targetSHA, fmt.Sprintf("expected_target_sha %s does not match current tip %s", req.ExpectedTargetSHA, targetSHA)) return } + sourceMovedMsg := "source branch moved: the source branch does not contain commit " + req.sourcePin + if !sourceExists { + writeRefGuardFailed(w, "expected_source_sha", ref.ZeroSHA, sourceMovedMsg) + return + } if err := wmEnsureMergeTreeGit(ctx); err != nil { s.wmInternal(w, r, "probe merge-tree support", err) @@ -592,7 +601,7 @@ func (s *server) handleMerge(w http.ResponseWriter, r *http.Request) { return } if !contained { - writeErr(w, http.StatusConflict, "source branch moved: the source branch does not contain commit "+req.sourcePin) + writeRefGuardFailed(w, "expected_source_sha", sourceSHA, sourceMovedMsg) return } sourceSHA = req.sourcePin diff --git a/internal/api/problem_test.go b/internal/api/problem_test.go index 907f63cc8..c9c7714fc 100644 --- a/internal/api/problem_test.go +++ b/internal/api/problem_test.go @@ -177,9 +177,9 @@ func upstreamSpecificCodes(t *testing.T) map[string]int { // open.storage emits to upstream's list, with the same status. func TestSpecificProblemCodesMatchUpstream(t *testing.T) { spec := upstreamSpecificCodes(t) - // Guard the parser itself: upstream's 2026-10-08 list has 22 items. - if len(spec) != 22 { - t.Fatalf("parsed %d specific codes from the spec, want 22: %v", len(spec), spec) + // Guard the parser itself: upstream's 2026-10-27 list has 23 items. + if len(spec) != 23 { + t.Fatalf("parsed %d specific codes from the spec, want 23: %v", len(spec), spec) } for code, status := range specificProblemCodes { if want, ok := spec[code]; !ok || want != status { @@ -218,6 +218,35 @@ func TestWriteProblemCodeAssertions(t *testing.T) { } } +// TestWriteRefGuardFailed: ref_guard_failed always carries a known guard +// and a full current_sha, and writeProblem cannot send it without them. +func TestWriteRefGuardFailed(t *testing.T) { + zero := strings.Repeat("0", 40) + for name, write := range map[string]func(){ + "writeProblem": func() { writeProblem(httptest.NewRecorder(), http.StatusConflict, "ref_guard_failed", "boom") }, + "unknown guard": func() { writeRefGuardFailed(httptest.NewRecorder(), "expected_ref_sha", zero, "boom") }, + "short sha": func() { writeRefGuardFailed(httptest.NewRecorder(), "expected_target_sha", "abc", "boom") }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s did not panic", name) + } + }() + write() + }() + } + rr := httptest.NewRecorder() + msg := "source branch moved: the source branch does not contain commit " + zero + writeRefGuardFailed(rr, "expected_source_sha", zero, msg) + if got := assertProblemFields(t, rr.Code, rr.Body.Bytes()); got != msg { + t.Fatalf("error = %q", got) + } + if body := rr.Body.String(); !strings.Contains(body, `"guard":"expected_source_sha","current_sha":"`+zero+`"`) { + t.Fatalf("body = %s", body) + } +} + // expectedProblemCodes is the test oracle for `code`: upstream's specific // code for the failures open.storage maps (issue #352), keyed on status // and message independently of the implementation, else the generic code. @@ -251,6 +280,9 @@ func expectedProblemCodes(t *testing.T, status int, msg string) []string { code = "credential_exists" case status == http.StatusConflict && msg == "repository already deleted": code = "repository_deleted" + case status == http.StatusConflict && (strings.HasPrefix(msg, "source branch moved: ") || + strings.HasPrefix(msg, "expected_target_sha ") && strings.Contains(msg, " does not match current tip ")): + code = "ref_guard_failed" } if code != "" { if got := upstreamSpecificCodes(t)[code]; got != status { @@ -289,6 +321,8 @@ type problemBody struct { Detail string `json:"detail"` Error string `json:"error"` Instance string `json:"instance"` + Guard string `json:"guard"` + CurrentSHA string `json:"current_sha"` InvalidParams []struct { Name string `json:"name"` Reason string `json:"reason"` @@ -300,6 +334,47 @@ type problemBody struct { // (TestProblemMembersMatchUpstream pins it to the spec). var problemMembers = []string{"code", "detail", "error", "instance", "invalid_params", "status", "title", "type"} +// refGuardMembers are the members RefGuardProblemDetails adds to +// ProblemDetails. A body with code ref_guard_failed must carry both, and +// no other body may (TestProblemMembersMatchUpstream pins them). +var refGuardMembers = []string{"current_sha", "guard"} + +// upstreamRefGuardSchema returns the properties of RefGuardProblemDetails' +// own allOf item and the `guard` enum, by indentation: the schema at 4 +// spaces, "- properties:" at 8, each property at 12, enum items at 16. +func upstreamRefGuardSchema(t *testing.T) (props, guards []string) { + t.Helper() + raw, err := os.ReadFile(upstreamSpecPath) + if err != nil { + t.Fatal(err) + } + inSchema, inProps, inEnum := false, false, false + for l := range strings.SplitSeq(string(raw), "\n") { + indent := len(l) - len(strings.TrimLeft(l, " ")) + trimmed := strings.TrimSpace(l) + switch { + case indent == 4 && trimmed == "RefGuardProblemDetails:": + inSchema = true + case inSchema && indent <= 4 && trimmed != "": + inSchema, inProps = false, false + case inSchema && indent == 8: + inProps = trimmed == "- properties:" + case inProps && indent == 12 && strings.HasSuffix(trimmed, ":"): + props = append(props, strings.TrimSuffix(trimmed, ":")) + inEnum = false + case inProps && indent == 14: + inEnum = props[len(props)-1] == "guard" && trimmed == "enum:" + case inEnum && indent == 16 && strings.HasPrefix(trimmed, "- "): + guards = append(guards, strings.TrimPrefix(trimmed, "- ")) + } + } + if len(props) == 0 || len(guards) == 0 { + t.Fatalf("RefGuardProblemDetails has properties %v and guards %v in %s", props, guards, upstreamSpecPath) + } + slices.Sort(props) + return props, guards +} + // upstreamSchemaProperties returns the property names of one schema under // components/schemas in the vendored spec, by indentation: the schema at // 4 spaces, "properties:" at 6, each property at 8. @@ -343,6 +418,13 @@ func TestProblemMembersMatchUpstream(t *testing.T) { if got := upstreamSchemaProperties(t, "InvalidParam"); !slices.Equal(got, []string{"name", "reason"}) { t.Fatalf("InvalidParam properties = %v, want [name reason]", got) } + props, guards := upstreamRefGuardSchema(t) + if !slices.Equal(props, refGuardMembers) { + t.Fatalf("RefGuardProblemDetails properties = %v, want %v", props, refGuardMembers) + } + if want := []string{"expected_target_sha", "expected_source_sha", "expected_notes_ref_sha"}; !slices.Equal(guards, want) { + t.Fatalf("RefGuardProblemDetails guard enum = %v, want %v", guards, want) + } raw, err := os.ReadFile(upstreamSpecPath) if err != nil { t.Fatal(err) @@ -508,6 +590,9 @@ func TestWriteProblemInvalidParamsAssertions(t *testing.T) { } } +// fullSHARE matches a full lowercase SHA-1 object id. +var fullSHARE = regexp.MustCompile(`^[0-9a-f]{40}$`) + // assertProblemFields checks the RFC 9457 fields of a plain-envelope error // body against its HTTP status and returns the legacy `error` message. // `code` must be one expectedProblemCodes derives from the status and @@ -530,11 +615,18 @@ func assertProblemFields(t *testing.T, status int, body []byte) string { if err := json.Unmarshal(body, &members); err != nil { t.Fatal(err) } + refGuard := p.Code == "ref_guard_failed" for m := range members { - if !slices.Contains(problemMembers, m) { + if !slices.Contains(problemMembers, m) && !(refGuard && slices.Contains(refGuardMembers, m)) { t.Fatalf("problem body has member %q outside upstream's schema (body %s)", m, body) } } + if refGuard { + _, guards := upstreamRefGuardSchema(t) + if _, ok := members["current_sha"]; !ok || !slices.Contains(guards, p.Guard) || !fullSHARE.MatchString(p.CurrentSHA) { + t.Fatalf("ref_guard_failed body needs guard in %v and a 40-hex current_sha (body %s)", guards, body) + } + } if raw, ok := members["invalid_params"]; ok { if status != http.StatusBadRequest || len(p.InvalidParams) == 0 || string(raw) == "null" { t.Fatalf("invalid_params on status %d must be a non-empty list on a 400 (body %s)", status, body) diff --git a/internal/api/write_merge_source_pin_test.go b/internal/api/write_merge_source_pin_test.go index f515a5e34..399761d80 100644 --- a/internal/api/write_merge_source_pin_test.go +++ b/internal/api/write_merge_source_pin_test.go @@ -12,7 +12,7 @@ import ( // TestMergeExpectedSourceSHA pins the source commit with // expected_source_sha (spec 2026-10-16, #531): the merge uses the pinned // commit, not the source tip, and a pin that source_ref does not contain -// is a 409 problem body with code conflict before any merge work. +// is a 409 ref_guard_failed problem body before any merge work (#568). func TestMergeExpectedSourceSHA(t *testing.T) { runBackends(t, func(t *testing.T, e *testEnv) { srv := wmServer(t, e) @@ -50,7 +50,7 @@ func TestMergeExpectedSourceSHA(t *testing.T) { } }) - t.Run("pin outside source_ref is 409 conflict and mutates nothing", func(t *testing.T) { + t.Run("pin outside source_ref is 409 ref_guard_failed and mutates nothing", func(t *testing.T) { tree := coreRevParse(t, fx.work, fx.c2+"^{tree}") // featTip is a commit on another branch, 1111… is absent from // the odb, and tree is not a commit. Source "conflict" would @@ -65,16 +65,10 @@ func TestMergeExpectedSourceSHA(t *testing.T) { wmMergeBody("conflict", "main", "merge", map[string]any{ "author": wmTestAuthor, "expected_source_sha": pin, })) - if resp.StatusCode != http.StatusConflict { - t.Fatalf("pin %s: status = %d, body %s", pin, resp.StatusCode, raw) - } - body := wmDecode(t, raw) - want := "source branch moved: the source branch does not contain commit " + pin - if body["code"] != "conflict" || body["detail"] != want || body["error"] != want { - t.Fatalf("pin %s: body = %v, want problem code conflict and %q", pin, body, want) - } - if _, has := body["conflict_paths"]; has { - t.Fatalf("pin %s: merge ran before the guard: %v", pin, body) + wmRequireRefGuard(t, resp, raw, "expected_source_sha", fx.conflictTip, + "source branch moved: the source branch does not contain commit "+pin) + if _, has := wmDecode(t, raw)["conflict_paths"]; has { + t.Fatalf("pin %s: merge ran before the guard: %s", pin, raw) } if refsAfter := wmRefsSnapshot(t, e, fx.repoID); !reflect.DeepEqual(refsBefore, refsAfter) { t.Fatalf("pin %s: refs changed on a refused merge", pin) @@ -89,6 +83,35 @@ func TestMergeExpectedSourceSHA(t *testing.T) { } }) + t.Run("pin on a missing source branch is 409 with 40 zeros", func(t *testing.T) { + // Upstream (spec 2026-10-27, changelog 2026-10-22): a source + // branch that does not exist fails the guard, and current_sha is + // 40 zeros. Without a pin it stays a 404. + refsBefore := wmRefsSnapshot(t, e, fx.repoID) + resp, raw := wmDo(t, srv, "POST", "/api/v1/repos/merge", fx.token, + wmMergeBody("gone", "main", "merge", map[string]any{"author": wmTestAuthor, "expected_source_sha": fx.featTip})) + wmRequireRefGuard(t, resp, raw, "expected_source_sha", strings.Repeat("0", 40), + "source branch moved: the source branch does not contain commit "+fx.featTip) + if refsAfter := wmRefsSnapshot(t, e, fx.repoID); !reflect.DeepEqual(refsBefore, refsAfter) { + t.Fatal("refs changed on a refused merge") + } + resp, raw = wmDo(t, srv, "POST", "/api/v1/repos/merge", fx.token, + wmMergeBody("gone", "main", "merge", map[string]any{"author": wmTestAuthor})) + if resp.StatusCode != http.StatusNotFound || wmResult(t, wmDecode(t, raw))["message"] != "source branch gone not found" { + t.Fatalf("no pin: status %d body %s, want 404 source branch gone not found", resp.StatusCode, raw) + } + // The target guard runs first, and a missing target is a 404. + resp, raw = wmDo(t, srv, "POST", "/api/v1/repos/merge", fx.token, + wmMergeBody("gone", "main", "merge", map[string]any{"expected_source_sha": fx.featTip, "expected_target_sha": fx.c1})) + wmRequireRefGuard(t, resp, raw, "expected_target_sha", fx.c2, + "expected_target_sha "+fx.c1+" does not match current tip "+fx.c2) + resp, raw = wmDo(t, srv, "POST", "/api/v1/repos/merge", fx.token, + wmMergeBody("gone", "nope", "merge", map[string]any{"expected_source_sha": fx.featTip})) + if resp.StatusCode != http.StatusNotFound || wmResult(t, wmDecode(t, raw))["message"] != "target branch nope not found" { + t.Fatalf("missing target: status %d body %s, want 404 target branch nope not found", resp.StatusCode, raw) + } + }) + t.Run("malformed pin is 400", func(t *testing.T) { for _, pin := range []any{"", nil, "abc123", fx.ffMid + "0", 5} { resp, raw := wmDo(t, srv, "POST", "/api/v1/repos/merge", fx.token, @@ -112,9 +135,8 @@ func TestMergeExpectedSourceSHA(t *testing.T) { return e.doReq(t, http.MethodPost, path, fx.token, payload, "") } resp, raw := post(fx.featTip) - if resp.StatusCode != http.StatusConflict || wmDecode(t, raw)["code"] != "conflict" { - t.Fatalf("stale pin: status %d body %s, want 409 conflict", resp.StatusCode, raw) - } + wmRequireRefGuard(t, resp, raw, "expected_source_sha", fx.ffTip, + "source branch moved: the source branch does not contain commit "+fx.featTip) resp, raw = post(fx.ffMid) if body := wmDecode(t, raw); resp.StatusCode != http.StatusOK || body["commit_sha"] != fx.ffMid { t.Fatalf("status %d body %s, want fast_forward to %s", resp.StatusCode, raw, fx.ffMid) diff --git a/internal/api/write_merge_test.go b/internal/api/write_merge_test.go index 4b3fb5dfc..1c5e206c8 100644 --- a/internal/api/write_merge_test.go +++ b/internal/api/write_merge_test.go @@ -92,6 +92,27 @@ func wmDecode(t *testing.T, body []byte) map[string]any { return decoded } +// wmRequireRefGuard asserts a failed merge guard (#568): 409 with a +// RefGuardProblemDetails body (code ref_guard_failed, the oracle in +// assertProblemFields), the exact guard, current_sha and detail, and no +// result envelope. +func wmRequireRefGuard(t *testing.T, resp *http.Response, raw []byte, guard, currentSHA, detail string) { + t.Helper() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("status = %d, want 409 (body %s)", resp.StatusCode, raw) + } + if msg := requireEnvelope(t, resp, raw); msg != detail { + t.Fatalf("error = %q, want %q", msg, detail) + } + body := wmDecode(t, raw) + if body["code"] != "ref_guard_failed" || body["guard"] != guard || body["current_sha"] != currentSHA { + t.Fatalf("body = %s, want code ref_guard_failed, guard %s, current_sha %s", raw, guard, currentSHA) + } + if _, has := body["result"]; has { + t.Fatalf("failed guard carries a result envelope: %s", raw) + } +} + // wmResult extracts the failure envelope's result object. func wmResult(t *testing.T, body map[string]any) map[string]any { t.Helper() @@ -556,24 +577,15 @@ func TestMergeConflictsAndGuards(t *testing.T) { } }) - t.Run("stale expected_target_sha is 409 conflict and leaves state", func(t *testing.T) { - // #532: upstream's merge contract answers a failed guard with a - // problem body, code conflict, not ADR-22's 412. + t.Run("stale expected_target_sha is 409 ref_guard_failed and leaves state", func(t *testing.T) { + // #532, #568: upstream's merge contract answers a failed guard + // with a RefGuardProblemDetails body, not ADR-22's 412. resp, raw := wmDo(t, srv, "POST", "/api/v1/repos/merge", fx.token, wmMergeBody("feature", "main", "merge", map[string]any{ "author": wmTestAuthor, "expected_target_sha": fx.c1, // stale })) - if resp.StatusCode != http.StatusConflict { - t.Fatalf("status = %d, body %s", resp.StatusCode, raw) - } - body := wmDecode(t, raw) - want := "expected_target_sha " + fx.c1 + " does not match current tip " + fx.c2 - if body["code"] != "conflict" || body["status"] != float64(http.StatusConflict) || body["detail"] != want || body["error"] != want { - t.Fatalf("body = %v, want problem code conflict and %q", body, want) - } - if _, has := body["result"]; has { - t.Fatalf("stale guard carries a result envelope: %v", body) - } + wmRequireRefGuard(t, resp, raw, "expected_target_sha", fx.c2, + "expected_target_sha "+fx.c1+" does not match current tip "+fx.c2) if got := wmRefsSnapshot(t, e, fx.repoID)["refs/heads/main"]; got != fx.c2 { t.Fatalf("main moved to %q after a stale guard", got) } diff --git a/internal/githttp/merge_e2e_test.go b/internal/githttp/merge_e2e_test.go index bd2ef2e16..740f8569e 100644 --- a/internal/githttp/merge_e2e_test.go +++ b/internal/githttp/merge_e2e_test.go @@ -13,8 +13,8 @@ import ( // TestMerge_ExpectedSourceSHA drives the merge source pin (#531) through a // real serve node on every lane (pg, local, s3wal): the merge uses the // pinned commit although the source branch has moved past it, and a pin -// that the source branch does not contain is a 409 with code conflict that -// leaves the target unchanged. +// that the source branch does not contain is a 409 with code +// ref_guard_failed (#568) that leaves the target unchanged. func TestMerge_ExpectedSourceSHA(t *testing.T) { repoURL := fmt.Sprintf("t531-merge-pin-%d", time.Now().UnixNano()) repoID := registerRepo(t, repoURL) @@ -68,8 +68,9 @@ func TestMerge_ExpectedSourceSHA(t *testing.T) { } status, body := merge(side) - if status != http.StatusConflict || body["code"] != "conflict" { - t.Fatalf("pin on another branch: %d %v, want 409 code conflict", status, body) + if status != http.StatusConflict || body["code"] != "ref_guard_failed" || + body["guard"] != "expected_source_sha" || body["current_sha"] != tipOf("feature") { + t.Fatalf("pin on another branch: %d %v, want 409 ref_guard_failed with the feature tip", status, body) } if got := tipOf("target"); got != base { t.Fatalf("target moved to %s after a refused merge, want %s", got, base)