feat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899) - #703
feat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899)#703martinsifra wants to merge 15 commits into
Conversation
…) [DMD-1899] L2 RFC decisions from the 2026-08-26 brainstorm: service shape (house pattern, verb_noun methods), derived status (4 derivates - derived_state, merge_blockers, allowed_actions, viewer; polyfill until DMD-1988), create source-branch via resolve_branch, merge-409 error codes mapped in the service (backend counterpart DMD-1984), conflict resolution uniformly via rebase (take ours/theirs; UI reset-to-default discrepancy filed as DMD-1987), per-path 3-way diff presentation, post-merge cleanup mirroring delete_branch, feature-constant rename; mcp_parity section marked obsolete (map removed in 0.85.0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
…899] merge() waits for the Storage job (Layer 3, MERGE_JOB_MAX_WAIT) and adds the two service-level concerns from the RFC: 409 mapping (_remap_merge_conflict): the shape carrying storage.mergeRequests.notReadyToMerge (read from details.api_error_code, plumbed by the earlier http_base commit) becomes MR_NOT_READY_TO_MERGE, retryable -- all three causes are transient; a 409 without the code is the conflict validation and becomes MR_MERGE_CONFLICT, not retryable, with 'kbagent merge-request conflicts' named as the next step. Mapped here and not in http_base because only this call site knows the 409 came from the merge endpoint. Non-409 errors (STORAGE_JOB_FAILED rollback included) pass through unmapped. Post-merge cleanup mirrors BranchService.delete_branch: branchFromId is captured from the PRE-merge payload (nullable once published), active_branch_id resets only if it pointed at the merged branch (the was_active logic -- deliberately not get_merge_url's unconditional reset), and the sync branch-mapping entry is unlinked. The whole cleanup block is best-effort: the merge already happened, so a failure degrades to cleanup_warnings in the result instead of a failed command. The message says the source branch 'is being deleted' -- never that it is gone (second async job, no handle). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…D-1899] The conflict-resolution half of the service: - list_conflicts: pass-through with count (conflicts are computed live by the backend on every call/merge attempt -- rebasing each listed config is sufficient, no re-validate step exists). - get_config_diff: flattens the nested base/ours/theirs (each nullable) into a per-path classification -- changed_by: ours | theirs | both, built by intersecting two compute_diff_entries() pairwise diffs. A side that removed a key shows None (never the base value); an untouched side shows the base. rows compare wholesale. onto_version carries theirs.version -- the rebase version trap spelled out once, in data. - resolve_conflict: every mode goes through the rebase endpoint (RFC decision; the UI's reset-to-default for take=theirs is DMD-1987). take=ours|theirs composes the full replace body from the diff side with version=theirs.version; take=ours of a deleted side becomes the delete resolution; take=delete sends the tombstone; a caller-authored resolved body must spell out name/rows/configuration explicitly (rebase REPLACES -- a defaulted key would be silent data loss). The config must be in the MR's live conflict set, and rebase runs the feature pre-flight (the one endpoint gated on branches-merge-requests specifically). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
…actions, derive_state honesty [DMD-1899] Adversarial review of every wire assumption against Connection source (tasks/pr-703-opus-wire-review.md): 7 CONFIRMED, 3 MISMATCH. The fixes: 409 mapping (finding #4): the conflict 409 is NOT code-less -- an earlier reading missed ExceptionConverter, which serializes MergeValidationException's own string code storage.mergeRequests.validation top-level as `code`, plus the conflicting configurations in `params.errors`. _remap_merge_conflict now matches BOTH codes explicitly (code-less 409 falls back to conflict for older stacks; a 409 with any OTHER code passes through unmapped instead of being confidently mislabeled a conflict), and http_base surfaces `params` as details.api_error_params so the conflict list travels with the error instead of demanding a second round trip. Stale 'carries no code' claims corrected in errors.py, the L3 docstring, error-codes.md, the notes wire-truth table and the RFC. allowed_actions (finding #7): `approve` removed from the `approved` tuple -- the transition's sole `from` place is in_review; from approved the backend answers 422 (the UI button offering it there is wrong). `update` added to in_merge (the server blocks update only in terminal states). Docstring records the AddApprovalGuard gating and that with the non-SOX default of 0 required approvals, approve is 422 in every state and in_review itself is unreachable. derive_state honesty (finding #10, the significant one): reviewers[].status is populated only within a review round anchored by a review_requested activity event -- which skip_review never writes -- and explicit reviewers shadow every non-reviewer's decision (the creator can never BE a reviewer). So in a default non-SOX project the rejected / self-closed derivations never fire; the UI badge has the identical blind spot, since our table is its port. Documented in the docstring and the RFC rather than re-derived from the activity log client-side: the reliable fix is server-side (commented on DMD-1988 -- derive from the activity log, not reviewers[]). Sharp edges from confirmed items: a take side with a null/empty `name` (nullable in the diff envelope, required non-empty by the rebase validator) is refused as a contract violation instead of sailing into a server 400; _branch_from_id_of documents that its null check is racy (branchFromId is nulled by the FK when the async branch delete lands, not by the state change); the viewer docstring notes detail/conflicts require an admin token anyway (MergeRequestVoter). Regression tests for each fix; 87 service tests total. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… [DMD-1899]
Two quality nits from the pre-review self-audit: the side_value helper was
re-defined on every classification loop iteration (it captures nothing
loop-scoped -- hoisted above the loop); the test _arm fixture abused
Ellipsis as a keep-default sentinel with a type: ignore -- replaced with a
typed module object sentinel, which also unbreaks `make typecheck` over
tests (ty rejects an EllipsisType default on a dict|None parameter).
Also pinned down the test_changelog_render 'environmental' failures for
good: they reproduce exactly when FORCE_COLOR is set (Warp exports
FORCE_COLOR=3), which makes Rich emit ANSI inside the asserted substrings
('New: ' renders bold, splitting 'New: alpha thing.'). A main-branch test
robustness issue, unrelated to this PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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).
…ew) [DMD-1899]
get_merge_request's verify_token skip accepted ANY dict as a server viewer,
while derive_viewer required isCreator/hasApproved keys -- a bare
`viewer: {}` (or one with foreign keys) would skip the call and yield
{None, None} instead of the local derivation. Both sites now share
_server_viewer(), the single predicate for 'did DMD-1988 land', so they
cannot disagree. Regression test included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…899] The pre-flight refusal said the same thing to two very different projects: a SOX project (protected-default-branch -- refused as deliberate CLI policy, nothing to enable) and a plain project that simply does not have merge requests turned on (something to ask support for). The features cache is already loaded by the first has_feature call, so the distinction is free: a new PROTECTED_DEFAULT_BRANCH_FEATURE constant + a dedicated SOX message pointing at the Keboola UI, vs. the enable-the-feature hint. Both paths stay FeatureNotEnabledError / FEATURE_NOT_ENABLED -- the split is in the wording, not the contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Seven findings surfaced while writing the Layer 1 command RFC (DMD-1900),
all verified against Connection (tasks/dmd-1899-findings-from-layer1.md):
1. get_config_diff now derives the branch from the merge request
(signature: alias, merge_request_id, component_id, config_id) -- the
same make-the-wrong-call-unrepresentable reasoning resolve_conflict
already carries; a caller-supplied branch id had no relation to any MR.
The resolved branch_id is echoed in the result; a published/canceled MR
is refused readably. The old tests passed the shifted positional args
straight into MagicMock without failing -- rewritten to pin the wiring
(the L3 diff call must receive branchFromId).
2. _enrich_row now adds allowed_actions alongside derived_state, so every
return (list rows, find, create, update, transitions, merge's post-merge
state) answers 'what can I do next' without a second call.
3. SAFETY FACT recorded: autoMergeStrategy=immediately makes a background
backend tick merge any approved MR through the same MergeProcessor under
a system token (AutoMergeCandidateRepository.php:38-47,
AutoMergeTickHandler.php:86) -- create+request_review can end in a
production merge with merge() never called. New notes section + one
sentence in both create/update docstrings.
4. Notes wire-truth table gains the full MergeRequestResponse shape:
merge{mergedAt,mergerId,mergerName} is NESTED, createdAt is top-level,
autoMergeStrategy/autoMergeAt are response fields too
(MergeRequestResponseProvider.php:86-117; list==detail item shape).
5. MergeRequestVoter recorded in the notes (admin-identity axis vs. the
role axis -- not a contradiction), 403 added to the detail/conflicts
rows in the layer3 doc, derive_viewer docstring softened (the None-flags
branch is defense in depth, not a reachable path via get_merge_request).
6. An empty list result now carries feature_enabled from has_feature, so
'no merge requests' and 'feature not enabled' stop being the same 200 +
[]; the extra verify_token GET is spent only on the empty case.
7. STATE_FILTER_VOCABULARY and TAKE_MODES are public for Layer 1 to
enumerate in help text and pre-validate to exit 2 (precedent:
notification_service's KNOWN_EVENTS).
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 is
docs/merge-requests-layer2.md(first commit) — every decision below is written down there. 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).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