[DMD-1833] Merge requests — Part 1, Layer 3 (client) - #556
Conversation
aaabc0d to
fb885af
Compare
337c6b2 to
4021e0e
Compare
There was a problem hiding this comment.
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_requestsnamespace (9 endpoints) via aStorageRequesterProtocol + 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, andrebase_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.
| # 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` |
There was a problem hiding this comment.
Fixed in a93225e — reworded to "the Part 2 service layer must call has_feature() with this constant before writes".
| def get_config_diff( | ||
| self, | ||
| component_id: str, | ||
| configuration_id: str, | ||
| branch_id: int, | ||
| ) -> dict[str, Any]: |
There was a problem hiding this comment.
Fixed in a93225e — the new methods now use config_id, matching the rest of the mixin (RFC method inventory synced too).
- 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>
- 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>
81c8473 to
a93225e
Compare
Note for the rebase: the
|
The class docstring said 20 call sites across client/. That total holds only on the #556 branch, which adds merge_requests.merge(); on main it is 19 -- storage_tables.py 16x, branches.py 2x, workspaces.py 1x. The earlier "15x" for storage_tables came from a grep that required the HTTP verb on the same line as _request(, which missed change_sharing_type's multi-line call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
padak
left a comment
There was a problem hiding this comment.
Code review — Layer 3 merge-request client
Reviewed the full diff against a local checkout of ms/dmd-1833. Verification I ran in an isolated worktree:
pytest tests/→ 5770 passed, 166 skippedruff checkclean;tyreports only the 3 pre-existing warnings unrelated to this diffloc-check,changelog-check,check-sentinel-guards,command-sync-check,skill-check→ all OK
Conventions #16 (E2E) and #17 (plugin/docs surfaces) are both command-bound, so a Layer-3-only PR correctly triggers neither. check-sentinel-guards passes because _MergeRequestsMixin descends from _CoreClient, which the script already settles as bearer-capable — no new entry needed.
Overall this is well above the bar: the StorageRequester seam, the "do not copy the neighbouring form-encoded idiom" warnings traced to Connection validators, and tests that pin {} vs null vs [] by dict equality rather than membership are all the right instincts. Three findings below; only the first one I would want resolved before merge.
1. 🔴 rebase_config: omitting configuration / is_disabled destroys state rather than preserving it
src/keboola_agent_cli/client/configs.py:688,691
rebase_config applies the house "None means omit the key" idiom, but /rebase is a full replacement, not a partial update. The RFC in this same PR (docs/merge-requests-layer3-rfc.md:109-110, citing RebaseRequest::validateDiff) documents that the backend defaults diff.configuration to {} and diff.isDisabled to false. On a replacement endpoint, "omit" therefore means "reset", not "leave unchanged".
Concrete failure: Part 2 resolves a conflict on a disabled extractor and calls
client.rebase_config(component_id, config_id, branch_id=123, version=7, name="X", rows=[...])which mirrors the signature's own signal that name and rows are the required content fields. The wire body becomes {"version": 7, "diff": {"name": "X", "rows": [...]}}; the backend fills in configuration={} and isDisabled=false. The rebased dev-branch configuration loses its entire parameters block and is silently re-enabled. Merging the MR then promotes an empty, running configuration into production.
name and rows are required precisely to make that class of loss unrepresentable (RFC, D6) — these two parameters leave the same hole open. D1 does acknowledge "omitting it defaults to false server-side", but the docstring states the consequence as a neutral fact (configuration: Resolved configuration body (backend default: {})), which reads as harmless.
Suggested fix — either is fine:
- make
configurationandis_disabledrequired for a keep rebase (consistent with D6's "no illegal combination expressible"), or - keep the signature and reword both docstring lines to say omitting resets the field server-side, so no Layer 2 caller has to infer it.
2. 🟡 The already-terminal-job guard is on the wrong layer
src/keboola_agent_cli/client/merge_requests.py:293
The guard added in the second commit is correct, but placing it in merge() leaves the blind spot it fixes unpatched everywhere else. _CoreClient._wait_for_storage_job (client/_core.py:209) early-returns the initial job dict whenever status in ("success", "error") — it only raises on a polled error. Two other call sites pass that return value straight through:
client/storage_tables.py:658—import-async, i.e.storage upload-table/storage load-fileclient/storage_tables.py:1132— the export path
So a Storage job that comes back already failed in the POST response is reported to the user as a successful import/export. Moving the check into _wait_for_storage_job fixes merge and both of those at once, and means the next job-backed method does not have to remember to re-add it.
3. 🟢 _optional_mr_fields is called positionally
src/keboola_agent_cli/client/merge_requests.py:196 and :224
The helper exists explicitly so the create/update bodies "cannot drift" — but both call sites pass its five arguments positionally, and four of them are str | None. The one drift mode the helper cannot catch is exactly the type-correct one: transposing auto_merge_at and auto_merge_strategy in a future edit to one call site passes ruff and ty, and the existing coverage asserts autoMergeStrategy == "scheduled" only for create — so update would ship sending a timestamp as the strategy and fail as a backend 422 at runtime. Passing the five by keyword removes the failure mode entirely.
Note on the disclosed limitation
The PR body says the _do_request write-retry hazard (a committed-but-lost POST/PUT replaying into a misleading terminal error — create → 404 "already has an MR", merge → 409 notReadyToMerge, rebase → 400 "version not newer") is a follow-up issue rather than a Layer 3 patch. Agreed on the scoping — the diagnosis is right and it is genuinely cross-cutting. I could not find the follow-up issue in this repo, though; worth filing before it drops off the radar, since MR writes are the first place where a masked-success replay has user-visible consequences.
Things worth calling out as good
- The
StorageRequesterProtocol +_ClientRequesteradapter: the namespace never sees the client, which is what makestest_namespace_works_against_a_stub_requesterpossible with no HTTP client at all, and makes the #595 transport swap a one-line change. MERGE_JOB_MAX_WAITfollows the existingIMPORT_JOB_MAX_WAIT/EXPORT_JOB_MAX_WAITprecedent rather than inventing a new knob.- Percent-encoding uses
safe='', the stricter of the two forms already present inconfigs.py. - The docstrings document the
versionwire-name trap (it is the target branch's version fromtheirs.version, not the dev branch config's) — that one would have cost someone an afternoon.
|
Withdrawing finding 2 — I missed your note above before posting, sorry. The terminal-error guard placement is already handled centrally in #603, with the same root-cause analysis (initial-body special case in Findings 1 ( |
|
Findings 1 and 3 are implemented in #606, which targets
|
|
Update on #606: the open question is no longer open. I checked the Connection source instead of leaving it to you.
Those two sources also draw the line cleanly:
|
padak
left a comment
There was a problem hiding this comment.
Approving — re-reviewed at 0b88991b
Both findings from my earlier review are resolved, and finding 2 was yours in #603 all along.
Re-verified on the merged branch rather than assuming the merge carried things across:
rebase_confignow takesconfiguration: dict[str, Any],is_disabled: boolanddescription: str | Noneas required parameters — the whole replaced body.change_descriptioncorrectly stays optional._optional_mr_fieldsis keyword-only in the signature.- The squash carried no
Co-Authored-Bytrailer. make checkgreen on0b88991b: 5774 passed, 12 skipped,tyclean apart from the 3 pre-existing warnings, and every drift gate OK (SKILL.md, command-sync across all 260 commands, changelog, error-codes, sentinel-guards, file-size).
The RFC's D1 now carries the replace-vs-patch reasoning with both Connection sources cited, so the split between required and optional reads as a decision rather than an inconsistency.
One 🟢 nit, and it is mine
docs/merge-requests-layer3-rfc.md:379-380 still says:
rebase_configsendsis_disabled=Falsebut omitsis_disabled=None
That is stale as of #606 — is_disabled is bool now, so None is not expressible at all. I updated D1 and the signature table and missed the Testing section. Suggested replacement for that bullet:
- **Presence detection** — `create` / `update` omit unset optionals; `rebase_config` always
sends the replaced body (`name` / `rows` / `configuration` / `isDisabled`) and omits only
`description=None` and an unset `change_description`; `rows=[]` is sent, not treated as absent.Not blocking — it is a doc line in an internal RFC, and the code and tests are right. Happy to push it as a one-line PR if you would rather not touch it.
Not in scope here
- The
_do_requestwrite-retry hazard stays open, as agreed — a cross-cuttinghttp_baseconcern. - No CI ran on #606 (
ci.ymlis scoped topull_request: branches: [main]), so this PR's run againstmainis the first real CI on those two commits.
- 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>
#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>
0b88991 to
25a80a5
Compare
Approval re-confirmed at
|
a0dfa99 to
6aa5346
Compare
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>
6aa5346 to
a01ee36
Compare
…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.
…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.
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
StorageRequesterProtocol (not on the client) via a temporary_ClientRequesteradapter — 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 inclient/,with a dedicated 600 s
MERGE_JOB_MAX_WAITbudget._optional_mr_fields(shared bycreate/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 andsurface only as a backend 422.
client/configs.py:get_config_diff+rebase_config/rebase_config_delete.branch_idis required (no production fallback — the endpoints 400 on the defaultbranch). Keep vs delete rebase are two methods so no illegal combination is expressible;
the
diffenvelope stays private to them. The keep rebase requires the full replacedbody (
name,rows,configuration,is_disabled,description):/rebasereplacesrather than patches, so an omitted key takes the server-side default — a caller sending
only
name+rowswould wipe the configuration body and re-enable a disabled config,then merge that into production.
constants.py:FEATURE_BRANCHES_MERGE_REQUESTSfor Part 2's pre-flight featurecheck (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 vsbranch-prefixed paths, JSON bodies with real types (the surrounding
configs.pyteachesform encoding — the opposite), presence detection, the
diffenvelope, the{}deleteresolution, 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
commands), no changelog entry (no version bump), and no plugin/docs surfaces change
(convention v0.6.0: Branch lifecycle management + security hardening #17 binds commands).
SESSION_UNSUPPORTED_FEATURESentry — verified in Connection code: bearer-sessionauth on Storage routes is route-agnostic, the session resolves to the user's real admin
Storage token before any MR action runs.
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_waiton theStorageRequesterProtocol, shapedbefore the Protocol has external consumers.
rebase_configand the keyword-only_optional_mr_fields([DMD-1833] Require the replaced body fields on rebase; keyword-only MR optional fields #606)._wait_for_storage_jobreturned analready-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 blindspot 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 implementationmust raise on a failed job, initial-body or polled —
merge()no longer re-checks.Known limitation (deliberately not fixed here):
_do_requestretries POST/PUT ontimeout/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_baseconcern affecting every non-idempotent write in the codebase —follow-up issue, not a Layer 3 patch.
Testing
make checkgreen locally.pytest_httpx); live E2E lands with Part 2'scommands.
🤖 Generated with Claude Code