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 16 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.
🔵 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.
🟡 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
| if listing.exit_code != 0: | ||
| pytest.skip(f"merge-request list failed on the E2E project: {listing.output}") |
There was a problem hiding this comment.
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.
| for warning in result.get("warnings") or []: | ||
| formatter.warning(str(warning)) |
There was a problem hiding this comment.
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).
| _hint_next( | ||
| formatter, | ||
| f"edit {output}, then `{resolve_cmd} --resolved @{output}`", | ||
| ) |
There was a problem hiding this comment.
Confirmed and fixed in 2042c3b -- the path is escaped before the hint-next line; same test covers a [x] in the filename.
| if label == "ours": | ||
| missing = [ | ||
| key | ||
| for key in ("name", "rows", "configuration", "isDisabled") | ||
| if key not in envelope | ||
| ] |
There was a problem hiding this comment.
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).
| def test_lifecycle_create_inspect_merge(self) -> None: | ||
| """branch -> config on the branch -> create MR -> detail/conflicts -> approve 422 -> merge.""" |
There was a problem hiding this comment.
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.
| assert approve.exit_code != 0, approve.output | ||
| assert json.loads(approve.output)["error"]["code"] not in (ErrorCode.FEATURE_NOT_ENABLED,) |
There was a problem hiding this comment.
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).
| Decided 2026-08-26: **two new `ErrorCode` members, mapped in the service** — only the service | ||
| knows the 409 came from the merge endpoint, and the conflict shape carries no string code, so | ||
| the generic `http_base` layer cannot tell it apart: |
There was a problem hiding this comment.
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>
df6e018 to
2042c3b
Compare
…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>
zajca
left a comment
There was a problem hiding this comment.
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.
Addendum to my review — three more findings, one of them must-fixA second pass (parallel security / silent-failure / design passes) surfaced three things my first 3. (must-fix)
|
…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>
2042c3b to
2e8dc0e
Compare
…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>
…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>
keboola-pr-reviewer-bot
left a comment
There was a problem hiding this comment.
Verdict: needs_human (risk 4/5) · profile keboola-mcp-server
PR exceeds size cap (4214 source LOC across 30 source files; 0 test/fixture LOC across 0 files excluded from the cap).
|
Thanks — all five must-fixes are in ( What changed since your review — one model, not two: You pointed at the same hazard from two sides: a permission that depends on a GET (finding 4 / "third copy"), and an exit 2 that depends on state nobody typed (push-back on decision #2). Both came from the escalations being conditional — on a flag's value, or on whether the MR happened to be armed. We dropped the conditions entirely. Destructive is now a static property of the command: Auto-merge is its own command ( Per item:
Design push-backs: A (state-derived exit 2) and B ( E2E still cannot run (the E2E project lacks |
zajca
left a comment
There was a problem hiding this comment.
Actionable findings from the automated review.
|
|
||
| def _envelope_holes(side: dict[str, Any]) -> list[str]: | ||
| """Required content keys ABSENT from a (non-null, non-deleted) side's envelope. | ||
| An empty envelope reports all of them.""" |
There was a problem hiding this comment.
Reviewed by Opus.
_envelope_holes treats a required content key as present whenever it is a key of the envelope (key not in envelope), but the replace guard in resolve_conflict rejects a body where the key is present and null (key not in body or body[key] is None), and additionally rejects an empty/whitespace name.
So for a side whose envelope carries an explicit "name": null (the diff envelope declares name nullable — the guard's own comment says so), _envelope_holes returns []. Consequently:
_resolution_candidatecomposes a candidate instead of returningNone,_diff_warningsemits no warning,merge_request_diff --output FILEhappily writes the file and the hint line tells the user toresolve --resolved @FILE,resolve_conflictthen fails that body withConfigError("A resolved body must spell out the full replaced content (rebase REPLACES): missing name.")— blaming the caller for a file kbagent itself wrote.
This is exactly the drift _resolution_candidate's docstring says the shared _DIFF_CONTENT_KEYS constant prevents ("a prefill built anywhere else that dropped description when null … would produce a file kbagent itself then refuses").
Failure scenario. Backend returns a diff whose ours.diff carries "name": null with all other required keys present. kbagent merge-request diff --component-id X --config-id Y --output cand.json exits 0, emits no warning, writes the candidate and prints Next: edit cand.json, then `merge-request resolve ... --resolved @cand.json` . Running that resolve exits 5 with "missing name".
Verified against the head tree:
ours = {"isDeleted": False, "diff": {"name": None, "rows": [], "configuration": {},
"isDisabled": False, "description": "x"}}
_envelope_holes(ours) -> []
_resolution_candidate(ours) -> {'name': None, 'description': 'x', 'configuration': {},
'isDisabled': False, 'rows': []}
resolve_conflict guard on that body -> missing ['name'] (ConfigError, exit 5)
The same presence-only criterion also feeds _classify_three_way.classifiable(), so such a side is classified against a None value rather than being reported as unclassifiable.
Fix: make _envelope_holes mirror the guard — treat a present-but-None required key (and a blank name) as a hole — so the candidate is suppressed and the reason lands in warnings[] instead of in a file the user is told to edit.
| "given set REPLACES the current reviewers -- it never appends" | ||
| ), | ||
| ) | ||
| _EXTERNAL_ID_OPT = typer.Option( |
There was a problem hiding this comment.
Reviewed by Opus.
--reason gets an explicit CLI pre-check in merge_request_request_changes (line 355) whose comment states the intent: "this pre-check exists only so the flag error carries exit 2 like every other bad flag". --external-id has no such pre-check on create/update, so an over-cap value reaches MergeRequestService._too_long, which raises KeboolaApiError(error_code=INVALID_ARGUMENT). _handle_error routes that through map_error_to_exit_code, and INVALID_ARGUMENT is not in any of its branches — it falls through to the generic return 1.
Verified on the head tree:
map_error_to_exit_code(KeboolaApiError(error_code=ErrorCode.INVALID_ARGUMENT)) -> 1
Over serve the same code maps correctly (_CALLER_REFUSAL_CODES -> HTTP 400), so only the CLI surface is inconsistent — which undercuts the PR's own stated resolution of review item 2 ("both surfaces map INVALID_ARGUMENT"). A --json script cannot tell "you passed a bad flag" (2) from "the operation failed" (1), and the two sibling caps in the same group disagree with each other.
Failure scenario. kbagent --json merge-request create --branch 123 --title t --external-id <256 chars> exits 1 with INVALID_ARGUMENT, while kbagent --json merge-request request-changes --id 7 --reason <1001 chars> exits 2 with the same error class. A wrapper branching on exit 2 for usage errors misclassifies the first as a transient failure and retries it.
Fix: add the same len(external_id) > MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH -> _usage_error(...) pre-check to merge_request_create and merge_request_update (or map INVALID_ARGUMENT to exit 2 in map_error_to_exit_code, which would also cover _require_in_conflict_set and the absent-branchFromId case this PR re-coded to INVALID_ARGUMENT).
…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>
…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>
…oth 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>
2e8dc0e to
e6954b4
Compare
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. Destructive is a property of the command — never of a flag or of the MR's state.
Anything that moves a merge request toward or into production is destructive, always:
merge,request-review(on the non-SOX default of 0 approvals it lands the MR directly inapproved),approve(the last approval is what a merge waits for),resolve(removes theblocker a merge waits on) and
auto-merge.create,update,request-changesare writes.So
OPERATION_REGISTRYcarries the class per command,FLAG_ESCALATIONSis back to itssingle original entry, a policy is evaluated from the command name alone before any network
call, and
--deny-destructiveyields an agent that can observe and shape merge requestsbut never move one.
This replaces the first draft's flag/state-derived escalations, which Zajca's review pointed at
twice (a permission depending on a GET; an exit 2 depending on state nobody typed). Deliberately
over-broad in one place: on a 2-approval project
request-reviewonly reachesin_review, yetit is destructive here — the required-approvals count is unreadable with a Storage token
(DMD-1969), and a class that flips on a number the CLI cannot read would be the state condition
again by another name.
2. Auto-merge is its own command.
merge-request auto-merge --strategy immediately|scheduled|none [--at TS]— destructive, prompts in human mode when arming. A backendscheduler runs every
approvedMR whose strategy is set through the sameMergeProcessor,on its own, retrying every tick (
AutoMergeCandidateRepository.php:44-47,AutoMergeTickHandler.php:86): a delayed production merge withmergenever called. Arming istherefore a consciously separate step, not a flag on
create/update(which no longer takeone). The disarm rides the same command, same class — a caller who could not arm never needs to
disarm. Under the hood it is
update_merge_request(auto_merge_*); Layer 2 is untouched.3. Under
--json, every destructive command must name its target (--merge-request-idor--branch), checked before anything is resolved. Every destructive kbagent command eitherprompts or is told its target;
--jsonhas no prompt. Humans keep the active-branch fallback(
--merge-request-id/--idoptional everywhere) and get a prompt onmergeand arming.Inverting
--yesunder--jsonwas considered and rejected (zero precedent across 48 commands).4.
serveenforces the policy on every route (Depends(require_permission)— until nowonly
/auth/*did). Because the class is static, the route dependency is the whole check;nothing is evaluated from the request body or a prior GET.
PUT …/{id}/auto-mergeis thetwelfth route.
5. One error handler, no per-command
except.FeatureNotEnabledError(aConfigErrorwith
FEATURE_NOT_ENABLED) surfaces from the resolver behind every omitted id — reads included— 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 (now one service-owned validator);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.
Zajca's review (2026-09-07), how each item landed
Must-fix 1 (
_deleted_side_messageNoneordering — a missing side recommended--take ours, which resolves as DELETE): fixed,Nonechecked first, no recommendation, two pins.2 (hand-synced
_REASON_MAX_LENGTH, unvalidated--external-id):MERGE_REQUEST_*_MAX_LENGTHin
constants.py, validated once in the service, both surfaces mapINVALID_ARGUMENT.3 (unescaped
derived_statein the shared success renderer): escaped, pinned with aserver-supplied
[/x]. 4 (get_merge_request_rowwithout the feature pre-flight on theexplicit-id path): lazy pre-flight on a 403 only, happy path stays one GET, three pins.
5 (
register()via a private Typer attribute, false precedent):app.command(name)(fn).Design push-backs: exit 2 carrying a state-derived precondition and
resolveescalatingtoo broadly are both resolved by decision 1 above — there is no state-derived condition left to
carry a code for, and
resolveis destructive by command with the reasoning stated. Third copyof the escalation rule: gone with the escalation. Scope (which parts of
layer2.mddescribe#703 vs this PR): the section above.
--outputOSError branch losing warnings: reordered.Test classes named by provenance: regrouped by behaviour. Route-level escalation
coverage: every destructive route incl. the disarm now has a 403 test. The
--timeoutquestion: yes, accepted — the merge job continues server-side and the project-wide merge lock
makes a premature retry answer
MR_NOT_READY_TO_MERGErather than start a second merge; now ingotchas beside the no-
--timeoutline.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), ~25 router (test_server_router_calls.py),~25 service; every destructive command/route has a real-engine denial test (exit 6 / HTTP 403). 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