Repository navigation
feat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899) - #703
Conversation
The big one (#2, upgraded): the diff sides' wire shape was WRONG in the implementation and in the test fixtures that defined it. Verified against connection (ConfigurationVersionResponse + ConfigurationDiffData OA schemas, now recorded in the notes wire-truth table): each side is {version, isDeleted, diff: {name, description, changeDescription, isDisabled, configuration, rows}} -- content NESTED under diff, version/deletion as side metadata. The classification and both take modes now read the envelope; the flat-side code would have been dead on arrival against the live API. #1: resolve_conflict no longer takes branch_id -- it derives the branch from the MR itself (branches.branchFromId), so the conflict-set guard and the branch being written to can never disagree; a caller-supplied id could point the REPLACing rebase at an unrelated dev branch the guard never checked. A published/canceled MR (null branchFromId) is refused readably. #3: take=theirs of a deleted side collapses to the delete resolution, symmetric with ours ('production deleted it, dev changed it' is a live conflict shape). #4: deletion surfaces as top-level ours_deleted/theirs_deleted booleans on get_config_diff (None = side never existed) -- it is side metadata, not a content path. #5: a 'both' row where the sides agree on the identical value carries agreed: true -- agreement, not a conflict hotspot. #2 (message half): a take side missing required envelope keys is reported as a backend contract violation pointing at the resolved-body workaround, not as caller error. #6: wire ids compared via _same_id / int-coerced (find_merge_request_for_ branch, merge()'s was_active) -- a string-serialized branchFromId can no longer silently defeat the post-merge cleanup. #7: the feature pre-flight raises FeatureNotEnabledError carrying the new ErrorCode.FEATURE_NOT_ENABLED (value matches the string SearchService already emits; categorized 'configuration' like PAYG_NOT_AVAILABLE). #8: ConfigError imported from ..errors like every other service; the isDefault scan hoisted to services.base.find_default_branch_id and the copies in config/sync/workspace services migrated (lib.py keeps its own loop -- the SDK facade does not import the services layer); verify_token is skipped when the server already serialized viewer (the polyfill's cost dies with the polyfill); list --state validates against the closed vocabulary instead of returning a silent count: 0 on a typo. #9: test imports hoisted (no mid-file noqa), mocks spec'd at the L3 seam (KeboolaClient + MergeRequests -- a renamed L3 method now fails the tests), and regression tests added for every finding (82 tests total). Doc drift: layer2/layer3 references updated to BRANCHES_MERGE_REQUESTS_ FEATURE, the layer3 open nit closed, tokens.py line ref fixed. Review: tasks/pr-703-review.md (2026-08-27). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0e7e87f to
51eaa2c
Compare
There was a problem hiding this comment.
Pull request overview
Implements Layer 2 (services) support for Keboola “merge requests” (Branches 2.0), including client-side derived-status polyfills, merge lifecycle orchestration, and conflict resolution built on the existing Layer 3 client namespace.
Changes:
- Adds
MergeRequestServicewith list/get/create/update/review transitions, merge error remapping, and conflict diff/resolve workflows. - Introduces derived-status polyfill helpers (
derived_state,merge_blockers,allowed_actions,viewer) with “server-first, fallback” behavior. - Extends shared infrastructure: structured diff entries, richer API error details (
code/params), verify-token viewer anchoring (admin_id), and a shared default-branch resolver.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
tests/test_merge_request_service.py |
New unit tests covering MR service behavior, derived status, merge 409 remapping, and conflict resolution flows. |
tests/test_json_utils.py |
Adds coverage for new compute_diff_entries() structured diff walker and its formatting parity with compute_diff(). |
tests/test_http_base.py |
Adds tests ensuring API error body code and params are surfaced into KeboolaApiError.details. |
src/keboola_agent_cli/services/workspace_service.py |
Switches default-branch detection to shared find_default_branch_id(). |
src/keboola_agent_cli/services/sync_service.py |
Uses shared find_default_branch_id() for sync init default branch resolution. |
src/keboola_agent_cli/services/merge_request_service.py |
New Layer 2 service: lifecycle ops, derived status, merge cleanup, conflict diff/resolve. |
src/keboola_agent_cli/services/config_service.py |
Replaces duplicated default-branch scan with find_default_branch_id(). |
src/keboola_agent_cli/services/base.py |
Adds shared helper find_default_branch_id(). |
src/keboola_agent_cli/models.py |
Extends TokenVerifyResponse with admin_id/admin_name used for viewer-relative derivation. |
src/keboola_agent_cli/json_utils.py |
Adds DiffEntry + compute_diff_entries(); refactors compute_diff() into a formatter over entries. |
src/keboola_agent_cli/http_base.py |
Surfaces API error code/params into KeboolaApiError.details for higher-layer branching. |
src/keboola_agent_cli/errors.py |
Adds FEATURE_NOT_ENABLED, MR-specific error codes, and FeatureNotEnabledError. |
src/keboola_agent_cli/constants.py |
Renames merge-request feature flag constant to BRANCHES_MERGE_REQUESTS_FEATURE. |
src/keboola_agent_cli/client/tokens.py |
Parses verify-token admin block into TokenVerifyResponse.admin_id/admin_name. |
src/keboola_agent_cli/client/merge_requests.py |
Updates docs/comments to new constant name and clarifies merge 409 conflict shape. |
docs/merge-requests-notes.md |
Adds verified backend “wire truth” notes, including 409 shapes and diff/rebase envelopes. |
docs/merge-requests-layer3.md |
Documents shipped Layer 3 surface and contracts; updated with verified conflict 409 code/params. |
docs/merge-requests-layer2.md |
Design/decision record for Layer 2 service behavior, derivations, preflight, merge cleanup, and conflict resolution. |
docs/merge-requests-layer1.md |
Working notes for future CLI commands and UX wording (Layer 1). |
docs/error-codes.md |
Documents newly introduced error codes for feature gating and MR merge conflict/not-ready cases. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| admin_id: int | None = None | ||
| if not isinstance(mr.get("viewer"), dict): | ||
| admin_id = client.verify_token().admin_id |
There was a problem hiding this comment.
Valid catch — the skip predicate was looser than derive_viewer's server-field predicate, so a bare viewer: {} (or one with foreign keys) would skip verify_token and yield {None, None} instead of the local derivation. Fixed in cbdf1d1: both sites now share a single _server_viewer() predicate (dict AND has isCreator/hasApproved), so they cannot disagree; regression test added (test_empty_server_viewer_falls_back_to_local_derivation).
zajca
left a comment
There was a problem hiding this comment.
Changes requested. See the published review findings.
zajca
left a comment
There was a problem hiding this comment.
Selected findings from the automated read-only review.
| # it nullable, but the rebase validator requires a non-empty | ||
| # trimmed string -- a null would sail through a bare presence | ||
| # check straight into a server 400. | ||
| missing = [key for key in ("name", "rows", "configuration") if key not in body] |
There was a problem hiding this comment.
Reviewed by Opus.
The guard exists precisely because rebase REPLACES rather than patches ("a missing key would silently wipe data, so it is refused instead of defaulted"), and the code already recognises that a present-but-null value defeats a bare presence check -- that is exactly why the name special case on the next line was added.
configuration has the same hole and a worse outcome. missing only tests key not in body, so a body carrying "configuration": None passes, and rebase_config(..., configuration=body["configuration"]) posts {"configuration": null}. Per this PR's own wire-truth table (docs/merge-requests-notes.md: RebaseRequest::mapValidatedData uses ?? new stdClass()), PHP's null-coalescing operator fires on null as well as on an absent key, so the backend substitutes {} -- the configuration body is replaced with an empty object and the rebase returns 200. No error, no warning.
Failure scenario: a caller invokes resolve_conflict(alias, mr_id, component_id, config_id, resolved={"name": "My config", "rows": [], "configuration": None}). missing is empty (all three keys present), the non-empty check applies only to name, so rebase_config POSTs {"version": N, "diff": {"name": "My config", "rows": [], "configuration": null}}. The backend's ?? new stdClass() fires on the null and REPLACES the dev-branch configuration body with {}. The call returns 200 with resolution: "custom" -- the entire configuration payload is destroyed with no error surfaced.
Reachable two ways:
- The caller-authored path is fully under caller control.
resolve_conflict(resolved={...})is the public service entry point that Layer 1 (DMD-1900) will feed from a user-supplied JSON/YAML resolution file; a file with a nullconfigurationis refused nowhere. - The
take=ours|theirspath composesbodyfrom the server'sdiffenvelope. The comment above acknowledges that envelope declaresnamenullable despite being required; ifconfigurationis nullable on the same schema, a null there is indistinguishable from a legitimate one here.
Suggested fix: extend the non-null check beyond name -- treat a None value for configuration (and rows, whose []-deletes-all-rows semantics make a null equally ambiguous) as missing, so both take the existing refusal path rather than reaching rebase_config.
The existing regression test test_null_name_on_a_take_side_is_a_contract_violation covers only name; no test pins the null-configuration case.
There was a problem hiding this comment.
Confirmed and fixed in 1e239b4 — the guard now refuses present-but-null for every required key (key not in body or body[key] is None), so a configuration: null (which PHP's ?? new stdClass() would turn into a silent full-body wipe) and a null rows both take the existing refusal path. A genuine rows: [] (delete-all) stays legal — pinned by test_empty_rows_list_stays_a_legal_resolution; new regressions cover null configuration on the resolved path and null rows on a take side.
| return {"alias": alias, **_enrich_row(mr)} | ||
| finally: | ||
| client.close() | ||
| raise KeboolaApiError( |
There was a problem hiding this comment.
Reviewed by Opus.
This PR added feature_enabled to list_merge_requests for exactly this ambiguity, with the rationale spelled out in the comment at line 330: "The list endpoint is ungated, so a project without the feature answers 200 + [] -- indistinguishable from a genuinely empty project, while every subsequent write would fail."
find_merge_request_for_branch reads the same ungated client.merge_requests.list() and did not get the same treatment. On a project without branches-merge-requests the loop sees an empty list and raises NOT_FOUND whose message asserts a fact it cannot know ("Branch N has no merge request") and prescribes a next step that is guaranteed to fail -- create_merge_request runs _require_merge_requests_feature first and refuses with FEATURE_NOT_ENABLED.
Failure scenario: on a project where branches-merge-requests is not enabled, find_merge_request_for_branch(alias, 123) gets 200 + [] from the ungated list endpoint, matches nothing, and raises NOT_FOUND with "Branch 123 has no merge request in project 'prod'. Create one with kbagent merge-request create." The user runs that command and gets FEATURE_NOT_ENABLED ("Merge requests are not enabled on this project") -- the first error's diagnosis and its prescribed next step were both wrong, and the actual cause was knowable from the already-loaded features cache.
This matters more than for list: the docstring identifies this method as "the resolver behind L1's optional --mr-id", so it is on the path of every merge-request command invoked without an explicit id. The user is sent in a loop -- told to create an MR, then told merge requests are not enabled.
Suggested fix: mirror the list_merge_requests treatment -- on the no-match path only (so the cost is not paid on the happy path), call client.has_feature(BRANCHES_MERGE_REQUESTS_FEATURE) before raising, and either raise FeatureNotEnabledError or qualify the message when the feature is absent. The client.close() in the finally currently runs before the raise, so the check needs to move inside the try.
There was a problem hiding this comment.
Confirmed and fixed in 1e239b4 — find_merge_request_for_branch now runs _require_merge_requests_feature on the no-match path (inside the try, so the client is still open), mirroring the list_merge_requests treatment: a featureless project gets FEATURE_NOT_ENABLED (with the SOX/enable message split) instead of a NOT_FOUND prescribing a create that cannot succeed. The check is spent only when nothing matched — test_find_happy_path_does_not_spend_the_feature_call pins that the happy path never pays the extra GET.
| # re-fetch data the error already delivered. | ||
| api_error_params = body.get("params") | ||
| if isinstance(api_error_params, dict) and api_error_params: | ||
| details["api_error_params"] = api_error_params |
There was a problem hiding this comment.
Reviewed by Opus.
api_error_params copies the response body's params object verbatim into KeboolaApiError.details, with no size bound, at all four raise sites (401/403/404 and the generic path) -- so it now rides along on every 4xx/5xx from every BaseHttpClient subclass (KeboolaClient, AiServiceClient, DataScienceClient, AuthClient via its _raise_api_error override), not just the merge 409 this was added for. Numerous command-layer sites re-propagate it with details=exc.details (commands/data_app.py, commands/_data_app_runtime.py, commands/_data_app_git.py, commands/_semantic_layer_helpers.py), and output.py emits details into the --json error envelope whenever non-empty.
The asymmetry is the concern: api_message twenty lines below is deliberately capped at MAX_API_ERROR_LENGTH, and the comment at line 495 establishes bounding server-supplied text as the rule for this function. params gets no equivalent. The merge-conflict case is the motivating example and also the worst one -- params.errors carries one entry per conflicting configuration, so a branch with hundreds of conflicts produces a proportionally large blob. In a CLI whose --json output is consumed by agents, that lands unbounded in a context window.
Failure scenario: a merge over a branch with several hundred conflicting configurations answers 409 with params.errors listing every one. _raise_api_error copies the whole object into details["api_error_params"], MergeRequestService._remap_merge_conflict forwards details=exc.details into the MR_MERGE_CONFLICT error, and the --json error envelope emits the entire list verbatim -- while the human error message beside it was truncated to MAX_API_ERROR_LENGTH.
Suggested fix: bound what is copied (cap the serialized size, or cap list lengths inside params) so the details payload has a ceiling like the message does. Alternatively, narrow the passthrough to the specific keys a caller is documented to branch on rather than copying the whole object.
There was a problem hiding this comment.
Confirmed and fixed in 1e239b4 — api_error_params is now bounded like the message beside it: over MAX_API_ERROR_PARAMS_LENGTH (8192 serialized) every top-level list is truncated to MAX_API_ERROR_PARAMS_LIST_ITEMS (20) entries; if still over, params is dropped entirely. Either way details.api_error_params_truncated: true marks it, and an unserializable payload is dropped rather than crashing the error path. Three regression tests (small passes verbatim / oversized list truncated with the kept prefix verbatim / untruncatable dropped with marker).
| - `--auto-merge-strategy` accepts exactly `immediately` | `scheduled` | `none`. | ||
| - `--external-id` is capped at 255 characters server-side. | ||
| - **`--state` stays a plain `str`, not a Typer enum.** The vocabulary lives in the service | ||
| (`_STATE_FILTER_VOCABULARY`) and an unknown value already fails with the accepted list. A |
There was a problem hiding this comment.
Reviewed by Sonnet.
The Layer 1 RFC says the state-filter vocabulary "lives in the service (_STATE_FILTER_VOCABULARY)", implying an underscore-prefixed private name. The constant this same PR actually defines in src/keboola_agent_cli/services/merge_request_service.py:80 is the public STATE_FILTER_VOCABULARY (no leading underscore) — its own comment says "Public: Layer 1 enumerates it in --state help text". A future implementer of DMD-1900 following this doc verbatim would try to import a name that doesn't exist. Low impact since it's a design doc for not-yet-built Layer 1, but worth a one-word fix to avoid confusion later.
There was a problem hiding this comment.
Already resolved — the Layer 1 RFC rewrite (980b28d, now pushed as part of this branch) references the public STATE_FILTER_VOCABULARY and TAKE_MODES names (docs/merge-requests-layer1.md:86: "importing the now-public STATE_FILTER_VOCABULARY and TAKE_MODES from the service"); the underscore-prefixed mention this comment anchored to no longer exists in the current revision.
The big one (#2, upgraded): the diff sides' wire shape was WRONG in the implementation and in the test fixtures that defined it. Verified against connection (ConfigurationVersionResponse + ConfigurationDiffData OA schemas, now recorded in the notes wire-truth table): each side is {version, isDeleted, diff: {name, description, changeDescription, isDisabled, configuration, rows}} -- content NESTED under diff, version/deletion as side metadata. The classification and both take modes now read the envelope; the flat-side code would have been dead on arrival against the live API. #1: resolve_conflict no longer takes branch_id -- it derives the branch from the MR itself (branches.branchFromId), so the conflict-set guard and the branch being written to can never disagree; a caller-supplied id could point the REPLACing rebase at an unrelated dev branch the guard never checked. A published/canceled MR (null branchFromId) is refused readably. #3: take=theirs of a deleted side collapses to the delete resolution, symmetric with ours ('production deleted it, dev changed it' is a live conflict shape). #4: deletion surfaces as top-level ours_deleted/theirs_deleted booleans on get_config_diff (None = side never existed) -- it is side metadata, not a content path. #5: a 'both' row where the sides agree on the identical value carries agreed: true -- agreement, not a conflict hotspot. #2 (message half): a take side missing required envelope keys is reported as a backend contract violation pointing at the resolved-body workaround, not as caller error. #6: wire ids compared via _same_id / int-coerced (find_merge_request_for_ branch, merge()'s was_active) -- a string-serialized branchFromId can no longer silently defeat the post-merge cleanup. #7: the feature pre-flight raises FeatureNotEnabledError carrying the new ErrorCode.FEATURE_NOT_ENABLED (value matches the string SearchService already emits; categorized 'configuration' like PAYG_NOT_AVAILABLE). #8: ConfigError imported from ..errors like every other service; the isDefault scan hoisted to services.base.find_default_branch_id and the copies in config/sync/workspace services migrated (lib.py keeps its own loop -- the SDK facade does not import the services layer); verify_token is skipped when the server already serialized viewer (the polyfill's cost dies with the polyfill); list --state validates against the closed vocabulary instead of returning a silent count: 0 on a typo. #9: test imports hoisted (no mid-file noqa), mocks spec'd at the L3 seam (KeboolaClient + MergeRequests -- a renamed L3 method now fails the tests), and regression tests added for every finding (82 tests total). Doc drift: layer2/layer3 references updated to BRANCHES_MERGE_REQUESTS_ FEATURE, the layer3 open nit closed, tokens.py line ref fixed. Review: tasks/pr-703-review.md (2026-08-27). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1e239b4 to
c218f2a
Compare
Turns the working notes into an implementation-ready RFC for the `kbagent merge-request` group over MergeRequestService (DMD-1899, #703). Decided in this pass: - `--mr-id` is optional everywhere, resolved `--mr-id` -> `resolve_branch()` -> `find_merge_request_for_branch()`; `merge` is not exempt. - `merge` is classified `destructive`, so `--deny-destructive` lets an agent run the whole flow and hands only the last step to a human. - No `--wait`/`--timeout` on merge in v1 (L3 always awaits, 600 s) and no `resolve --all` (rebase replaces; conflicts are meant to be walked). - A full `server/routers/merge_requests.py` ships with the commands, plus a serve-only `by-branch` route; routers are not gated by CI, so a skip would reach users as an HTTP 404 with nothing red. Facts the analysis surfaced that shape the commands: - The MR serializer emits no timestamps, so no date column is possible and the renderer must preserve the server's `createdAt DESC` order. - `FeatureNotEnabledError` carries `FEATURE_NOT_ENABLED`; the common `except ConfigError` idiom would flatten it to `CONFIG_ERROR`. - An empty `--reviewer-id` list is sent as `reviewerIds: []` and clears the reviewer set -- it must be normalised to None. - `detail`/`conflicts` 403 on a scoped token while `list` works. - `approve` answers 422 in every state on a 0-approval project, and `request-review` lands directly in `approved` -- neither has a happy path to assert, and there is no `close` command for the same reason. E2E is deliberately left open: no project carries the feature, kbagent cannot provision one, and the happy path necessarily merges into production. The RFC records the proposed path and marks it unsettled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| configuration = client.rebase_config( | ||
| component_id, | ||
| config_id, | ||
| branch_id, | ||
| version=onto_version, | ||
| name=body["name"], | ||
| rows=body["rows"], | ||
| configuration=body["configuration"], | ||
| is_disabled=bool(body.get("isDisabled", False)), | ||
| description=body.get("description"), | ||
| change_description=change_description, | ||
| ) |
There was a problem hiding this comment.
Valid — and independently raised as the blocking finding of the human review a day later; fixed in 01fb0fa. The guard now requires all five replaced-body keys: name/rows/configuration/isDisabled refuse absent-or-null (a null isDisabled would still reach bool() as False — the same silent re-enable), description refuses absence only — an explicit description: null is a legitimate clear-the-description decision, exactly as this comment suggests. is_disabled forwards verbatim via body["isDisabled"]. Pinned by test_missing_is_disabled_is_refused_not_defaulted and test_missing_description_is_refused_but_explicit_null_is_legal.
zajca
left a comment
There was a problem hiding this comment.
Reviewed the full diff on the branch, ran the complete test suite and every quality gate locally.
Gate status (on the PR branch): pytest tests/ 6527 passed / 183 skipped, ruff check + ruff format --check clean, ty check clean on the new modules, make loc-check / check-sentinel-guards / changelog-check all OK. I also reproduced the FORCE_COLOR caveat — FORCE_COLOR=3 pytest tests/test_changelog_render.py does fail 2 tests, pre-existing on main, unrelated to this PR.
The design work here is genuinely good — see "What works well" at the bottom. One finding blocks.
BLOCKING
1. resolve_conflict guards 3 of the 5 replace keys — isDisabled / description silently lose data
src/keboola_agent_cli/services/merge_request_service.py:1023-1024
is_disabled=bool(body.get("isDisabled", False)),
description=body.get("description"),Layer 3 warns about exactly this in rebase_config's own docstring (client/configs.py):
is_disabled: Required: on a missing key the backend substitutesfalse, re-enabling a config that was disabled.
description: … It has no default precisely because that default would silently drop an existing description.
Layer 2 reintroduces both defaults. The guard at :987-991 covers only name / rows / configuration, and its stated reasoning — "rebase REPLACES; a missing key would silently wipe data" — applies verbatim to the other two:
- a custom
resolvedbody withoutisDisabledsilently re-enables a disabled configuration in the dev branch, and the merge then pushes that into production; - a custom body without
descriptionwipes the description; - the
take=path has the same hole whenever the envelope omits a key — and the guard on the other three exists precisely because the envelope is not trusted.
tests/test_merge_request_service.py:892 (test_custom_body_rebases_verbatim) pins the permissive behaviour as expected: a body of {name, rows, configuration} passes and sends is_disabled=False.
Suggested fix — check for presence, not non-None (description: None is a legitimate resolved value, and the _side fixture sends exactly that):
missing = [k for k in ("name", "rows", "configuration") if k not in body or body[k] is None]
missing += [k for k in ("isDisabled", "description") if k not in body]plus is_disabled=bool(body["isDisabled"]).
NON-BLOCKING
2. allowed_actions points at a write that cannot succeed
_enrich_row (merge_request_service.py:244) is state-only. On a project without the feature, list_merge_requests returns feature_enabled: false and rows carrying allowed_actions: ["request_review", "merge", "update", "resolve_conflicts"]. A --json/agent consumer — the stated reason the field exists — is steered straight into the guaranteed FEATURE_NOT_ENABLED loop that find_merge_request_for_branch:371 deliberately closes. The features cache is already warm at that point on the empty-list path, so correcting it is free.
3. api_error_params is a cross-cutting output-contract change with no changelog line
http_base.py:485-497 adds up to 8 KB of server-supplied params to details on every 4xx/5xx from all seven clients, and existing commands already forward details=exc.details into --json (commands/data_app.py, commands/job.py:442, _semantic_layer_helpers.py, …). Two consequences:
- consumers treating
detailsas a presence flag (it was empty on 401/403/404 until now) start seeing content; paramsreaches the output with no masking at all, whereas the message at least goes through truncation. Low risk, but it deserves to be a stated decision rather than a side effect.
Deferring the changelog to the bump PR follows the #616 precedent, but that entry needs to mention this too — not just "merge-request Layer 2".
4. _classify_three_way: a null side with a non-empty base emits contradictory signals
merge_request_service.py:846-852 — when one side is null while base exists, every base key comes out as "that side removed it" (changed_by: "theirs", theirs: None), while theirs_deleted reports None, meaning "the side does not exist at all". A Layer 1 renderer will make nonsense of that pair. Either skip changes for a null side or flag it separately.
5. change_description is silently dropped for take="delete"
merge_request_service.py:973-975 — rebase_config_delete has no such parameter. Layer 1 will expose --change-description, the user will pass it, and nothing reports that it was ignored. A warning in the result (or plumbing it through L3) would close it.
NIT
- Comments narrating history/process, against the repo's own "Code Comments — never narrate the change" rule:
constants.py:539("Renamed fromFEATURE_BRANCHES_MERGE_REQUESTS"),services/base.py:124("previously copy-pasted"),merge_request_service.py:47,113("Opus wire review 2026-08-27"),:371,978("Zajca's PR review"). "Verified againstMergeRequestLifecycleStateMachine" is a fact about the code and belongs there; dated review references and reviewer names belong in the PR description. services/search_service.py:205still uses the raw string"FEATURE_NOT_ENABLED". Now that the enum exists, this is a one-line cleanup in the same spirit as thefind_default_branch_idhoist.merge()— theexcept Exceptionwrapsset_project_branchtoo, so a failed config write also skips the mapping cleanup. Two separatetryblocks would keep the two cleanups independent.merge():660—int(raw_branch_from)raisesValueErroron a non-numeric wire string, i.e. an unhandled exception instead of aKeboolaApiError._same_idexists precisely because the payload mixes id types.find_default_branch_id—int(branch["id"])raisesKeyErrorfor anisDefault: trueentry with noid; the rest of the function is defensive (branch.get("isDefault")).cleanup_branch_id_from_mapping(branch_from_id)matches on the numeric id alone with no project scoping — a sync workspace of a different project in the CWD with the same branch id gets unlinked. Inherited fromBranchService.delete_branch, so not a regression, butmergeextends it to another path.
What works well
- The tests are above average: a regression test per review finding,
MagicMock(spec=KeboolaClient)+spec=MergeRequestsat the L3 seam (a renamed client method fails the tests instead of leaving them falsely green), and the_DEFAULT_SIDEsentinel distinguishing "keep the fixture default" from "the side does not exist". _server_vieweras the single predicate for "did DMD-1988 land" — bothderive_viewerand theverify_tokenskip read it, so they cannot drift apart. Right call.resolve_conflict/get_config_diffderiving the branch from the MR instead of taking it as a parameter is honest make-the-wrong-call-unrepresentable design.- The 409 remap letting an unknown code pass through unmapped rather than guessing "conflict" is exactly right.
- The
find_default_branch_idhoist and thecompute_diff→compute_diff_entriessplit are clean refactors with tests; I verified the formatted output stays byte-identical. - The layer1/2/3 + notes doc set carrying the wire-truth table is what made the nested-
diff-envelope bug visible before production.
Verdict: NEEDS-WORK — finding #1 (silent isDisabled / description loss on rebase). Everything else is non-blocking.
|
Thanks — applied in 01fb0fa. Per finding: BLOCKING #1 (isDisabled/description) — confirmed and fixed. All five replaced-body keys are now required; one refinement over the suggested fix: #2 (allowed_actions on a featureless project) — respectfully declined, with the reasoning now documented on #3 (api_error_params output contract) — agreed. The unmasked pass-through is now a stated decision in the code (server-authored error context, same trust as the message text), and the PR body's bump-PR note now says the release changelog entry must cover #4 (null side vs. base) — confirmed and fixed: #5 (change_description on take=delete) — confirmed; fixed with a Nits — all applied except the last: history-narrating comments removed (facts like "verified against MergeRequestLifecycleStateMachine" stayed, names/dates went); Full triage record: |
compute_diff returned pre-formatted strings, so a caller needing the changed paths as data (the MR three-way conflict classification, DMD-1899, intersects two pairwise diffs per path) had nothing to build on short of parsing the strings back. Split the recursive walk into compute_diff_entries() returning frozen DiffEntry dataclasses (path + old/new with an _ABSENT sentinel distinct from an explicit None), and keep compute_diff() as a formatter over it -- byte-identical output, pinned by a delegation test against the existing format tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etails [DMD-1899] Three pieces of error plumbing the merge-request service needs: - ErrorCode.MR_NOT_READY_TO_MERGE / MR_MERGE_CONFLICT (+ docs/error-codes.md, both categorized 'conflict'). The merge 409 has four causes in two wire shapes; the split follows the backend's own line (the three transient causes carry storage.mergeRequests.notReadyToMerge, a conflict carries no code). Mapping happens in the service -- only it knows the 409 came from the merge endpoint. - http_base: a Keboola user error's machine string `code` now survives into KeboolaApiError.details['api_error_code'] (all four raise sites). The message holds only the human `error` text, so without this no caller can branch on the code. Additive; no behavior change when absent. - constants: FEATURE_BRANCHES_MERGE_REQUESTS renamed to BRANCHES_MERGE_REQUESTS_FEATURE (the file's dominant suffix convention; the constant was unused until now). SOX-fence assumption spelled out in the comment per the L2 RFC. Backend counterpart asking for codes on every MR error: DMD-1984. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fy [DMD-1899]
The four derivates from the L2 RFC ('Derived status'), as pure module-level
functions in the new services/merge_request_service.py: derive_state (the UI
list badge's decision table incl. the rejected / closed-by-creator reviewer
overrides), derive_merge_blockers (a list, so concurrent blockers don't mask
each other; conflicts=None means not-fetched, not conflict-free),
derive_allowed_actions (state-only; roles/features stay with the pre-flight),
derive_viewer (is_creator / has_approved relative to the caller).
All four are a POLYFILL: they read the future server-serialized field first
(derivedState / mergeBlockers / allowedActions / viewer -- Connection issue
DMD-1988) and fall back to the local tables; the fallbacks get deleted when
DMD-1988 lands. Same defensive-read pattern as changeLog and the DMD-1969
approvals count.
verify_token now also parses the response's top-level admin block into
TokenVerifyResponse.admin_id/admin_name (additive; absent for scoped
tokens) -- the anchor derive_viewer compares creator.id and approverId
against. approverId is a string on the wire, ids are ints; comparisons
normalize via str() like the UI does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ail [DMD-1899] The service class (BaseService DI: ConfigStore + client_factory) and its three read methods: - list_merge_requests: rows enriched with derived_state; --state filters client-side (the endpoint has no query params) and matches the derived vocabulary (rejected/merged/closed/...) as well as raw states. - find_merge_request_for_branch: the branch->MR resolver behind L1's optional --mr-id (a branch has at most one MR ever, so the match is unambiguous); no MR -> NOT_FOUND naming merge-request create as the next step. - get_merge_request: detail with the full derived status -- merge_blockers/ mergeable/allowed_actions/viewer + the live conflicts list, fetched only for open MRs (a published/canceled MR's source branch is deleted, the conflicts endpoint is moot). viewer anchors on verify_token's admin id; a scoped token yields honest None flags. Derivations stay informational: the merge 409 remains the authority. Also the write pre-flight helper (_require_merge_requests_feature) with the SOX-fence assumption spelled out, used by the write methods that follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- create_merge_request: resolves the target itself (always the default branch -- the backend rejects any other) and refuses the default branch as source with a readable error instead of the backend's confusing 404. The source branch arrives explicit; L1 resolves it via the house resolve_branch() idiom per the RFC decision. - update_merge_request: None = leave unchanged (the API cannot clear to null; empty string clears description/externalId server-side). - request_review / approve / request_changes: thin transitions with the feature pre-flight; request_changes doubles as the close mechanism (no cancel endpoint exists -- the UI's cancel is this call by the creator, and derived_state renders it as closed). Every write runs _require_merge_requests_feature first; returns are the raw MR enriched with derived_state. 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>
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>
…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>
…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>
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>
…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>
…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>
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>
…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>
…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>
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>
…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>
…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>
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>
…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>
…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>
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>
…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>
…rvice (DMD-1900) (#736) * Merge requests RFCs: general notes + per-layer (L3 as-built, L2 as-built, L1 as-implemented) [DMD-1899, DMD-1900] The five documents, at the state that holds after PR #703 (Layer 2, merged to main as 5281eef) and the Layer 1 implementation branch (PR #736): - merge-requests-notes.md verified backend facts, all layers - merge-requests-layer3.md the HTTP client, as shipped in #556 - merge-requests-layer2.md the service RFC + "Additions made for Layer 1" (get_merge_request_row, resolution_candidate, merge() cleanup_warnings -> warnings) - merge-requests-layer1.md the command RFC after walking #703's review findings into it (seven decisions), plus the pointer to the follow-ups below - merge-requests-layer2-followups.md non-blocking leftovers of #703 for L1 (F1 done on L1; F2-F8 open) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(service): the three Layer 2 additions the Layer 1 RFC needs [DMD-1900] Decided while walking PR #703's review findings into the Layer 1 RFC (docs/merge-requests-layer1.md, "Layer 2 changes shipping with this PR"). Each exists so Layer 1 does not re-derive something the service knows. - get_merge_request_row(alias, id): the row tier (_enrich_row) by id -- one GET, no conflicts(), no verify_token(). list/find already return rows but only by branch; the sole by-id method was the detail, three round trips and a dependency on the conflicts endpoint that a write (request-review on an armed MR, the merge confirmation prompt) has no business inheriting. L3's merge_requests.get() was always this GET. - get_config_diff -> resolution_candidate: the ours envelope through _DIFF_CONTENT_KEYS, description as an explicit null, changeDescription excluded; null when ours is absent/isDeleted. Composed in L2 so the `diff --output` prefill and the five-key replace guard in resolve_conflict share one constant -- a candidate built in L1 that dropped a null description would be a file kbagent writes and then refuses. Pinned by a round-trip test: candidate -> resolve_conflict unmodified. - merge(): cleanup_warnings -> warnings. One soft-failure key for the group (resolve_conflict already used `warnings`); a renderer reading `warnings` must not silently drop the post-merge ones a user must act on. Specificity stays in the text. Tests: 5 new (row tier cost pinned via assert_not_called on conflicts + verify_token; candidate shape; null cases; round trip; warnings key). L2 RFC updated in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli): merge-request group skeleton and the four read commands [DMD-1900] The `kbagent merge-request` group (hidden alias `mr`) over MergeRequestService: wiring, the helpers every command shares, and list / detail / conflicts / diff with their renderers. Writes follow. Skeleton -- the Layer 1 decisions from docs/merge-requests-layer1.md: - Target resolution (_resolve_target): --merge-request-id/--id optional; omitted -> resolve_branch() (--branch, else active branch) -> find_merge_request_for_branch(). Both flags at once is exit 2, not silent precedence. The resolution is reported on stderr in human mode and stamped into every --json result (merge_request_id, branch_from_id, resolved_from_branch) so a machine caller can assert on what was operated upon. - One error handler (_handle_error), no per-command except: keeps FeatureNotEnabledError's FEATURE_NOT_ENABLED code, which now surfaces from the resolver behind every omitted id -- reads included. - The destructive-under-json rule and the armed-auto-merge escalation helpers (used by the writes next): policy check first, then the explicit-target rule, which for state-derived escalations can only fire after resolution. - warnings[] rendered identically everywhere; hint-next Rich-only. Permissions: 11 registry entries (merge = destructive) + the serve-only by-branch; FLAG_ESCALATIONS gains five state/flag-derived destructive entries (arming auto-merge on create/update; request-review / approve / resolve on an armed MR) with the Connection citations that justify them. Renderers (_merge_request_render.py): every wire string escaped; derived_state never raw state; list preserves server order and shows optional columns only when populated; empty list tells feature-off apart via feature_enabled; detail says the change log is empty by design in development; diff checks the *_deleted flags BEFORE the table and recommends the --take, since a null side yields zero rows; --output writes the service's resolution_candidate verbatim and refuses when there is nothing to prefill. Table value columns fold rather than crop so --format full is actually full. Also hoists parse_json_arg into _helpers (transformation.py had the private copy; resolve is the third consumer). 35 CLI tests via CliRunner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli): merge-request writes -- create, update, transitions, merge, resolve [DMD-1900] The seven write commands, each routed through the shared skeleton (_resolve_target, _handle_error, _stamp_target, warnings, hint-next). Where a human says so, and where a policy does (docs/merge-requests-layer1.md): - merge: statically destructive. Under --json the explicit-target rule fires BEFORE any lookup (no --merge-request-id/--branch -> exit 2); in human mode the active-branch fallback stays and the prompt names the MR, its title and the branch that will be deleted. --yes skips the prompt. - create/update --auto-merge-strategy immediately|scheduled: arming is a delayed production merge, so it escalates to destructive (FLAG_ESCALATIONS), needs an explicit target under --json (--branch for create), prompts in human mode worded as arming, and warns afterwards. `none` is the disarm and escalates nothing. The strategy/--auto-merge-at pairing is validated at exit 2. - request-review / approve / resolve on an ALREADY-armed MR escalate via the state-derived operation strings; the row comes free on the implicit path and via get_merge_request_row (one GET, never the detail) on the explicit path. Under --json with an implicit target this exits 2 only AFTER resolution -- deliberate, the information does not exist earlier; the error names the MR and the flag to pass. request-changes moves the MR away from approved and never escalates. - update with no field flags is exit 2 (PUT {} is a server no-op). --reviewer-id is normalised to None when absent -- [] would clear the set. - resolve: exactly one of --take/--resolved (exit 2 otherwise); --resolved parsed via the hoisted parse_json_arg and must be an object; a --change-description on a delete is the service's warning, not a Layer 1 refusal (the implicit-delete collapse is only known after the diff). Escalations are tested against the real engine (--deny-destructive -> exit 6), not a mocked check. 38 more CLI tests (73 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(cli): split merge_request.py at the 800-code-line soft ceiling [DMD-1900] 829 code lines after the writes landed -- exactly what the RFC predicted for eleven commands at ~75 each. CONTRIBUTING lets a file sit over the soft ceiling until the next PR adds to it, but a brand-new module born over it is debt on day one, so split now: - merge_request.py -- app, callback, the four reads; mounts the writes - _merge_request_common -- what both need: option declarations, the ONE error handler, target resolution, the destructive-under-json rule, escalation, output - _merge_request_writes -- the seven writes on their own Typer, mounted flat via register(app) so permission keys stay merge-request.* and --help lists one group (precedent: _storage_describe.register) - _merge_request_render -- unchanged A third module instead of reads importing writes (or vice versa): both import common, only merge_request imports writes -- no cycle. Behaviour unchanged; 73 CLI tests green; every module well under 800. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(serve): merge-requests router, 1:1 with the CLI group, permission-enforced [DMD-1900] server/routers/merge_requests.py: twelve routes under /merge-requests -- one per CLI command plus GET /{project}/by-branch/{branch_id}, the branch->MR resolver the CLI hides behind an omitted --merge-request-id (no active-branch idiom over HTTP; registered as the serve-only merge-request.by-branch). Declared before /{project}/{merge_request_id} so FastAPI never tries to read 'by-branch' as an id. Skipped on purpose: `diff --output PATH` -- GET .../diff returns resolution_candidate and the caller writes its own file. Every route declares Depends(require_permission(...)). Until now only /auth/* did; here it is not optional -- the CLI classifies merge as destructive and escalates arming auto-merge and the transitions on an armed MR (FLAG_ESCALATIONS), and without the same checks over HTTP that analysis would be decorative for serve callers. The static class is a route dependency; the flag/state-derived escalations run in the route body: arming in the create/update body -> check_or_raise the flag string; request-review/approve/resolve -> one row GET (get_merge_request_row, never the three-call detail) and check_or_raise when armed. Caller errors (unknown state/take, both-or-neither take/resolved, empty update body, broken auto-merge pairing) raise INVALID_ARGUMENT -> 400, the REST twin of the CLI's exit 2. POST .../merge documents that it is synchronous for up to 600 s. Wiring: ServiceRegistry.merge_request, include_router, an OPENAPI_TAGS entry (endpoints-gen would otherwise emit an untagged section), and docs/web-server-endpoints.md regenerated (endpoints-check green). 14 router tests: kwarg parity per route (the drift this file exists to catch) and the permission story over HTTP -- merge 403 under deny_destructive, arming 403 while `none` passes, armed request-review 403 via the row tier, reads pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(cli): merge-request on every convention-#17 surface; deprecate branch merge; E2E [DMD-1900] The silent-drift surfaces, all of them (nothing but check_command_sync gates any of this): - CLAUDE.md "All CLI Commands": the eleven signatures plus the block that matters most -- what may happen without a human saying so (merge is destructive; arming auto-merge is a delayed production merge; the --json explicit-target rule; the 0-approval facts; no `close`; the conflict loop; the error shapes; the feature-blind allowed_actions). - commands/context.py AGENT_CONTEXT: a Merge Requests section after Branches, same content compressed for the agent. - commands-reference.md: the group's cheat sheet. - gotchas.md: one `(since vNEXT)` entry covering every non-obvious behaviour the RFC listed for it. - keboola-expert.md: a tool-selection-matrix row with the anti-patterns (--auto-merge-strategy treated as metadata; --json merge with no target; a partial --resolved body; reading allowed_actions as feature-aware; approve on a 0-approval project). - SKILL.md: triggers (merge request, mr, merge branch, auto-merge, review request), the description, the workflow link; decision table via `make skill-gen`. - New merge-request-workflow.md: the short path, the --json path, the auto-merge table, the conflict loop, output semantics, the error table. - branch-workflow.md points at the new group. `branch merge` is deprecated with a CONDITIONAL pointer: it only builds a UI URL (and unconditionally resets the active branch), but it works on projects WITHOUT the feature, so it is not a 1:1 replacement. Behaviour unchanged; `deprecation` key in --json, a warning in human mode. E2E (convention #16): TestE2EMergeRequestLifecycle -- branch -> config on the branch -> create -> list/detail/conflicts (id and --branch) -> approve asserts the 422 -> bare --json merge exits 2 -> merge -> config in production -> explicit teardown. GATED ON THE FEATURE: `list` on a feature-less project answers feature_enabled: false and the suite skips with the one-time enable command in the reason -- explicit, never silent. The E2E project does not carry the feature today and this environment has no E2E credentials; recorded in the ship ledger, not hidden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plugin): vNEXT tags out of headings; SKILL description back under 1024 chars [DMD-1900] check_version_gates: a vNEXT inside a heading would rewrite the anchor slug on release; the three new sections carry the tag on their first body line instead. test_skill_frontmatter: the description hit 1130/1024 chars; kept the 'merge request' trigger, dropped the redundant ones, and compressed three neutral list phrases (and -> /). No trigger word lost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli,serve,service): apply the Phase-5 self-review round [DMD-1900] Three reviews (Opus, Sonnet, /code-review) over the implementation; every finding either fixed here with a pin, or recorded below as deferred. High: - _handle_error dropped exc.details, so the merge 409's conflict list and the truncation marker never reached --json, and the RFC's human render of MR_MERGE_CONFLICT (entries + "list truncated -- run conflicts") was never implemented. Both fixed; details flow through, the list renders escaped, the truncation line names no number. - Unescaped wire/user strings in the ad-hoc console.print sites outside the renderer module (conflicts hint, diff hint, resolve success line, merge message): a `[/x]` in a config id raised MarkupError AFTER the rebase had landed server-side. Escaped everywhere. Medium: - branch_from_id was null on every explicit --merge-request-id path, beside a payload saying branches.branchFromId: 123. _stamp_target now derives it from the result (row branches, diff branch_id); conflicts fetches the row tier (one GET) since its result carries no branch. - The auto-merge vocabulary was copied into the CLI and the router -- the exact drift the RFC forbids, and a SAFETY divergence (one surface would stop escalating an arming value the other still knows). Now AUTO_MERGE_STRATEGIES / AUTO_MERGE_DISARMED / validate_auto_merge_flags / arms_auto_merge live in the service module; both surfaces import them. - next_step_hints silently dropped unknown action names; once DMD-1988 serialises a camelCase vocabulary every hint-next line would vanish. Falls back to the raw name. - Over `serve`, MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE answered 502 with no details (retry-inviting, list dropped). app.py maps them to 409 and _format_error carries non-empty details. - The service's two caller-mistake refusals (resolving/diffing a finished MR, a config outside the conflict set) were VALIDATION_ERROR -> 502 over serve; now INVALID_ARGUMENT -> 400. CLI exit code unchanged. - resolve_conflict coerced a caller body's isDisabled with bool(), so a hand-edited "false" DISABLED the config on replace and returned 200. Non-bool is refused (the guard's refuse-don't-default policy). Low: - The armed-auto-merge warning is human-only (formatter.warning), no longer injected into the payload -- Layer 1 does not manufacture data. - A hole in the ours envelope no longer becomes an explicit-null candidate that resolve then blames the caller for; get_config_diff returns resolution_candidate: null + a warning, and --output words the three null shapes apart (deleted / absent / envelope hole). - parse_json_arg turns OSError (a directory, permissions) into the ValueError the callers expect; docstring stops claiming config.py's copy is gone. --output on an unwritable path is a readable exit 2. - merge skips the row GET when no prompt will show (--yes / --json). - --reason cap enforced on the REST route too. CLAUDE.md --state line stops hand-listing a subset of the vocabulary. - FEATURE_NOT_ENABLED pinned on every command, as the RFC promised; the misnamed CLI "round-trip" test renamed (the real round trip is pinned at the service layer). Deferred to PR #703 (Layer 2 design/refactor findings from /code-review, which reviewed the L2 branch; too large for the tail of this run): _classify_three_way missing `both` rows for nested-vs-parent edits; the `or code is None` 409 fallback; SOX-project reads reporting feature_enabled: false; the tuple return in http_base._bound_error_params; the post-merge cleanup being a third copy of BranchService's; the try/finally client idiom vs the context manager. Tests: 6643 passed. The 9 failures in test_release_kbagent_ai_kit_sync are environmental (git commit signing via 1Password unavailable to the test process), unrelated to this diff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli,service): align Layer 1 with Zajca's third Layer 2 review [DMD-1900] Three second-order effects of rebasing onto 7cd1855: - _branch_from_id_of: the L2 round split the message for an absent vs a non-numeric branchFromId, but the Phase-5 INVALID_ARGUMENT re-code had made both faults carry the caller's code. They blame different parties: absent = the caller is resolving a finished MR (INVALID_ARGUMENT, 400 over serve); non-numeric = the server's payload (VALIDATION_ERROR). - diff renderer: the L2 round makes _classify_three_way return zero rows for an empty-envelope side too, not only a null/deleted one. With no deletion flag set the renderer would have claimed "this conflict has cleared" while the service's warning beside it said "envelope hole". When there are no rows and the result carries warnings, say that no classification could be produced and let the warning explain. - RFC: the five-key rule is the CALLER-body rule; a --take side composes an absent description as null (wire-identical -- the rebase omits the key), and a hole or non-boolean isDisabled there is a backend contract violation (VALIDATION_ERROR), never a caller error. The table said "refused when absent" for both paths. Pinned: the two error codes; the no-rows-with-warning render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli,service): apply the Layer 2 follow-ups inherited by Layer 1 (F2-F5, F7) [DMD-1900] docs/merge-requests-layer2-followups.md collects the non-blocking leftovers of PR #703 that go through this PR. Per item: - F2: _classify_three_way's docstring now says what is true -- the classifier is deliberately STRICTER than resolve_conflict (an empty envelope yields no rows here, a VALIDATION_ERROR there; collapsing it to the delete resolution would destroy a configuration). And the empty-envelope half finally has a test. Beyond the docstring: an empty envelope on EITHER side is now reported in `warnings` (_diff_warnings replaces the ours-only _candidate_warnings), so the diff renderer's "no rows + warnings" branch fires instead of claiming the conflict cleared -- which it would have done for a theirs-side hole. - F3: merge() records the branch-id degradation structurally -- `cleanup_skipped: true` + `branch_from_id_raw` -- and the message says "Source branch id could not be read; see warnings." instead of nothing. The CLI's merge renderer keys on the flag (a "Local cleanup skipped" line naming the raw value) and its hint-next points at branch reset + sync branch-unlink. A legitimate published-MR null carries no flag. - F4: find_default_branch_id logs the skipped non-numeric entry instead of folding it into None silently (the callers then say "no default branch" for a project that DID report one). The `sync init` exits-0-with-empty- branches decision is a UX call left for Martin -- not changed. - F5: the detail tier is feature-aware for free (has_feature after the verify_token it already pays): `feature_enabled` on the detail payload, a "Feature: not enabled" line in the panel, and hint-next refusing to recommend a write that cannot succeed. `list` stays feature-blind on non-empty results, as Layer 2 decided; docs say which is which. - F7: config_service.py's last `if folder_branch_id:` truthiness test -> `is not None`; the positive assertion for "Active branch reset to main." on a successful reset; the 110-char docstring line rewrapped. The isDisabled-before-missing ordering is left as noted (house pattern). Not in this PR: F6 (cleanup_branch_id_from_mapping project scope -- both call sites, standalone PR) and F8 (test_changelog_render under FORCE_COLOR -- main, unrelated). 9 new tests; make check exit 0 (6672). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(cli): parenthesize implicit string concatenation in tuples (ruff 0.16 ISC004) [DMD-1900] main's ruff upgrade (9d823d5, >=0.16 default rule set) fires ISC004 on two tuple items in the detail renderer. No behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(serve): register the merge-requests routes in SERVE_COMMAND_MAP [DMD-1900] main's #731 (command telemetry) requires every serve route in the route->CLI-command map, enforced by test_serve_telemetry::test_command_map_matches_every_route_exactly. The twelve /merge-requests routes mirror their commands; by-branch is serve-only (empty string, logged under its route label). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli,service,e2e): apply Copilot's (Balanced) review of #736 [DMD-1900] Eight inline findings, all confirmed against the code and fixed with pins: - E2E setup skipped on ANY `merge-request list` failure, turning a crash or an auth regression into a green run. It now asserts success and skips only on feature_enabled: false -- the one gate the class documents. - The E2E covered 6 of 11 commands. The scenario now manufactures a REAL conflict (config in production, branch inherits it, both sides change it) and walks every command: create, update, list, detail, conflicts (via --branch), diff (+ --output candidate), resolve --take ours, request-review (-> approved), request-changes (-> development), approve, the bare --json merge exit 2, merge, and the production content check. - `approve`'s refusal was asserted as "any error but FEATURE_NOT_ENABLED"; it now asserts API_ERROR with the 422 in the message. - warnings[] text (backend / exception prose) reached Rich unescaped -- an unbalanced tag would raise MarkupError after the irreversible operation succeeded. Escaped. - `diff --output` wrote with the platform encoding (a name outside a Windows code page would fail the promised round trip); utf-8 now. And the path was interpolated into Rich markup unescaped. - A HOLED (partial) envelope still classified: content() omitted the missing key and the intersection reported it as that side's removal; holes on theirs were not warned about. A side missing any required content key is now unclassifiable on either side, and _diff_warnings names the holes for both (one shared _envelope_holes criterion feeds the classifier, the candidate and the warnings). The eighth finding (a stale "code-less conflict" rationale in the L2 RFC) is fixed on ms/merge-requests-rfcs (76a2adb); this branch's first commit is rebuilt from it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli,serve)!: destructive is a property of the command -- static classes, `auto-merge` command [DMD-1900] Replaces the flag- and state-derived escalations of the first draft with one static rule: anything that moves a merge request toward or into production is destructive, always. Decided with Martin 2026-09-10 after Zajca's review of #736 pointed at the same hazard twice (a permission that depends on a GET; an exit 2 that depends on state nobody typed) and Martin asked for the flag to leave the condition entirely. request-review destructive (0-approval default lands directly in approved) approve destructive (the last approval is what a merge waits for) resolve destructive (removes the blocker a merge waits on) merge destructive auto-merge destructive (NEW; arms the backend scheduler = delayed merge) create/update/request-changes write - `auto-merge --strategy immediately|scheduled|none [--at TS]` is its own command; `create`/`update` no longer take `--auto-merge-strategy`. Arming is a consciously separate step, prompts in human mode; the disarm rides the same command, same class (a caller who could not arm never needs to disarm). Under the hood: update_merge_request(auto_merge_*). L2 untouched. - FLAG_ESCALATIONS is back to its single original entry. The five merge-request escalation keys, `_escalate_if_armed` (CLI + router copies), `_warn_armed`, `_Target.armed`, `auto_merge_armed`, `armed_escalation_operation` are gone. The router's permission check is the route dependency alone -- no body inspection, no prior GET. - The --json explicit-target rule now runs BEFORE any network call for every destructive command, since the class is known from the name. The transitions no longer fetch the MR row; the armed warning is read off the write's own result. - PUT /merge-requests/{p}/{id}/auto-merge added (router, SERVE_COMMAND_MAP). - Zajca's must-fixes from the review ride along: _deleted_side_message None ordering (a missing side recommended --take ours, which resolves as DELETE); reason/external-id caps validated once in the service from constants.py; derived_state escaped in the shared success renderer; get_merge_request_row runs the feature pre-flight lazily on a 403; register() through Typer's public app.command(name)(fn); --output OSError branch emits warnings first; route-level 403 coverage for every destructive route incl. the disarm. - Tests regrouped by behaviour (TestStaticDestructiveClass, TestAutoMerge; provenance-named classes dissolved). Docs: all six convention-#17 surfaces; gotchas answers the --timeout question (retry is harmless behind the merge lock). BREAKING for anyone on the unreleased draft only: --auto-merge-strategy / --auto-merge-at on create/update are gone; request-review, approve and resolve are denied under --deny-destructive. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(cli,service): the candidate uses the resolve guard's criterion; both field caps exit 2 [DMD-1900] Zajca's second review of #736, two findings. 1. `_envelope_holes` (the predicate behind `resolution_candidate`, the diff warnings and `classifiable()`) tested key PRESENCE, while the replace guard in `resolve_conflict` refuses a key that is present but `None` and a blank `name`. An ours envelope with `"name": null` thus composed into a candidate that `diff --output` wrote and `resolve --resolved @file` then refused -- kbagent blaming the caller for its own file, the exact drift the shared constant was meant to prevent. The predicate now mirrors the guard: absent OR `None` is a hole, so is a blank `name`. Candidate suppressed, reason in `warnings[]`, side excluded from classification. Pinned in the service suite for `None` and `" "`. 2. `--reason` over its cap was pre-checked to exit 2, `--external-id` over its cap reached the service, whose INVALID_ARGUMENT `map_error_to_exit_code` does not map -- exit 1. Same kind of flag error, two exit codes. `create`/`update` now pre-check `--external-id` the same way (`_check_external_id`, one call per command); the service keeps the cap as the single rule (serve still answers 400 from it). Pinned for all three flag/command pairs, asserting no service call. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Layer 2 of the merge-request stack (DMD-1899):
services/merge_request_service.pyover the Layer 3 namespace shipped in #556. No Layer 1 commands yet — those are DMD-1900, so none of the convention-#17 doc surfaces or E2E tests apply to this PR; unit tests only (tests/test_merge_request_service.py, 94 tests).The design record — the L2 RFC plus the wire-truth notes every decision below is written down in — lives on branch
ms/merge-requests-rfcs(RFCs deliberately do not merge to main). A full self-review pass (2026-08-27) was applied in the final commit; see "Review fixes" below.What's in
derived_state(the UI list badge's decision table incl. the rejected / closed-by-creator reviewer overrides),merge_blockers+mergeable(a list, so concurrent blockers don't mask each other),allowed_actions(state-machine table),viewer(is_creator/has_approved, anchored onverify_token's newadmin_id). Every function reads the future server-serialized field first (DMD-1988) and falls back to the local table — the fallbacks get deleted when Connection serializes.list_merge_requests(client-sidestatefilter over the closed derived + raw vocabulary, typos refused),find_merge_request_for_branch(the resolver behind L1's optional--mr-id; a branch has at most one MR ever),get_merge_request(detail with full derived status; conflicts fetched only for open MRs).create_merge_request(targets the default branch itself; refuses the default as source with a readable error instead of the backend's 404),update_merge_request,request_review/approve/request_changes— all behind thebranches-merge-requestspre-flight raisingFEATURE_NOT_ENABLED(a missing feature is otherwise a 403 byte-identical to a role denial; SOX-fence assumption in the comment).merge: 409 remapped onto its two wire shapes —MR_NOT_READY_TO_MERGE(retryable; carriesstorage.mergeRequests.notReadyToMerge) vsMR_MERGE_CONFLICT(not retryable, namesmerge-request conflictsas the next step); backend counterpart asking for codes on every MR error is DMD-1984. Post-merge cleanup mirrorsBranchService.delete_branch(conditionalactive_branch_idreset, sync-mapping unlink, best-effort with warnings); output says the source branch "is being deleted", never that it's gone.list_conflicts,get_config_diffflattened to a per-pathchanged_by: ours|theirs|bothclassification (+agreed: truewhen both sides made the identical change;ours_deleted/theirs_deletedflags for tombstoned sides), andresolve_conflictwhere every mode goes through the rebase endpoint:take=ours|theirscomposes the full replace body from the side'sdiffenvelope withversion=theirs.version, a deleted side collapses to the delete resolution (both directions), a custom body must spell outname/rows/configuration(rebase REPLACES — a defaulted key would be silent data loss). The branch is derived from the MR itself, never caller-supplied. The UI's reset-to-default alternative for take=theirs is tracked as DMD-1987.http_basenow surfaces a Keboola user error's machine stringcodeasKeboolaApiError.details["api_error_code"](additive, all four raise sites);TokenVerifyResponsegainsadmin_id/admin_name;FEATURE_BRANCHES_MERGE_REQUESTSrenamed toBRANCHES_MERGE_REQUESTS_FEATURE; theisDefaultscan hoisted toservices.base.find_default_branch_idand the config/sync/workspace copies migrated;json_utils.compute_diffsplit into structuredcompute_diff_entries+ a byte-identical formatter.Review fixes (final commit,
tasks/pr-703-review.md)The self-review found one critical wire-shape error: the diff sides were assumed flat, but the verified shape (connection
ConfigurationVersionResponse+ConfigurationDiffData) nests all content under adiffenvelope withversion/isDeletedas side metadata — the original take/classify code would have been dead on arrival against the live API, invisibly, because the test fixtures encoded the same wrong assumption. Now recorded indocs/merge-requests-notes.md's wire-truth table. Also fixed:resolve_conflictcould rebase into an unrelated branch (branch now derived from the MR), deleted-side asymmetry,_same_idon branch ids,FEATURE_NOT_ENABLED, spec'd mocks at the L3 seam, and regression tests for every finding.Second review: wire truth vs. Connection (2026-08-27)
An adversarial Opus review verified every wire assumption against the Connection source (report:
tasks/pr-703-opus-wire-review.md): 7 CONFIRMED, 3 MISMATCH, all fixed in the final commit:ExceptionConverterserializesstorage.mergeRequests.validationtop-level plus the conflicting configs inparams.errors. The remap now matches both codes explicitly (unknown 409 codes pass through unmapped), andhttp_basesurfacesparamsasdetails.api_error_params— the conflict list travels with the error.approveexists only inin_review(fromapprovedthe backend answers 422 — the UI button offering it there is wrong);allowed_actionscorrected,updateadded toin_merge. With the non-SOX default of 0 required approvals,approveis 422 everywhere andin_reviewis unreachable.derive_state'srejected/self-closedrows are best-effort by wire design:reviewers[].statusneeds areview_requestedanchor thatskip_reviewnever writes, and explicit reviewers shadow non-reviewer decisions — so on a default non-SOX project those states are underivable fromreviewers[]. The UI badge has the identical blind spot (our table is its port). Documented, not re-derived client-side — the reliable fix is server-side and is now recorded on DMD-1988 (derive from the activity log).Layer 1 RFC findings applied (2026-08-28, commit 07daa50)
Writing the Layer 1 command RFC (DMD-1900, PR #708) surfaced seven Layer 2 findings (
tasks/dmd-1899-findings-from-layer1.md); all verified against Connection and applied:get_config_diffderives the branch from the MR (signature nowalias, merge_request_id, component_id, config_id) — the same make-the-wrong-call-unrepresentable reasoningresolve_conflictalready had; the resolvedbranch_idis echoed, a published/canceled MR is refused readably. The old tests passed shifted positional args straight into MagicMock — rewritten to pin the wiring.allowed_actions(list rows, find, create, update, transitions, merge's post-merge state) — a--jsonconsumer answers "what can I do next" without a second call.autoMergeStrategy=immediatelymakes a background backend tick merge any approved MR through the sameMergeProcessorunder a system token (AutoMergeCandidateRepository.php:38-47,AutoMergeTickHandler.php:86) —create+request-reviewcan end in a production merge withmerge()never called. New notes section + both docstrings.MergeRequestResponsewire shape in the notes table (merge{}is nested,createdAttop-level,autoMerge*are response fields);MergeRequestVotercited (scoped token → 403 on detail/conflicts, a different axis than role whitelisting) + 403 added to the two read rows in the layer3 doc.listnow carriesfeature_enabled— 200 +[]on a project without the feature stops being indistinguishable from a genuinely empty project (the extraverify_tokenGET is spent only on the empty case).STATE_FILTER_VOCABULARYandTAKE_MODESare public so Layer 1 enumerates them in help text and pre-validates to exit 2 (precedent:notification_service.KNOWN_EVENTS).Known CI caveats
make changelog-checkis green again. No version bump here: this lands in the DMD-1899/1900 stack and the bump PR owns the changelog entry (the feat(token): addtoken list, stop retrying non-idempotent writes (#599) #616 precedent). Note for the release PR: the entry must also coverdetails.api_error_params(+api_error_params_truncated) — a cross-cutting, bounded addition to the--jsonerror envelope on every 4xx/5xx from all clients — not just the merge-request command group.tests/test_changelog_render.pyfails (2 tests) wheneverFORCE_COLORis set in the environment (Warp exportsFORCE_COLOR=3): Rich then emits ANSI inside the asserted substrings (New:renders bold, splitting"New: alpha thing."). Pre-existing main-branch test fragility, fully reproducible and unrelated to this PR; worth a tiny follow-up fix on main.Review pointers
resolve_conflict's conflict-set check, the MR-derived branch, andonto_version = theirs.versionare the service-level guards Layer 3 deliberately does not have.🤖 Generated with Claude Code