Skip to content

[DMD-1833] Merge requests — Part 1, Layer 3 (client) - #556

Merged
martinsifra merged 1 commit into
mainfrom
ms/dmd-1833
Aug 19, 2026
Merged

martinsifra merged 1 commit into
mainfrom
ms/dmd-1833

Conversation

@martinsifra

@martinsifra martinsifra commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

What

Part 1 of merge-request support in kbagent — Layer 3 only (HTTP client). The design
decisions live in the module and method docstrings; this PR is code-only.

Linear: DMD-1701 — milestone "Branches 2.0".

  • client/merge_requests.py (new): the nine MR endpoints as a namespace —
    client.merge_requests.{list,get,conflicts,create,update,request_review,approve,request_changes,merge}.
    The namespace depends on a StorageRequester Protocol (not on the client) via a temporary
    _ClientRequester adapter — the seam the client-split work (RFC: Split KeboolaClient into a transport and resource namespaces #595) later builds under.
    merge() awaits the Storage job implicitly, like every job-backed method in client/,
    with a dedicated 600 s MERGE_JOB_MAX_WAIT budget. _optional_mr_fields (shared by
    create/update so their camelCase mappings cannot drift) is keyword-only: four of its five
    parameters are str | None, so a positional transposition would type-check cleanly and
    surface only as a backend 422.
  • client/configs.py: get_config_diff + rebase_config / rebase_config_delete.
    branch_id is required (no production fallback — the endpoints 400 on the default
    branch). Keep vs delete rebase are two methods so no illegal combination is expressible;
    the diff envelope stays private to them. The keep rebase requires the full replaced
    body
    (name, rows, configuration, is_disabled, description): /rebase replaces
    rather than patches, so an omitted key takes the server-side default — a caller sending
    only name+rows would wipe the configuration body and re-enable a disabled config,
    then merge that into production.
  • constants.py: FEATURE_BRANCHES_MERGE_REQUESTS for Part 2's pre-flight feature
    check (Layer 3 deliberately does no feature check; a missing feature is a 403 identical
    to a role denial, so only a Layer 2 pre-flight can word the error).
  • tests/test_merge_request_client.py: pins the wire contract — bare vs
    branch-prefixed paths, JSON bodies with real types (the surrounding configs.py teaches
    form encoding — the opposite), presence detection, the diff envelope, the {} delete
    resolution, merge-job waiting and its 600 s budget, the replaced-body requirement,
    include=activityLog, and the stub-requester seam.

What this deliberately does NOT do

Review history (now squashed into the single feat commit)

An adversarial self-review, a Copilot round and follow-up PRs (#606, #608) shaped the
branch before it was squashed; the notable outcomes, in the code above:

  • MERGE_JOB_MAX_WAIT (600 s) + max_wait on the StorageRequester Protocol, shaped
    before the Protocol has external consumers.
  • The full-replaced-body requirement on rebase_config and the keyword-only
    _optional_mr_fields ([DMD-1833] Require the replaced body fields on rebase; keyword-only MR optional fields #606).
  • The self-review also surfaced a poller blind spot: _wait_for_storage_job returned an
    already-terminal error body instead of raising, so a fast synchronous merge failure came
    back as success. This PR initially carried a local guard in merge() for it; the blind
    spot was a house-wide bug across every call site of the helper, so it was fixed centrally
    in fix(client): raise on an already-failed Storage job instead of returning it #603 (DMD-1898, merged first) and the guard was dropped here. What remains is the
    contract statement on StorageRequester.wait_for_storage_job: a Protocol implementation
    must raise on a failed job, initial-body or polled — merge() no longer re-checks.

Known limitation (deliberately not fixed here): _do_request retries POST/PUT on
timeout/5xx house-wide with no opt-out, so a committed-but-lost MR write can replay into a
misleading terminal error (create → 404 "already has an MR", merge → 409 notReadyToMerge,
rebase → 400 "version not newer") masking an operation that actually succeeded. This is a
cross-cutting http_base concern affecting every non-idempotent write in the codebase —
follow-up issue, not a Layer 3 patch.

Testing

  • make check green locally.
  • Everything Layer 3 is verifiable offline (pytest_httpx); live E2E lands with Part 2's
    commands.

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

DMD-1833

DMD-1898

@martinsifra
martinsifra marked this pull request as draft August 5, 2026 06:34
@martinsifra
martinsifra force-pushed the ms/dmd-1833 branch 9 times, most recently from aaabc0d to fb885af Compare August 11, 2026 23:55
@martinsifra
martinsifra force-pushed the ms/dmd-1833 branch 6 times, most recently from 337c6b2 to 4021e0e Compare August 17, 2026 22:13
@martinsifra martinsifra changed the title [DMD-1833] Merge requests [DMD-1833] Merge requests — Part 1, Layer 3 (client) Aug 17, 2026
@martinsifra
martinsifra marked this pull request as ready for review August 17, 2026 23:06
@martinsifra
martinsifra requested a lite review from Copilot August 18, 2026 09:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Layer 3 (HTTP client) support for Storage API merge requests as a new, test-pinned client namespace, plus the related config diff/rebase endpoints needed for conflict resolution. This fits the repo’s 3-layer architecture by expanding the client/ surface area (Layer 3) without introducing any CLI/service behavior yet (Part 2).

Changes:

  • Introduces client.merge_requests namespace (9 endpoints) via a StorageRequester Protocol + temporary client adapter seam for the upcoming client-split work.
  • Adds branch-scoped config conflict helpers to client/configs.py: get_config_diff, rebase_config, and rebase_config_delete (JSON body + diff-envelope).
  • Adds a dedicated merge-job wait budget constant and comprehensive offline contract tests for paths/bodies/envelopes/job-wait behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/keboola_agent_cli/client/merge_requests.py New merge-requests namespace + requester Protocol seam + cached client property; implements MR endpoints and merge job waiting.
src/keboola_agent_cli/client/configs.py Adds config diff and rebase endpoints (branch-only, JSON bodies, diff envelope).
src/keboola_agent_cli/client/_client.py Composes the new _MergeRequestsMixin into KeboolaClient.
src/keboola_agent_cli/constants.py Adds MERGE_JOB_MAX_WAIT and FEATURE_BRANCHES_MERGE_REQUESTS.
tests/test_merge_request_client.py New pytest-httpx suite pinning the wire contract and the requester seam behavior.
docs/merge-requests-layer3-rfc.md RFC documenting backend contract + Layer 3 design decisions and test strategy.
docs/merge-requests-layer2-notes.md Companion notes capturing verified backend semantics for Part 2 (service/commands).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +468 to +472
# Feature flag gating the non-SOX merge-request flow. Layer 3
# (client/merge_requests.py) does no feature check itself -- a missing
# feature is a 403 identical to a role denial -- so Part 2's service calls
# has_feature() with this constant before writes and words the error. It
# also doubles as the SOX fence: server-side, `protected-default-branch`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a93225e — reworded to "the Part 2 service layer must call has_feature() with this constant before writes".

Comment on lines +491 to +496
def get_config_diff(
self,
component_id: str,
configuration_id: str,
branch_id: int,
) -> dict[str, Any]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a93225e — the new methods now use config_id, matching the rest of the mixin (RFC method inventory synced too).

martinsifra added a commit that referenced this pull request Aug 18, 2026
- configs.py: rename the new methods' `configuration_id` parameter to
  `config_id`, matching the rest of the mixin (get_config_detail, ...);
  RFC method inventory synced.
- constants.py: reword the FEATURE_BRANCHES_MERGE_REQUESTS comment so the
  "Part 2 service must call has_feature()" intent reads unambiguously.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
martinsifra added a commit that referenced this pull request Aug 18, 2026
- configs.py: rename the new methods' `configuration_id` parameter to
  `config_id`, matching the rest of the mixin (get_config_detail, ...);
  RFC method inventory synced.
- constants.py: reword the FEATURE_BRANCHES_MERGE_REQUESTS comment so the
  "Part 2 service must call has_feature()" intent reads unambiguously.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@martinsifra
martinsifra requested a lite review from Copilot August 18, 2026 10:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@martinsifra

martinsifra commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Note for the rebase: the merge() terminal-error guard becomes dead code

The known-limitation guard in merge() is being fixed centrally in a separate PR
(#603, branch ms/wait-for-storage-job), which lands before this one.

Root cause, for the record: _wait_for_storage_job (client/_core.py:208-210) returns an
already-terminal initial job body as-is, including status == "error" — only the
polled body raises (:220-227). That is a duplicated terminal-state check that diverged,
and it affects all 20 call sites of the helper on this branch -- 19 on main
(storage_tables.py 16x, branches.py 2x, workspaces.py 1x) plus merge() here. The sibling pollers wait_for_queue_job
(queue.py:245) and wait_for_query_job (query.py:137) already have the correct shape —
one terminal check inside the loop, no initial-body special case — so the fix converges the
storage poller on that shape rather than patching the divergent branch.

Action item when rebasing this PR onto the fixed main — the local guard is then
redundant and should be dropped:

  • client/merge_requests.py:293-302 — the if job.get("status") == "error": guard in merge()
  • client/merge_requests.py:36 — from ..errors import ErrorCode, KeboolaApiError (no other use in the file)
  • merge() docstring — the "also when the 202 body itself already carries a terminal error…" clause
  • tests/test_merge_request_client.py — test_merge_raises_when_202_body_is_already_terminal_error

test_merge_failed_job_raises stays: it covers the polled-error path, which is unaffected.

Unrelated and still open (also noted in the PR description): _do_request retries POST/PUT
with no opt-out, so a committed-but-lost MR write can replay into a misleading terminal
error. Separate http_base concern.

martinsifra added a commit that referenced this pull request Aug 18, 2026
#603

Rebased onto main with #603, where _wait_for_storage_job itself raises on
an already-terminal error body (converged on the wait_for_queue_job shape).
Per the rebase checklist on #556: the local guard, its now-unused errors
import, the docstring clause, and its dedicated test are removed;
test_merge_failed_job_raises stays (polled-error path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@padak

padak commented Aug 18, 2026

Copy link
Copy Markdown
Member

Approval re-confirmed at f994f3b7

The branch was rebased onto main (now carrying #603 and #604) and gained two commits after I approved at 0b88991b. Since dismiss_stale_reviews_on_push is off on both rulesets, that approval stayed green on code it had not seen — so I re-reviewed the delta rather than let it stand on its own.

25a80a5 — dropping merge()'s local guard. Correct, and I checked the premise rather than the commit message: _wait_for_storage_job on the rebased base now evaluates the terminal state in one place inside the loop, so an already-terminal error initial body raises instead of being returned. The removed test is not lost coverage either — test_already_failed_body_raises and test_already_successful_body_returns_without_polling in test_client.py pin exactly that, one layer down where it now belongs. Removing the local guard, the unused errors import, the docstring clause and the dedicated test is the whole checklist, executed.

f994f3b7 — my own doc fix (#608).

I also re-checked the RFC for anything the guard removal left stale: D3 and the "Merge waiting" testing bullet both describe the behaviour via the shared helper, which is now more accurate than before, not less. Nothing to change.

make check green on the rebased branch: 5782 passed, 12 skipped, ty clean, all drift gates OK.

One thing to decide at merge time

Three commits on this branch carry a Co-Authored-By: Claude Fable 5 trailer (4954b33, f302d87, 25a80a5). GitHub's squash default concatenates the commit messages, so those trailers follow onto main unless the merge is given an explicit subject and body. Flagging it because this repo's convention is not to carry them — entirely your call, and nothing to change on the branch itself.

@martinsifra
martinsifra force-pushed the ms/dmd-1833 branch 4 times, most recently from a0dfa99 to 6aa5346 Compare August 19, 2026 13:55
client/merge_requests.py -- the nine MR endpoints as a namespace,
client.merge_requests.{list,get,conflicts,create,update,request_review,
approve,request_changes,merge}. The namespace depends on a StorageRequester
Protocol, not on the client; a temporary _ClientRequester adapter satisfies
it until the client-split work (draft #595) builds a real transport under
the seam. Two invariants deliberately break the surrounding idioms and are
called out in docstrings: paths are NEVER branch-prefixed (every MR endpoint
is project-level), and bodies are JSON with real types (the backend asserts
branchFromId as int; form-encoded values stay strings and fail validation).
_optional_mr_fields keeps create/update from drifting and is keyword-only:
four of its five parameters are str | None, so a positional transposition
would type-check cleanly and surface only as a backend 422.

merge() awaits the Storage job implicitly like every job-backed method in
client/, with a dedicated MERGE_JOB_MAX_WAIT (600 s) budget -- merging a
many-config branch can outlive the default 60 s. It does NOT re-check the
returned job: raising on a failed job (fast-fail included) is the poller's
contract since #603, stated as a requirement on the Protocol so a future
transport cannot reintroduce the blind spot. The await covers the merge
outcome only; the source-branch deletion runs as a second, unhandled job.

client/configs.py -- get_config_diff + rebase_config/rebase_config_delete.
branch_id is required with no production fallback (the endpoints 400 on the
default branch). Keep and delete rebases are separate methods so no illegal
combination is expressible. The keep rebase requires the FULL replaced body
(name, rows, configuration, is_disabled, description): /rebase replaces
rather than patches, so an omitted key takes the server-side default -- a
caller sending only name+rows would wipe the configuration and re-enable a
disabled config, then merge that into production.

constants.py -- MERGE_JOB_MAX_WAIT and FEATURE_BRANCHES_MERGE_REQUESTS.
Layer 3 does no feature check itself (a missing feature is a 403 identical
to a role denial); Part 2's service pre-flights with the constant.

tests/test_merge_request_client.py pins the wire contract: bare vs
branch-prefixed paths, JSON types, presence detection, the diff envelope and
the {} delete resolution, merge-job waiting and its 600 s budget, the
replaced-body requirement, include=activityLog, and the stub-requester seam.

Part 2 (service + commands) follows separately; no CLI command is added
here, so no E2E / docs surfaces change yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@martinsifra
martinsifra merged commit b7b66af into main Aug 19, 2026
4 checks passed
@martinsifra
martinsifra deleted the ms/dmd-1833 branch August 19, 2026 14:13
padak added a commit that referenced this pull request Aug 20, 2026
…85.1

The version bump to 0.86.0 already landed on main (#615), but the release
notes it produces were incomplete in two ways.

Missing entries. PR #616 (`token list`, plus the retry-policy and
exceptionId changes) carried no changelog note at all -- its commit message
says "No version bump: this lands in a stack of PRs released as one version.
The (since v0.86.0) doc tags assume 0.86.0 and the bump PR must confirm
that", and the bump PR did not. `make changelog-check` cannot catch this: it
verifies every published GitHub release has an entry, not that every merged
PR has a note. #556/#606 (merge-request endpoints, Layer 3) and #610
(winget job disabled) were likewise unannounced. All four are added.

Phantom 0.85.1. pyproject went 0.85.0 -> 0.85.1 (#614) -> 0.86.0 (#615)
without a tag in between, so 0.85.1 exists only as a changelog bucket -- no
release, no artifact, nobody running it. `format_whats_new` shows the notes
of the *target* version only, so every user upgrading 0.85.0 -> 0.86.0 would
have silently missed those four fixes (Azure ciphertext prefix, the
`parameters` wrapper, GCP/Azure sync ciphertext, the encrypt-values docs).
The bucket is folded into 0.86.0 verbatim.

The same phantom leaked into the agent-facing version gates, which is the
worse half: `keboola-expert.md` told users to "upgrade to 0.85.1+" and four
gotchas.md entries were tagged `(since v0.85.1)` -- a version nobody can
install. Retagged to 0.86.0, along with two source comments.

Three of the new notes had to lead with a shorter sentence to satisfy
`test_newest_release_notes_are_not_truncated` (the headline is the note's
first sentence, capped at 160 chars).

No behaviour change; documentation and release metadata only.
padak added a commit that referenced this pull request Aug 20, 2026
…85.1 (#619)

* chore(release): complete the 0.86.0 changelog and drop the phantom 0.85.1

The version bump to 0.86.0 already landed on main (#615), but the release
notes it produces were incomplete in two ways.

Missing entries. PR #616 (`token list`, plus the retry-policy and
exceptionId changes) carried no changelog note at all -- its commit message
says "No version bump: this lands in a stack of PRs released as one version.
The (since v0.86.0) doc tags assume 0.86.0 and the bump PR must confirm
that", and the bump PR did not. `make changelog-check` cannot catch this: it
verifies every published GitHub release has an entry, not that every merged
PR has a note. #556/#606 (merge-request endpoints, Layer 3) and #610
(winget job disabled) were likewise unannounced. All four are added.

Phantom 0.85.1. pyproject went 0.85.0 -> 0.85.1 (#614) -> 0.86.0 (#615)
without a tag in between, so 0.85.1 exists only as a changelog bucket -- no
release, no artifact, nobody running it. `format_whats_new` shows the notes
of the *target* version only, so every user upgrading 0.85.0 -> 0.86.0 would
have silently missed those four fixes (Azure ciphertext prefix, the
`parameters` wrapper, GCP/Azure sync ciphertext, the encrypt-values docs).
The bucket is folded into 0.86.0 verbatim.

The same phantom leaked into the agent-facing version gates, which is the
worse half: `keboola-expert.md` told users to "upgrade to 0.85.1+" and four
gotchas.md entries were tagged `(since v0.85.1)` -- a version nobody can
install. Retagged to 0.86.0, along with two source comments.

Three of the new notes had to lead with a shorter sentence to satisfy
`test_newest_release_notes_are_not_truncated` (the headline is the note's
first sentence, capped at 160 chars).

No behaviour change; documentation and release metadata only.

* fix(changelog): correct the serve route and give every 0.86.0 note a recognised prefix

Two findings from Devin's review of this PR, plus one they could not see.

The serve route for `token list` was cited as `GET /tokens/{project}`. Both
halves are wrong: the router carries `prefix="/token"` (singular) and the
operation is registered at `/{project}/list`, so the real path is
`GET /token/{project}/list` -- confirmed against the runtime OpenAPI schema,
not the source, because that is what a caller actually hits. Worth noting the
review's proposed correction (`/tokens/{project}/list`) is itself wrong on the
prefix; taking it verbatim would have swapped one 404 for another.

`CI:` is not a recognised note prefix. `_PREFIX_STYLES` / `_PREFIX_RE` in
commands/changelog.py define the set, the module docstring states the contract,
and an unrecognised label renders unhighlighted. Retitled to `Note:`, which
also reads better: the winget job being disabled has a user-facing consequence
(WinGet users stay on the last published version), so burying it under a dim
`Internal:` would understate it.

The finding Devin could not report: the four notification notes carried by
#615/#618 have no prefix at all. They were outside this PR's diff, so no
reviewer looking at the diff would flag them -- but they ship in the same
release block and break the same contract, leaving half of v0.86.0 rendering
flat. Prefixed `New:` / `Note:` with no change of meaning. Every 0.86.0 note
now matches `_PREFIX_RE`, verified by asserting over the live CHANGELOG rather
than by reading.

Each replacement is written to disk on its own. Running several in one script
means a later failed assert discards the earlier successful writes, which is
precisely how #618's stale "server-side ?event=" claim survived its own fix
pass.
martinsifra added a commit that referenced this pull request Sep 3, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 3, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 3, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 3, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 4, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 5, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 5, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 10, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 14, 2026
…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>
martinsifra added a commit that referenced this pull request Sep 15, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants