-
Notifications
You must be signed in to change notification settings - Fork 10
feat(cli): merge-request command group -- Layer 1 over MergeRequestService (DMD-1900) - #736
feat(cli): merge-request command group -- Layer 1 over MergeRequestService (DMD-1900) #736martinsifra wants to merge 14 commits into
Conversation
17cadc1 to
9734b81
Compare
...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>
9734b81 to
2e2c808
Compare
...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>
2e2c808 to
297b273
Compare
...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>
297b273 to
cbdabe5
Compare
...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>
cbdabe5 to
df6e018
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
It introduces a large cross-cutting feature (new CLI group + permission-sensitive REST surface + service behavior/shape changes) that warrants final human review despite strong test coverage.
Pull request overview
Adds the kbagent merge-request (alias mr) command group as a thin Layer 1 wrapper over MergeRequestService, plus a kbagent serve REST mirror that enforces permissions (including the "auto-merge arming/armed transitions are destructive" escalations). This extends the CLI’s Branches 2.0 (non‐SOX) lifecycle support end-to-end: listing/creating/updating MRs, review transitions, conflict inspection/diff, resolution, and merge.
Changes:
- Introduces the
merge-requestCLI group (with shared resolver/error-handling helpers) and deprecatesbranch mergewith an explicit pointer to merge requests. - Adds
serveREST endpoints for merge requests, wires them into the app, and ensures MR merge-state 409s preservedetails(conflict list) over HTTP. - Applies small Layer 2 follow-ups (row-tier getter, unified
warnings, diffresolution_candidate+ warnings) and updates tests + documentation/agent surfaces accordingly.
File summaries
| File | Description |
|---|---|
| tests/test_server_router_calls.py | Adds REST-router forwarding/validation/permission-escalation tests for /merge-requests/*. |
| tests/test_merge_request_service.py | Updates Layer 2 tests for warnings key and adds follow-up coverage (row getter, candidate/warnings, branch-id degradation logging). |
| tests/test_e2e.py | Adds feature-gated E2E lifecycle test for merge requests (create → inspect → merge → teardown). |
| src/keboola_agent_cli/services/merge_request_service.py | Adds auto-merge flag validation helpers, get_merge_request_row, unified warnings, diff resolution_candidate + diff warnings, and adjusted error-code blame for invalid branchFromId. |
| src/keboola_agent_cli/services/config_service.py | Fixes default-branch-id truthiness check (is not None) when listing config folder metadata. |
| src/keboola_agent_cli/services/base.py | Logs (non-silently) when an isDefault branch id is non-numeric and skipped. |
| src/keboola_agent_cli/server/routers/merge_requests.py | New serve router implementing REST mirror routes with permission enforcement + destructive escalations. |
| src/keboola_agent_cli/server/dependencies.py | Registers MergeRequestService in the server-side service registry. |
| src/keboola_agent_cli/server/app.py | Registers the router + OpenAPI tag and maps MR 409s to HTTP 409 while preserving error details. |
| src/keboola_agent_cli/server/_serve_command_map.py | Adds serve↔CLI telemetry mapping for merge-request endpoints (incl. serve-only by-branch resolver). |
| src/keboola_agent_cli/permissions.py | Adds merge-request operations + FLAG_ESCALATIONS and marks merge-request.by-branch as serve-only. |
| src/keboola_agent_cli/commands/transformation.py | Reuses centralized parse_json_arg helper instead of a local copy. |
| src/keboola_agent_cli/commands/merge_request.py | New Layer 1 read commands for the merge-request group (writes mounted from the split module). |
| src/keboola_agent_cli/commands/_merge_request_common.py | Shared resolver, destructive-under---json rule, armed escalation checks, and common output helpers for the merge-request group. |
| src/keboola_agent_cli/commands/_helpers.py | Adds parse_json_arg as a shared `JSON |
| src/keboola_agent_cli/commands/context.py | Documents merge-request group and notes branch merge deprecation (since vNEXT). |
| src/keboola_agent_cli/commands/branch.py | Marks branch merge deprecated and adds a structured deprecation field (plus human warning). |
| src/keboola_agent_cli/cli.py | Wires merge-request group and hidden alias mr; registers MergeRequestService in CLI context. |
| plugins/kbagent/skills/kbagent/SKILL.md | Updates skill triggers/decision table to include merge requests and deprecates branch merge entry. |
| plugins/kbagent/skills/kbagent/references/merge-request-workflow.md | New workflow reference for merge-request lifecycle, conflicts, auto-merge safety, and --json targeting rule. |
| plugins/kbagent/skills/kbagent/references/gotchas.md | Adds merge-request gotchas (auto-merge is destructive; --json merge requires explicit target; other sharp edges). |
| plugins/kbagent/skills/kbagent/references/commands-reference.md | Adds merge-request section + documents branch merge deprecation. |
| plugins/kbagent/skills/kbagent/references/branch-workflow.md | Updates branch workflow to point to merge-request for CLI-based merges. |
| plugins/kbagent/agents/keboola-expert.md | Updates agent matrix with merge-request guidance and safety notes. |
| docs/web-server-endpoints.md | Regenerates endpoint inventory and adds merge-requests router section. |
| docs/merge-requests-layer3.md | Adds Layer 3 "as-built" documentation for merge-requests client surface and wire contract. |
| docs/merge-requests-layer2-followups.md | Adds/updates Layer 2 follow-up tracking document inherited by Layer 1. |
| CLAUDE.md | Updates command inventory and merge-request operational notes (since vNEXT). |
Review details
- Files reviewed: 34/34 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Diff-envelope handling, Rich output safety, file encoding, and E2E coverage contain unresolved correctness gaps.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 34/34 changed files
- Comments generated: 8
- Review effort level: Balanced
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 2042c3b -- setup now asserts merge-request list succeeds and skips only on feature_enabled: false; a crash or auth regression is a failure, as the class docstring promised.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 2042c3b -- _emit_warnings escapes each warning before it reaches Rich. Pinned by test_warning_text_with_markup_does_not_crash (a [/x] in a post-merge warning).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 2042c3b -- the path is escaped before the hint-next line; same test covers a [x] in the filename.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 2042c3b -- a side missing any required content key (name/rows/configuration/isDisabled) is now unclassifiable on EITHER side, and _diff_warnings names the holes for both. One shared _envelope_holes criterion feeds the classifier, the resolution candidate and the warnings so they cannot drift. Pinned by test_holed_theirs_envelope_yields_no_rows_and_a_warning (before: a fabricated 'theirs removed configuration.limit' row).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 2042c3b -- the E2E now manufactures a real conflict (config in production, branch inherits it, both sides change it) and walks all eleven commands: create, update, list, detail, conflicts (via --branch), diff + --output, resolve --take ours, request-review, request-changes, approve, the bare --json merge exit 2, merge, and the production content check. Still feature-gated -- the E2E project lacks branches-merge-requests today; the skip reason carries the one-time enable command.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 2042c3b -- asserts error.code == API_ERROR with 422 in the message (the 422 rides the generic http_base path, so that is the precise shape).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed -- the rationale now says both 409 shapes carry a machine code but the codes are endpoint-specific, so the mapping lives in the service (http_base maps by status alone). The L2 RFC lives on ms/merge-requests-rfcs (76a2adb); this branch's first commit is rebuilt from it.
...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>
...-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>
...MD-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>
..., 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>
...ng [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>
...n-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>
...ranch 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>
...r 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>
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>
...D-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>
...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>
...f 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>
...[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>
...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>
df6e018 to
2042c3b
Compare
@zajca
zajca
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Review: merge-request Layer 1 (DMD-1900)
Read the whole diff, walked every convention-#17 surface, and ran the full gate locally on
ms/dmd-1900/cli-layer-1. Everything is green: ruff, ruff format, ty (1 pre-existing
downgraded unresolved-import warning), skill-check, version-check, version-gate-check
(27 vNEXT awaiting the release PR — correct for a feature PR), command-sync (279 commands),
endpoints-check, error-codes, sentinel-guards, loc-check (all four new command modules inside
budget), 6697 passed / 184 skipped. No pyproject.toml bump and no changelog.py entry —
also correct for a feature PR.
The design work here is strong, and the three decisions you asked reviewers to push on hold up
under attack. Two things I would fix before merge, both small; the rest is non-blocking.
1. _deleted_side_message branch ordering misreports the state — and the --take it recommends silently deletes
commands/_merge_request_render.py:361-383
The None checks sit after the is True and not <other> pair, so they are unreachable for the
combinations that matter. Reproduced against the branch:
ours=None theirs=True -> "Production deleted this configuration; your branch changed it."
"Resolve with `--take delete` (drop it) or `--take ours` (keep your version)."
ours=True theirs=None -> "Your branch deleted this configuration; production changed it."
"... or `--take theirs` (keep production's version)."
ours_deleted is None means the config is not in the branch at all (merge_request_service.py:963:
bool(ours.get("isDeleted")) if ours is not None else None) — so "your branch changed it" is false, and
there is no "your version" to keep. It gets worse one layer down: resolve_conflict
(merge_request_service.py:1192-1197) collapses --take ours on a None side into
resolution = "delete". So both options the user is offered as opposites execute the same
destructive resolution, while the sentence presents one of them as the way to preserve their work.
Symmetric for the ours=True, theirs=None case.
The module docstring already states the intent — a None flag should "render defensively, no
recommendation" — the ordering is what defeats it. Moving the two is None branches above the pair
fixes it. Nothing currently covers None/True: the only ours_deleted fixture in
test_merge_request_cli.py:676 is True.
Given --take on this path is destructive and irreversible from the CLI's point of view, this is the
one item I would not merge without.
2. _REASON_MAX_LENGTH = 1000 is hand-synced across two surfaces, with no service-side guard
commands/_merge_request_writes.py:55 and server/routers/merge_requests.py:42
This is precisely the drift hazard the PR argues against for the auto-merge vocabulary — and solves
correctly there, by putting validate_auto_merge_flags / arms_auto_merge in the service so "the CLI
and the serve router cannot drift". The reason cap got the opposite treatment:
MergeRequestService.request_changes (merge_request_service.py:668-685) only documents the cap in
its docstring and validates nothing, so the two hand-copied literals are the only guards. The router
test name says it out loud — test_merge_request_request_changes_caps_reason_like_the_cli.
It is also the only hardcoded *_MAX_LENGTH anywhere under commands/, server/routers/ or
services/ in the repo, against CONTRIBUTING's "no magic numbers — all configuration values go in
constants.py".
One constant in constants.py, validated once in request_changes, both surfaces mapping the
resulting error, removes the class of problem rather than the instance.
Same class, worth deciding together: --external-id help (_merge_request_writes.py:85) advertises
"max 255 chars" and nothing validates it — no client, service, CLI or router check. Either validate
both consistently or describe both as server-enforced; documenting one cap and enforcing the other in
two places is the confusing middle.
Non-blocking
3. Test classes are named after the review that found the bug, not the behaviour they protect.
test_merge_request_cli.py:1422 TestOpusReviewFollowUps, :1606 TestLayer2Followups, :1647
TestCopilotBalancedFollowUps, with inline # M1: / # F3: round references. The repo's comment
convention is "describe state or reason, never the change", and these scatter behaviour by provenance:
the Rich-markup escaping test and the UTF-8 --output test live under "CopilotBalancedFollowUps"
rather than beside the renderer / TestDiffOutputErrors tests. Six months from now, nobody looking for
"does the merge renderer key on cleanup_skipped?" will look in TestLayer2Followups. The
behaviour-named classes to fold them into already exist.
4. The E2E suite is good and has never run. TestE2EMergeRequestLifecycle is a genuinely thorough
16-step scenario (manufactured conflict, both resolution paths, the 422, the bare---json merge exit 2,
explicit teardown) — but it skips on feature_enabled: false and the E2E project has no
branches-merge-requests, so all eleven commands ship with zero real-API verification. The PR is
honest about that, so this is not a hidden gap. My concern is the unexercised assertions rather than the
policy: step 4 asserts created["branch_from_id"] == branch_id, and create never passes through
_stamp_target (_merge_request_writes.py:190-191 sets only merge_request_id /
resolved_from_branch), so that key must come from the service payload. Since enabling the flag is the
one-time super-admin command the PR already documents, running the suite once before merge converts
"written" into "verified" cheaply — and is the only way the wire shapes get checked at all.
5. Route-level escalation coverage is partial. test_server_router_calls.py proves the armed-MR
escalation for request-review and the arming escalation for create — approve, resolve and
update --auto-merge-strategy ride the shared helpers with no route-level 403 test. Normally I would not
mention it, but this PR's own argument is that without the HTTP checks the classification is decorative,
which makes coverage here load-bearing rather than decorative itself.
6. Two small consistency notes.
- The hoisted
parse_json_arg(commands/_helpers.py:79) is now shared, but the two call sites map its
ValueErrordifferently:INVALID_ARGUMENT/ exit 2 in the merge-request group,
INPUT_ERRORattransformation.py:352. One helper, two codes for the same malformed@file. - The generated SKILL.md table gained 22 rows because the hidden
mralias is enumerated beside
merge-request. Pre-existing generator behaviour (slalready contributes 33 rows), so not this PR's
bug — but ahidden=Truefilter ingenerate_skill_table.pywould be a cheap follow-up for an
agent-facing table.
Verified sound (so it does not get re-litigated)
- The disarm does not escalate, on both surfaces, with tests pinning it
(test_merge_request_cli.py:818test_disarmed_none_is_NOT_destructive,test_server_router_calls.py:2839). This was the easiest thing in
the design to get backwards and it is right. FLAG_ESCALATIONSreally is enforced, not just declared:permissions.py:_matches_patternconsults
it beforeOPERATION_REGISTRY, socli:destructiveand--deny-destructivedo cover the five escalated
strings. Escalation tests run against the realPermissionEngine(exit 6 / HTTP 403), not a mocked check
— which is the difference between testing the rule and testing the assertion about the rule.- The
mralias cannot bypass policy: the group callback hardcodes
check_cli_permission(ctx, "merge-request")(merge_request.py:80), sokbagent mr mergeevaluates
merge-request.merge. Worth stating because registering the same Typer app twice is exactly where an
alias-shaped hole would live. --stateis case-insensitive end to end (command pre-check, service filter, router) — the obvious
"validated lowered, filtered raw" bug is not there.- Every convention-#17 surface is updated, and the gotchas / workflow /
AGENT_CONTEXTprose is
unusually good — the "WHAT MAY HAPPEN WITHOUT A HUMAN SAYING SO" block in CLAUDE.md is the right
amount of alarm in the right place.
One question, not a change request
merge blocks up to MERGE_JOB_MAX_WAIT (600 s) with no --timeout, and both STORAGE_JOB_TIMEOUT and
MR_NOT_READY_TO_MERGE carry retryable=True. For a non-idempotent destructive operation that tells a
timed-out caller to retry into a merge still in flight, which then hits the project merge lock. The
timeout message does say the job continues server-side, so it is survivable, and both flags are
pre-existing Layer 2/3 code rather than this PR's — but workspace load (#687) grew a --timeout for
exactly this shape. Is the answer "yes, accepted, and the lock makes the retry harmless"? If so it
belongs in the gotchas entry beside the existing "no --wait/--timeout" line.
Fix 1 and 2 and this is ready from my side. Nice work — the auto-merge analysis in particular is the kind
of thing that only gets found by someone actually reading the backend, and the group is safer for it.
zajca
commented
Sep 7, 2026
Addendum to my review — three more findings, one of them must-fix
A second pass (parallel security / silent-failure / design passes) surfaced three things my first
review missed. I re-verified each against the branch before writing it up — the first one I
reproduced by running the code.
3. (must-fix) _print_row_success interpolates derived_state into Rich markup unescaped
commands/_merge_request_common.py:391-392
state = str(d.get("derived_state") or d.get("state") or "").replace("_", " ") c.print(f"[bold green]Success:[/bold green] {headline} -- state: {state}")
state is the one interpolation in this module that does not go through escape(), and it is
wire-controlled: derive_state() (services/merge_request_service.py:181-183) returns the
backend's mr.get("derivedState") verbatim when present — the DMD-1988 server field, no
vocabulary check, no escaping — and that lands in result["derived_state"], which is exactly what
this renderer reads.
Reproduced:
$ derived_state = "in_review [/x]"
>>> rich.errors.MarkupError: closing tag '[/x]' at position 80 doesn't match any open tag
_print_row_success is the human-mode success renderer for create / update / request-review /
approve / request-changes / resolve — every write except merge, which has its own
(correctly-escaped) renderer. So the crash lands after the mutating call already succeeded,
including on approve / resolve against an already-armed MR: the auto-merge countdown has
started, and the operator sees a traceback instead of the success line and the
_warn_armed warning that was supposed to tell them so. --json is unaffected.
One-line fix:
state = escape(str(d.get("derived_state") or d.get("state") or "").replace("_", " "))
Worth calling out because the PR description says this class of bug was "fixed everywhere,
including the ad-hoc prints outside the renderer module" — the sweep just missed the shared helper
that all six write commands funnel through, which is the highest-traffic instance of it. Same
root cause as the [/x]-in-a-config-id case Phase 5 caught; the module docstring (:9-15) states
the invariant this violates.
4. get_merge_request_row skips the feature pre-flight — and rule #2 pushes --json callers straight onto that path
services/merge_request_service.py:473-479
It is the only method in MergeRequestService without a _require_merge_requests_feature(client)
call — every sibling has one (create_merge_request :576, update_merge_request :626,
request_review :651, approve :662, request_changes :681, merge :742, resolve_conflict
:1168, and find_merge_request_for_branch's no-match path :445; 8 call sites total). That is a
deliberate, tested tradeoff at its own call site
(test_row_by_id_is_one_get_and_nothing_else) — the gap is that it was not carried through to
the callers that made the row fetch unconditional:
conflicts --merge-request-id N(merge_request.py:206,need_row=True)resolve --merge-request-id N(_merge_request_writes.py:557,need_row=Truealways)request-review/approve --merge-request-id N(_merge_request_writes.py:313,
escalate_when_armed=Trueforces it)merge --merge-request-id Ninteractive, without--yes(_merge_request_writes.py:451)- and over
serve,_escalate_if_armed(routers/merge_requests.py:76) for the same three
transitions
On a feature-less or SOX project each of these 403s in the row fetch first, so the operator gets
the bare role-denial-shaped error the class docstring (:325-327, :366-368) explicitly says only
a client-side check can word properly — and goes hunting for a token/RBAC problem instead of
enabling branches-merge-requests. The correctly-guarded write method they were about to call
never runs.
The interaction is what makes this worth fixing rather than documenting: decision #2 requires
--json callers to pass an explicit --merge-request-id for destructive ops, and the explicit-id
path is precisely the one that loses the friendly error. resolve --json --merge-request-id N —
the recommended scripting form — gets the raw 403. (merge --json --merge-request-id N escapes it
only because need_row=False there, so the shape is inconsistent between two commands in the same
group.)
Cheapest fix that keeps the "one GET" property: check the feature lazily — only when the GET
itself 403s — mirroring what find_merge_request_for_branch already does on its no-match path.
5. register() uses a private Typer attribute, and the precedent cited for it is not real
commands/_merge_request_writes.py:601-603
app.registered_commands.extend(writes_app.registered_commands)
registered_commands is Typer internals, and this is the only use of it anywhere in src/ or
tests/ (1 grep hit, repo-wide). The docstring at :7-8 justifies it as "precedent:
_storage_describe.register" — but _storage_describe.register (_storage_describe.py:32-36)
declares its commands with the public @app.command() decorator inside register(app):
def register(app: typer.Typer) -> None: """Mount the describe commands onto ``app`` (the ``storage`` Typer group).""" @app.command("describe-bucket", rich_help_panel=_DESCRIBE) def storage_describe_bucket(...):
So both halves are off: the mechanism is not the same, and the file cited as precedent is an
example of the safer alternative. The docstring is the part I'd insist on — it is what will tell
the next person this pattern is established here when it isn't, and a private-list mutation is
what breaks silently on a Typer bump. Switching to the decorator form gets the same flat mount
with the public API.
Non-blocking design push-backs (decisions, not defects)
On decision #2 — exit 2 is carrying a state-derived precondition. Your own test names it:
test_armed_implicit_target_under_json_exits_2_after_resolution
(tests/test_merge_request_cli.py:1055). The identical invocation succeeds while the MR is
unarmed and exits 2 once somebody else arms it — nothing about the command line changed. Per
CONTRIBUTING, 2 is a usage error, and a script currently cannot tell "fix your flags" from "your
target became dangerous behind your back" except by parsing the message text. The rule itself I
think is right; I'd keep exit 2 if you prefer the precedent and just give the state-derived case
its own ErrorCode so callers can branch on it.
On decision #1 — resolve's escalation is the over-broad one. The part you flagged as
possibly over-broad is actually fine: update's non-arming fields genuinely cannot move an MR
toward approved, because the required-approvals count is decoupled project metadata
(docs/merge-requests-notes.md:107-112), not derived from the reviewer list — so gating update
purely on the value it is about to write is correct. resolve is where it bites: it escalates on
any armed MR regardless of distance from mergeable, so a fresh MR with five conflicts and two
required approvers needs destructive rights to fix conflict #1 the moment anyone arms it. A
"walk the conflicts, never merge" agent policy stops being expressible under
--deny-destructive — which pushes teams to drop --deny-destructive for MR work entirely and
lose it for the ops it is well-suited to. Since FLAG_ESCALATIONS keys are already just
operation strings, a narrower trigger (escalate only on the last unresolved conflict) or a
distinct key for resolve-on-armed both fit the existing engine.
Third copy of the escalation rule. routers/merge_requests.py:66-79 hand-reimplements
_escalate_if_armed with the same intent as the CLI's version, different signature, no shared
code. Byte-for-byte correct today (I checked the key strings) — but it is the same class as my
finding #2 about _REASON_MAX_LENGTH: safety-critical logic maintained by hand in two files, and
the next narrowing of the policy (per the point above) has to land in both or the surfaces
diverge silently. Worth extracting alongside whatever you do about #2.
Scope, one specific ask. docs/merge-requests-layer2.md and -layer2-followups.md (422 lines)
do not exist on origin/main — this branch is writing the Layer 2 design doc retroactively, for a
service that shipped separately in #703, while also patching that same service by +206/-26. I'm
not asking for a split. I am asking for a line in the PR description saying which parts of
layer2.md describe what #703 already shipped versus what this PR changes, because nobody
reviewing "the CLI layer" is positioned to catch drift between that doc and the merged service.
One sub-threshold note, since it is cheap. diff --output PATH's OSError branch
(merge_request.py:301-306) exits before formatter.output / _emit_warnings, so a diff that
also carried an envelope-hole warning loses it when the file write fails. Narrow double-failure
window, mentioned only because it is a one-line reorder.
Additionally cleared (so it does not get re-checked)
The parallel passes independently confirmed, beyond what my first review covered:
- No destructive-classification bypass. All 12 route permission keys resolve to real,
correctly-classifiedOPERATION_REGISTRYentries — none fall through to the fail-closed
writedefault. Every escalation check provably precedes itsservice.*call (create 141-153,
update 235-244,_transition315-327, resolve 561-569).--auto-merge-atwithout a strategy is
a hard exit 2, not a silent accept.request-changesnot escalating is structurally right — it
moves the MR away fromapprovedand deletes approvals. _stamp_targetnever stamps a stale fact:setdefaultcannot overwrite a service-computed
key, and thebranch_from_idfallback chain matches the shape each result actually carries
(nestedbranches.branchFromIdfor row-shaped results, flatbranch_idfor the diff)._emit_warningsreally is on every path,detail'sfeature_enabled is Falseearly return
included (it returns after the emit at:169).- No swallowed total failure: the 409 remap and
STORAGE_JOB_FAILEDboth propagate; only the
genuinely best-effort post-merge cleanup degrades towarnings[]+cleanup_skipped, keeping
exit 0 — which is the correct call. - No token or secret handling in any new file (zero grep hits).
Revised ask: findings 1, 2 and 3 before merge; 4 and 5 I'd take here too since both are a few
lines, but I won't hold the PR on them if you'd rather issue them. Everything under "design
push-backs" is a decision for you — an answer in the thread is enough, no code required.
Uh oh!
There was an error while loading. Please reload this page.
The
kbagent merge-requestcommand group (Layer 1, DMD-1900) overMergeRequestService(Layer 2, #703), plus its
serveREST router. Implementsdocs/merge-requests-layer1.md. Supersedes #735, which was merged into the L2 branch and then undone when L2 was rebased back to a pure Layer 2 (the L2 branch has since been squash-merged tomainas5281eef)..Base
Layer 2 (#703) is in
main; this PR is rebased onto that squash (5281eef) and targetsmaindirectly. The four
docs/merge-requests-*.mdfiles ride along -- they lived onms/merge-requests-rfcsandmaindoes not have them yet.The Layer 2 documents in this PR: what describes #703, what this PR changes
docs/merge-requests-layer2.mdand-layer2-followups.mdland here becausemainnever gotthem (they lived on
ms/merge-requests-rfcs). To keep "the CLI layer" review honest about theservice it also patches:
layer2.mdup to andincluding Rebase / conflict resolution semantics, minus the two items below; all of
merge-requests-notes.mdand-layer3.md.section of
layer2.md(get_merge_request_row,resolution_candidate+ diffwarnings,merge()cleanup_warnings→warnings,feature_enabledon detail,cleanup_skipped+branch_from_id_rawon merge) and thewarnings[]bullet under Post-merge cleanup; inthe Merge: 409 handling section the rationale paragraph was corrected (both 409 shapes carry
a string code -- the mapping lives in the service because the codes are endpoint-specific,
not because one shape is code-less).
-layer2-followups.mdis the review-leftover list fromfeat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899) #703 's approval; each item's State line says whether this PR took it.
services/merge_request_service.py, +206/−26) is exactly the secondbullet plus the field-cap validation and the row tier's lazy feature pre-flight from Zajca's
review.
What ships
Eleven commands (hidden alias
mr), one per service method, plus a permission-enforcedREST mirror:
Three small Layer 2 additions ride along (decided while walking #703's review findings into
the RFC):
get_merge_request_row(the row tier by id — one GET, never the three-calldetail),
get_config_diff → resolution_candidate(the ours envelope through the sameconstant as the five-key replace guard, so
diff --outputcan never write a fileresolverefuses), and
merge()cleanup_warnings → warnings(one soft-failure key for the group).The decisions a reviewer should push on
1. Nothing irreversible happens without a human saying so.
mergeis destructive. So isarming auto-merge: a backend scheduler runs every
approvedMR whoseautoMergeStrategyisimmediately|scheduledthrough the sameMergeProcessor, on itsown, retrying every tick —
mergenever called(
AutoMergeCandidateRepository.php:44-47,AutoMergeTickHandler.php:86). HenceFLAG_ESCALATIONS:create/updatethat arm, andrequest-review/approve/resolveonan already-armed MR, escalate to destructive;
none(the disarm) never does. Confirmationsits where a human chooses the outcome (
merge, arming); escalation wherever one iscaused. An agent under
--deny-destructivecan run the whole flow and cannot complete amerge by any route. Deliberately conservative: the required-approvals count is unreadable
with a Storage token (DMD-1969).
2. Under
--json, a destructive invocation must name its target. Every destructivekbagent command either prompts or is told its target;
--jsonhas no prompt. A bare--json mr mergewould be the first command where nothing on the command line identifieswhat gets destroyed. Humans keep the active-branch fallback (
--merge-request-id/--idoptional everywhere;
--branch→active_branch_id→find_merge_request_for_branch);scripts, which have the id from their previous call's payload, pass it. For the
state-derived escalations this exits 2 only after resolution — the information does not
exist earlier. Inverting
--yesunder--jsonwas considered and rejected (zero precedentacross 48 commands).
3.
serveenforces the policy on every route (Depends(require_permission)— until nowonly
/auth/*did), with the flag/state-derived escalations evaluated in the route body.Without it the classification above would be decorative over HTTP.
POST .../mergeissynchronous for up to 600 s and says so.
4. One error handler, no per-command
except.FeatureNotEnabledError(aConfigErrorwith
FEATURE_NOT_ENABLED) now surfaces from the resolver behind every omitted id — readsincluded — exactly where a copied
branch.pyidiom would flatten it. Pinned per command.Renderers worth a glance
diffchecks the*_deletedflags before the table and recommends the--take: sincefeat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899) #703 finding Phase 4: Agent context, doctor, CLAUDE.md, README #4 a null side yields zero per-path rows, so "production deleted it, dev
changed it" would otherwise render as three empty sections and "No changes".
MR_MERGE_CONFLICTrenders the conflict list fromdetailsand appends list truncated —run
merge-request conflictswhenapi_error_params_truncatedis set (the list is boundedto 20 entries server-side of kbagent).
escape(); the Phase-5 review caught a[/x]in aconfig id raising
MarkupErrorafter the rebase had landed — fixed everywhere,including the ad-hoc prints outside the renderer module.
Review already applied (Phase 5)
Three independent passes (Opus, Sonnet,
/code-review); 28 findings, all in-scope onesfixed with a pin — see the "apply the Phase-5 self-review round" commit for the full list. Notable:
detailswere being droppedfrom every merge-request error envelope;
branch_from_idwasnullbeside a payload saying123; the auto-merge vocabulary was duplicated CLI vs router (a safety divergence — onesurface would stop escalating an arming value the other still knows);
bool("false")on ahand-edited
isDisabledwould have disabled a config on replace and returned 200; overservethe MR 409s answered 502 with no details.Deferred to #703 (Layer 2 design/refactor findings, too large for this PR's tail):
_classify_three_waymissingbothrows for nested-vs-parent edits; theor code is None409 fallback; SOX-project reads reporting
feature_enabled: false; the tuple return inhttp_base._bound_error_params; post-merge cleanup being a third copy ofBranchService's;the try/finally client idiom vs the context manager.
Layer 2 follow-ups applied here
docs/merge-requests-layer2-followups.md(the non-blocking leftovers of #703, written afterZajca's approval) routes its fixes through this PR. Done: F2 (classifier docstring + the
missing empty-envelope test; an empty envelope on either side now lands in
warnings, so the diffrenderer says "no classification possible" instead of "the conflict cleared"), F3
(
cleanup_skipped: true+branch_from_id_rawon a merge whose source branch id could not beread; the renderer keys on the flag), F4 (the shared
find_default_branch_idlogs the skippednon-numeric entry), F5 (
detailcarriesfeature_enabled-- free there -- and hint-next refusesto recommend a write that cannot succeed;
liststays feature-blind as L2 decided), F7(truthiness sweep, positive reset assertion, long docstring line). Each item's State is recorded
in the doc. Not here: F6 (mapping cleanup project scope -- both call sites, standalone PR) and
F8 (a
FORCE_COLORtest fragility onmain).Open decision for the reviewer/owner (F4):
sync initexits 0 with an emptybrancheslistwhen no usable default branch is found -- pre-existing behaviour, left unchanged; the doc asks
whether that should be an error.
Still-open Layer 2 design findings (from the Phase-5
/code-reviewpass; not in thefollow-ups doc, too large for this PR):
_classify_three_wayproduces nobothrow when one sideedits a nested key and the other replaces the parent; the
or code is None409 fallback treats acode-less 409 as a conflict; SOX-project reads report
feature_enabled: false; the tuple return inhttp_base._bound_error_params; the post-merge cleanup being a third copy ofBranchService's.Worth a follow-up issue once this lands.
Verification
make checkgreen: ruff, format, ty, skill-check, version sync, version gates(
vNEXTtags on body lines), command-sync (279 commands), endpoints-check, error-codes,sentinel guards, loc-check (all six new/changed modules under 800 —
merge_request.pywassplit at 829 into common/reads/writes), 6643 tests.
test_merge_request_cli.py), 19 router (test_server_router_calls.py),~20 service. Escalations are tested against the real engine (
--deny-destructive→ exit 6 /HTTP 403), not a mocked check.
TestE2EMergeRequestLifecycle(branch → config on thebranch → create → list/detail/conflicts via id and
--branch→approveasserts the 422→ bare
--json mergeexits 2 → merge → config in production → explicit teardown) is gatedon the project feature:
listanswersfeature_enabled: falseand the suite skips with theone-time enable command in the reason. The E2E project does not carry
branches-merge-requeststoday, and kbagent cannot provision one. Enable it once:kbagent feature project-add --project kbagent-e2e --feature branches-merge-requests.Not silent: the skip reason says exactly this.
Silent-drift surfaces (convention #17), all touched
CLAUDE.md command list ·
commands/context.py·commands-reference.md·gotchas.md(
since vNEXT) ·keboola-expert.mdmatrix row ·SKILL.mdtriggers + generated table ·new
merge-request-workflow.md·branch-workflow.mdpointer ·docs/web-server-endpoints.md(generated).
branch mergeis deprecated with a conditional pointer — it works onprojects without the feature, so it is not a 1:1 replacement; behaviour unchanged,
deprecationin--json.🤖 Generated with Claude Code