Repository navigation
[DMD-1833] Merge requests — Part 1, Layer 3 (client) - #556
Conversation
aaabc0d to
fb885af
Compare
337c6b2 to
4021e0e
Compare
There was a problem hiding this comment.
Pull request overview
Adds Layer 3 (HTTP client) support for Storage API merge requests as a new, test-pinned client namespace, plus the related config diff/rebase endpoints needed for conflict resolution. This fits the repo’s 3-layer architecture by expanding the client/ surface area (Layer 3) without introducing any CLI/service behavior yet (Part 2).
Changes:
- Introduces
client.merge_requestsnamespace (9 endpoints) via aStorageRequesterProtocol + temporary client adapter seam for the upcoming client-split work. - Adds branch-scoped config conflict helpers to
client/configs.py:get_config_diff,rebase_config, andrebase_config_delete(JSON body + diff-envelope). - Adds a dedicated merge-job wait budget constant and comprehensive offline contract tests for paths/bodies/envelopes/job-wait behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/keboola_agent_cli/client/merge_requests.py |
New merge-requests namespace + requester Protocol seam + cached client property; implements MR endpoints and merge job waiting. |
src/keboola_agent_cli/client/configs.py |
Adds config diff and rebase endpoints (branch-only, JSON bodies, diff envelope). |
src/keboola_agent_cli/client/_client.py |
Composes the new _MergeRequestsMixin into KeboolaClient. |
src/keboola_agent_cli/constants.py |
Adds MERGE_JOB_MAX_WAIT and FEATURE_BRANCHES_MERGE_REQUESTS. |
tests/test_merge_request_client.py |
New pytest-httpx suite pinning the wire contract and the requester seam behavior. |
docs/merge-requests-layer3-rfc.md |
RFC documenting backend contract + Layer 3 design decisions and test strategy. |
docs/merge-requests-layer2-notes.md |
Companion notes capturing verified backend semantics for Part 2 (service/commands). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # Feature flag gating the non-SOX merge-request flow. Layer 3 | ||
| # (client/merge_requests.py) does no feature check itself -- a missing | ||
| # feature is a 403 identical to a role denial -- so Part 2's service calls | ||
| # has_feature() with this constant before writes and words the error. It | ||
| # also doubles as the SOX fence: server-side, `protected-default-branch` |
There was a problem hiding this comment.
Fixed in a93225e — reworded to "the Part 2 service layer must call has_feature() with this constant before writes".
| def get_config_diff( | ||
| self, | ||
| component_id: str, | ||
| configuration_id: str, | ||
| branch_id: int, | ||
| ) -> dict[str, Any]: |
There was a problem hiding this comment.
Fixed in a93225e — the new methods now use config_id, matching the rest of the mixin (RFC method inventory synced too).
- configs.py: rename the new methods' `configuration_id` parameter to `config_id`, matching the rest of the mixin (get_config_detail, ...); RFC method inventory synced. - constants.py: reword the FEATURE_BRANCHES_MERGE_REQUESTS comment so the "Part 2 service must call has_feature()" intent reads unambiguously. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- configs.py: rename the new methods' `configuration_id` parameter to `config_id`, matching the rest of the mixin (get_config_detail, ...); RFC method inventory synced. - constants.py: reword the FEATURE_BRANCHES_MERGE_REQUESTS comment so the "Part 2 service must call has_feature()" intent reads unambiguously. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
81c8473 to
a93225e
Compare
Note for the rebase: the
|
#603 Rebased onto main with #603, where _wait_for_storage_job itself raises on an already-terminal error body (converged on the wait_for_queue_job shape). Per the rebase checklist on #556: the local guard, its now-unused errors import, the docstring clause, and its dedicated test are removed; test_merge_failed_job_raises stays (polled-error path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0b88991 to
25a80a5
Compare
Approval re-confirmed at
|
a0dfa99 to
6aa5346
Compare
client/merge_requests.py -- the nine MR endpoints as a namespace,
client.merge_requests.{list,get,conflicts,create,update,request_review,
approve,request_changes,merge}. The namespace depends on a StorageRequester
Protocol, not on the client; a temporary _ClientRequester adapter satisfies
it until the client-split work (draft #595) builds a real transport under
the seam. Two invariants deliberately break the surrounding idioms and are
called out in docstrings: paths are NEVER branch-prefixed (every MR endpoint
is project-level), and bodies are JSON with real types (the backend asserts
branchFromId as int; form-encoded values stay strings and fail validation).
_optional_mr_fields keeps create/update from drifting and is keyword-only:
four of its five parameters are str | None, so a positional transposition
would type-check cleanly and surface only as a backend 422.
merge() awaits the Storage job implicitly like every job-backed method in
client/, with a dedicated MERGE_JOB_MAX_WAIT (600 s) budget -- merging a
many-config branch can outlive the default 60 s. It does NOT re-check the
returned job: raising on a failed job (fast-fail included) is the poller's
contract since #603, stated as a requirement on the Protocol so a future
transport cannot reintroduce the blind spot. The await covers the merge
outcome only; the source-branch deletion runs as a second, unhandled job.
client/configs.py -- get_config_diff + rebase_config/rebase_config_delete.
branch_id is required with no production fallback (the endpoints 400 on the
default branch). Keep and delete rebases are separate methods so no illegal
combination is expressible. The keep rebase requires the FULL replaced body
(name, rows, configuration, is_disabled, description): /rebase replaces
rather than patches, so an omitted key takes the server-side default -- a
caller sending only name+rows would wipe the configuration and re-enable a
disabled config, then merge that into production.
constants.py -- MERGE_JOB_MAX_WAIT and FEATURE_BRANCHES_MERGE_REQUESTS.
Layer 3 does no feature check itself (a missing feature is a 403 identical
to a role denial); Part 2's service pre-flights with the constant.
tests/test_merge_request_client.py pins the wire contract: bare vs
branch-prefixed paths, JSON types, presence detection, the diff envelope and
the {} delete resolution, merge-job waiting and its 600 s budget, the
replaced-body requirement, include=activityLog, and the stub-requester seam.
Part 2 (service + commands) follows separately; no CLI command is added
here, so no E2E / docs surfaces change yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6aa5346 to
a01ee36
Compare
…85.1 The version bump to 0.86.0 already landed on main (#615), but the release notes it produces were incomplete in two ways. Missing entries. PR #616 (`token list`, plus the retry-policy and exceptionId changes) carried no changelog note at all -- its commit message says "No version bump: this lands in a stack of PRs released as one version. The (since v0.86.0) doc tags assume 0.86.0 and the bump PR must confirm that", and the bump PR did not. `make changelog-check` cannot catch this: it verifies every published GitHub release has an entry, not that every merged PR has a note. #556/#606 (merge-request endpoints, Layer 3) and #610 (winget job disabled) were likewise unannounced. All four are added. Phantom 0.85.1. pyproject went 0.85.0 -> 0.85.1 (#614) -> 0.86.0 (#615) without a tag in between, so 0.85.1 exists only as a changelog bucket -- no release, no artifact, nobody running it. `format_whats_new` shows the notes of the *target* version only, so every user upgrading 0.85.0 -> 0.86.0 would have silently missed those four fixes (Azure ciphertext prefix, the `parameters` wrapper, GCP/Azure sync ciphertext, the encrypt-values docs). The bucket is folded into 0.86.0 verbatim. The same phantom leaked into the agent-facing version gates, which is the worse half: `keboola-expert.md` told users to "upgrade to 0.85.1+" and four gotchas.md entries were tagged `(since v0.85.1)` -- a version nobody can install. Retagged to 0.86.0, along with two source comments. Three of the new notes had to lead with a shorter sentence to satisfy `test_newest_release_notes_are_not_truncated` (the headline is the note's first sentence, capped at 160 chars). No behaviour change; documentation and release metadata only.
…85.1 (#619) * chore(release): complete the 0.86.0 changelog and drop the phantom 0.85.1 The version bump to 0.86.0 already landed on main (#615), but the release notes it produces were incomplete in two ways. Missing entries. PR #616 (`token list`, plus the retry-policy and exceptionId changes) carried no changelog note at all -- its commit message says "No version bump: this lands in a stack of PRs released as one version. The (since v0.86.0) doc tags assume 0.86.0 and the bump PR must confirm that", and the bump PR did not. `make changelog-check` cannot catch this: it verifies every published GitHub release has an entry, not that every merged PR has a note. #556/#606 (merge-request endpoints, Layer 3) and #610 (winget job disabled) were likewise unannounced. All four are added. Phantom 0.85.1. pyproject went 0.85.0 -> 0.85.1 (#614) -> 0.86.0 (#615) without a tag in between, so 0.85.1 exists only as a changelog bucket -- no release, no artifact, nobody running it. `format_whats_new` shows the notes of the *target* version only, so every user upgrading 0.85.0 -> 0.86.0 would have silently missed those four fixes (Azure ciphertext prefix, the `parameters` wrapper, GCP/Azure sync ciphertext, the encrypt-values docs). The bucket is folded into 0.86.0 verbatim. The same phantom leaked into the agent-facing version gates, which is the worse half: `keboola-expert.md` told users to "upgrade to 0.85.1+" and four gotchas.md entries were tagged `(since v0.85.1)` -- a version nobody can install. Retagged to 0.86.0, along with two source comments. Three of the new notes had to lead with a shorter sentence to satisfy `test_newest_release_notes_are_not_truncated` (the headline is the note's first sentence, capped at 160 chars). No behaviour change; documentation and release metadata only. * fix(changelog): correct the serve route and give every 0.86.0 note a recognised prefix Two findings from Devin's review of this PR, plus one they could not see. The serve route for `token list` was cited as `GET /tokens/{project}`. Both halves are wrong: the router carries `prefix="/token"` (singular) and the operation is registered at `/{project}/list`, so the real path is `GET /token/{project}/list` -- confirmed against the runtime OpenAPI schema, not the source, because that is what a caller actually hits. Worth noting the review's proposed correction (`/tokens/{project}/list`) is itself wrong on the prefix; taking it verbatim would have swapped one 404 for another. `CI:` is not a recognised note prefix. `_PREFIX_STYLES` / `_PREFIX_RE` in commands/changelog.py define the set, the module docstring states the contract, and an unrecognised label renders unhighlighted. Retitled to `Note:`, which also reads better: the winget job being disabled has a user-facing consequence (WinGet users stay on the last published version), so burying it under a dim `Internal:` would understate it. The finding Devin could not report: the four notification notes carried by #615/#618 have no prefix at all. They were outside this PR's diff, so no reviewer looking at the diff would flag them -- but they ship in the same release block and break the same contract, leaving half of v0.86.0 rendering flat. Prefixed `New:` / `Note:` with no change of meaning. Every 0.86.0 note now matches `_PREFIX_RE`, verified by asserting over the live CHANGELOG rather than by reading. Each replacement is written to disk on its own. Running several in one script means a later failed assert discards the earlier successful writes, which is precisely how #618's stale "server-side ?event=" claim survived its own fix pass.
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilt, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rvice (DMD-1900) (#736) * Merge requests RFCs: general notes + per-layer (L3 as-built, L2 as-built, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(service): the three Layer 2 additions the Layer 1 RFC needs [DMD-1900] Decided while walking PR #703's review findings into the Layer 1 RFC (docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR"). Each exists so Layer 1 does not re-derive something the service knows. - get_merge_request_row(alias, id): the row tier (_enrich_row) by id -- one GET, no conflicts(), no verify_token(). list/find already return rows but only by branch; the sole by-id method was the detail, three round trips and a dependency on the conflicts endpoint that a write (request-review on an armed MR, the merge confirmation prompt) has no business inheriting. L3's merge_requests.get() was always this GET. - get_config_diff -> resolution_candidate: the ours envelope through _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription excluded; null when ours is absent/isDeleted. Composed in L2 so the `diff --output` prefill and the five-key replace guard in resolve_conflict share one constant -- a candidate built in L1 that dropped a null description would be a file kbagent writes and then refuses. Pinned by a round-trip test: candidate -> resolve_conflict unmodified. - merge(): cleanup_warnings -> warnings. One soft-failure key for the group (resolve_conflict already used `warnings`); a renderer reading `warnings` must not silently drop the post-merge ones a user must act on. Specificity stays in the text. Tests: 5 new (row tier cost pinned via assert_not_called on conflicts + verify_token; candidate shape; null cases; round trip; warnings key). L2 RFC updated in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli): merge-request group skeleton and the four read commands [DMD-1900] The `kbagent merge-request` group (hidden alias `mr`) over MergeRequestService: wiring, the helpers every command shares, and list / detail / conflicts / diff with their renderers. Writes follow. Skeleton -- the Layer 1 decisions from docs/merge-requests-layer1.md: - Target resolution (_resolve_target): --merge-request-id/--id optional; omitted -> resolve_branch() (--branch, else active branch) -> find_merge_request_for_branch(). Both flags at once is exit 2, not silent precedence. The resolution is reported on stderr in human mode and stamped into every --json result (merge_request_id, branch_from_id, resolved_from_branch) so a machine caller can assert on what was operated upon. - One error handler (_handle_error), no per-command except: keeps FeatureNotEnabledError's FEATURE_NOT_ENABLED code, which now surfaces from the resolver behind every omitted id -- reads included. - The destructive-under-json rule and the armed-auto-merge escalation helpers (used by the writes next): policy check first, then the explicit-target rule, which for state-derived escalations can only fire after resolution. - warnings[] rendered identically everywhere; hint-next Rich-only. Permissions: 11 registry entries (merge = destructive) + the serve-only by-branch; FLAG_ESCALATIONS gains five state/flag-derived destructive entries (arming auto-merge on create/update; request-review / approve / resolve on an armed MR) with the Connection citations that justify them. Renderers (_merge_request_render.py): every wire string escaped; derived_state never raw state; list preserves server order and shows optional columns only when populated; empty list tells feature-off apart via feature_enabled; detail says the change log is empty by design in development; diff checks the *_deleted flags BEFORE the table and recommends the --take, since a null side yields zero rows; --output writes the service's resolution_candidate verbatim and refuses when there is nothing to prefill. Table value columns fold rather than crop so --format full is actually full. Also hoists parse_json_arg into _helpers (transformation.py had the private copy; resolve is the third consumer). 35 CLI tests via CliRunner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli): merge-request writes -- create, update, transitions, merge, resolve [DMD-1900] The seven write commands, each routed through the shared skeleton (_resolve_target, _handle_error, _stamp_target, warnings, hint-next). Where a human says so, and where a policy does (docs/merge-requests-layer1.md): - merge: statically destructive. Under --json the explicit-target rule fires BEFORE any lookup (no --merge-request-id/--branch -> exit 2); in human mode the active-branch fallback stays and the prompt names the MR, its title and the branch that will be deleted. --yes skips the prompt. - create/update --auto-merge-strategy immediately|scheduled: arming is a delayed production merge, so it escalates to destructive (FLAG_ESCALATIONS), needs an explicit target under --json (--branch for create), prompts in human mode worded as arming, and warns afterwards. `none` is the disarm and escalates nothing. The strategy/--auto-merge-at pairing is validated at exit 2. - request-review / approve / resolve on an ALREADY-armed MR escalate via the state-derived operation strings; the row comes free on the implicit path and via get_merge_request_row (one GET, never the detail) on the explicit path. Under --json with an implicit target this exits 2 only AFTER resolution -- deliberate, the information does not exist earlier; the error names the MR and the flag to pass. request-changes moves the MR away from approved and never escalates. - update with no field flags is exit 2 (PUT {} is a server no-op). --reviewer-id is normalised to None when absent -- [] would clear the set. - resolve: exactly one of --take/--resolved (exit 2 otherwise); --resolved parsed via the hoisted parse_json_arg and must be an object; a --change-description on a delete is the service's warning, not a Layer 1 refusal (the implicit-delete collapse is only known after the diff). Escalations are tested against the real engine (--deny-destructive -> exit 6), not a mocked check. 38 more CLI tests (73 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(cli): split merge_request.py at the 800-code-line soft ceiling [DMD-1900] 829 code lines after the writes landed -- exactly what the RFC predicted for eleven commands at ~75 each. CONTRIBUTING lets a file sit over the soft ceiling until the next PR adds to it, but a brand-new module born over it is debt on day one, so split now: - merge_request.py -- app, callback, the four reads; mounts the writes - _merge_request_common -- what both need: option declarations, the ONE error handler, target resolution, the destructive-under-json rule, escalation, output - _merge_request_writes -- the seven writes on their own Typer, mounted flat via register(app) so permission keys stay merge-request.* and --help lists one group (precedent: _storage_describe.register) - _merge_request_render -- unchanged A third module instead of reads importing writes (or vice versa): both import common, only merge_request imports writes -- no cycle. Behaviour unchanged; 73 CLI tests green; every module well under 800. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(serve): merge-requests router, 1:1 with the CLI group, permission-enforced [DMD-1900] server/routers/merge_requests.py: twelve routes under /merge-requests -- one per CLI command plus GET /{project}/by-branch/{branch_id}, the branch->MR resolver the CLI hides behind an omitted --merge-request-id (no active-branch idiom over HTTP; registered as the serve-only merge-request.by-branch). Declared before /{project}/{merge_request_id} so FastAPI never tries to read 'by-branch' as an id. Skipped on purpose: `diff --output PATH` -- GET .../diff returns resolution_candidate and the caller writes its own file. Every route declares Depends(require_permission(...)). Until now only /auth/* did; here it is not optional -- the CLI classifies merge as destructive and escalates arming auto-merge and the transitions on an armed MR (FLAG_ESCALATIONS), and without the same checks over HTTP that analysis would be decorative for serve callers. The static class is a route dependency; the flag/state-derived escalations run in the route body: arming in the create/update body -> check_or_raise the flag string; request-review/approve/resolve -> one row GET (get_merge_request_row, never the three-call detail) and check_or_raise when armed. Caller errors (unknown state/take, both-or-neither take/resolved, empty update body, broken auto-merge pairing) raise INVALID_ARGUMENT -> 400, the REST twin of the CLI's exit 2. POST .../merge documents that it is synchronous for up to 600 s. Wiring: ServiceRegistry.merge_request, include_router, an OPENAPI_TAGS entry (endpoints-gen would otherwise emit an untagged section), and docs/web-server-endpoints.md regenerated (endpoints-check green). 14 router tests: kwarg parity per route (the drift this file exists to catch) and the permission story over HTTP -- merge 403 under deny_destructive, arming 403 while `none` passes, armed request-review 403 via the row tier, reads pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(cli): merge-request on every convention-#17 surface; deprecate branch merge; E2E [DMD-1900] The silent-drift surfaces, all of them (nothing but check_command_sync gates any of this): - CLAUDE.md "All CLI Commands": the eleven signatures plus the block that matters most -- what may happen without a human saying so (merge is destructive; arming auto-merge is a delayed production merge; the --json explicit-target rule; the 0-approval facts; no `close`; the conflict loop; the error shapes; the feature-blind allowed_actions). - commands/context.py AGENT_CONTEXT: a Merge Requests section after Branches, same content compressed for the agent. - commands-reference.md: the group's cheat sheet. - gotchas.md: one `(since vNEXT)` entry covering every non-obvious behaviour the RFC listed for it. - keboola-expert.md: a tool-selection-matrix row with the anti-patterns (--auto-merge-strategy treated as metadata; --json merge with no target; a partial --resolved body; reading allowed_actions as feature-aware; approve on a 0-approval project). - SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review request), the description, the workflow link; decision table via `make skill-gen`. - New merge-request-workflow.md: the short path, the --json path, the auto-merge table, the conflict loop, output semantics, the error table. - branch-workflow.md points at the new group. `branch merge` is deprecated with a CONDITIONAL pointer: it only builds a UI URL (and unconditionally resets the active branch), but it works on projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour unchanged; `deprecation` key in --json, a warning in human mode. E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on the branch -> create -> list/detail/conflicts (id and --branch) -> approve asserts the 422 -> bare --json merge exits 2 -> merge -> config in production -> explicit teardown. GATED ON THE FEATURE: `list` on a feature-less project answers feature_enabled: false and the suite skips with the one-time enable command in the reason -- explicit, never silent. The E2E project does not carry the feature today and this environment has no E2E credentials; recorded in the ship ledger, not hidden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plugin): vNEXT tags out of headings; SKILL description back under 1024 chars [DMD-1900] check_version_gates: a vNEXT inside a heading would rewrite the anchor slug on release; the three new sections carry the tag on their first body line instead. test_skill_frontmatter: the description hit 1130/1024 chars; kept the 'merge request' trigger, dropped the redundant ones, and compressed three neutral list phrases (and -> /). No trigger word lost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli,serve,service): apply the Phase-5 self-review round [DMD-1900] Three reviews (Opus, Sonnet, /code-review) over the implementation; every finding either fixed here with a pin, or recorded below as deferred. High: - _handle_error dropped exc.details, so the merge 409's conflict list and the truncation marker never reached --json, and the RFC's human render of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was never implemented. Both fixed; details flow through, the list renders escaped, the truncation line names no number. - Unescaped wire/user strings in the ad-hoc console.print sites outside the renderer module (conflicts hint, diff hint, resolve success line, merge message): a `[/x]` in a config id raised MarkupError AFTER the rebase had landed server-side. Escaped everywhere. Medium: - branch_from_id was null on every explicit --merge-request-id path, beside a payload saying branches.branchFromId: 123. _stamp_target now derives it from the result (row branches, diff branch_id); conflicts fetches the row tier (one GET) since its result carries no branch. - The auto-merge vocabulary was copied into the CLI and the router -- the exact drift the RFC forbids, and a SAFETY divergence (one surface would stop escalating an arming value the other still knows). Now AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags / arms_auto_merge live in the service module; both surfaces import them. - next_step_hints silently dropped unknown action names; once DMD-1988 serialises a camelCase vocabulary every hint-next line would vanish. Falls back to the raw name. - Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with no details (retry-inviting, list dropped). app.py maps them to 409 and _format_error carries non-empty details. - The service's two caller-mistake refusals (resolving/diffing a finished MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged. - resolve_conflict coerced a caller body's isDisabled with bool(), so a hand-edited "false" DISABLED the config on replace and returned 200. Non-bool is refused (the guard's refuse-don't-default policy). Low: - The armed-auto-merge warning is human-only (formatter.warning), no longer injected into the payload -- Layer 1 does not manufacture data. - A hole in the ours envelope no longer becomes an explicit-null candidate that resolve then blames the caller for; get_config_diff returns resolution_candidate: null + a warning, and --output words the three null shapes apart (deleted / absent / envelope hole). - parse_json_arg turns OSError (a directory, permissions) into the ValueError the callers expect; docstring stops claiming config.py's copy is gone. --output on an unwritable path is a readable exit 2. - merge skips the row GET when no prompt will show (--yes / --json). - --reason cap enforced on the REST route too. CLAUDE.md --state line stops hand-listing a subset of the vocabulary. - FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the misnamed CLI "round-trip" test renamed (the real round trip is pinned at the service layer). Deferred to PR #703 (Layer 2 design/refactor findings from /code-review, which reviewed the L2 branch; too large for the tail of this run): _classify_three_way missing `both` rows for nested-vs-parent edits; the `or code is None` 409 fallback; SOX-project reads reporting feature_enabled: false; the tuple return in http_base._bound_error_params; the post-merge cleanup being a third copy of BranchService's; the try/finally client idiom vs the context manager. Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync are environmental (git commit signing via 1Password unavailable to the test process), unrelated to this diff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli,service): align Layer 1 with Zajca's third Layer 2 review [DMD-1900] Three second-order effects of rebasing onto 7cd1855: - _branch_from_id_of: the L2 round split the message for an absent vs a non-numeric branchFromId, but the Phase-5 INVALID_ARGUMENT re-code had made both faults carry the caller's code. They blame different parties: absent = the caller is resolving a finished MR (INVALID_ARGUMENT, 400 over serve); non-numeric = the server's payload (VALIDATION_ERROR). - diff renderer: the L2 round makes _classify_three_way return zero rows for an empty-envelope side too, not only a null/deleted one. With no deletion flag set the renderer would have claimed "this conflict has cleared" while the service's warning beside it said "envelope hole". When there are no rows and the result carries warnings, say that no classification could be produced and let the warning explain. - RFC: the five-key rule is the CALLER-body rule; a --take side composes an absent description as null (wire-identical -- the rebase omits the key), and a hole or non-boolean isDisabled there is a backend contract violation (VALIDATION_ERROR), never a caller error. The table said "refused when absent" for both paths. Pinned: the two error codes; the no-rows-with-warning render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli,service): apply the Layer 2 follow-ups inherited by Layer 1 (F2-F5, F7) [DMD-1900] docs/merge-requests-layer2-followups.md collects the non-blocking leftovers of PR #703 that go through this PR. Per item: - F2: _classify_three_way's docstring now says what is true -- the classifier is deliberately STRICTER than resolve_conflict (an empty envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to the delete resolution would destroy a configuration). And the empty-envelope half finally has a test. Beyond the docstring: an empty envelope on EITHER side is now reported in `warnings` (_diff_warnings replaces the ours-only _candidate_warnings), so the diff renderer's "no rows + warnings" branch fires instead of claiming the conflict cleared -- which it would have done for a theirs-side hole. - F3: merge() records the branch-id degradation structurally -- `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says "Source branch id could not be read; see warnings." instead of nothing. The CLI's merge renderer keys on the flag (a "Local cleanup skipped" line naming the raw value) and its hint-next points at branch reset + sync branch-unlink. A legitimate published-MR null carries no flag. - F4: find_default_branch_id logs the skipped non-numeric entry instead of folding it into None silently (the callers then say "no default branch" for a project that DID report one). The `sync init` exits-0-with-empty- branches decision is a UX call left for Martin -- not changed. - F5: the detail tier is feature-aware for free (has_feature after the verify_token it already pays): `feature_enabled` on the detail payload, a "Feature: not enabled" line in the panel, and hint-next refusing to recommend a write that cannot succeed. `list` stays feature-blind on non-empty results, as Layer 2 decided; docs say which is which. - F7: config_service.py's last `if folder_branch_id:` truthiness test -> `is not None`; the positive assertion for "Active branch reset to main." on a successful reset; the 110-char docstring line rewrapped. The isDisabled-before-missing ordering is left as noted (house pattern). Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both call sites, standalone PR) and F8 (test_changelog_render under FORCE_COLOR -- main, unrelated). 9 new tests; make check exit 0 (6672). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(cli): parenthesize implicit string concatenation in tuples (ruff 0.16 ISC004) [DMD-1900] main's ruff upgrade (9d823d5, >=0.16 default rule set) fires ISC004 on two tuple items in the detail renderer. No behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(serve): register the merge-requests routes in SERVE_COMMAND_MAP [DMD-1900] main's #731 (command telemetry) requires every serve route in the route->CLI-command map, enforced by test_serve_telemetry::test_command_map_matches_every_route_exactly. The twelve /merge-requests routes mirror their commands; by-branch is serve-only (empty string, logged under its route label). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli,service,e2e): apply Copilot's (Balanced) review of #736 [DMD-1900] Eight inline findings, all confirmed against the code and fixed with pins: - E2E setup skipped on ANY `merge-request list` failure, turning a crash or an auth regression into a green run. It now asserts success and skips only on feature_enabled: false -- the one gate the class documents. - The E2E covered 6 of 11 commands. The scenario now manufactures a REAL conflict (config in production, branch inherits it, both sides change it) and walks every command: create, update, list, detail, conflicts (via --branch), diff (+ --output candidate), resolve --take ours, request-review (-> approved), request-changes (-> development), approve, the bare --json merge exit 2, merge, and the production content check. - `approve`'s refusal was asserted as "any error but FEATURE_NOT_ENABLED"; it now asserts API_ERROR with the 422 in the message. - warnings[] text (backend / exception prose) reached Rich unescaped -- an unbalanced tag would raise MarkupError after the irreversible operation succeeded. Escaped. - `diff --output` wrote with the platform encoding (a name outside a Windows code page would fail the promised round trip); utf-8 now. And the path was interpolated into Rich markup unescaped. - A HOLED (partial) envelope still classified: content() omitted the missing key and the intersection reported it as that side's removal; holes on theirs were not warned about. A side missing any required content key is now unclassifiable on either side, and _diff_warnings names the holes for both (one shared _envelope_holes criterion feeds the classifier, the candidate and the warnings). The eighth finding (a stale "code-less conflict" rationale in the L2 RFC) is fixed on ms/merge-requests-rfcs (76a2adb); this branch's first commit is rebuilt from it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli,serve)!: destructive is a property of the command -- static classes, `auto-merge` command [DMD-1900] Replaces the flag- and state-derived escalations of the first draft with one static rule: anything that moves a merge request toward or into production is destructive, always. Decided with Martin 2026-09-10 after Zajca's review of #736 pointed at the same hazard twice (a permission that depends on a GET; an exit 2 that depends on state nobody typed) and Martin asked for the flag to leave the condition entirely. request-review destructive (0-approval default lands directly in approved) approve destructive (the last approval is what a merge waits for) resolve destructive (removes the blocker a merge waits on) merge destructive auto-merge destructive (NEW; arms the backend scheduler = delayed merge) create/update/request-changes write - `auto-merge --strategy immediately|scheduled|none [--at TS]` is its own command; `create`/`update` no longer take `--auto-merge-strategy`. Arming is a consciously separate step, prompts in human mode; the disarm rides the same command, same class (a caller who could not arm never needs to disarm). Under the hood: update_merge_request(auto_merge_*). L2 untouched. - FLAG_ESCALATIONS is back to its single original entry. The five merge-request escalation keys, `_escalate_if_armed` (CLI + router copies), `_warn_armed`, `_Target.armed`, `auto_merge_armed`, `armed_escalation_operation` are gone. The router's permission check is the route dependency alone -- no body inspection, no prior GET. - The --json explicit-target rule now runs BEFORE any network call for every destructive command, since the class is known from the name. The transitions no longer fetch the MR row; the armed warning is read off the write's own result. - PUT /merge-requests/{p}/{id}/auto-merge added (router, SERVE_COMMAND_MAP). - Zajca's must-fixes from the review ride along: _deleted_side_message None ordering (a missing side recommended --take ours, which resolves as DELETE); reason/external-id caps validated once in the service from constants.py; derived_state escaped in the shared success renderer; get_merge_request_row runs the feature pre-flight lazily on a 403; register() through Typer's public app.command(name)(fn); --output OSError branch emits warnings first; route-level 403 coverage for every destructive route incl. the disarm. - Tests regrouped by behaviour (TestStaticDestructiveClass, TestAutoMerge; provenance-named classes dissolved). Docs: all six convention-#17 surfaces; gotchas answers the --timeout question (retry is harmless behind the merge lock). BREAKING for anyone on the unreleased draft only: --auto-merge-strategy / --auto-merge-at on create/update are gone; request-review, approve and resolve are denied under --deny-destructive. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(cli,service): the candidate uses the resolve guard's criterion; both field caps exit 2 [DMD-1900] Zajca's second review of #736, two findings. 1. `_envelope_holes` (the predicate behind `resolution_candidate`, the diff warnings and `classifiable()`) tested key PRESENCE, while the replace guard in `resolve_conflict` refuses a key that is present but `None` and a blank `name`. An ours envelope with `"name": null` thus composed into a candidate that `diff --output` wrote and `resolve --resolved @file` then refused -- kbagent blaming the caller for its own file, the exact drift the shared constant was meant to prevent. The predicate now mirrors the guard: absent OR `None` is a hole, so is a blank `name`. Candidate suppressed, reason in `warnings[]`, side excluded from classification. Pinned in the service suite for `None` and `" "`. 2. `--reason` over its cap was pre-checked to exit 2, `--external-id` over its cap reached the service, whose INVALID_ARGUMENT `map_error_to_exit_code` does not map -- exit 1. Same kind of flag error, two exit codes. `create`/`update` now pre-check `--external-id` the same way (`_check_external_id`, one call per command); the service keeps the cap as the single rule (serve still answers 400 from it). Pinned for all three flag/command pairs, asserting no service call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
Part 1 of merge-request support in kbagent — Layer 3 only (HTTP client). The design
decisions live in the module and method docstrings; this PR is code-only.
Linear: DMD-1701 — milestone "Branches 2.0".
client/merge_requests.py(new): the nine MR endpoints as a namespace —client.merge_requests.{list,get,conflicts,create,update,request_review,approve,request_changes,merge}.The namespace depends on a
StorageRequesterProtocol (not on the client) via a temporary_ClientRequesteradapter — the seam the client-split work (RFC: Split KeboolaClient into a transport and resource namespaces #595) later builds under.merge()awaits the Storage job implicitly, like every job-backed method inclient/,with a dedicated 600 s
MERGE_JOB_MAX_WAITbudget._optional_mr_fields(shared bycreate/update so their camelCase mappings cannot drift) is keyword-only: four of its five
parameters are
str | None, so a positional transposition would type-check cleanly andsurface only as a backend 422.
client/configs.py:get_config_diff+rebase_config/rebase_config_delete.branch_idis required (no production fallback — the endpoints 400 on the defaultbranch). Keep vs delete rebase are two methods so no illegal combination is expressible;
the
diffenvelope stays private to them. The keep rebase requires the full replacedbody (
name,rows,configuration,is_disabled,description):/rebasereplacesrather than patches, so an omitted key takes the server-side default — a caller sending
only
name+rowswould wipe the configuration body and re-enable a disabled config,then merge that into production.
constants.py:FEATURE_BRANCHES_MERGE_REQUESTSfor Part 2's pre-flight featurecheck (Layer 3 deliberately does no feature check; a missing feature is a 403 identical
to a role denial, so only a Layer 2 pre-flight can word the error).
tests/test_merge_request_client.py: pins the wire contract — bare vsbranch-prefixed paths, JSON bodies with real types (the surrounding
configs.pyteachesform encoding — the opposite), presence detection, the
diffenvelope, the{}deleteresolution, merge-job waiting and its 600 s budget, the replaced-body requirement,
include=activityLog, and the stub-requester seam.What this deliberately does NOT do
commands), no changelog entry (no version bump), and no plugin/docs surfaces change
(convention v0.6.0: Branch lifecycle management + security hardening #17 binds commands).
SESSION_UNSUPPORTED_FEATURESentry — verified in Connection code: bearer-sessionauth on Storage routes is route-agnostic, the session resolves to the user's real admin
Storage token before any MR action runs.
Review history (now squashed into the single feat commit)
An adversarial self-review, a Copilot round and follow-up PRs (#606, #608) shaped the
branch before it was squashed; the notable outcomes, in the code above:
MERGE_JOB_MAX_WAIT(600 s) +max_waiton theStorageRequesterProtocol, shapedbefore the Protocol has external consumers.
rebase_configand the keyword-only_optional_mr_fields([DMD-1833] Require the replaced body fields on rebase; keyword-only MR optional fields #606)._wait_for_storage_jobreturned analready-terminal error body instead of raising, so a fast synchronous merge failure came
back as success. This PR initially carried a local guard in
merge()for it; the blindspot was a house-wide bug across every call site of the helper, so it was fixed centrally
in fix(client): raise on an already-failed Storage job instead of returning it #603 (DMD-1898, merged first) and the guard was dropped here. What remains is the
contract statement on
StorageRequester.wait_for_storage_job: a Protocol implementationmust raise on a failed job, initial-body or polled —
merge()no longer re-checks.Known limitation (deliberately not fixed here):
_do_requestretries POST/PUT ontimeout/5xx house-wide with no opt-out, so a committed-but-lost MR write can replay into a
misleading terminal error (create → 404 "already has an MR", merge → 409 notReadyToMerge,
rebase → 400 "version not newer") masking an operation that actually succeeded. This is a
cross-cutting
http_baseconcern affecting every non-idempotent write in the codebase —follow-up issue, not a Layer 3 patch.
Testing
make checkgreen locally.pytest_httpx); live E2E lands with Part 2'scommands.
🤖 Generated with Claude Code