From 17494e2ec24e41b4820e92fa6440eb7c55251e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Wed, 5 Aug 2026 08:33:24 +0200 Subject: [PATCH 01/16] 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 --- docs/merge-requests-layer1.md | 653 ++++++++++++++++++++++++ docs/merge-requests-layer2-followups.md | 131 +++++ docs/merge-requests-layer2.md | 291 +++++++++++ docs/merge-requests-layer3.md | 158 ++++++ docs/merge-requests-notes.md | 246 +++++++++ 5 files changed, 1479 insertions(+) create mode 100644 docs/merge-requests-layer1.md create mode 100644 docs/merge-requests-layer2-followups.md create mode 100644 docs/merge-requests-layer2.md create mode 100644 docs/merge-requests-layer3.md create mode 100644 docs/merge-requests-notes.md diff --git a/docs/merge-requests-layer1.md b/docs/merge-requests-layer1.md new file mode 100644 index 00000000..0dd26703 --- /dev/null +++ b/docs/merge-requests-layer1.md @@ -0,0 +1,653 @@ +# Merge requests — Layer 1 (commands), RFC + +Linear: [DMD-1900](https://linear.app/keboola/issue/DMD-1900). The `kbagent merge-request` +command group over `MergeRequestService` (Layer 2, DMD-1899). Backend facts with citations live +in [`merge-requests-notes.md`](merge-requests-notes.md) — **it is the authority on wire shapes; +this document never restates a field list**. The service contract is +[`merge-requests-layer2.md`](merge-requests-layer2.md), the HTTP client +[`merge-requests-layer3.md`](merge-requests-layer3.md). Scope stays **non-SOX**. + +Layer 2 settles most of the surface: twelve service methods map to twelve commands with one +invention -- `auto-merge` is split out of `update` so that arming is its own step (see *What is +destructive*). What this RFC decides is what Layer 2 left to the caller — target resolution, +rendering, error presentation, and above all **what may happen without a human saying so**. + +## Shape + +- Group **`merge-request`**, mounted in `cli.py` under the `_DEV` help panel immediately after + `branch`; hidden alias **`mr`** (precedent: `sl` for `semantic-layer`, `cli.py:155`; hidden + subtrees are skipped by `check_command_sync.py:84`, so the alias trips no doc gate). +- Two modules: `commands/merge_request.py` (Typer commands) and + `commands/_merge_request_render.py` (Rich renderers). The reason is **not** a budget wall — + `output.py` is at 679 code lines against a 1000 soft ceiling (`make loc-report`; budgets are + code lines, not raw lines, `CONTRIBUTING.md:185`) and has ample room. The reason is + qualitative: four non-trivial renderers (list table, detail panel, conflicts table, three-way + diff) are a coherent unit that no other group's output shares, and eleven commands at this + repo's real ~75-code-lines-per-command average land near the 800 soft ceiling on their own. + Precedent for a renderer-only private sibling: `_auth_picker.py`. +- Group callback `check_cli_permission(ctx, "merge-request")`. +- Service registered in `cli.py` as `ctx.obj["merge_request_service"]`. + +## Command surface + +Twelve commands over twelve service methods (`auto-merge` and `update` share one). `--project` resolves via `resolve_project_alias` +and is **single-project throughout** — every service method takes one `alias: str` and there is +no multi-project entry point, so this group never fans out the way `branch list` or `job list` +do. + +| Command | Service method | +|---|---| +| `list [--state V]` | `list_merge_requests` | +| `detail [--activity-log]` | `get_merge_request` | +| `create --title T [--description D] [--reviewer-id ID ...] [--external-id X]` | `create_merge_request` | +| `update [--title] [--description] [--reviewer-id ...] [--external-id]` | `update_merge_request` | +| `request-review` | `request_review` | +| `approve` | `approve` | +| `request-changes [--reason TEXT]` | `request_changes` | +| `auto-merge --strategy immediately\|scheduled\|none [--at TS]` | `update_merge_request` (the auto-merge fields only) | +| `merge` | `merge` | +| `conflicts` | `list_conflicts` | +| `diff --component-id C --config-id I [--format short\|full] [--output PATH]` | `get_config_diff` | +| `resolve --component-id C --config-id I (--take ours\|theirs\|delete \| --resolved JSON\|@file\|-) [--change-description TEXT]` | `resolve_conflict` | + +Every command above additionally accepts `--project A`, and every one except `list` and +`create` accepts the target pair `[--merge-request-id N | --id N] [--branch B]` (see *Target +resolution*). `create` takes `--branch` only — its target is the source branch. + +`find_merge_request_for_branch` gets no command of its own: it is the resolver behind an +omitted `--merge-request-id`, and what it resolved is reported in every command's output. + +### Flag naming + +The repo's convention, measured across `commands/*.py`: a **bare noun** is the context you work +*in* — `--project` (206 uses), `--branch` (94), `--model` (23) — while **`---id`** is the +object you act *on*: `--component-id` (40), `--config-id` (30), `--table-id` (17), `--app-id` +(15), and ~16 more. A merge request is an object, not a context, so the flag is +**`--merge-request-id`**, with **`--id`** as a short alias on the same parameter (precedent: +the `agent` group accepts `--id` beside `--task-id`). + +### Flag details that are not free choices + +- **`--reviewer-id` must never be declared as `typer.Option([], ...)`.** On this stack + (typer 0.26.7 / click 8.4.1) `list[int] | None = typer.Option(None, ...)` yields `None` when + omitted, which is correct — but the `typer.Option([], ...)` style, live in this repo at + `commands/agent.py:862`, yields `[]`, and `_optional_mr_fields` sends `reviewerIds` whenever + it is not `None` (`client/merge_requests.py:113-114`). An empty list therefore **replaces the + reviewer set with nothing** on any `update` that never mentioned reviewers. +- Passing `--reviewer-id` at all **replaces** the set; it never appends. There is consequently + no way to clear reviewers from the CLI (the wire accepts `[]`, the flag cannot express it). + Deliberately not solved in v1 — a `--no-reviewers` sentinel is the shape if it is ever wanted. +- **`update` semantics:** `None` = leave unchanged; an **empty string clears** `description` / + `externalId`. `PUT {}` is a server-side no-op, so `update` with no field flags must be + refused at exit 2 rather than reporting success having changed nothing. +- **`auto-merge --at` is required by the backend only with `--strategy scheduled`** and + meaningless otherwise; the pairing is validated once in the service + (`validate_auto_merge_flags`) and both surfaces map the result (exit 2 / HTTP 400). Keep it a + `str` — a `datetime` annotation makes Typer coerce and reformat the caller's value. +- **`--external-id` (255) and `--reason` (1000) are capped, and both caps exit 2.** The cap + itself lives once, in the service (`_too_long` → `INVALID_ARGUMENT`; over `serve` that is the + 400). Layer 1 pre-checks each flag before any call, because a service-raised + `INVALID_ARGUMENT` is *not* mapped by `map_error_to_exit_code` and falls to exit 1 — for a while + `--reason` had the pre-check and `--external-id` did not, so the two sibling caps exited + differently (#736 review, fixed 2026-09-11). The alternative — mapping `INVALID_ARGUMENT` to + exit 2 globally — touches ~20 raise sites in other groups and was not taken here. +- **`--state` and `--take` pre-validate in Layer 1 to exit 2** (`INVALID_ARGUMENT`), importing + the now-public `STATE_FILTER_VOCABULARY` and `TAKE_MODES` from the service (precedent: + `commands/notification.py:33` importing `VALID_CHANNELS`). Without the pre-check a typo + reaches the service and exits 5 (`CONFIG_ERROR`), where every comparable bad-enum flag in the + repo exits 2. Never copy the vocabularies into a local `Enum` — that is the drift convention + #17 exists to prevent. +- **`resolve --take delete --change-description "…"`: the service accepts and WARNS** (the + delete wire body is exactly `{"version": N, "diff": {}}` — `changeDescription` lives inside + the diff envelope, so the tombstone has nowhere to carry it and the server records its + default message). Layer 2's `resolve_conflict` returns the drop in `warnings[]`, including + the implicit-delete collapse when `--take ours|theirs` picks a side whose `isDeleted` is + true — which a pre-flight on the flag combination alone could never catch, because the + resolved mode is only known after the diff is fetched. The command therefore does NOT + pre-validate the combination: it surfaces `result["warnings"]` (human mode: Rich warning; + `--json`: pass the key through). Refusing at exit 2 would either duplicate the service's + handling or hide the warning for the implicit case. + +### `request-review` and `approve` on a default project + +Both ship because the state machine has them, but on a non-SOX project with the default **0 +required approvals** neither has a happy path: `request_review` is auto-finished by the backend +(`skip_review`), so the MR jumps straight to `approved` and `in_review` is never reached — and +`approve`, whose only `from` place is `in_review`, answers **422 in every state**. `merge` works +directly from `development` anyway. Their `--help` states this plainly rather than letting a +user discover it as an unexplained 422. + +There is also **no `close` command**, for a related reason: creator-request-changes is the UI's +cancel, but it leaves `state=development`, and the `closed` derivation depends on +`reviewers[].status`, which a 0-approval project never populates. A `close` command would look +like a no-op. Documented in `request-changes --help` and `gotchas.md` instead. + +## Target resolution + +`--merge-request-id` is **optional**, resolved in three steps: + +1. `--merge-request-id` / `--id` given → use it. +2. Otherwise `resolve_branch()` — the idiom of 12 command modules: explicit `--branch`, else + `active_branch_id` from `config.json`. +3. On that branch, `find_merge_request_for_branch()` → the MR id. + +With neither, the house error: *pass `--branch` or run `branch use`*. With **both** +`--merge-request-id` and `--branch` given, exit 2 (*pass one or the other*) — they are two +ways of naming the same target, and silently letting step 1 win would hide a contradiction +(MR 7 not being *from* branch 123) exactly where a `--json` script would never notice it. The +chain cannot be ambiguous — a branch has **at most one MR, ever**, so step 3 finds one or none. Nothing is +cached: `active_branch_id` is persisted because it is a *user decision*, while branch→MR is +*derivable server state* and caching it would be a cache with no invalidation. Persist +decisions, derive facts. + +Every implicit resolution is reported, on stderr in human mode and in the payload always: + +``` +Info: Using active branch (ID: 123) for project 'acme' +Info: Resolved merge request #7 from branch 123 +``` + +`--json` results carry `merge_request_id`, `branch_from_id` and `resolved_from_branch` +regardless of how the target was reached, so a machine caller can always assert on what was +actually operated upon. + +### The one exception, and the rule behind it + +Surveying every destructive command in the repo produces a rule that holds without exception: + +> **Implicit branch resolution selects the *scope*. The *target* is always named on the command +> line.** + +`storage delete-table --table-id X` takes the active branch (`commands/storage.py:1017`) but +still requires the table. `config delete` requires `--config-id`. `branch delete` requires +`--branch` — there the branch *is* the target, which is why that one command has no fallback +even though 12 modules use `resolve_branch`. The only branch command with an active-branch +fallback is `branch merge`, which merges nothing (it prints a URL). + +Layered on that, the repo's destructive commands take one of two shapes: **prompt** +(12 sites of `if not yes and not formatter.json_mode` + `typer.confirm`) or **mandatory target** +(`branch delete`). Not one of them relies on the prompt for machine safety — `--json` implies +consent everywhere, in all 48 commands carrying `--yes`. + +A bare `kbagent --json merge-request merge --project acme` would satisfy **neither** shape: it +does not prompt and it names nothing. It would be the first command in kbagent where nothing on +the command line identifies what gets destroyed. Hence: + +> **A destructive command under `--json` requires an explicit target — `--merge-request-id` +> or `--branch`.** + +One rule, mechanically checkable, and — because the destructive class is a **static** property +of the command (next section) — checkable **before any network call**: the rule never depends on +a flag's value or on the MR's state, so a script never sees the same invocation pass one day and +exit 2 the next. A human at a terminal keeps the full fallback and gets the prompt where one +exists; a script, which received the id in the JSON payload of its previous call, names it. The +ergonomic objection to ids is an objection about humans, and humans never pass one. + +(An earlier draft escalated `request-review` / `approve` / `resolve` only when the MR *was* +armed, which made the rule fire after a GET and turn on state nobody typed. PR #736's review +named exactly that: exit 2 carrying a state-derived precondition. Decided 2026-09-10: the +class is static, the condition is gone.) + +`--yes` keeps its house meaning everywhere (skip the prompt; `--json` implies it). Inverting it +for one command was considered and **rejected**: it has zero precedent across 48 commands, and +a familiar flag with reversed semantics is worse than a rule that names the target. + +## What is destructive -- a property of the command, never of a flag or of the MR's state + +Decided 2026-09-10, replacing the flag- and state-derived escalations of the first draft (they +were the hazard PR #736's review pointed at twice: a permission that depends on a GET, a +`--json` exit code that depends on state nobody typed, and the same rule hand-maintained in +two files). The replacement is one sentence: + +> **Anything that moves a merge request toward or into production is destructive, always.** + +| command | class | why | +|---|---|---| +| `merge` | destructive | deletes the source branch, rewrites production | +| `request-review` | destructive | on the non-SOX default of 0 approvals it lands the MR directly in `approved` — where `merge` needs nothing more and an armed auto-merge fires | +| `approve` | destructive | the last approval is what a merge (or an armed auto-merge) waits for | +| `resolve` | destructive | removes the blocker a merge is waiting on (on an armed MR the last resolution is what lets the backend merge) | +| `auto-merge` | destructive | arms the backend scheduler (below); `--strategy none` disarms and rides the same command, same class | +| `create` / `update` / `request-changes` | write | shape the MR without moving it; `request-changes` moves it *away* from `approved` | + +Consequences: + +- `OPERATION_REGISTRY` carries the class per command; `FLAG_ESCALATIONS` is back to its single + original entry. Nothing in the group escalates by flag value or by fetched state. +- The serve router's permission check is the route dependency alone — no body inspection, no + prior GET. One classification, two surfaces, nothing to drift. +- `--deny-destructive` yields an agent that can **observe and shape** merge requests — list, + inspect, diff, open, retitle, send back — and **cannot move one** by any route. +- The transitions no longer fetch the MR row before writing. `get_merge_request_row` stays, + for the two places that *render* from the row: `merge`'s prompt (title + branch) and + `conflicts`' `branch_from_id`. +- Over-broad by design and worth saying: on a 2-approval project `request-review` lands in + `in_review` and merges nothing, yet it is destructive here. Distinguishing that would need the + required-approvals count, which is **unreadable with a Storage token** (project metadata on + the Manage API; [DMD-1969](https://linear.app/keboola/issue/DMD-1969)) — and a class that + flips on a number the CLI cannot read would be the state-derived condition again by another + name. A policy that wants "walk the conflicts, never merge" allows `merge-request.resolve` + explicitly; the keys are per command, so that is one line. + +### Auto-merge is its own command + +`autoMergeStrategy` is not metadata. A background scheduler +(`AutoMergeScheduleProvider.php:24-26`, every `AUTO_MERGE_INTERVAL`) selects every `approved` MR +whose strategy is `immediately` (or `scheduled` and due) and runs it through the **same +`MergeProcessor`** the merge endpoint uses, under a system token +(`AutoMergeCandidateRepository.php:44-47`, `AutoMergeTickHandler.php:86`). It is polling, not a +hook on approve: the approve path has no auto-merge trigger, and `applyAutoMerge` +(`MergeRequestService.php:226-236`) only persists the strategy. A blocked tick **retries every +tick indefinitely** — an auto-merge cannot be left to fail; it stops only when the strategy is +set back to `none`. Order does not matter: armed before approval, it fires when the state +arrives. And nothing kbagent returns reports it — the arming call answers 200 and the merge +happens later, invisibly. + +So arming is a delayed production merge and gets the shape such an act deserves: **its own +command**, `merge-request auto-merge --strategy immediately|scheduled|none [--at TS]`, in the +destructive class, prompting in human mode when it arms. `create` and `update` carry no +auto-merge flag at all — a flag that silently promotes a write to a merge was the first draft's +mistake, and splitting it out is what lets `create`/`update` stay honest writes. Under the hood +it is `update_merge_request(auto_merge_strategy=…, auto_merge_at=…)`; Layer 2 is untouched. + +The disarm is destructive too, deliberately: the alternative — a `none` special case — is +exactly a value-dependent class, and a caller who could not arm never needs to disarm. + +After `request-review` / `approve` / `resolve` on an MR that turns out to be armed, human mode +warns — read off the write's **own result** (the enriched row carries `autoMergeStrategy`), no +extra request, nothing injected into the payload: + +``` +Success: Review requested for merge request #7 -- state: approved +Warning: Auto-merge is armed (immediately) -- the backend will merge it into production on + its next tick. Disarm with `merge-request auto-merge --strategy none` if that is not + intended. +``` + +## Human rendering + +Wire shapes come from `merge-requests-notes.md`; this section decides presentation only. + +**`list`** — a Rich table, **in server order** (the endpoint returns `createdAt DESC` and the +renderer must not re-sort): `ID`, `Status`, `Title`, `Author`, `Branch`, `Reviewers`. `Status` +is `derived_state`, never the raw state — the point of the derivation is that the CLI agrees +with the web UI. `Branch` renders `—` when `branchFromId` is null (published/canceled MRs have +no branch). `External ID`, `Created` and `Merged by` appear only when some row carries a value, +so the common table stays narrow. When the result is empty, use the service's `feature_enabled` +(present only on an empty *unfiltered* result) to print *"merge requests are not enabled on this +project"* instead of *"No merge requests"* — the read endpoints are ungated, so the two are +otherwise the same HTTP 200. + +**`detail`** — a panel, then sections: mergeable / `merge_blockers` spelled out; `viewer` (*you +created this MR*, *you have approved* — a `None` flag renders as nothing, never as "no"); +`allowed_actions` rendered as the commands that produce them; the auto-merge state when armed; +branches, reviewers with status, approvals; the **change log**, which is legitimately empty +while the MR sits in `development` (the backend writes it at review time) and must say so +rather than show a bare empty table; conflicts for open MRs; `activityLog` with +`--activity-log`. + +**Every wire-sourced string goes through `rich.markup.escape()`** before entering a `Table` or +`Panel` — titles, descriptions, names, conflict messages, reasons. Rich interprets markup by +default, so an MR titled `Fix [bold] parsing` mangles the table and an unbalanced `[/]` raises +`MarkupError`. Ten command modules already import `escape` for exactly this. + +**`warnings[]` is the one and only soft-failure channel** (decided 2026-09-03). Layer 2 today +returns "the operation succeeded, something secondary did not, exit stays 0" under two names: +`resolve_conflict` → `warnings[]` (a dropped `--change-description` on the delete tombstone; the +implicit collapse to delete when the taken side is `isDeleted`) and `merge` → +`cleanup_warnings[]` (a failed active-branch reset or sync-mapping unlink after a merge that +did land). Same concept, two keys — and a renderer written against `result.get("warnings")` +would silently swallow exactly the merge warnings a user must act on (the active branch now +points at a branch being deleted). `merge` therefore renames `cleanup_warnings` → `warnings` +(ships with this PR; one word, one test). The specificity the old name carried belongs in the +warning *text* — which already says "Post-merge active-branch reset failed: …" — not in the +key: a `--json` consumer wants one place to look for "is there something I should know", not a +per-command vocabulary. Rule for the whole group: **any result may carry `warnings[]`; the +renderer prints it in every command, in the same Rich warning shape, after the main output and +before the hint-next line.** + +**Hint-next.** In human mode every command ends with a one-line next step. Rich-only: since +`_enrich_row` now applies `allowed_actions` to every return, `--json` consumers have the same +information as data, and Layer 1 never manufactures a payload the service did not produce. No +`--no-hint-next` flag in v1. `allowed_actions` is **state-derived and feature-blind** — see +*Known gaps* for the one project shape where it recommends a write that cannot succeed; Layer 1 +does not compensate. + +## Conflicts, diff, resolve + +**`conflicts`** — `componentId` / `configurationId` / `isDeleted` / `message`. The entry's +`isDeleted` is the **dev-branch** side's flag, not production's. + +**`diff`** — three sections from the service's per-path `changed_by` classification: **Both +changed** (the hotspots; rows flagged `agreed` are demoted to the bottom and marked as +agreement), **Only you changed**, **Only production changed**. Long values elide by default, +`--format full` prints them whole. The branch is derived from the merge request by the service +(`get_config_diff(alias, merge_request_id, component_id, config_id)`), so `diff` never takes a +branch of its own. + +**Deletions are not paths, and the renderer branches on them first** (decided 2026-09-03). +Since PR #703's review (finding #4, `175d45b`) `_classify_three_way` returns **no per-path rows +when either side is null** — it used to fabricate a row per base key reading "that side removed +it" next to a `*_deleted` flag saying the side does not exist, two signals contradicting each +other. Side-level facts now live only on the top-level `ours_deleted` / `theirs_deleted` flags, +and a "production deleted it, dev changed it" conflict — a live conflict shape per the notes +doc — arrives as `changes: []` plus `theirs_deleted: true`. A table-first renderer would print +three empty sections and "No changes" for the sharpest conflict there is. + +So the renderer checks the flags **before** the table. When either flag is truthy or `None`, the +table is not drawn; one sentence states what happened and, because this is the one place the +user faces a binary choice the command already knows, **recommends the resolution**: + +- `theirs_deleted: true` → *Production deleted this configuration; your branch changed it. + Resolve with `--take delete` (drop it) or `--take ours` (keep your version).* +- `ours_deleted: true` → the mirror: *Your branch deleted this configuration; production changed + it. Resolve with `--take delete` or `--take theirs`.* +- a `None` flag means the side does not exist at all — which a conflict should never produce + (it requires the config on both sides), so this renders defensively as *…is not present on + the production/dev side* with no recommendation, and `--json` carries the value as-is. + +"No changes" is printed **only** when `changes` is empty **and** both flags are `false` — and +even then it is worded as *the conflict cleared between `conflicts` and `diff`*, not as +"nothing changed". `--output` in the deleted case is already covered by `resolution_candidate` +being `null` (above). The CLI tests pin a `theirs_deleted: true, changes: []` fixture: the +output contains the recommendation and **none** of the three section headings. + +`--output PATH` writes the ours-prefilled resolution candidate. Edit, then +`resolve --resolved @file`: the git-mergetool loop with a file as the third pane. + +**The candidate is composed by Layer 2, not Layer 1** (decided 2026-09-03; ships in the same PR +as the commands). Rebase *replaces* the whole configuration, so after PR #703's blocking review +finding `resolve_conflict` requires **all five** replaced-body keys of a **caller-authored** +(`--resolved`) body and refuses anything less: + +| key | refused when (`--resolved` body) | +|---|---| +| `name`, `rows`, `configuration`, `isDisabled` | absent **or** `null` (`isDisabled` must also be a real boolean — `bool("false")` is `True`) | +| `description` | absent only — an explicit `null` is a legitimate "clear it" decision | + +A `--take` side is the server's own envelope with no intent to elicit, so there (#703's third +review) an absent `description` composes as `null` — wire-identical, the rebase omits the key — +while a non-boolean `isDisabled` or a missing content key is a *backend* contract violation +(`VALIDATION_ERROR`, "author the resolution manually"), never a caller error. + +A prefill that wrote only the "interesting" keys, or dropped `description` when it is `null`, +would produce a file that `resolve --resolved` refuses — a file kbagent itself wrote. The +missing-`isDisabled` case is the worst one: the backend defaults it to `false`, so resolving a +*disabled* configuration re-enables it and the merge pushes that into production. + +So `get_config_diff` gains a `resolution_candidate` field: the ours side's envelope filtered +through the same `_DIFF_CONTENT_KEYS` the service already uses to compose `--take` bodies +(`:797`, `:887`) — `name`, `description`, `isDisabled`, `configuration`, `rows`, with +`description` emitted as an explicit `null` when null and `changeDescription` excluded (it is a +per-version commit message, not content; the rebase takes its own via `--change-description`). +`resolution_candidate` is `null` when the ours side is absent or `isDeleted` — there is nothing +to prefill, and `--output` then refuses with a pointer to `--take delete` rather than writing a +misleading skeleton. Layer 1 writes the field to disk verbatim and adds nothing. + +One constant feeds both the guard and the candidate — and so does **one criterion**: the +constant alone did not prevent drift. `_envelope_holes` (behind `resolution_candidate`, the diff +`warnings[]` and the classifier's `classifiable()`) first tested key *presence*, while the guard +refuses a key that is present but `null` and a blank `name`; an ours envelope carrying +`"name": null` therefore composed into a candidate the guard then refused (#736 review, fixed +2026-09-11). The predicate now mirrors the guard row for row: absent **or** `null` is a hole, a +blank `name` is a hole. The CLI test suite pins the round trip end to end: the file `diff +--output` writes must pass `resolve --resolved @file` **unmodified**; the service suite pins the +`null` / blank-`name` suppression. + +A missing key is reported on two different exit paths depending on who is at fault, and the +renderer must not collapse them: a hole in a `--take` side's envelope is a backend contract +violation (`VALIDATION_ERROR`, exit 1, "author the resolution manually"); a hole in a +`--resolved` body is the caller's (`ConfigError`, exit 5, naming the missing keys). + +**`resolve` has no `--all`.** Rebase *replaces*, so a bulk `--all --take theirs` is a bulk +irreversible overwrite of the dev branch behind one keystroke — and conflicts exist to be walked, +not waved away. (Layer 2 also left it out of v1, on the different grounds that it is a trivial +Layer 1 loop; the decision here stands on the blast radius.) A caller who wants the loop can +write it over `conflicts --json`. + +## Merge + +- **No `--wait` / `--timeout` in v1.** Layer 3 always awaits the Storage job with + `MERGE_JOB_MAX_WAIT` (600 s, `constants.py:207`) and exposes no parameter; threading one + through would mean re-reviewing two already-reviewed layers, and `--no-wait` would need + polling that does not exist. The help says the command can block for up to 10 minutes. A + timeout reports `STORAGE_JOB_TIMEOUT` → exit 4, which scripts can tell apart from a failure. +- **Confirmation** in human mode unless `--yes`, naming the MR and the source branch that will + be deleted. Under `--json`, no prompt (house semantics) and an explicit target instead. +- **Wording:** the source branch **"is being deleted"**, never "is deleted" — a second async job + with no handle. The service already words this; the renderer must not upgrade it to a fact. +- **A merge whose source branch id could not be read** carries `cleanup_skipped: true` and + `branch_from_id_raw` (followups F3): the local active-branch reset and sync unlink did **not** + run. The renderer keys on the flag — never on warning text — with a "Local cleanup skipped" + line and a hint-next pointing at `branch reset` + `sync branch-unlink`. A legitimate + published-MR null carries no flag. +- The 409 arrives pre-mapped as `MR_MERGE_CONFLICT` or `MR_NOT_READY_TO_MERGE`, both carrying + the next step. Layer 1 prints the messages as-is. +- **The conflict list inside the 409 may be truncated, and the renderer must say so** (decided + 2026-09-03). `MR_MERGE_CONFLICT` carries the conflicting configurations in + `details.api_error_params.errors` — deliberately, so the user need not call `conflicts` + again. After PR #703's review (finding O003, `22274dc`) that payload is **bounded**: over + `MAX_API_ERROR_PARAMS_LENGTH` every top-level list is cut to `MAX_API_ERROR_PARAMS_LIST_ITEMS` + entries; if still over, `params` is dropped entirely; either way + `details.api_error_params_truncated: true` is set. A renderer that prints the list and stops + would show 20 conflicts to a user who has 300 — they fix 20, merge again, get 20 more, and + never learn the total. So the human render of `MR_MERGE_CONFLICT` lists the entries it has + and, whenever `api_error_params_truncated` is true — list cut *or* `params` absent — appends + one line: *list truncated — run `merge-request conflicts` for the full set* (the conflicts + endpoint is unbounded). The line names no number: the cap is a Layer 2 constant that may + move, and "truncated" is the fact that matters. `--json` adds nothing — the marker is already + in the envelope. Hint-next for this error is `conflicts` in every case; the truncated case + merely makes it the *only* way to see the whole set. + +## Errors and exit codes + +- **`FeatureNotEnabledError` must not be flattened — and it can now surface from every + command, reads included.** It is a `ConfigError` subclass carrying + `error_code = FEATURE_NOT_ENABLED` (`errors.py:219-233`). The common `except ConfigError` + idiom — and especially the shared `_handle_config_service_error` (`commands/config.py:585`, + which hardcodes `ErrorCode.CONFIG_ERROR` at `:593`) — would discard it, leaving a `--json` + consumer unable to tell "merge requests are not enabled on this project" from a bad alias. + Use `getattr(exc, "error_code", ErrorCode.CONFIG_ERROR)`, as `server/app.py:760` does. Exit 5. + + Since PR #703's review (finding O002, `22274dc`), `find_merge_request_for_branch` runs the + feature pre-flight on its no-match path: the ungated list answers `200 + []` on a project + without the feature, and the old `NOT_FOUND` prescribed a `create` that was guaranteed to + fail — a loop. So the error is no longer a write-path concern: it sits under the resolver + behind an omitted `--merge-request-id`, i.e. under `detail`, `conflicts` and `diff` too. The + read commands are exactly where an implementer copies `branch.py`'s + `except ConfigError → CONFIG_ERROR` idiom, and exactly where it would flatten. + + **Decided 2026-09-03: the group has ONE error handler and no command has its own `except`.** + `commands/merge_request.py` defines `_handle_error(formatter, exc)` — `ConfigError` (and + subclasses) → `getattr` code, exit 5; `KeboolaApiError` → `map_error_to_exit_code` — and all + eleven commands route through it. Eleven inline copies are eleven places to flatten; one + function is none. `_handle_config_service_error` is the precedent for the *shape* and the + counter-example for the *body*: same signature, corrected code lookup. The CLI tests pin it + with one case per command: an omitted target on a feature-less project yields + `error.code == "FEATURE_NOT_ENABLED"`, never `CONFIG_ERROR`. + + The error also has **two wordings** (`0257675`): the feature is missing (*enable + `branches-merge-requests`*) vs. the project carries only `protected-default-branch` (*SOX; + kbagent does not support this flow*). Both are `FEATURE_NOT_ENABLED`, exit 5 — a + configuration fact about the project, not a usage error — and `gotchas.md` carries both, so + an agent on a SOX project is not told to "enable the feature". +- **Plain `ConfigError` → exit 5** (`CONFIG_ERROR`). The service raises it for the default-branch + source, the `--take`/`--resolved` mutual exclusion, and an incomplete resolved body. The + vocabulary errors are pre-empted in Layer 1 at exit 2 (above), so they never reach this path. +- `KeboolaApiError` → `map_error_to_exit_code` unchanged. **`MR_NOT_READY_TO_MERGE` is + deliberately not added to the exit-4 set**: 4 means transport-level retryable, and conflating a + backend "another MR is processing" with a connection failure would make exit 4 useless. The + envelope already carries `retryable: true` and the code — that is what a script branches on. +- A scoped Storage token 403s on everything but `list` (`MergeRequestVoter`, recorded in the + notes doc). Surfaced as `ACCESS_DENIED`; documented in `gotchas.md`, not special-cased. +- No new error codes: `MR_MERGE_CONFLICT` / `MR_NOT_READY_TO_MERGE` shipped with Layer 2 and are + in `docs/error-codes.md`. + +## Permissions + +``` +merge-request.list read +merge-request.detail read +merge-request.conflicts read +merge-request.diff read +merge-request.create write +merge-request.update write +merge-request.request-changes write +merge-request.request-review destructive +merge-request.approve destructive +merge-request.resolve destructive +merge-request.merge destructive +merge-request.auto-merge destructive +merge-request.by-branch read # serve-only +``` + +Static, per command, no `FLAG_ESCALATIONS` entries — see *What is destructive* for the why of +each row. The useful consequence: an agent under `--deny-destructive` can observe and shape +merge requests and cannot move one by any route, direct or armed. + +## `kbagent serve` + +A full `server/routers/merge_requests.py` ships with the commands, prefix `/merge-requests`, +paths `/{project}/…` per the `branches.py` convention. CONTRIBUTING requires the 1:1 mirror, +the service returns plain dicts, and `check_command_sync.py` deliberately does **not** gate +routers (`:34-37`) — a missing route reaches users as an HTTP 404 with nothing red in CI. + +- **Every route declares `Depends(require_permission("merge-request."))`.** Today + `require_permission` appears only in `server/routers/auth.py`; the other ~30 routers do not + check the engine. Without it the destructive classification above means nothing over HTTP, + which would make the whole analysis decorative for `serve` callers. Because the class is + static, the route dependency is the whole check — nothing is evaluated from the body or from a + prior GET. `PUT /{project}/{id}/auto-merge` is the twelfth route (body `{strategy, at}`). +- **`GET /merge-requests/{project}/by-branch/{branch_id}`** exposes + `find_merge_request_for_branch` — over HTTP there is no active-branch idiom to hide it behind. + Register `merge-request.by-branch` in `OPERATION_REGISTRY` and add it to + `SERVE_ONLY_OPERATIONS` so the dead-key check passes (precedent: `auth.projects`). **Declare + it before** any `GET /merge-requests/{project}/{merge_request_id}`, or FastAPI matches + `by-branch` as an id. +- **`POST …/merge` can block for 600 s** — no proxy or HTTP client tolerates that by default. + Document it on the route. +- Skipped: **`diff --output PATH`**, which writes to the host's disk. `GET …/diff` returns the + same payload and the caller writes its own file. Note the skip in the PR description. +- Wiring, all required and none of it gated: a `merge_request` field + `__post_init__` line in + `server/dependencies.py`; `app.include_router(...)` in `server/app.py`; an `OPENAPI_TAGS` + entry (without it `make endpoints-gen` emits an `(untagged)` section); cases in + `tests/test_server_router_calls.py`, whose purpose is catching router→service kwarg drift. + Then `make endpoints-gen`. + +## `kbagent branch merge` + +Deprecate-with-pointer, same release. **Not a 1:1 replacement**: it only builds a UI URL and +works on **any** project, including one without `branches-merge-requests`. The notice is +therefore conditional — *if this project has merge requests enabled, use `kbagent merge-request +create` + `merge`* — and the command keeps working unchanged; removal is a later decision. It +also **unconditionally resets `active_branch_id`** (`services/branch_service.py:348-349`, which +`merge-requests-layer2.md` calls "the worse of the two precedents"), so describing it as a +harmless URL builder would mislead. Its `write` classification does not change. + +## Layer 2 changes shipping with this PR + +Layer 2 (PR #703) is merged first and this PR retargets to `main`; the three changes below are +small, each was decided while walking the review findings above, and each exists so that Layer +1 does not re-derive something the service already knows. They land in this PR, with tests, and +`merge-requests-layer2.md` is updated in the same commit. + +| change | why | where in this RFC | +|---|---|---| +| `get_config_diff` returns `resolution_candidate` — the ours envelope through `_DIFF_CONTENT_KEYS`, `description` as explicit `null`, `changeDescription` excluded; `null` when ours is absent/deleted | the five-key replace guard and the `--output` prefill must come from one constant, or the file kbagent writes is a file kbagent refuses | *Conflicts, diff, resolve* | +| `merge()` renames `cleanup_warnings` → `warnings` | one soft-failure key for the whole group; a renderer reading `warnings` must not silently drop the merge's | *Human rendering* | +| new `get_merge_request_row(alias, merge_request_id)` — `client.merge_requests.get(id)` through `_enrich_row`, nothing else | the merge prompt (title + branch) and `conflicts`' `branch_from_id` need the MR row by id without the detail's `conflicts()` + `verify_token()`; also the row tier a caller can read before a write | *What is destructive* | +| `get_merge_request` carries `feature_enabled` | followups F5: the detail already pays `verify_token`, so the feature state is free there and hint-next must not recommend a write that cannot succeed | *Known gaps* | +| `merge()` carries `cleanup_skipped: true` + `branch_from_id_raw` when the source branch id could not be read | followups F3: the prose alone left that result byte-identical to a legitimate published-MR null; the renderer keys on the flag | *Merge* | +| `get_config_diff` reports an empty envelope on **either** side in `warnings` | followups F2: the classifier emits no rows for it, and a renderer must not read that as "the conflict cleared" | *Conflicts, diff, resolve* | + +Nothing else in Layer 2 moves. In particular the feature-blindness of `list` rows' +`allowed_actions` stays as Layer 2 decided it (see *Known gaps*); the non-blocking leftovers of +#703 that this PR does take are marked *done on L1* in `merge-requests-layer2-followups.md` +(F2, F3, F4's log line, F5 for detail, F7); F6 and F8 stay open. + +## Bookkeeping + +### Tests + +- `tests/test_merge_request_cli.py` — **mandatory** (`CONTRIBUTING.md:392-394`: service-layer, + CLI-layer and E2E are three separate requirements). Layers 2 and 3 already shipped + `test_merge_request_service.py` / `test_merge_request_client.py`; the CLI file is the missing + third. Given that E2E is unresolved below, **this is the only automated coverage this work + will actually have** — it is not optional and not implicit. +- `tests/test_server_router_calls.py` additions, per the serve section. + +### E2E (convention #16) + +**Still open — this RFC does not settle it.** What follows is analysis and a proposed path, not +a decision. + +No E2E project carries `branches-merge-requests`, and kbagent cannot provision one: +`ManageClient` has `get_project` / `list_organization_projects` but no project create, and +Connection's own suite creates its projects itself. A new project is not needed, though — the +feature is additive and kbagent ships the command to enable it: + +``` +kbagent feature project-add --project kbagent-e2e --feature branches-merge-requests +``` + +(super-admin manage token required). Until then the tests gate the way `conditional_flows` does +— `pytest.skip` on a `FEATURE_NOT_ENABLED` pre-flight — so the suite stays green and starts +covering the group the moment the flag lands. Two properties of the scenario are not obvious: + +- **The happy path merges into production.** There is no dry-run merge, so the test creates a + throwaway config in a dev branch, merges it, and deletes it from production afterwards. That + is inside the blast radius the flow/config E2E tests already have, but it must be an explicit + teardown. +- **`merge` takes a project-wide lock** and refuses while another MR in the project is + processing, so concurrent runs collide with `MR_NOT_READY_TO_MERGE`. Serialise the MR test or + accept it as a known flake source. + +`approve` and `request-review` have no happy path to assert (see above); the E2E asserts the +422 refusal, not a success. + +### Documentation + +Convention #17 silent-drift surfaces, all mandatory: `commands/context.py` `AGENT_CONTEXT`, the +CLAUDE.md `## All CLI Commands` section, `keboola-expert.md`, `SKILL.md` triggers, +`commands-reference.md`, `gotchas.md`, and a new `merge-request-workflow.md`. Plus +`make skill-gen` (CI-gated via `skill-check`) and `make endpoints-gen`. + +`gotchas.md` entries, each tagged `(since vNEXT)`: auto-merge arming as a destructive act and +its invisibility; the `--json` explicit-target rule for destructive invocations; the scoped-token +403 on everything but `list`; the empty change log in `development`; `--reviewer-id` replacing +rather than appending; empty-string-clears; the source branch deleted asynchronously; `approve` +answering 422 on a 0-approval project. + +No `mcp_parity.py` work — that map was deleted with the MCP passthrough in 0.85.0. + +## Known gaps, documented rather than handled + +> Layer 2 leftovers inherited by this layer -- the non-blocking items from PR #703's approval +> and the deferrals of its earlier review rounds -- are collected in +> [`merge-requests-layer2-followups.md`](merge-requests-layer2-followups.md). Several are only +> visible from the command layer (soft-failure key naming, feature-blind actions, the +> degradation flag a renderer should key on), which is why they attach here rather than to a +> second Layer 2 PR. + +- **`derived_state` never reports `rejected` / `closed` on a default non-SOX project** — those + overrides read `reviewers[].status`, which only a review round anchored by a real + `request_review` event populates, and `skip_review` writes none. The UI badge has the + identical blind spot, so the CLI and the web still agree; the fix is server-side + ([DMD-1988](https://linear.app/keboola/issue/DMD-1988)). +- **"What will this MR merge" is unavailable while it is in `development`** — the change log is + written at review time and the UI computes its preview client-side with no endpoint behind it. +- **Required-approvals count is unreadable with a Storage token** (DMD-1969), which is why + `request-review` is destructive even on a project where it would only reach `in_review` — a + class that flipped on a number the CLI cannot read would be a state-derived condition again. +- **On a project where `branches-merge-requests` was later switched off, `list` rows' + `allowed_actions` recommend writes that end in `FEATURE_NOT_ENABLED`.** The MRs survive the + feature (they are data; the flag gates endpoints), and `_enrich_row` derives actions from + state alone. PR #703's review raised this (finding #2, non-blocking) and Layer 2 **declined** + the per-list `verify_token` GET for a rare project shape; `feature_enabled` is emitted on the + *empty* list only, where that GET is already spent. **`detail` is the exception** (followups + F5, decided 2026-09-04): it already pays `verify_token` for the viewer polyfill, so the + features cache is warm and `feature_enabled` is free there — the payload carries it, the panel + says so, and hint-next refuses to recommend a write that cannot succeed. The residual gap is + the list's; the user learns the truth on `detail` or on the first write, and the error is the + readable `FEATURE_NOT_ENABLED`. Owner of the real fix: DMD-1988 (server-side `allowedActions`, + which knows about the feature). diff --git a/docs/merge-requests-layer2-followups.md b/docs/merge-requests-layer2-followups.md new file mode 100644 index 00000000..2d3c19c7 --- /dev/null +++ b/docs/merge-requests-layer2-followups.md @@ -0,0 +1,131 @@ +# Merge requests — Layer 2 follow-ups inherited by Layer 1 + +Layer 2 shipped as PR [#703](https://github.com/keboola/cli/pull/703) (DMD-1899), approved by +Zajca on 2026-09-03 with the verdict *"nothing in this round loses data or fails silently"*. The +PR was deliberately frozen at that point. Everything below is what the review rounds left as +**non-blocking**, plus the items earlier rounds explicitly deferred — collected in one place so +Layer 1 (DMD-1900, branch `ms/dmd-1900/cli-layer-1`, RFC `merge-requests-layer1.md`) can pick +them up, since several of them are only visible from the command layer anyway. + +Every item names its origin (review round), the exact code site on `7cd1855` (PR #703 head), +a recommended fix, and — where Layer 1 already moves in that direction — the current state on +the L1 branch. None of them changes a wire contract; two of them (F3, F5) change the `--json` +shape additively. + +## Ownership rule + +Layer 1 lands **after** Layer 2. Where Layer 1 already renamed or reshaped something (F1), the +L1 branch owns the reconciliation; where a fix is a pure Layer 2 change (F2–F8), it still goes +through the L1 PR — a second Layer 2 PR for cosmetics would be more review traffic than the +findings warrant. Operational note, now spent: the L1 branch carried `7b2bba9` (the third review round's fix +as first authored on the L1 tree) beside its pure-L2 port `7cd1855`; the L1 rebase onto the +merged L2 (2026-09-03) collapsed it -- nothing of it survived that was not already in `7cd1855`. + +## F1 — Two soft-failure keys: `cleanup_warnings` (merge) vs `warnings` (resolve_conflict) + +*Origin: Zajca approval, "Cross-PR".* On L2, `merge()` reports post-merge cleanup problems under +`cleanup_warnings` while `resolve_conflict()` uses `warnings`. The L1 branch (`f32d013`) already +unified `merge()` onto `warnings` and documents it as "the group's one soft-failure key", and its +`_emit_warnings` renderer reads only `warnings`. **State: done on L1** (rebase onto the merged L2 completed +2026-09-03; the two L2 tests assert `result["warnings"]`; `git grep cleanup_warnings` returns +nothing on the L1 branch). + +## F2 — `_classify_three_way` docstring claims a shared criterion it does not share + +*Origin: Zajca approval, #1.* `merge_request_service.py:887-905`. The docstring says the skip is +"the same 'no content to take' criterion `resolve_conflict` uses". True for `isDeleted`; the +`bool(side.get("diff"))` clause (empty envelope) is not shared — on the same shape +`resolve_conflict(take=…)` raises `VALIDATION_ERROR` naming the missing keys. The reviewer's +verdict, which this RFC adopts: **do not change behaviour** (collapsing an empty envelope to the +delete resolution would destroy a configuration; refusing it in `resolve_conflict` is the safe +direction). Fix the docstring to say the classifier is *stricter* than the resolver on purpose, +and add the missing test — `test_tombstoned_side_yields_no_classification_rows` covers the +tombstone half only; the empty-envelope half (`{"version": 4, "isDeleted": False, "diff": {}}` +→ `changes: []`, `ours_deleted: False`) has none. **State: done on L1** (docstring rewritten, +`test_empty_envelope_side_yields_no_rows_and_a_warning`; beyond the ask, an empty envelope on +either side is now reported in `warnings`, so the diff renderer says "no classification possible" +instead of "the conflict cleared"). + +## F3 — `merge()` records the branch-id degradation in prose only + +*Origin: Zajca approval, #3.* `merge_request_service.py:693-704`. When `branchFromId` does not +coerce to int, the result is byte-identical to the legitimate published-MR null +(`branch_from_id: null`, `was_active: false`) — a `--json` consumer can only tell by +string-matching the warning. And on that path `message` says nothing about the branch while the +warning beside it talks about "the merged branch". Fix (additive): a structured key, +`cleanup_skipped: true`, plus echo the raw value (`branch_from_id_raw: "0123x"`); optionally a +neutral message sentence ("Source branch id could not be read; see warnings."). Layer 1's merge +renderer should then key on `cleanup_skipped`, not on warning text. **State: done on L1** +(`cleanup_skipped: true`, `branch_from_id_raw`, the neutral message sentence; the CLI merge +renderer keys on the flag and points at `branch reset` + `sync branch-unlink`). + +## F4 — `find_default_branch_id` collapses "absent" and "not numeric" into one `None` + +*Origin: Zajca approval, #2.* `services/base.py:128-136`. The same commit split those two cases +in `_branch_from_id_of` precisely because one message contradicted the state printed beside it; +the shared helper still folds them, and its callers then print "reports no default branch" +(`merge_request_service.py:624`) / "No default branch found" (`workspace_service.py:239`) for a +project that *did* report one. Strictly better than the previous `ValueError` mid-operation, just +asymmetric. Fix: `logger.warning("isDefault branch carries a non-numeric id %r -- skipped", …)` +on the skip path; optionally let callers word "default branch has an unusable id" when the +branch list was non-empty. Related, deferred since review round 1: `sync init` writes an empty +`branches` list and exits 0 when the helper returns `None` (`sync_service.py:332-339`) — decide +explicitly whether that should be an error; it predates the hoist (the old inline scan had the +identical `None` path), so it is a Layer-1-visible UX decision, not a regression. **State: the +log line is done on L1**; the `sync init` decision is still open — not taken by the L1 PR. + +## F5 — `allowed_actions` is feature-blind (documented decision, revisit from L1) + +*Origin: Zajca round 2, #2 (declined on L2 with reasoning).* `_enrich_row` derives actions from +state only. On a project that once had merge requests and lost the feature, rows still advertise +write actions the pre-flight will refuse. Declined on L2 because closing it costs a +`verify_token` GET on every non-empty list for a rare configuration, and the pre-flight answers +any attempted write with the precise `FEATURE_NOT_ENABLED`. Layer 1 now has the context L2 did +not: the row tier (`get_merge_request_row`) is read *before* writes, and the detail tier already +pays `verify_token`. If the L1 UX wants honest actions in the detail view, the features cache is +warm there and the fix is free for detail only. The durable fix stays DMD-1988 (server-side +`allowedActions` can honor features). **State: done on L1 for `detail`** (`feature_enabled` on the +payload; the panel says so and hint-next refuses to recommend a write that cannot succeed). +`list` stays feature-blind on non-empty results, as decided on L2. + +## F6 — `cleanup_branch_id_from_mapping` matches on the numeric id alone + +*Origin: Zajca round 2, nit; deferred.* `sync/branch_mapping.py`. A sync workspace of a +*different* project in the CWD with the same branch id gets unlinked. Inherited from +`BranchService.delete_branch`; `merge()` extended it to a second call site. Fix belongs to both +call sites at once (scope the match on project id, which the mapping entry would need to carry) +— a small standalone PR, not an L1 concern, listed here so it is not lost. **State: open.** + +## F7 — Small ends from the approval + +*Origin: Zajca approval, #4.* +- `config_service.py:172` still tests `if folder_branch_id:` by truthiness; `sync_service.py:338` + was tightened to `is not None`. One site left out of the sweep. +- In `resolve_conflict`, the `isDisabled` type check runs before the `missing` report, so a body + missing `name` and carrying `"false"` reports only the `isDisabled` fault. Errors are not + accumulated. Noted, not necessarily to change — a caller fixing one fault at a time is the + house pattern elsewhere. +- The outcome-gated "Active branch reset to main." message has only the negative test + assertion; nothing asserts the sentence appears when the reset succeeds (the reset itself is + covered via `active_branch_id is None`). One positive assertion closes it. +- `merge_request_service.py:130` is a 110-char docstring line against `line-length = 100` + (`E501` is ignored and `ruff format` does not rewrap docstrings — no gate catches it). + +**State: done on L1** for the truthiness sweep, the positive reset assertion and the docstring line; +the `isDisabled`-before-`missing` ordering is left as noted. + +## F8 — Test fragility on main: `tests/test_changelog_render.py` under `FORCE_COLOR` + +*Origin: PR #703 CI caveat; unrelated to merge requests.* Two tests assert plain substrings on +Rich output; with `FORCE_COLOR` set (Warp exports `FORCE_COLOR=3`) Rich emits ANSI inside the +asserted text (`New: ` renders bold, splitting `"New: alpha thing."`). Reproduces on a clean main +checkout. Fix on main: render through a `Console(no_color=True, force_terminal=False)` in the +test helper, or assert on `Text.plain`. Listed so the next person hitting it does not re-diagnose. + +## External follow-ups (backend), for completeness + +- **DMD-1984** — machine-readable string codes on every merge-request endpoint error. +- **DMD-1987** — UI "Keep production version" uses reset-to-default while the CLI resolves via + rebase; which behaviour is intended. +- **DMD-1988** — serialize the derived MR status server-side; the comment on the issue says why + it must come from the activity log, not `reviewers[]`. diff --git a/docs/merge-requests-layer2.md b/docs/merge-requests-layer2.md new file mode 100644 index 00000000..d99f3fd6 --- /dev/null +++ b/docs/merge-requests-layer2.md @@ -0,0 +1,291 @@ +# Merge requests — Layer 2 (service), working notes + +Linear: [DMD-1899](https://linear.app/keboola/issue/DMD-1899). Layer 3 shipped in #556 (see +[`merge-requests-layer3.md`](merge-requests-layer3.md)); backend behavior facts with citations +live in [`merge-requests-notes.md`](merge-requests-notes.md). Commands/UX material is in +[`merge-requests-layer1.md`](merge-requests-layer1.md). Scope stays **non-SOX**. + +## Service shape + +Decided 2026-08-26: the house pattern, no deviations. + +- One `MergeRequestService` class in `services/merge_request_service.py` (DI: `ConfigStore` + + `client_factory`, like every service). +- Method names are full `verb_noun` (`list_merge_requests`, `get_merge_request`, + `create_merge_request`, …) — the convention of all ~30 services. The L3 namespace de-dup + (`client.merge_requests.list()`) does not transfer: at L2 call sites the instance lives in a + generic `service` variable, so the noun must be in the method name or it is nowhere. + Methods whose name carries the noun another way stay short (`list_conflicts`, + `resolve_conflict`, `get_config_diff`). +- The class holds orchestration and I/O only. Pure logic with no dependencies — state + derivation, diff flattening, rebase-payload composition — goes into module-level functions + (testable without mocks). +- Single file until `make loc-check` says otherwise; the natural split line is lifecycle + (create/list/get/transitions/merge) vs. conflict resolution (conflicts/diff/rebase). + Precedent for a second file: the non-1:1 services (`member_service`, `variables_service`, …). + +## Derived status (decided 2026-08-26) + +Callers branch on data, not parsed prose. Long-term the derivation belongs to the backend — +one evaluation point, every client (UI, CLI, MCP) consumes it evaluated, the way GitHub +serializes `mergeable_state` / `reviewDecision` / `viewer*` instead of letting every client +re-derive them. Connection has no capacity now, so the CLI ships a **polyfill**: one pure +module-level function, server-first (`mr.get(...)` prefers the future serialized field), +local fallback implementing the tables below. The fallback carries a comment pointing at the +Connection issue — [DMD-1988](https://linear.app/keboola/issue/DMD-1988) — and is deleted +when the backend serializes. Precedent for the defensive +read: `changeLog`, and the required-approvals count +([DMD-1969](https://linear.app/keboola/issue/DMD-1969)). + +Evidence the derivation must not live in clients: the UI already disagrees with itself — the +list badge (`MergeRequestRow.tsx`: `published`→"Merged", `canceled`→"Closed", `rejected`/ +closed-by-creator derived from `reviewers[]`, no `in_merge` badge) vs. the detail panel +(`MergeRequestInfoPanel.tsx:12-19`: "Published", "Canceled", "Merging", no derivations at +all). The same MR shows "Rejected" in the list and "Development" in the panel. + +Four derivates; all `--json` fields are additive, raw `state` is always emitted alongside +(derivation never replaces wire truth): + +**1. `derived_state`** (list + detail) — the UI list badge's decision table, evaluated in +order; canonical vocabulary for all clients: + +| value | derivation | GitHub analog | +|---|---|---| +| `rejected` | `development` + a non-creator reviewer with `status=rejected` | open + CHANGES_REQUESTED | +| `closed` | `canceled`, or `development` + creator self-rejection (the UI "cancel" trick) | closed | +| `in_development` | `development` otherwise | open | +| `in_review` | `in_review` | open + REVIEW_REQUIRED | +| `approved` | `approved` | open + APPROVED | +| `in_merge` | `in_merge` (the one state the UI badge omits — we name it) | — | +| `merged` | `published` | merged | + +Reliability caveat (Opus wire review 2026-08-27, verified against Connection): the +`rejected` / self-`closed` rows depend on `reviewers[].status`, which the backend populates +only within a review round anchored by a real `request_review` activity event — and +`skip_review` writes none. With the non-SOX default of 0 required approvals, every +`request-review` takes the skip path, so `status` is always `null` and those two overrides +never fire; additionally, explicit reviewers shadow every non-reviewer's decision and the +creator can never *be* a reviewer (422). **The UI badge has the identical blind spot** — +this table is its port. The reliable source is the MR's **activity log** +(`changes_requested` events, un-anchored and un-shadowed), which is what DMD-1988 asks +Connection to derive `derivedState` from server-side. The CLI polyfill stays a best-effort +port of the UI on purpose: matching the UI's (flawed) behavior until the backend serializes +the truth beats maintaining a third, differently-wrong derivation. + +**2. `merge_blockers`** (detail only; list omits it — conflicts are not fetched per row) — a +*list*, not a single enum, so concurrent blockers don't mask each other; plus sugar +`mergeable: bool` (= empty list). Purely mechanical, **not a guard** — the merge 409 stays +the authority: + +| blocker | derivation | +|---|---| +| `conflicts` | live conflicts list non-empty (count + list emitted alongside) | +| `approvals` | `state == in_review` (the state collapses the requirement; quantitative "1 of 2" only when DMD-1969 lands — read defensively) | +| `state` | `in_merge` / `published` / `canceled` — merge not applicable | + +Note the honest consequence of the backend facts: a `rejected` MR has **no** blocker — it +sits in `development` and a non-SOX merge from there succeeds (auto-`skipReview`). The story +is told by `derived_state`, not by a fake blocker. + +**3. `allowed_actions`** (detail) — subset of `{request_review, approve, request_changes, +merge, update, resolve_conflicts}`, mechanically from the state machine. Corrected against +the real workflow (Opus wire review 2026-08-27): `approve` exists **only in `in_review`** +(its sole `from` place — from `approved` the backend answers 422; the UI button showing it +there is wrong), and even in `in_review` it is further gated by `AddApprovalGuard` (not the +creator, not already approved, required count not reached) — with the non-SOX default of 0 +required approvals, `approve` is 422 in every state and `in_review` itself is unreachable. +`update` is blocked only in terminal states (an `in_merge` MR is still updatable); +`request_changes` from `in_review|approved`; send-for-review only in `development`. The +polyfill does *not* mix roles/features in (the pre-flight owns those); the backend adds them +when it takes over. + +**4. `viewer`** (detail) — `{is_creator, has_approved}`, relative to the caller's identity +(admin id from `verify_token`, compared against `creator.id` and `approvals[].approverId`). +What the UI's approve button derives today (`ApproveMergeRequestButton.tsx:161`), and what an +MCP/agent response needs to phrase the next step: blocker `approvals` + `has_approved=true` +→ "wait for the other reviewers", + `is_creator=true` → "you cannot approve your own MR". + +`approvals[]` gives *who* approved; `reviewers[].status` (`approved`/`rejected`/null) gives +*who is still pending* — both stay available raw in the detail output. + +## Pre-flight feature check + +`GET /merge-request` list/detail/conflicts are ungated; a write without the feature is a 403 +byte-for-byte identical to a role denial. So the service calls +`has_feature(BRANCHES_MERGE_REQUESTS_FEATURE)` (`client/tokens.py:302`, cache populated on +every `verify_token`) before writes and words the "not enabled" error itself. + +Caveats to carry into the implementation: + +- **SOX fence assumption:** server-side, the six MR writes accept *either* feature; only + `/rebase` requires `branches-merge-requests` specifically. The pre-flight fences off SOX + projects **only if** a SOX project never also has `branches-merge-requests` — state that + assumption explicitly in the code comment. +- The pre-flight is **stricter than the server** for a project with only + `protected-default-branch`: kbagent refuses what the API would allow. Deliberate (the SOX + approvals semantics are out of scope), but the error message should mention it. +- Constant name decided 2026-08-26: rename to `BRANCHES_MERGE_REQUESTS_FEATURE` when wiring + the pre-flight — the file's dominant convention is the `…_FEATURE` suffix + (`STORAGE_BRANCHES_FEATURE`, `GLOBAL_SEARCH_FEATURE`, `PAYG_FEATURE`); the prefix form is + the lone outlier. Two touch points: `constants.py:439` + the docstring mention in + `client/merge_requests.py:132`. + +## Client-side `state` filtering + +The list endpoint has no query parameters — a `--state` filter is the service's job. + +## Merge: 409 handling and error codes + +The merge 409 has four causes in two shapes (`MergeAction.php:97-109`): three "not ready" +cases carry the machine-readable `storage.mergeRequests.notReadyToMerge`; a conflict raises +`MergeValidationException` with its own code `storage.mergeRequests.validation` (plus the +conflicting configurations in `params.errors` -- see the notes doc, corrected 2026-08-27). +Today both fall through `http_base.py`'s +generic `API_ERROR` catch-all (`http_base.py:306-336`; neither 409 nor 422 is mapped, neither +retryable). + +Decided 2026-08-26 (rationale corrected 2026-09-05 — the original text still carried the +superseded "code-less conflict" reading): **two new `ErrorCode` members, mapped in the +service** — both 409 shapes carry a machine string code (`storage.mergeRequests.notReadyToMerge` +vs `storage.mergeRequests.validation`, see the notes doc), but those codes are specific to the +merge endpoint, and only the service knows that is where the 409 came from; the generic +`http_base` layer maps by HTTP status alone and must not learn endpoint vocabularies: + +- `MR_NOT_READY_TO_MERGE` — the 409 carrying `storage.mergeRequests.notReadyToMerge` (three + causes: merge lock / wrong state / another MR processing; distinguishable only by message + text, hence one code). Transient states → `retryable=True`. +- `MR_MERGE_CONFLICT` — the 409 carrying `storage.mergeRequests.validation` (matched by + code; a code-less 409 falls back here for older stacks, any *other* code passes through + unmapped). The body's `params.errors` lists the conflicting configurations and is passed + through in details. `retryable=False`, message names the conflicts command as next step. + +Names may be polished to the enum's convention at implementation time. Both must be +documented in `docs/error-codes.md` (`scripts/check_error_codes.py` enforces in CI). + +## Post-merge cleanup + +A successful merge always deletes the source branch (second async job, no handle — the await +covers the merge only; see notes doc). + +Decided 2026-08-26 — mirror `delete_branch` (`services/branch_service.py:255-307`), which +already performs exactly this cleanup today. After a successful merge: + +- reset `active_branch_id` **only if** it points at the merged source branch (the + `was_active` logic of `delete_branch:286-288`; do *not* copy `get_merge_url:349`'s + unconditional reset — that is the worse of the two precedents), +- read `branches.branchFromId` from the MR payload **before** calling merge — it is + nullable once the MR is published, +- clean the sync branch mapping via `cleanup_branch_id_from_mapping`. The helper swallows + read errors (returns `None`) but its final `save_branch_mapping` can raise on IO — so + `merge()` wraps the whole cleanup block: a cleanup failure degrades to a warning in the + result, never changes the success exit code, +- a failed merge does no cleanup (the branch is still alive), +- workspaces on the branch need nothing: the server drops them with the branch (notes doc); + leftovers are `workspace list --orphaned` / `workspace gc` territory, +- output says the source branch "is being deleted" — never "is deleted" — and the result is + structured like `delete_branch`'s (`was_active`, `mapping_cleanup`, `message`), +- cleanup failures land under **`warnings[]`** (decided 2026-09-03, with the Layer 1 RFC): the + same key `resolve_conflict` uses, so the group has one soft-failure channel — "the operation + landed, something secondary did not, exit stays 0". It was briefly `cleanup_warnings`; a + renderer reading `warnings` would have silently dropped exactly the post-merge ones a user + must act on. + +## Additions made for Layer 1 (2026-09-03) + +Three small additions decided while walking PR #703's review findings into the Layer 1 RFC +([`merge-requests-layer1.md`](merge-requests-layer1.md), "Layer 2 changes shipping with this +PR"). Each exists so Layer 1 does not re-derive something the service already knows: + +- **`get_merge_request_row(alias, merge_request_id)`** — the row tier (`_enrich_row`: + raw + `derived_state` + `allowed_actions`) addressed by id. `list` / `find` already return + rows, but only by branch; the sole by-id method was the **detail**, which also spends a + conflicts GET and a `verify_token` GET. Layer 1 needs one field *before* a write (is the MR + armed for auto-merge? which branch will `merge` delete?) and must not inherit a dependency on + the conflicts endpoint for it. Layer 3's `merge_requests.get()` was always this one GET. +- **`get_config_diff` → `resolution_candidate`** — the ours envelope through + `_DIFF_CONTENT_KEYS` (`name`, `description`, `isDisabled`, `configuration`, `rows`; + `description` as an explicit `null`; `changeDescription` excluded), or `null` when ours is + absent / `isDeleted`. Composed here so the prefill `diff --output` writes and the five-key + replace guard in `resolve_conflict` are fed by the same constant — a candidate built in Layer + 1 that dropped a null `description` would be a file kbagent writes and then refuses. Pinned + by a round-trip test: the candidate passes `resolve_conflict(resolved=…)` unmodified. +- **`merge()` `cleanup_warnings` → `warnings`** — above. + +Three more from the follow-ups (2026-09-04, `merge-requests-layer2-followups.md`): `get_merge_request` +carries **`feature_enabled`** (F5 — the detail already pays `verify_token`, so the features cache +is warm; `list` stays feature-blind on non-empty results); `merge()` carries **`cleanup_skipped: +true` + `branch_from_id_raw`** when the source branch id could not be read (F3 — the prose alone +left that result byte-identical to a legitimate published-MR null); `get_config_diff` reports an +**empty envelope on either side in `warnings`** (F2 — the classifier emits no rows for it, and +"no rows" must not read as "the conflict cleared"). + +## Rebase / conflict resolution semantics + +Layer 3 deliberately does no payload validation (the signature covers structure). Service +concerns: + +- `version` for a rebase comes from the diff's `theirs.version` (it is the default-branch + version being re-anchored onto). +- Whether the config is in the MR's conflict set, and whether the resolved body is a sensible + three-way merge, are service checks. +- A conflict requires the config to exist on both sides, so `theirs` of a conflicting config's + diff is always populated; rebasing every conflicting config makes the MR mergeable (no + re-validate step). +- Flattening the nested `base`/`ours`/`theirs` diff for presentation is Layer 2's job (each + side may be null). + +Decided 2026-08-26: `resolve_conflict` offers four modes, **all via the rebase endpoint** +(one uniform mechanism, no new Layer 3 method): + +- `take=theirs` — the diff's theirs side (production content) rebased onto `theirs.version`; +- `take=ours` — the ours side (dev content) rebased onto `theirs.version`; +- `delete` — `rebase_config_delete` (the `{}` tombstone); +- a caller-supplied resolved body (JSON/@file) — pass-through with the conflict-set check, + the escape hatch for a genuine manual three-way merge. + +Edge case: an ours side with `isDeleted` turns `take=ours` into the delete resolution. + +Known deviation from the UI: its "Keep production version" button calls +`POST …/reset-to-default` (the config drops out of the MR entirely — not in the changeLog, +untouched by the merge), while our `take=theirs` via rebase keeps the config in the changeset +(changeLog entry + a content-no-op write at merge). Which behavior is intended is DMD-1987; +if reset wins, Layer 3 gains a `reset_config_to_default` method and `take=theirs` switches. +A bulk "resolve all one way" is deliberately out of v1 — it is a trivial Layer 1 loop over +`list_conflicts` + `resolve_conflict`. + +Presenting the three-way diff (decided 2026-08-26): no three panes — a **per-path change +classification**. A pure Layer 2 function computes two pairwise diffs (`base→ours`, +`base→theirs`) and tags every touched path `changed_by: ours | theirs | both`; only `both` +paths are the actual conflict. Tooling to steal: `json_utils.compute_diff` already has the +recursive walk but returns formatted strings — refactor it into a structured per-path +variant (entries as data) and keep the string output as a formatter over it +(`config_service` uses it today). The human rendering (Layer 1, DMD-1900 material) is a +table in three sections — *Both changed / Only you changed / Only production changed* — +with long values elided behind a `--format full`. `--json` carries the entries plus all +three raw sides. Manual merge stays marker-free: `diff --output resolved.json` writes an +ours-prefilled candidate, the caller edits it and submits via `resolve --file` — the +git-mergetool loop with a file as the third pane. + +## ~~`mcp_parity.py` and the canary~~ (obsolete since 0.85.0) + +This section predates 0.85.0 and no longer applies: the parity map (`mcp_parity.py`), +`scripts/check_mcp_parity.py` and the weekly `mcp-parity-canary` were all deleted with the +MCP passthrough removal (#609, 2026-08-19). No parity entries are needed anywhere for the +MR commands; the historical tool-to-command map lives in `docs/mcp-migration.md`. The +parallel `keboola-mcp-server` MR-tools effort continues independently, untracked by kbagent +CI. + +## Open decisions + +- ~~Whether `merge-request create` takes the source branch from `--branch` or from + `active_branch_id`~~ — **decided 2026-08-26: the standard `resolve_branch()` idiom**, like + every other branch-taking command. Explicit `--branch` wins, else `active_branch_id`; with + neither, a readable error ("pass --branch or run branch use") — no further fallback. The + create output must state which branch the MR was created from. +- The fate of `kbagent branch merge` (the UI-URL escape hatch, `branch_service.py:309`): + deprecate-with-pointer, already committed as DMD-1900 scope (pattern: the #390 tool-group + deprecation). +- SOX flow, branch creation/deletion changes, auto-merge scheduling UX beyond passing the + fields through — all out of scope for now. +- E2E tests are mandatory with the commands (convention #16) — they need a + `branches-merge-requests` project. diff --git a/docs/merge-requests-layer3.md b/docs/merge-requests-layer3.md new file mode 100644 index 00000000..d50f7f8d --- /dev/null +++ b/docs/merge-requests-layer3.md @@ -0,0 +1,158 @@ +# Merge requests — Layer 3 (HTTP client), as built + +**Status: shipped.** [DMD-1701](https://linear.app/keboola/issue/DMD-1701), PR +[#556](https://github.com/keboola/cli/pull/556), squash-merged to main as `b7b66af` +(2026-08-19), released in 0.86.0 (changelog completed by #619). This document is the as-built +record distilled from the original RFC and the review cycle; behavioral backend facts live in +[`merge-requests-notes.md`](merge-requests-notes.md). + +Code: `client/merge_requests.py` (namespace + Protocol + adapter + mixin), +`client/configs.py` (diff/rebase), `constants.py` (`BRANCHES_MERGE_REQUESTS_FEATURE` -- +renamed from `FEATURE_BRANCHES_MERGE_REQUESTS` when Layer 2 wired the pre-flight, +`MERGE_JOB_MAX_WAIT`), `tests/test_merge_request_client.py`. + +## Backend contract (what shapes the client) + +Project-level — `isAvailableInBranch: false`, so **never** branch-prefixed: + +| Method / path | Body | Success | Notable failures | +|---|---|---|---| +| `GET /v2/storage/merge-request` | — | 200 | — | +| `POST /v2/storage/merge-request` | JSON | **201** | 404 invalid branch, 422 invalid reviewer, 403 | +| `GET /v2/storage/merge-request/{id}` | — | 200 | 404, 403 (scoped token -- `MergeRequestVoter` requires an admin identity) | +| `PUT /v2/storage/merge-request/{id}` | JSON | 200 | 403, 404, 422 | +| `PUT …/{id}/request-review` | — | 200 | 403, 404, 422 | +| `PUT …/{id}/approve` | — | 200 | 403, 404, 422 | +| `PUT …/{id}/request-changes` | JSON `{reason?}` | 200 | 403, 404, 422 | +| `PUT …/{id}/merge` | — | **202** + a Job | **409**, 403, 404 | +| `GET …/{id}/conflicts` | — | 200 | 404, 403 (scoped token -- same voter) | + +Branch-scoped — `isAvailableInBranch: true, isAvailableWithoutBranch: false`, so **always** +branch-prefixed: + +| Method / path | Body | Success | Notable failures | +|---|---|---|---| +| `GET …/branch/{branch}/components/{c}/configs/{cfg}/diff` | — | 200 | 400 on default branch, 404 if absent in both branches | +| `POST …/branch/{branch}/components/{c}/configs/{cfg}/rebase` | JSON | 200 + the rebased configuration | 400 default branch / target version not newer / bad `diff`, 403, 404 | + +`GET /merge-request` declares **no query parameters** — a `state` filter is necessarily +client-side. `GET /merge-request/{id}` takes `include=activityLog`. Only `merge` is +asynchronous. + +**Bodies are JSON with real types** — `#[MapRequestBody]` accepts form data but +`FormDataExtractor` does no type coercion, and validators require real types (`branchFromId` +`Assert\Type('int')`, rebase `version` `Assert\Type('integer')`). Form-encoded values stay +strings and fail validation. No client-side `json.dumps` for nesting either — the rebase +action's `realJsonMapProps: ['diff']` re-encodes server-side, preserving `{}` vs `[]`. + +**The rebase `diff` envelope** (connection#8040): keep = +`{"version": N, "diff": {"name", "rows", "configuration", "isDisabled", "description"?, +"changeDescription"?}}`; delete = `{"version": N, "diff": {}}`. `version` stays top-level and +is the **default-branch** version being re-anchored onto (from the diff's `theirs.version`) — +the wire name is a trap, kept for wire fidelity, spelled out in the docstring. + +## Shipped surface + +`client.merge_requests.*` (namespace, raw parsed JSON returns): + +| Method | Endpoint | +|---|---| +| `list()` | `GET /v2/storage/merge-request` | +| `get(id, include_activity_log=False)` | `GET …/{id}[?include=activityLog]` | +| `conflicts(id)` | `GET …/{id}/conflicts` | +| `create(branch_from_id, branch_into_id, title, …)` | `POST /v2/storage/merge-request` | +| `update(id, …)` | `PUT …/{id}` | +| `request_review(id)` | `PUT …/{id}/request-review` | +| `approve(id)` | `PUT …/{id}/approve` | +| `request_changes(id, reason=None)` | `PUT …/{id}/request-changes` | +| `merge(id)` | `PUT …/{id}/merge` + awaits the Storage job | + +On `_ConfigsMixin` (flat, config endpoints): `get_config_diff(component_id, config_id, +branch_id)`, `rebase_config(…, version, name, rows, configuration, is_disabled, description, +change_description=None)`, `rebase_config_delete(…, version)`. + +## Design decisions, as built + +- **Namespace over flat methods** (`client.merge_requests.*`). Flat naming collides + (`request_merge_request_review`, `merge_merge_request`); the namespace keeps verbs + wire-faithful. **Normative for new endpoint families only** — existing flat families stay + flat (policy stated in the module docstring; consider promoting to CONTRIBUTING.md with + Part 2). +- **The namespace depends on a `StorageRequester` Protocol, not on the client.** + `_ClientRequester` is a marked temporary adapter delegating to `_CoreClient`'s protected + methods; the client-split RFC (#595, branch `martinsifra/requestor`) builds the real + transport under this seam later — swap is one line, the namespace and its tests stay + byte-identical. Keep the Protocol minimal. (Honest caveat from review: `request()` returning + `httpx.Response` ties the future transport to httpx — today it is more a rename of + `_request` than an abstraction.) +- **JSON bodies throughout**, deviating from `configs.py`'s form idiom; every method's + docstring says so. (Post-rebase note: #598 already added JSON-body methods to `configs.py`, + so "unlike this file's idiom" phrasing was softened.) +- **`merge()` awaits implicitly** like every Storage-job method in `client/` — no `wait` flag. + Budget `MERGE_JOB_MAX_WAIT` = 600 s (precedent `IMPORT/EXPORT_JOB_MAX_WAIT`): a many-config + merge plausibly outlives the default 60 s, and a mid-merge `STORAGE_JOB_TIMEOUT` with + `retryable=True` would be actively misleading. Returns the completed job dict whose + `results` carry the MR incl. change log. **The await covers the merge outcome only** — the + source-branch deletion is a second job with no handle (docstring states it). +- **Contract dependency on the poller (#603):** `wait_for_storage_job` must raise on a failed + job whether the failure arrives in the initial body or polled — `merge()` does **not** + re-check. History: the poller originally returned an already-terminal error body silently + (a house-wide bug across all 19 call sites); `merge()` carried a local guard until the + central check-then-fetch fix merged as #603 (`2f0544d`, v0.84.3) and the guard was dropped + on rebase. The requirement is stated on the Protocol docstring and pinned by #603's + `TestWaitForStorageJob`, not by a merge-level test. +- **diff/rebase live in `client/configs.py`** — `client/` is split by URL family (#520), not + by feature. Their `branch_id: int` is **required with no production fallback** (both answer + 400 on the default branch) — production is unrepresentable rather than a runtime error, + deliberately breaking the house `branch_id: int | None = None` idiom. +- **Keep and delete rebases are two methods.** `rebase_config` requires `name` and `rows` + (matching `validateDiffName`/`validateDiffRows`); `rebase_config_delete` sends exactly + `{"version": N, "diff": {}}`. The signature does the validation; no illegal combination is + expressible. +- **Rebase REPLACES, so every replaced-body field is required** (`name`, `rows`, + `configuration`, `is_disabled`, `description` — the last required-but-nullable). Optional + params with defaults would make silent data loss the signature's default (wiped config body, + re-enabled config). Landed via padak's #606 during review. `change_description` is the one + genuine optional (not part of the replaced body; null selects a default message). + Presence-detection (`is not None`, omit unset) stays correct for `create`/`update`, which + genuinely patch; `_optional_mr_fields` is keyword-only (four of five params are + `str | None`). +- **No feature-flag plumbing at Layer 3.** A missing feature is a 403 identical to a role + denial — only a Layer 2 pre-flight can word the error. Part 1 contributed only the constant + `BRANCHES_MERGE_REQUESTS_FEATURE`. +- Tried and reverted: keyword-only `rebase_config` (bare `*`). The "ids are positional + house-wide" premise was false (Layer 2 call sites are mixed), so no placement had a + consistency case; signature stays shaped like its `configs.py` siblings. If keyword-only is + ever wanted, make it a house-wide CONTRIBUTING.md rule, not a one-method exception. +- Python gotcha hit: `MergeRequests.list` shadows the builtin in class-scope annotations → + module-level aliases `_DictList`/`_IntList`. + +## What the tests pin (`tests/test_merge_request_client.py`, 27 tests) + +- Path construction: MR paths never branch-prefixed even with an active branch; diff/rebase + always are. +- JSON encoding: content-type, real nested JSON, JSON ints for `version`/`branchFromId`, + real booleans. +- The `diff` envelope: `version` top-level, content inside `diff`; delete sends exactly + `{"version": N, "diff": {}}` (object, not null/`""`/`[]`); `rows=[]` is sent, not omitted. +- Required replaced-body params: a TypeError loop over each omitted field + a wire test that + `configuration`/`isDisabled` always reach the body. +- `merge()` waits with `MERGE_JOB_MAX_WAIT`, not the default — pinned via a recording stub + requester (httpx mocks can't see the kwarg). +- The seam: one test constructs `MergeRequests` against a stub `StorageRequester`, no HTTP. + +## Deferred / follow-ups recorded during review + +- ~~`FEATURE_BRANCHES_MERGE_REQUESTS` naming~~ -- resolved by Layer 2 (PR #703): renamed to + `BRANCHES_MERGE_REQUESTS_FEATURE` when the pre-flight was wired. Original note: off the + file's dominant convention (suffix: + `STORAGE_BRANCHES_FEATURE`, `GLOBAL_SEARCH_FEATURE`, `PAYG_FEATURE`) and the constant is + unused until Layer 2 calls it — decide rename vs. keep when wiring the pre-flight. +- SOX-fence caveat on the constant's comment: the fence holds only if a SOX project never also + has `branches-merge-requests` — see the layer2 doc for the pre-flight consequences. +- Docstring nit: `request_review`/`approve` PUT an empty body while `request_changes` without + a reason PUTs `{}` — both correct, difference undocumented. +- Retry policy: since #616 POST is no longer retried; PUT/DELETE are, and the four PUT + transitions verifiably cannot double-apply (#617; see the notes doc, *PUT transitions cannot + double-apply*). The residual lost-response-replay caveat (attempt 2's misleading 422/409) is + house-wide, not MR-specific. diff --git a/docs/merge-requests-notes.md b/docs/merge-requests-notes.md new file mode 100644 index 00000000..40ac7e5a --- /dev/null +++ b/docs/merge-requests-notes.md @@ -0,0 +1,246 @@ +# Merge requests — verified backend facts (all layers) + +Everything here was verified directly against `keboola/connection` and is cited to a file and +line. Scope is the **non-SOX** flow (`branches-merge-requests`); SOX +(`protected-default-branch`) is out of scope. Layer-specific material lives in the siblings: +[`merge-requests-layer3.md`](merge-requests-layer3.md) (the shipped HTTP client), +[`merge-requests-layer2.md`](merge-requests-layer2.md) (service, DMD-1899), +[`merge-requests-layer1.md`](merge-requests-layer1.md) (commands UX). + +## State machine + +States and transitions are enums (`MergeRequestLifecycle/MergeRequestLifecycleState.php`, +`…Transition.php`): + +- States: `development`, `in_review`, `approved`, `in_merge`, `published`, `canceled`. +- Transitions: `request_review`, `skip_review`, `approve`, `finish_review`, `merge`, + `rollback_merge`, `request_changes`, `publish`, `cancel`. + +`skip_review`, `finish_review`, `rollback_merge` and `publish` have **no endpoint** — they are +driven internally. The lifecycle is a Symfony Workflow `state_machine`, which matters for +retries (see *PUT transitions cannot double-apply* below). + +## Merge behavior + +`MergeProcessor::process` (`Storage/MergeRequests/Merge/MergeProcessor.php:45-80`) does more +than enqueue: + +1. **If the MR is in `development` and already has enough approvals, it calls `skipReview` + itself.** On a non-SOX project with the default of 0 required approvals this means `merge` + works **directly from `development`** — no explicit `request-review` needed — and + `skipReview` populates the change log on the way through + (`MergeRequestService.php:130-137`). This materially shortens the CLI's happy path. +2. Acquires a **project-wide lock**; a held lock raises `BranchIsNotReadyToMerge`. +3. Checks the state machine can apply `merge`; otherwise `BranchIsNotReadyToMerge` with + `Cannot merge, branch is in "" state.` +4. Rejects if another MR in the project is already processing (`isOtherMrInProjectProcessing`). +5. Validates conflicts, then `setInMerge` and enqueues the job. + +**409 therefore has four distinct causes, in two different response shapes** +(`MergeAction.php:97-109`): the three `BranchIsNotReadyToMerge` cases carry the machine-readable +`storage.mergeRequests.notReadyToMerge`, while a **conflict** raises `MergeValidationException`, +whose own string code is **`storage.mergeRequests.validation`** (`getStringCode`, +`MergeValidationException.php:174-177`) -- serialized top-level as `code` by +`ExceptionConverter` (`legacy-app/.../ExceptionConverter.php:99-125`), alongside the human +message in `error` and **the conflicting configurations in `params.errors`** (the +HttpException context). "Not ready" vs "conflicted" is a code-vs-code match, not +code-vs-absence -- an earlier reading of `MergeAction` missed the converter and recorded the +conflict 409 as code-less (corrected by the Opus wire review, 2026-08-27). + +The merge itself is atomic: the job applies the configuration changes and transitions to +`published` in one transaction, rolling back to `approved` on failure (`MergeRequestService.php` +`publish:194` / `rollbackMerge:186`, both wrapped in `transactionManager->transactional`). +There is no publish endpoint. + +## Conflicts are computed live + +`DefaultConflictValidator::validateMergeRequest` +(`Storage/MergeRequests/Merge/DefaultConflictValidator.php:70-98`) compares each dev-branch +config's **version(1)** `versionIdentifier` against the default branch's current one. Not a +conflict when: the config exists only in the default branch; both sides are deleted; or the +identifiers match. Otherwise +`MergeValidationExceptionError::createConfigurationInDefaultBranchChanged(componentId, +configurationId, isDeleted, devVersionIdentifier, defaultVersionIdentifier)` — which is exactly +the shape `GET …/conflicts` returns. + +Two consequences: a conflict requires the configuration to exist on **both** sides, so the +`theirs` side of a conflicting config's diff is always populated; and because the check runs on +every merge attempt, rebasing every conflicting config is sufficient to make the MR mergeable — +there is no MR-level "re-validate" step. + +## A successful merge deletes the source branch + +After the merge transaction commits, `MergeDevBranchJob` enqueues a `DevBranchDelete` job for +`branchFromId` (`Storage/Jobs/MergeDevBranchJob.php:179-187`). This is the happy path, every +time — there is no keep-the-branch option. Consequences: + +- **Only the merged configurations survive**, applied to the default branch. Everything else + scoped to the dev branch — its buckets, tables, files, workspaces — is dropped with it. +- **It is a second, separate async job with no job handle returned.** `merge_requests.merge()` + awaits the *merge* job; when that returns `success` the MR is `published`, but the branch + deletion has only just been enqueued. Callers must not assume the branch is already gone — + nor that it still exists. +- **Every local reference to the branch goes stale** — cleaning that up is Layer 2's job (see + the layer2 doc, *Post-merge cleanup*). + +There is **no cancel endpoint**: an MR is canceled only as a side effect of deleting its source +branch (`legacy-app/src/Storage/Job/DevBranch/DevBranchDelete.php:201` → +`mergeRequestService->cancel`). Deletion is thus how every MR lifecycle ends — published or +canceled, the branch ceases to exist. Related create-time nuance: the existence check +(`MergeRequestsModel::fetchForBranchFrom`) filters by `branchFromId` **only — no state filter** +— so a branch has at most one MR *ever*, not merely one *open* MR. In practice the readings +coincide because both terminal states end with branch deletion, but the code's rule is the +stronger one. + +## Approvals + +- **The state machine collapses the approvals requirement into the state.** An MR sits in + `in_review` only while approvals are insufficient; the moment the requirement is met the + backend auto-transitions to `approved` (internal `finish_review`). With the non-SOX default + of **0** required approvals, `request-review` lands straight in `approved` and the approve + step never runs. The `state` field is therefore the authoritative answer to "are approvals + satisfied?" — no count needed. +- **Approvals are deleted on `request_changes` and on `cancel`** + (`MergeRequestService.php:139-152`, `:163-176`, both + `approvalRepository->deleteAllForMergeRequest`) and nowhere else — so a rebase does not cost + you an approval. +- **The required count itself is unreadable with a Storage token** — a documented trap. It is + **project** metadata (`KBC.branches-merge-requests.required-approvals-count`), exposed only + on the Manage API; branch metadata is a different store entirely, so + `get_branch_metadata_value(key, branch_id="default")` would not fail, it would quietly + report the key as absent. Nor is the count in any MR response: `MergeRequestResponse` + carries `approvals` (`{approverId, approverName, createdAt}`) and `reviewers` + (`{id, name, email, status}` with `status` ∈ `approved`/`rejected`/null), but no + required-count field. +- The count's mechanics: `RequiredApprovalsCountProvider` computes + `hasEnoughApprovals = given >= required`, defaults 0 (non-SOX) / 2 (SOX), reading project + metadata (provider `user`) via `Controller/Manage/Projects/ProjectListMetadataAction.php:24`. + The Keboola UI *can* show "1 of 2 approvals" because it runs as an admin session and reads + that Manage endpoint as a side channel. kbagent deliberately does not chase that parity — + its manage-token policy is default-deny (convention #12), and requiring a manage token to + render a status line would invert it for cosmetics. **Connection is expected to add the + count to the Storage API** ([DMD-1969](https://linear.app/keboola/issue/DMD-1969)) — the + recommended shape is a field + serialized into `MergeRequestResponse` (the provider already exists server-side), which + flows through Layer 3's raw dicts with zero client change. Read it defensively. +- Server-side quirk (filed as keboola/connection#8209, surfaced by the #616 audit): + `bi_rMergeRequestsApprovals` has no unique constraint on `(mergeRequestId, idAdmin)` and + `hasEnoughApprovals()` counts rows rather than distinct admins. + +## Auto-merge: `immediately` merges WITHOUT anyone calling merge + +`autoMergeStrategy` is not metadata. A background tick selects every MR in `approved` whose +strategy is `immediately` (or `scheduled` with `autoMergeAt <= now`) -- +`AutoMerge/AutoMergeCandidateRepository.php:38-47` (`findCandidates`: `WHERE mr.state = +:approved AND (mr.autoMergeStrategy = :immediately OR (:scheduled AND autoMergeAt <= :now))`) +-- and drives it through the **same `MergeProcessor`** the merge endpoint uses, under a +system token (`AutoMerge/AutoMergeTickHandler.php:86`: +`$this->mergeProcessor->process($legacyRow, new SystemToken(...))`). A conflict blocks the +scheduled merge and the tick retries until it clears (`:88-94`). + +Consequence: on a non-SOX project with the default 0 required approvals, +`create(auto_merge_strategy="immediately")` + `request_review()` ends in a production merge +and the source branch's deletion, with `merge()` never called; an +`update(auto_merge_strategy="immediately")` on an already-approved MR is enough on its own. +Both service docstrings say so; Layer 1 escalates the flag's permission class accordingly. + +## Roles and feature gating (non-SOX) + +Every write carries `#[MergeRequestsAllowedRoles(roles: [ProjectRole::ROLE_ADMIN, +ProjectRole::ROLE_SHARE])]` — verified on all six: create (`:39`), update (`:44`), +request-review (`:43`), approve (`:43`), reject (`:49`), merge (`:40`). The `reviewer`, +`developer` and `production_manager` roles appear only in the sibling +`#[ProtectedBranchAllowedRoles]` attribute, which `StorageRouteGuard` selects for the **SOX** +feature — so those roles carry no MR privileges in a non-SOX project. Reads (list, detail, +conflicts) are `#[AsReadOnlyAction]` with no role whitelist. + +Role whitelisting is not the only access axis, though: every `/merge-request/{id}` route -- +the read-only detail and conflicts actions included -- runs `MergeRequestVoter`, which denies +a token with **no admin identity** (`Voters/MergeRequestVoter.php:49-56`, via +`MergeRequestService::requireMergeRequest`). A scoped Storage token therefore gets 403 on +detail/conflicts while the un-votered `GET /merge-request` list still works. Different axis +(admin identity vs. role), not a contradiction of the sentence above. + +All six MR writes accept **either** `protected-default-branch` **or** +`branches-merge-requests`; the reads and `/diff` are ungated; `/rebase` alone requires +`branches-merge-requests` (`StorageRouteGuard::canAccessStorageScope`, +`Core/Storage/RouteGuard/StorageRouteGuard.php:158-180`). A failed feature check makes the +route guard return false, which `RouteGuardListener` turns into `AccessDeniedException` +(`RouteGuardListener.php:75`) — **HTTP 403, byte-for-byte indistinguishable from a role +denial**. Only a client-side pre-flight can produce the right "not enabled" message — hence +Layer 2's `has_feature` check (layer2 doc). + +The dev branch is locked for editing only while the MR is `in_merge` +(`Core/Storage/RouteGuard/StorageRouteGuard.php:108`, `:125` — `$isBranchLocked = +$mr->isInMerge()`), so editing and rebasing are allowed in `development`, `in_review` and +`approved`. + +## The change log + +`Model_Row_MergeRequest::updateChangeLog` (`legacy-app/src/Model/Row/MergeRequest.php:324-331`) +writes `$changeLog['configurations'] = $changes`, and is called from `requestReview` +(`MergeRequestService.php:120`) and `skipReview` (`:134`) — **not** at merge. Shape: +`{configurations: [{componentId, configurationId, lastVersionIdentifier, isDeleted}]}`. So the change +list is legitimately empty while the MR sits in `development`, and appears the moment it is +sent for review (or skipped past review by a merge from `development`, per *Merge behavior*). +Read it defensively. + +## PUT transitions cannot double-apply + +kbagent's retry policy (`RETRY_SAFE_METHODS`, since #616) treats PUT as retry-safe, and the MR +client uses PUT for four action-style transitions (`/request-review`, `/approve`, +`/request-changes`, `/merge`). Verified against Connection (recorded in #617): a retried +transition **cannot fire twice**. Three of the four are refused structurally on a second call +(a Symfony state machine enables a transition only from its declared `from` place); `/approve` +is the one self-loop and carries `AddApprovalGuard` instead. Notifications ride +`workflow.merge_request_lifecycle.completed` from inside `apply()`, inside +`MergeRequestService`'s `transactional()` — no transition, no notification. + +Caveat that survives: a retried PUT that succeeded but lost its response reports **attempt 2's +error** (a 422/409 on an operation that actually applied). That applies to every retried +PUT/DELETE in kbagent, not just merge requests. + +## Misc limits + +- `reason` on request-changes is capped at 1000 characters + (`MergeRequestRejectRequest::REASON_MAX_LENGTH`); the body is `required: false`. +- `reviewerIds` duplicates are de-duplicated server-side (`array_unique` in + `mapValidatedData`). +- `AutoMergeStrategy` is exactly `immediately` | `scheduled` | `none`. +- `externalId` max 255 (`Assert\Length(max: 255)`, create and update DTOs). +- update semantics: null = leave unchanged, absent ≡ null, no clear-to-null — but an **empty + string** clears `description`/`externalId` (`?? null` mapping + `!== null` guards in + `MergeRequestService::updateMergeRequest`). `PUT {}` is a no-op returning the MR. + +## Wire-truth verification table + +Re-verified against Martin's local `keboola/connection` checkout on 2026-08-19, during the +final Layer 3 review: + +| Claim | Backend evidence | +|---|---| +| MR endpoints project-level, never branch-prefixed | `isAvailableInBranch: false` on every `Controller/Storage/MergeRequest/*Action.php` route | +| `branchFromId`/`branchIntoId` must be JSON ints | `Assert\Type('int')` in `MergeRequestCreateRequest::getConstraint()` | +| Non-default target & existing-MR-per-source-branch → **404** (not 400) | both throw `InvalidBranchException` in `MergeRequestCreateProcessor`, caught → `HTTP_NOT_FOUND` | +| merge answers 202 + Storage job | `JsonResponse($job->toApiResponse(...), 202)` in `MergeAction` | +| 409: "not ready" carries `storage.mergeRequests.notReadyToMerge`, a conflict carries `storage.mergeRequests.validation` + `params.errors` with the conflicting configs | 3 `BranchIsNotReadyToMerge` sites in `MergeProcessor`; `MergeValidationException::getStringCode` + `ExceptionConverter.php:99-125` (re-verified 2026-08-27; previously mis-recorded as code-less) | +| Failed merge rolls back the MR | `rollbackMerge` in `MergeDevBranchJob`'s catch | +| Source branch deleted as a second job, no handle returned | `createAndEnqueueJobFromJob(..., DevBranchDelete::OPERATION_NAME, ...)` after commit in `MergeDevBranchJob` | +| diff/rebase 400 on default branch | `ConfigurationRebaseNotAvailableOnDefaultBranchException` / diff OA doc → `createBadRequestException` | +| Diff shape `base`/`ours`/`theirs`, each nullable | `ConfigurationDiffResponse` | +| Each diff side = `{version, isDeleted, diff: {name, description, changeDescription, isDisabled, configuration, rows}}` -- content NESTED under `diff`, version/deletion as side metadata; all six `diff` keys `required` | `ConfigurationVersionResponse` + `ConfigurationDiffData` OA schemas (re-verified 2026-08-27; a flat-side assumption breaks every take/classify consumer) | +| Full `MergeRequestResponse` item: `id, creator{id,name}, title, description, state, branches{branchFromId,branchIntoId}, merge{mergedAt,mergerId,mergerName}, createdAt, externalId, autoMergeStrategy, autoMergeAt, approvals[], reviewers[]` -- `merge{}` is NESTED (no flat mergerName), `createdAt` is top-level; list and detail share this item shape byte-for-byte (detail adds `changeLog`, `?include=activityLog` adds `activityLog`) | `MergeRequestResponseProvider.php:86-117` (`getCreateMRResponseArray`), `:132-139` (list maps the same builder) | +| Rebase replaces; missing `configuration` → `{}`, `isDisabled` → `false`, `description` → null | `RebaseRequest::mapValidatedData` (`?? new stdClass()`, `?? false`, `isset` → null); "complete 3-way diff result … fully replaces" verbatim in `ConfigurationRebaseService` docblock | +| `rows` required for keep; `[]` deletes all rows; order = sort order | `validateDiffRows` + OA schema | +| Empty `diff` `{}` = delete resolution (tombstone) | `validateDiff` empty-stdClass branch → `isDelete: true` | +| `changeDescription` null → default rebase message | `ConfigurationRebaseService` line ~102 | +| Target version must be newer → 400 | `ConfigurationRebaseTargetVersionNotNewerException` → 400 (ULID comparison) | +| `protected-default-branch` passes the same feature gate | `StorageRouteGuard` loops `RequireFeature.features` with OR semantics; MR routes list both features, rebase lists only `branches-merge-requests` | + +Resolved subtlety worth remembering: `RebaseRequest::validateDiff` expects `diff` as a *string* +and `json_decode`s it — which at first glance contradicts the client sending a real nested +object. It doesn't: the rebase action maps the body with +`#[MapRequestBody(realJsonMapProps: ['diff'])]`, and `JsonExtractor` re-encodes a nested `diff` +object back into a JSON string before validation (preserving the `{}`-vs-`[]` distinction). So +the nested-object body is correct, and `{}` survives as the delete sentinel while `[]` is +rejected ("diff must be an object"). From 385c12fd18c9ee96ed0eae84784e3da583b5a089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 02:12:26 +0200 Subject: [PATCH 02/16] 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 --- .../services/merge_request_service.py | 64 ++++++++++- tests/test_merge_request_service.py | 108 +++++++++++++++++- 2 files changed, 162 insertions(+), 10 deletions(-) diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py index 32e37139..8adc2e49 100644 --- a/src/keboola_agent_cli/services/merge_request_service.py +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -416,6 +416,29 @@ def find_merge_request_for_branch(self, alias: str, branch_id: int) -> dict[str, retryable=False, ) + def get_merge_request_row(self, alias: str, merge_request_id: int) -> dict[str, Any]: + """The MR's enriched row by id -- one GET, nothing else. + + The row tier ``list_merge_requests`` / ``find_merge_request_for_branch`` + already return (raw MR + ``derived_state`` + ``allowed_actions``), addressed + by id instead of by branch. ``get_merge_request`` is the DETAIL tier and + pays for it -- a conflicts GET and a ``verify_token`` GET on top of the + record -- so a caller that needs one field before a write (Layer 1's + auto-merge escalation check, the merge confirmation prompt, the + ``branch_from_id`` every ``--json`` result carries) would otherwise + spend three round trips for it AND inherit a dependency on the + conflicts endpoint that has nothing to do with the write. Layer 3's + ``merge_requests.get()`` was always this single GET; Layer 2 only ever + exposed it as the first step of the detail (L1 RFC, DMD-1900). + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + mr = client.merge_requests.get(merge_request_id) + finally: + client.close() + return {"alias": alias, **_enrich_row(mr)} + def get_merge_request( self, alias: str, @@ -690,14 +713,14 @@ def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: was_active = branch_from_id is not None and project.active_branch_id == branch_from_id mapping_cleanup: dict[str, Any] | None = None - cleanup_warnings: list[str] = [] + warnings: list[str] = [] if raw_branch_from is not None and branch_from_id is None: # "Absent" and "present but not numeric" must not collapse # silently: with no usable id the local cleanup below is skipped, # leaving active_branch_id and the sync mapping pointing at the # branch the merge just doomed -- and the caller must hear that. logger.warning("branchFromId %r is not a numeric branch id", raw_branch_from) - cleanup_warnings.append( + warnings.append( f"branchFromId {raw_branch_from!r} is not a numeric branch id -- local " "cleanup (active-branch reset, sync-mapping unlink) was skipped. If this " "project's active branch pointed at the merged branch, run " @@ -713,7 +736,7 @@ def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: branch_reset_done = True except Exception as exc: logger.warning("Post-merge active-branch reset failed: %s", exc) - cleanup_warnings.append( + warnings.append( f"Post-merge active-branch reset failed: {exc}. The active branch " "still points at the deleted branch -- run `kbagent branch reset " f"--project {alias}`." @@ -723,7 +746,7 @@ def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: mapping_cleanup = cleanup_branch_id_from_mapping(branch_from_id) except Exception as exc: logger.warning("Post-merge sync-mapping cleanup failed: %s", exc) - cleanup_warnings.append(f"Post-merge sync-mapping cleanup failed: {exc}") + warnings.append(f"Post-merge sync-mapping cleanup failed: {exc}") results = job.get("results") mr_after: dict[str, Any] = results if isinstance(results, dict) else {} @@ -754,8 +777,14 @@ def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: result["allowed_actions"] = derive_allowed_actions(mr_after) if mapping_cleanup: result["mapping_cleanup"] = mapping_cleanup - if cleanup_warnings: - result["cleanup_warnings"] = cleanup_warnings + if warnings: + # `warnings` is the group's one soft-failure key (resolve_conflict + # uses the same name): "the operation landed, something secondary + # did not, exit stays 0". A renderer reading `warnings` must not + # silently drop the post-merge ones -- they are the ones a user + # must act on (an active branch now pointing at a branch being + # deleted). The text carries the specificity the key does not. + result["warnings"] = warnings return result def _remap_merge_conflict(self, exc: KeboolaApiError) -> None: @@ -881,9 +910,32 @@ def get_config_diff( bool(theirs.get("isDeleted")) if diff.get("theirs") is not None else None ), "changes": self._classify_three_way(diff), + "resolution_candidate": self._resolution_candidate(ours), "diff": diff, } + def _resolution_candidate(self, ours: dict[str, Any] | None) -> dict[str, Any] | None: + """The ours-prefilled body a caller edits and hands back to ``resolve_conflict``. + + Composed HERE, not in Layer 1, so that the prefill and the five-key + replace guard in ``resolve_conflict`` are fed by the same constant + (``_DIFF_CONTENT_KEYS``) and cannot drift: rebase REPLACES, the guard + refuses a body missing any of ``name``/``rows``/``configuration``/ + ``isDisabled`` (absent or null) or lacking ``description`` -- and a + prefill built anywhere else that dropped ``description`` when null, or + wrote only the "interesting" keys, would produce a file kbagent itself + then refuses. Every key is emitted, ``description`` as an explicit + ``null`` when null (an explicit null is a decision the guard accepts; + an omission is not). ``changeDescription`` is excluded: it is the + version's commit message, not content -- the rebase takes its own. + ``None`` when there is nothing to prefill: the ours side is absent or + ``isDeleted`` (the resolution there is ``take="delete"``, not a body). + """ + if ours is None or ours.get("isDeleted"): + return None + envelope = ours.get("diff") or {} + return {key: envelope.get(key) for key in self._DIFF_CONTENT_KEYS} + def _classify_three_way(self, diff: dict[str, Any]) -> list[dict[str, Any]]: """Intersect the two pairwise diffs (base->ours, base->theirs) per path. diff --git a/tests/test_merge_request_service.py b/tests/test_merge_request_service.py index 506cdb31..4ee0a609 100644 --- a/tests/test_merge_request_service.py +++ b/tests/test_merge_request_service.py @@ -541,7 +541,7 @@ def boom(branch_id: int) -> None: "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", boom ) result = _svc(store, factory).merge(ALIAS, 7) # must NOT raise - assert any("disk full" in w for w in result["cleanup_warnings"]) + assert any("disk full" in w for w in result["warnings"]) def test_409_with_code_maps_to_not_ready_retryable(self, store, client_factory) -> None: factory, mock = client_factory @@ -1331,7 +1331,7 @@ def test_non_numeric_wire_branch_id_degrades_instead_of_raising( assert called == [] # The degradation is RECORDED: cleanup was skipped, the caller hears # it and gets the manual recovery steps. - assert any("not-a-number" in w and "branch reset" in w for w in result["cleanup_warnings"]) + assert any("not-a-number" in w and "branch reset" in w for w in result["warnings"]) def test_failed_branch_reset_does_not_skip_mapping_cleanup( self, store, client_factory, monkeypatch @@ -1353,8 +1353,7 @@ def boom(alias: str, branch_id: object) -> None: ) result = _svc(store, factory).merge(ALIAS, 7) assert any( - "active-branch reset failed" in w and "branch reset" in w - for w in result["cleanup_warnings"] + "active-branch reset failed" in w and "branch reset" in w for w in result["warnings"] ) # The message reports the OUTCOME, not the precondition: no claim of # a reset that did not happen. @@ -1492,3 +1491,104 @@ def test_no_default_branch_is_none(self) -> None: assert find_default_branch_id([]) is None assert find_default_branch_id([{"isDefault": False, "id": 1}]) is None + + +class TestLayer1RfcWalkFollowUps: + """The three Layer 2 additions 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", 2026-09-03).""" + + # -- get_merge_request_row ------------------------------------------------- + + def test_row_by_id_is_one_get_and_nothing_else(self, store, client_factory) -> None: + # The detail tier pays for conflicts + verify_token; the row tier must + # not -- it is what Layer 1 reads BEFORE a write (armed check, merge + # prompt) and must not inherit a dependency on the conflicts endpoint. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr( + 7, "development", branch_from=123, autoMergeStrategy="immediately" + ) + result = _svc(store, factory).get_merge_request_row(ALIAS, 7) + mock.merge_requests.get.assert_called_once_with(7) + mock.merge_requests.conflicts.assert_not_called() + mock.verify_token.assert_not_called() + # Same enrichment as a list/find row: raw + derived_state + allowed_actions. + assert result["alias"] == ALIAS + assert result["id"] == 7 + assert result["autoMergeStrategy"] == "immediately" + assert result["derived_state"] == "in_development" + assert "merge" in result["allowed_actions"] + assert "merge_blockers" not in result # detail-tier only + assert "viewer" not in result + + # -- merge(): warnings is the one soft-failure key ------------------------ + + def test_merge_soft_failures_land_under_warnings(self, store, client_factory) -> None: + factory, mock = client_factory + store.set_project_branch(ALIAS, 123) + mock.merge_requests.get.return_value = _wire_mr(7, "approved", branch_from=123) + mock.merge_requests.merge.return_value = {"id": 1, "status": "success", "results": {}} + broken_store = MagicMock(wraps=store) + broken_store.set_project_branch.side_effect = OSError("disk full") + result = MergeRequestService(broken_store, client_factory=factory).merge(ALIAS, 7) + assert "cleanup_warnings" not in result + assert any("disk full" in w for w in result["warnings"]) + + # -- resolution_candidate --------------------------------------------------- + + def test_candidate_carries_all_five_keys_with_explicit_null_description( + self, store, client_factory + ) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=_side({"limit": 250}, version=7), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + candidate = result["resolution_candidate"] + assert set(candidate) == {"name", "description", "isDisabled", "configuration", "rows"} + assert "description" in candidate and candidate["description"] is None + assert "changeDescription" not in candidate + assert candidate["configuration"] == {"limit": 500} + assert candidate["isDisabled"] is False + + def test_candidate_is_null_when_ours_is_deleted_or_absent(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + svc = _svc(store, factory) + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4, is_deleted=True), + theirs=_side({"limit": 250}, version=7), + ) + assert svc.get_config_diff(ALIAS, 7, "keboola.ex-db", "111")["resolution_candidate"] is None + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=None, + theirs=_side({"limit": 250}, version=7), + ) + assert svc.get_config_diff(ALIAS, 7, "keboola.ex-db", "111")["resolution_candidate"] is None + + def test_candidate_round_trips_through_resolve_unmodified(self, store, client_factory) -> None: + # THE pin: the file `diff --output` writes must pass `resolve --resolved` + # as-is. Guard and candidate are fed by one constant; this test is what + # fails if anyone ever splits them. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=_side({"limit": 250}, version=7), + ) + mock.rebase_config.return_value = {"id": "111", "version": 8} + svc = _svc(store, factory) + candidate = svc.get_config_diff(ALIAS, 7, "keboola.ex-db", "111")["resolution_candidate"] + result = svc.resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", resolved=candidate) + assert result["resolution"] == "custom" + kwargs = mock.rebase_config.call_args.kwargs + assert kwargs["configuration"] == {"limit": 500} + assert kwargs["is_disabled"] is False + assert kwargs["description"] is None From 6b0567481bcb4bfb386a2b19cc493528e995a8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 02:22:53 +0200 Subject: [PATCH 03/16] 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 --- src/keboola_agent_cli/cli.py | 6 + src/keboola_agent_cli/commands/_helpers.py | 28 + .../commands/_merge_request_render.py | 423 +++++++++++ .../commands/merge_request.py | 572 ++++++++++++++ .../commands/transformation.py | 27 +- src/keboola_agent_cli/permissions.py | 50 +- tests/test_merge_request_cli.py | 707 ++++++++++++++++++ 7 files changed, 1789 insertions(+), 24 deletions(-) create mode 100644 src/keboola_agent_cli/commands/_merge_request_render.py create mode 100644 src/keboola_agent_cli/commands/merge_request.py create mode 100644 tests/test_merge_request_cli.py diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index a5411c95..72c9b05e 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -28,6 +28,7 @@ from .commands.job import job_app from .commands.kai import kai_app from .commands.lineage import lineage_app +from .commands.merge_request import merge_request_app from .commands.notification import notification_app from .commands.org import org_app from .commands.permissions import permissions_app @@ -73,6 +74,7 @@ from .services.kai_service import KaiService from .services.lineage_service import LineageService from .services.member_service import MemberService +from .services.merge_request_service import MergeRequestService from .services.notification_service import NotificationService from .services.org_service import OrgService from .services.project_service import ProjectService @@ -150,6 +152,8 @@ # -- Development -- _DEV = "Development" app.add_typer(branch_app, name="branch", rich_help_panel=_DEV) +app.add_typer(merge_request_app, name="merge-request", rich_help_panel=_DEV) +app.add_typer(merge_request_app, name="mr", rich_help_panel=_DEV, hidden=True) app.add_typer(workspace_app, name="workspace", rich_help_panel=_DEV) app.add_typer(sync_app, name="sync", rich_help_panel=_DEV) app.add_typer(encrypt_app, name="encrypt", rich_help_panel=_DEV) @@ -310,6 +314,7 @@ def main( member_service = MemberService(config_store=config_store) feature_service = FeatureService(config_store=config_store) branch_service = BranchService(config_store=config_store) + merge_request_service = MergeRequestService(config_store=config_store) sharing_service = SharingService(config_store=config_store) search_service = SearchService(config_store=config_store) snapshot_service = SnapshotService(config_store=config_store) @@ -370,6 +375,7 @@ def main( ctx.obj["member_service"] = member_service ctx.obj["feature_service"] = feature_service ctx.obj["branch_service"] = branch_service + ctx.obj["merge_request_service"] = merge_request_service ctx.obj["sharing_service"] = sharing_service ctx.obj["search_service"] = search_service ctx.obj["snapshot_service"] = snapshot_service diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index 8ecdce97..83a50118 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -8,9 +8,11 @@ """ import getpass +import json import os import secrets import sys +from pathlib import Path from typing import Any import typer @@ -74,6 +76,32 @@ def resolve_manage_token(*, allow_env: bool = False) -> str: raise typer.Exit(code=2) +def parse_json_arg(raw: str, *, label: str) -> Any: + """Parse a ``JSON|@file|-`` argument: inline JSON, ``@path``, or ``-`` for stdin. + + The house input contract for structured flags (``config update + --configuration``, ``transformation edit --op``, ``merge-request resolve + --resolved``). Lived as a private copy in ``transformation.py`` (and a + dict-only variant in ``config.py``); hoisted here so the third consumer + does not become a third copy. + + Raises: + ValueError: On a missing file or malformed JSON -- the message names + ``label`` (the flag) so the caller can print it at exit 2 as-is. + """ + try: + if raw == "-": + return json.loads(sys.stdin.read()) + if raw.startswith("@"): + file_path = Path(raw[1:]) + if not file_path.is_file(): + raise ValueError(f"{label}: file not found: {file_path}") + return json.loads(file_path.read_text(encoding="utf-8")) + return json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"{label}: invalid JSON: {exc}") from exc + + def read_password_stdin() -> str: """Read a password from stdin. diff --git a/src/keboola_agent_cli/commands/_merge_request_render.py b/src/keboola_agent_cli/commands/_merge_request_render.py new file mode 100644 index 00000000..815db614 --- /dev/null +++ b/src/keboola_agent_cli/commands/_merge_request_render.py @@ -0,0 +1,423 @@ +"""Rich renderers for the ``kbagent merge-request`` group. + +A private sibling of ``commands/merge_request.py`` (precedent: ``_auth_picker.py``): +the four renderers here -- list table, detail panel, conflicts table, three-way +diff -- are a coherent unit no other group's output shares. Presentation only; +every fact rendered comes from the service payload, whose wire shape is the +authority in ``docs/merge-requests-notes.md``. + +Two rules hold in every function: + +- **Every wire-sourced string goes through :func:`rich.markup.escape`** before it + enters a ``Table`` or ``Panel``. Rich interprets markup by default, so an MR + titled ``Fix [bold] parsing`` mangles the table and an unbalanced ``[/]`` + raises ``MarkupError``. Titles, descriptions, names, conflict messages, + reasons -- all user-authored. +- **``derived_state``, never raw ``state``, is what the user sees.** The whole + point of the derivation is that the CLI agrees with the web UI badge. +""" + +from __future__ import annotations + +import json +from typing import Any + +from rich.console import Console +from rich.markup import escape +from rich.panel import Panel +from rich.table import Table + +_DASH = "—" + +# derived_state -> how the list badge / detail header shows it. The vocabulary +# is the service's (STATE_FILTER_VOCABULARY); this is only its casing/colour. +_STATE_STYLE: dict[str, str] = { + "in_development": "cyan", + "in_review": "yellow", + "approved": "green", + "in_merge": "magenta", + "merged": "bold green", + "closed": "dim", + "rejected": "red", +} + +# allowed_actions vocabulary (service's _ALLOWED_ACTIONS_BY_STATE) -> the +# command that produces it. `resolve_conflicts` maps to the inspect step: the +# user has to see the conflicts before choosing a resolution. +_ACTION_COMMAND: dict[str, str] = { + "request_review": "merge-request request-review", + "approve": "merge-request approve", + "request_changes": "merge-request request-changes", + "merge": "merge-request merge", + "update": "merge-request update", + "resolve_conflicts": "merge-request conflicts", +} + +# Values longer than this are elided in the default `diff` render; --format full +# prints them whole. +_DIFF_VALUE_MAX = 60 + + +def _state_badge(derived_state: str) -> str: + style = _STATE_STYLE.get(derived_state, "") + text = escape(derived_state.replace("_", " ")) + return f"[{style}]{text}[/{style}]" if style else text + + +def _s(value: Any) -> str: + """Escape any wire value for a cell; ``None`` renders as a dash.""" + return _DASH if value is None else escape(str(value)) + + +def _reviewers_cell(reviewers: list[dict[str, Any]] | None) -> str: + parts = [] + for reviewer in reviewers or []: + name = _s(reviewer.get("name") or reviewer.get("id")) + status = reviewer.get("status") + parts.append(f"{name} ({escape(str(status))})" if status else name) + return ", ".join(parts) + + +# -- list ---------------------------------------------------------------------------- + + +def format_merge_requests_table(console: Console, data: dict[str, Any]) -> None: + """The list: server order preserved (``createdAt DESC`` -- the renderer never + re-sorts), optional columns shown only when some row carries a value so the + common table stays narrow, and the empty case told apart from the + feature-less one via the service's ``feature_enabled``.""" + rows: list[dict[str, Any]] = data.get("merge_requests") or [] + if not rows: + if data.get("feature_enabled") is False: + console.print( + "Merge requests are not enabled on this project " + "(the 'branches-merge-requests' feature is missing)." + ) + elif data.get("state_filter"): + console.print(f"No merge requests with state '{escape(str(data['state_filter']))}'.") + else: + console.print("No merge requests.") + return + + show_external = any(r.get("externalId") for r in rows) + show_created = any(r.get("createdAt") for r in rows) + show_merger = any((r.get("merge") or {}).get("mergerName") for r in rows) + + table = Table(title=f"Merge requests in '{escape(str(data.get('alias', '')))}'") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Status", no_wrap=True) + table.add_column("Title") + table.add_column("Author") + table.add_column("Branch", justify="right", no_wrap=True) + table.add_column("Reviewers") + if show_external: + table.add_column("External ID") + if show_created: + table.add_column("Created", no_wrap=True) + if show_merger: + table.add_column("Merged by") + + for mr in rows: + cells = [ + _s(mr.get("id")), + _state_badge(str(mr.get("derived_state") or mr.get("state") or "")), + _s(mr.get("title")), + _s((mr.get("creator") or {}).get("name")), + _s((mr.get("branches") or {}).get("branchFromId")), + _reviewers_cell(mr.get("reviewers")), + ] + if show_external: + cells.append(_s(mr.get("externalId"))) + if show_created: + cells.append(_s(mr.get("createdAt"))) + if show_merger: + cells.append(_s((mr.get("merge") or {}).get("mergerName"))) + table.add_row(*cells) + console.print(table) + + +# -- detail ---------------------------------------------------------------------------- + + +def _blockers_line(data: dict[str, Any]) -> str: + blockers = data.get("merge_blockers") or [] + if data.get("mergeable"): + return "[green]Mergeable[/green] (the merge itself is still the authority)" + if not blockers: + # mergeable is False but no blocker listed: conflicts were not fetched + # (closed MR) -- say nothing definite either way. + return "[dim]Merge readiness not evaluated[/dim]" + parts = [] + for blocker in blockers: + if blocker == "conflicts": + parts.append(f"conflicts ({data.get('conflicts_count', '?')})") + else: + parts.append(escape(str(blocker))) + return f"[red]Blocked by[/red]: {', '.join(parts)}" + + +def _viewer_line(viewer: dict[str, Any] | None) -> str | None: + """Only truthy flags render; ``None`` is "unknown", never "no".""" + if not viewer: + return None + facts = [] + if viewer.get("is_creator"): + facts.append("you created this merge request") + if viewer.get("has_approved"): + facts.append("you have approved it") + return ("You: " + ", ".join(facts)) if facts else None + + +def _kv_table(pairs: list[tuple[str, str]]) -> Table: + table = Table(show_header=False, box=None, pad_edge=False) + table.add_column(style="bold", no_wrap=True) + table.add_column() + for key, value in pairs: + table.add_row(key, value) + return table + + +def format_merge_request_detail(console: Console, data: dict[str, Any]) -> None: + title = _s(data.get("title")) + derived = str(data.get("derived_state") or data.get("state") or "") + header = f"Merge request #{_s(data.get('id'))}: {title} {_state_badge(derived)}" + if data.get("state") and data.get("state") != derived: + header += f" [dim](raw state: {escape(str(data['state']))})[/dim]" + console.print(Panel(header, expand=False)) + + branches = data.get("branches") or {} + merge_info = data.get("merge") or {} + pairs: list[tuple[str, str]] = [ + ("Readiness", _blockers_line(data)), + ] + viewer = _viewer_line(data.get("viewer")) + if viewer: + pairs.append(("", viewer)) + strategy = data.get("autoMergeStrategy") + if strategy and strategy != "none": + when = f" at {escape(str(data['autoMergeAt']))}" if data.get("autoMergeAt") else "" + pairs.append( + ( + "Auto-merge", + f"[red]armed[/red] ({escape(str(strategy))}{when}) -- the backend merges this " + "MR on its next tick once it is approved", + ) + ) + pairs.append( + ( + "Branches", + f"{_s(branches.get('branchFromId'))} → {_s(branches.get('branchIntoId'))}", + ) + ) + pairs.append(("Author", _s((data.get("creator") or {}).get("name")))) + if data.get("createdAt"): + pairs.append(("Created", _s(data["createdAt"]))) + if merge_info.get("mergedAt"): + pairs.append( + ("Merged", f"{_s(merge_info.get('mergedAt'))} by {_s(merge_info.get('mergerName'))}") + ) + if data.get("externalId"): + pairs.append(("External ID", _s(data["externalId"]))) + if data.get("description"): + pairs.append(("Description", _s(data["description"]))) + console.print(_kv_table(pairs)) + + reviewers = data.get("reviewers") or [] + if reviewers: + table = Table(title="Reviewers", title_justify="left") + table.add_column("Name") + table.add_column("Status") + for reviewer in reviewers: + table.add_row( + _s(reviewer.get("name") or reviewer.get("id")), _s(reviewer.get("status")) + ) + console.print(table) + + approvals = data.get("approvals") or [] + if approvals: + table = Table(title="Approvals", title_justify="left") + table.add_column("Approver") + table.add_column("At") + for approval in approvals: + table.add_row(_s(approval.get("approverName")), _s(approval.get("createdAt"))) + console.print(table) + + _print_change_log(console, data) + + if "conflicts" in data: + console.print() + format_conflicts_table(console, data, heading=True) + + activity = data.get("activityLog") + if isinstance(activity, list): + console.print() + console.print(f"[bold]Activity log[/bold] ({len(activity)} entries)") + for entry in activity: + console.print(f" {escape(json.dumps(entry, ensure_ascii=False, default=str))}") + + +def _print_change_log(console: Console, data: dict[str, Any]) -> None: + """The configurations the merge will apply -- written by the backend at + review time, so LEGITIMATELY empty while the MR sits in ``development``. + Say that instead of showing a bare empty table.""" + change_log = data.get("changeLog") + configurations = ( + (change_log or {}).get("configurations") if isinstance(change_log, dict) else None + ) + console.print() + if not configurations: + if (data.get("state") or "") == "development": + console.print( + "[bold]Change log[/bold]: empty until the merge request is sent for review " + "(the backend records the changed configurations at that point)." + ) + else: + console.print("[bold]Change log[/bold]: no configuration changes recorded.") + return + table = Table(title=f"Change log ({len(configurations)} configurations)", title_justify="left") + table.add_column("Component") + table.add_column("Configuration") + table.add_column("Deleted") + for entry in configurations: + table.add_row( + _s(entry.get("componentId")), + _s(entry.get("configurationId")), + "yes" if entry.get("isDeleted") else "", + ) + console.print(table) + + +def next_step_hints(allowed_actions: list[str] | None) -> list[str]: + """``allowed_actions`` -> the commands that produce them, in the service's order. + State-derived and feature-blind by Layer 2's decision: on a project where + the feature was later switched off these recommend writes that end in + FEATURE_NOT_ENABLED (RFC, Known gaps).""" + return [_ACTION_COMMAND[a] for a in (allowed_actions or []) if a in _ACTION_COMMAND] + + +# -- conflicts ------------------------------------------------------------------------------- + + +def format_conflicts_table( + console: Console, data: dict[str, Any], *, heading: bool = False +) -> None: + conflicts: list[dict[str, Any]] = data.get("conflicts") or [] + count = data.get("conflicts_count", len(conflicts)) + if not conflicts: + console.print( + "[green]No conflicts[/green] -- every changed configuration is unchanged in production." + ) + return + table = Table(title=f"Conflicts ({count})" if heading or count else None, title_justify="left") + table.add_column("Component") + table.add_column("Configuration") + # The entry's isDeleted is the DEV-branch side's flag, not production's. + table.add_column("Deleted in branch") + table.add_column("Message") + for entry in conflicts: + table.add_row( + _s(entry.get("componentId")), + _s(entry.get("configurationId")), + "yes" if entry.get("isDeleted") else "", + _s(entry.get("message")), + ) + console.print(table) + + +# -- diff --------------------------------------------------------------------------------------- + + +def _value_cell(value: Any, *, full: bool) -> str: + text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + if not full and len(text) > _DIFF_VALUE_MAX: + text = text[: _DIFF_VALUE_MAX - 1] + "…" + return escape(text) + + +def _deleted_side_message(data: dict[str, Any]) -> str | None: + """The side-level facts come FIRST: a null side yields zero per-path rows, + so 'production deleted it, your branch changed it' arrives as + ``changes: []`` + ``theirs_deleted: true`` -- the sharpest conflict there is, + and a table-first render would print three empty sections and "No changes". + This is the one place the user faces a binary choice the command already + knows, so the sentence recommends the resolution.""" + ours, theirs = data.get("ours_deleted"), data.get("theirs_deleted") + if theirs is True and not ours: + return ( + "[red]Production deleted this configuration; your branch changed it.[/red]\n" + "Resolve with `merge-request resolve --take delete` (drop it) or " + "`--take ours` (keep your version)." + ) + if ours is True and not theirs: + return ( + "[red]Your branch deleted this configuration; production changed it.[/red]\n" + "Resolve with `merge-request resolve --take delete` (drop it) or " + "`--take theirs` (keep production's version)." + ) + if ours is True and theirs is True: + return "Both sides deleted this configuration -- there is nothing to reconcile." + # A None flag means the side does not exist at all, which a conflict should + # never produce (it requires the config on both sides): render defensively, + # no recommendation. + if theirs is None: + return "This configuration is not present on the production side." + if ours is None: + return "This configuration is not present in the development branch." + return None + + +def format_config_diff(console: Console, data: dict[str, Any], *, full: bool = False) -> None: + console.print( + f"[bold]{_s(data.get('component_id'))}/{_s(data.get('config_id'))}[/bold] " + f"branch {_s(data.get('branch_id'))} → production " + f"[dim](rebase onto version {_s(data.get('onto_version'))})[/dim]" + ) + + deleted = _deleted_side_message(data) + if deleted: + console.print(deleted) + return + + changes: list[dict[str, Any]] = data.get("changes") or [] + if not changes: + # Both sides exist, neither deleted, nothing differs: the conflict + # cleared between `conflicts` and `diff` -- say that, not "nothing changed". + console.print( + "No differing paths: this conflict has cleared since it was listed " + "(run `merge-request conflicts` again)." + ) + return + + both = [c for c in changes if c.get("changed_by") == "both" and not c.get("agreed")] + agreed = [c for c in changes if c.get("changed_by") == "both" and c.get("agreed")] + ours_only = [c for c in changes if c.get("changed_by") == "ours"] + theirs_only = [c for c in changes if c.get("changed_by") == "theirs"] + + def section(title: str, rows: list[dict[str, Any]], *, columns: tuple[str, ...]) -> None: + if not rows: + return + table = Table(title=f"{title} ({len(rows)})", title_justify="left") + table.add_column("Path", style="cyan", no_wrap=True) + for column in columns: + # fold, never crop: in --format full the whole value must be + # readable, and Rich's default overflow would cut it with "…" -- + # indistinguishable from this renderer's own elision marker. + table.add_column(column, overflow="fold") + for change in rows: + cells = [escape(str(change.get("path")))] + for column in columns: + key = {"Base": "base", "Yours": "ours", "Production": "theirs"}[column] + cells.append(_value_cell(change.get(key), full=full)) + table.add_row(*cells) + console.print(table) + + section("Both changed -- decide", both, columns=("Base", "Yours", "Production")) + section("Both changed identically -- agreed", agreed, columns=("Base", "Yours")) + section("Only you changed", ours_only, columns=("Base", "Yours")) + section("Only production changed", theirs_only, columns=("Base", "Production")) + if not full and any( + len(json.dumps(c.get(k), default=str)) > _DIFF_VALUE_MAX + for c in changes + for k in ("base", "ours", "theirs") + ): + console.print("[dim]Long values elided -- pass --format full to print them whole.[/dim]") diff --git a/src/keboola_agent_cli/commands/merge_request.py b/src/keboola_agent_cli/commands/merge_request.py new file mode 100644 index 00000000..d7af5467 --- /dev/null +++ b/src/keboola_agent_cli/commands/merge_request.py @@ -0,0 +1,572 @@ +"""Merge-request commands -- the non-SOX Branches 2.0 lifecycle (DMD-1900). + +Thin CLI layer over :class:`services.merge_request_service.MergeRequestService`: +parse flags, resolve the target, call the service, render. No business logic +here. Design record: ``docs/merge-requests-layer1.md``; the wire facts it rests +on: ``docs/merge-requests-notes.md``. + +What this module decides that the service leaves to the caller: + +- **Target resolution.** ``--merge-request-id`` (alias ``--id``) is optional on + every command that takes one: omitted, the branch is resolved the house way + (``--branch`` -> ``active_branch_id``) and the service maps it to its MR. A + branch has at most one MR ever, so this cannot be ambiguous. See + :func:`_resolve_target`. +- **Nothing irreversible happens without a human saying so.** ``merge`` is + destructive; arming auto-merge IS a merge (a backend scheduler runs every + approved MR armed with it through the same MergeProcessor), so arming + escalates ``create``/``update`` to destructive, and ``request-review`` / + ``approve`` / ``resolve`` on an already-armed MR escalate too -- they are + what moves it into ``approved``. Confirmation sits where a human CHOOSES the + outcome (``merge``, arming); escalation wherever one is CAUSED. Under + ``--json`` a destructive invocation must name its target explicitly -- the + prompt is gone there, and no other destructive command in kbagent lets the + command line identify nothing (see :func:`_require_explicit_target_under_json`). +- **One error handler** (:func:`_handle_error`). ``FeatureNotEnabledError`` is a + ``ConfigError`` with its own code and surfaces from the resolver behind every + omitted id -- reads included -- exactly where a copied ``except ConfigError -> + CONFIG_ERROR`` idiom would flatten it. +- **One soft-failure channel**: every result may carry ``warnings[]``; it is + rendered the same way in every command, after the main output, before the + hint-next line (:func:`_emit_warnings`). + +Renderers live in ``_merge_request_render.py``. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, NoReturn + +import typer + +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..services.merge_request_service import STATE_FILTER_VOCABULARY +from ._helpers import ( + check_cli_operation, + check_cli_permission, + get_formatter, + get_service, + map_error_to_exit_code, + resolve_branch, + resolve_project_alias, +) +from ._merge_request_render import ( + format_config_diff, + format_conflicts_table, + format_merge_request_detail, + format_merge_requests_table, + next_step_hints, +) + +merge_request_app = typer.Typer( + help=( + "Merge requests: merge a development branch into production with review " + "(Branches 2.0, non-SOX)" + ) +) + + +@merge_request_app.callback(invoke_without_command=True) +def _merge_request_permission_check(ctx: typer.Context) -> None: + check_cli_permission(ctx, "merge-request") + + +# -- Shared option declarations ------------------------------------------------ +# +# Reused across commands so the help text and the flag names cannot drift +# between them. `--merge-request-id`/`--id`: a merge request is the OBJECT the +# command acts on, hence `---id` like --config-id/--table-id (the bare +# nouns --project/--branch are the CONTEXT you work in); `--id` is the short +# alias the `agent` group already established beside `--task-id`. + +_PROJECT_OPT = typer.Option( + None, + "--project", + help="Project alias (default: KBAGENT_PROJECT, then the `project use` pin, then the sole project)", +) +_MERGE_REQUEST_ID_OPT = typer.Option( + None, + "--merge-request-id", + "--id", + help=( + "Merge request ID. Omit to use the merge request of --branch, or of the " + "active branch (`branch use`)" + ), +) +_BRANCH_OPT = typer.Option( + None, + "--branch", + help=( + "Dev branch ID whose merge request to use (default: the active branch set " + "via `branch use`). Mutually exclusive with --merge-request-id" + ), +) + +_AUTO_MERGE_DISARMED = "none" + + +# -- Error handling --------------------------------------------------------------- + + +def _handle_error(formatter: Any, exc: ConfigError | KeboolaApiError) -> NoReturn: + """The group's ONE ``ConfigError``/``KeboolaApiError`` -> exit-code mapping. + + No command in this module has its own ``except``: eleven inline copies of + the idiom are eleven places to flatten ``FeatureNotEnabledError`` (a + ``ConfigError`` subclass carrying ``FEATURE_NOT_ENABLED``) into a bare + ``CONFIG_ERROR``, which is what ``commands/config.py``'s + ``_handle_config_service_error`` does and what a ``--json`` consumer cannot + recover from. Same shape as that helper, corrected code lookup -- the + pattern ``server/app.py``'s ConfigError handler already uses. + """ + if isinstance(exc, ConfigError): + formatter.error( + message=exc.message, + error_code=getattr(exc, "error_code", ErrorCode.CONFIG_ERROR), + ) + raise typer.Exit(code=5) from None + formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + + +def _usage_error(formatter: Any, message: str) -> NoReturn: + formatter.error(message=message, error_code=ErrorCode.INVALID_ARGUMENT) + raise typer.Exit(code=2) + + +# -- Target resolution ------------------------------------------------------------- + + +@dataclass(frozen=True) +class _Target: + """What a command operates on, and how that was decided. + + ``row`` is the service's enriched MR row (raw + ``derived_state`` + + ``allowed_actions``). It is always present when the target was resolved + from a branch (``find_merge_request_for_branch`` returns it for free) and + fetched on demand (``need_row``) when the id was explicit -- one GET via + ``get_merge_request_row``, never the three-call detail. + """ + + alias: str + merge_request_id: int + row: dict[str, Any] | None + branch_id: int | None + resolved_from_branch: bool + + @property + def auto_merge_strategy(self) -> str: + """``immediately`` | ``scheduled`` | ``none``; ``none`` when unknown.""" + if not self.row: + return _AUTO_MERGE_DISARMED + return str(self.row.get("autoMergeStrategy") or _AUTO_MERGE_DISARMED) + + @property + def armed(self) -> bool: + return self.auto_merge_strategy != _AUTO_MERGE_DISARMED + + +def _branch_from_row(row: dict[str, Any] | None) -> int | None: + raw = ((row or {}).get("branches") or {}).get("branchFromId") + try: + return int(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + +def _resolve_target( + ctx: typer.Context, + formatter: Any, + *, + project: str | None, + merge_request_id: int | None, + branch: int | None, + need_row: bool, +) -> _Target: + """Resolve which merge request a command operates on. + + 1. ``--merge-request-id`` given -> that (``row`` fetched only if ``need_row``). + 2. Else ``resolve_branch()``: explicit ``--branch``, else ``active_branch_id``. + 3. On that branch, ``find_merge_request_for_branch()`` -> the MR (and its row). + + Both flags at once is exit 2, not silent precedence: they are two ways of + naming one target, and a contradiction (MR 7 not being FROM branch 123) is + exactly what a ``--json`` script would never notice. With neither and no + active branch, the house wording: pass ``--branch`` or run ``branch use``. + Service errors propagate -- the caller's ``except`` routes them through + :func:`_handle_error` (a feature-less project surfaces here as + ``FEATURE_NOT_ENABLED``, since the resolver runs the feature pre-flight on + its no-match path). + """ + if merge_request_id is not None and branch is not None: + _usage_error( + formatter, + "Pass either --merge-request-id or --branch, not both -- they are two ways " + "of naming the same merge request.", + ) + alias = resolve_project_alias(ctx, formatter, project) + service = get_service(ctx, "merge_request_service") + + if merge_request_id is not None: + row = service.get_merge_request_row(alias, merge_request_id) if need_row else None + return _Target( + alias=alias, + merge_request_id=merge_request_id, + row=row, + branch_id=_branch_from_row(row), + resolved_from_branch=False, + ) + + config_store = get_service(ctx, "config_store") + _, branch_id = resolve_branch(config_store, formatter, alias, branch) + if branch_id is None: + formatter.error( + message=( + f"No merge request selected for project '{alias}': pass " + "--merge-request-id, or --branch, or run `kbagent branch use` first." + ), + error_code=ErrorCode.CONFIG_ERROR, + ) + raise typer.Exit(code=5) + row = service.find_merge_request_for_branch(alias, branch_id) + resolved_id = int(row["id"]) + if not formatter.json_mode: + formatter.err_console.print( + f"[bold blue]Info:[/bold blue] Resolved merge request #{resolved_id} " + f"from branch {branch_id}" + ) + return _Target( + alias=alias, + merge_request_id=resolved_id, + row=row, + branch_id=branch_id, + resolved_from_branch=True, + ) + + +def _stamp_target(result: dict[str, Any], target: _Target) -> dict[str, Any]: + """Add the target facts every ``--json`` result carries regardless of how + the target was reached -- so a machine caller can always assert on what was + actually operated upon. Never overwrites a key the service already set.""" + result.setdefault("merge_request_id", target.merge_request_id) + result.setdefault("branch_from_id", target.branch_id) + result.setdefault("resolved_from_branch", target.resolved_from_branch) + return result + + +# -- Destructive-under-json rule and auto-merge escalation ---------------------- + + +def _require_explicit_target_under_json( + formatter: Any, + *, + merge_request_id: int | None, + branch: int | None, + reason: str, + suggested_id: int | None = None, +) -> None: + """When an invocation resolves to the destructive class, ``--json`` requires + an explicit target. + + Every destructive command in kbagent either prompts or is told its target; + none relies on the prompt for machine safety (``--json`` implies consent + in all 48 commands carrying ``--yes``). A bare ``--json merge-request + merge`` would do neither -- the first command where nothing on the command + line identifies what gets destroyed. Humans keep the active-branch + fallback and get the prompt; a script, which received the id in its + previous call's payload, names it. + """ + if not formatter.json_mode or merge_request_id is not None or branch is not None: + return + hint = ( + f"--merge-request-id {suggested_id}" if suggested_id else "--merge-request-id or --branch" + ) + _usage_error( + formatter, + f"{reason} Under --json a destructive operation needs an explicit target: pass {hint}.", + ) + + +def _escalate_if_armed( + ctx: typer.Context, + formatter: Any, + target: _Target, + *, + operation: str, + merge_request_id: int | None, + branch: int | None, +) -> str | None: + """Apply the state-derived destructive escalation for an armed MR. + + Returns the strategy (``immediately`` / ``scheduled``) when the MR is armed + so the caller can say so in its output, or ``None``. Order matters: the + policy check comes first (a denial is the stronger statement -- telling a + denied caller to "pass --merge-request-id" would not help), then the + ``--json`` explicit-target rule. That rule can only fire AFTER resolution + here -- whether the invocation is destructive is only known from the + fetched row; the check cannot move earlier because the information does + not exist earlier. One wasted round trip on the rare path is the price of + a rule with no exceptions. + """ + if not target.armed: + return None + check_cli_operation(ctx, f"merge-request.{operation} --auto-merge-armed") + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason=( + f"Merge request #{target.merge_request_id} has auto-merge armed " + f"({target.auto_merge_strategy}), so `{operation}` will cause a production merge." + ), + suggested_id=target.merge_request_id, + ) + return target.auto_merge_strategy + + +def _armed_warning(strategy: str, *, what_happened: str) -> str: + return ( + f"Auto-merge is armed ({strategy}) -- {what_happened}, and the backend will merge " + "this merge request into production on its next tick. Disarm with " + "`merge-request update --auto-merge-strategy none` if that is not intended." + ) + + +# -- Output helpers ---------------------------------------------------------------- + + +def _emit_warnings(formatter: Any, result: dict[str, Any]) -> None: + """Render ``warnings[]`` -- the group's one soft-failure key -- in human mode. + In ``--json`` the key is in the payload; ``formatter.warning`` is human-only.""" + for warning in result.get("warnings") or []: + formatter.warning(str(warning)) + + +def _hint_next(formatter: Any, text: str) -> None: + """The one-line next step every command ends with in human mode. Rich-only: + ``--json`` consumers have ``allowed_actions`` as data on every result.""" + if not formatter.json_mode: + formatter.console.print(f"[dim]Next:[/dim] {text}") + + +# -- Reads ------------------------------------------------------------------------------ + + +@merge_request_app.command("list") +def merge_request_list( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + state: str | None = typer.Option( + None, + "--state", + help=( + "Show only merge requests in this state (client-side filter). Accepted: " + + ", ".join(sorted(STATE_FILTER_VOCABULARY)) + ), + ), +) -> None: + """List the project's merge requests, newest first. + + Status is the derived state the web UI shows (in_development, in_review, + approved, in_merge, merged, closed, rejected), not the raw lifecycle + state. Single-project: pass --project or rely on the `project use` pin. + """ + formatter = get_formatter(ctx) + if state is not None and state.lower() not in STATE_FILTER_VOCABULARY: + # Pre-validated here so a typo exits 2 like every other bad-enum flag + # in kbagent, instead of reaching the service and exiting 5. + _usage_error( + formatter, + f"Unknown --state value {state!r}. Accepted values: " + f"{', '.join(sorted(STATE_FILTER_VOCABULARY))}.", + ) + service = get_service(ctx, "merge_request_service") + try: + alias = resolve_project_alias(ctx, formatter, project) + result = service.list_merge_requests(alias, state=state) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + formatter.output(result, format_merge_requests_table) + _emit_warnings(formatter, result) + if result.get("count"): + _hint_next( + formatter, + "`merge-request detail --merge-request-id ` for readiness, reviewers and conflicts", + ) + elif result.get("feature_enabled") is not False: + _hint_next(formatter, "`merge-request create --title ...` from your active branch") + + +@merge_request_app.command("detail") +def merge_request_detail( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + activity_log: bool = typer.Option( + False, "--activity-log", help="Include the merge request's activity log" + ), +) -> None: + """Show one merge request: readiness, blockers, reviewers, change log, conflicts. + + Readiness (`mergeable` / `merge_blockers`) is informational -- the merge + itself stays the authority. The change log is empty until the merge + request is sent for review; that is the backend's behaviour, not a gap. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=False, + ) + result = _stamp_target( + service.get_merge_request( + target.alias, target.merge_request_id, include_activity_log=activity_log + ), + target, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + formatter.output(result, format_merge_request_detail) + _emit_warnings(formatter, result) + hints = next_step_hints(result.get("allowed_actions")) + if hints: + _hint_next(formatter, " | ".join(f"`{h}`" for h in hints)) + + +@merge_request_app.command("conflicts") +def merge_request_conflicts( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, +) -> None: + """List the configurations changed on both sides (computed live by the backend). + + Conflicts are re-validated on every call and on every merge attempt, so + rebasing each listed configuration (`merge-request resolve`) is sufficient + -- there is no separate re-validate step. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=False, + ) + result = _stamp_target( + service.list_conflicts(target.alias, target.merge_request_id), target + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + formatter.output(result, format_conflicts_table) + _emit_warnings(formatter, result) + conflicts = result.get("conflicts") or [] + if conflicts: + first = conflicts[0] + _hint_next( + formatter, + f"`merge-request diff --component-id {first.get('componentId')} " + f"--config-id {first.get('configurationId')}` to see what differs, then " + "`merge-request resolve --take ours|theirs|delete`", + ) + else: + _hint_next(formatter, "`merge-request merge` -- nothing blocks it on the conflict side") + + +@merge_request_app.command("diff") +def merge_request_diff( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + component_id: str = typer.Option(..., "--component-id", help="Component ID"), + config_id: str = typer.Option(..., "--config-id", help="Configuration ID"), + output_format: str = typer.Option( + "short", + "--format", + help="short (long values elided) | full (print every value whole)", + ), + output: Path | None = typer.Option( + None, + "--output", + help=( + "Write the resolution candidate (your branch's content, ready to edit) to " + "this file; hand it back with `merge-request resolve --resolved @FILE`" + ), + ), +) -> None: + """Three-way diff of one conflicting configuration, classified per path. + + Each differing path is tagged by who changed it: both (the actual + conflict), only you, or only production. Deletions are not paths -- a + side deleted wholesale is reported as such, with the resolution to pick. + The branch is the merge request's own; there is no --branch of the diff. + """ + formatter = get_formatter(ctx) + if output_format not in ("short", "full"): + _usage_error(formatter, f"Unknown --format value {output_format!r}: use short or full.") + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=False, + ) + result = _stamp_target( + service.get_config_diff(target.alias, target.merge_request_id, component_id, config_id), + target, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + if output is not None: + candidate = result.get("resolution_candidate") + if candidate is None: + # Nothing to prefill: the configuration is deleted (or absent) in + # the branch. A skeleton here would be a misleading file kbagent + # then refuses; the resolution for this shape is --take delete. + _usage_error( + formatter, + "--output has nothing to write: the configuration is deleted in your " + "branch, so there is no content to edit. Resolve with " + "`merge-request resolve --take delete` (or `--take theirs` to keep " + "production's version).", + ) + output.write_text(json.dumps(candidate, indent=2, ensure_ascii=False) + "\n") + result["output_path"] = str(output) + + formatter.output(result, lambda c, d: format_config_diff(c, d, full=output_format == "full")) + _emit_warnings(formatter, result) + resolve_cmd = f"merge-request resolve --component-id {component_id} --config-id {config_id}" + if output is not None: + _hint_next( + formatter, + f"edit {output}, then `{resolve_cmd} --resolved @{output}`", + ) + elif result.get("ours_deleted") or result.get("theirs_deleted"): + _hint_next(formatter, f"`{resolve_cmd} --take delete|ours|theirs` as recommended above") + else: + _hint_next( + formatter, + f"`{resolve_cmd} --take ours|theirs`, or `merge-request diff ... --output FILE` " + "to edit a resolution by hand", + ) diff --git a/src/keboola_agent_cli/commands/transformation.py b/src/keboola_agent_cli/commands/transformation.py index 1ebbc408..d5d1bcfb 100644 --- a/src/keboola_agent_cli/commands/transformation.py +++ b/src/keboola_agent_cli/commands/transformation.py @@ -9,7 +9,6 @@ from __future__ import annotations import json -import sys from pathlib import Path from typing import Any @@ -24,6 +23,7 @@ check_cli_permission, get_formatter, map_error_to_exit_code, + parse_json_arg, resolve_branch, resolve_project_alias, ) @@ -55,25 +55,6 @@ def _get_transformation_service(ctx: typer.Context) -> TransformationService: return service -def _parse_json_arg(raw: str, *, label: str) -> Any: - """Parse a JSON argument: inline JSON, @file, or - for stdin. - - Raises: - ValueError: On missing file or malformed JSON (message names the flag). - """ - try: - if raw == "-": - return json.loads(sys.stdin.read()) - if raw.startswith("@"): - file_path = Path(raw[1:]) - if not file_path.is_file(): - raise ValueError(f"{label}: file not found: {file_path}") - return json.loads(file_path.read_text(encoding="utf-8")) - return json.loads(raw) - except json.JSONDecodeError as exc: - raise ValueError(f"{label}: invalid JSON: {exc}") from exc - - def _render_blocks_human(console: Console, data: dict[str, Any]) -> None: """Render the block/code tree with synthetic IDs and SQL snippets.""" name = data.get("name") or "" @@ -365,7 +346,7 @@ def transformation_edit( try: raw_ops = _collect_ops(op, op_file) storage_payload = ( - _parse_json_arg(storage, label="--storage") if storage is not None else None + parse_json_arg(storage, label="--storage") if storage is not None else None ) except ValueError as exc: formatter.error(message=str(exc), error_code=ErrorCode.INPUT_ERROR) @@ -426,12 +407,12 @@ def _collect_ops(op: list[str] | None, op_file: Path | None) -> list[dict[str, A """ raw_ops: list[dict[str, Any]] = [] if op_file is not None: - parsed = _parse_json_arg(f"@{op_file}", label="--op-file") + parsed = parse_json_arg(f"@{op_file}", label="--op-file") if not isinstance(parsed, list): raise ValueError("--op-file must contain a JSON array of operation objects") entries = parsed else: - entries = [_parse_json_arg(item, label="--op") for item in op or []] + entries = [parse_json_arg(item, label="--op") for item in op or []] for index, entry in enumerate(entries): if not isinstance(entry, dict): diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index adfbc329..4ffa3b3f 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -129,6 +129,29 @@ "branch.metadata-get": "read", "branch.metadata-set": "write", "branch.metadata-delete": "destructive", + # Merge requests (non-SOX Branches 2.0). Reads are ungated on the server; + # `merge` irreversibly deletes the source branch and rewrites production + # (the class `branch.delete` occupies). `resolve` stays write despite + # replacing config content: a rebase adds a configuration version, it does + # not destroy the previous one. Five of the writes escalate to destructive + # per invocation via FLAG_ESCALATIONS below -- arming auto-merge IS a + # production merge, just a delayed one (docs/merge-requests-layer1.md). + "merge-request.list": "read", + "merge-request.detail": "read", + "merge-request.conflicts": "read", + "merge-request.diff": "read", + "merge-request.create": "write", + "merge-request.update": "write", + "merge-request.request-review": "write", + "merge-request.approve": "write", + "merge-request.request-changes": "write", + "merge-request.resolve": "write", + "merge-request.merge": "destructive", + # Serve-only: `GET /merge-requests/{project}/by-branch/{branch_id}` exposes + # the branch->MR resolver that the CLI hides behind an omitted + # --merge-request-id (there is no active-branch idiom over HTTP). No CLI + # leaf command -- exempted from the dead-key check via SERVE_ONLY_OPERATIONS. + "merge-request.by-branch": "read", # Workspace lifecycle "workspace.create": "write", "workspace.list": "read", @@ -398,6 +421,31 @@ # `auth`. FLAG_ESCALATIONS: dict[str, str] = { "auth.logout --remove-projects": "admin", + # A key here is an OPERATION STRING, not necessarily a literal flag: the + # engine looks the string up verbatim (`_matches_pattern`), so a condition + # the command derives from state works exactly like one it reads off a + # flag. The merge-request entries are the proof: + # + # `autoMergeStrategy` is not metadata. A backend scheduler runs every + # `approved` MR armed with it through the same MergeProcessor the merge + # endpoint uses (AutoMergeCandidateRepository.php:44-47, + # AutoMergeTickHandler.php:86) -- polling, retrying every tick until it + # lands. Arming it on create/update is therefore a production merge, just + # a delayed one; and request-review/approve/resolve on an ALREADY-armed MR + # are what move it into `approved`, i.e. what cause the merge. Classifying + # only `merge` as destructive would let `--deny-destructive` be bypassed by + # two write-class commands. `--auto-merge-strategy none` is the disarm and + # must NOT escalate (guard on the value, not the flag's presence), or the + # safety flag would lock the hazard in place. Deliberately conservative: + # on a 2-approval project request-review lands in in_review and merges + # nothing, but the required count is unreadable with a Storage token + # (DMD-1969), so every armed operation escalates. See + # docs/merge-requests-layer1.md, "Auto-merge is a destructive act". + "merge-request.create --auto-merge-strategy": "destructive", + "merge-request.update --auto-merge-strategy": "destructive", + "merge-request.request-review --auto-merge-armed": "destructive", + "merge-request.approve --auto-merge-armed": "destructive", + "merge-request.resolve --auto-merge-armed": "destructive", } # Operations that exist ONLY on the `kbagent serve` REST surface. They are real @@ -405,7 +453,7 @@ # they have no CLI leaf command, so the command-sync gate would otherwise report # them as dead keys -- `scripts/check_command_sync.py` subtracts this set before # its "key matching no live command" check. -SERVE_ONLY_OPERATIONS: frozenset[str] = frozenset({"auth.projects"}) +SERVE_ONLY_OPERATIONS: frozenset[str] = frozenset({"auth.projects", "merge-request.by-branch"}) # The operation namespace that disappeared with the MCP passthrough, and the diff --git a/tests/test_merge_request_cli.py b/tests/test_merge_request_cli.py new file mode 100644 index 00000000..33c4b188 --- /dev/null +++ b/tests/test_merge_request_cli.py @@ -0,0 +1,707 @@ +"""Tests for the `kbagent merge-request` command group via CliRunner (DMD-1900). + +The CLI-layer third of the group's coverage (service: test_merge_request_service.py, +client: test_merge_request_client.py). While the E2E path is unresolved -- no +project carries the feature -- this file is the only automated coverage the +commands have, so it pins every Layer 1 decision in docs/merge-requests-layer1.md +that the service cannot: target resolution, the one error handler (no +flattening of FEATURE_NOT_ENABLED), the destructive-under-json rule, the +auto-merge escalations, the warnings channel, and the renderers' branch points. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, ErrorCode, FeatureNotEnabledError, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig + +runner = CliRunner() +ALIAS = "prod" + + +def _store(config_dir: Path, *, active_branch: int | None = None) -> ConfigStore: + store = ConfigStore(config_dir=config_dir) + store.add_project( + ALIAS, + ProjectConfig( + stack_url="https://connection.keboola.com", + token="999-token", + project_name="Prod", + project_id=10, + active_branch_id=active_branch, + ), + ) + return store + + +def _run(args: list[str], store: ConfigStore, service: MagicMock, input: str | None = None) -> Any: + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.MergeRequestService") as MockService, + ): + MockStore.return_value = store + MockService.return_value = service + return runner.invoke(app, args, input=input) + + +def _json(result: Any) -> dict[str, Any]: + payload = json.loads(result.output) + return payload + + +def _row( + mr_id: int = 7, state: str = "development", branch_from: int | None = 123, **extra: Any +) -> dict[str, Any]: + """An enriched service row (what list/find/get_merge_request_row return).""" + derived = {"development": "in_development", "published": "merged", "canceled": "closed"}.get( + state, state + ) + return { + "alias": ALIAS, + "id": mr_id, + "state": state, + "title": "Add sales pipeline", + "description": "", + "creator": {"id": 42, "name": "Martin"}, + "reviewers": [], + "approvals": [], + "branches": {"branchFromId": branch_from, "branchIntoId": 1}, + "merge": {"mergedAt": None, "mergerId": None, "mergerName": ""}, + "createdAt": "2026-09-01T10:00:00+0200", + "externalId": "", + "autoMergeStrategy": "none", + "autoMergeAt": None, + "derived_state": derived, + "allowed_actions": ["request_review", "merge", "update", "resolve_conflicts"], + **extra, + } + + +@pytest.fixture +def service() -> MagicMock: + svc = MagicMock() + svc.find_merge_request_for_branch.return_value = _row() + svc.get_merge_request_row.return_value = _row() + return svc + + +# --------------------------------------------------------------------------- +# Target resolution -- the chain every command shares +# --------------------------------------------------------------------------- + + +class TestTargetResolution: + def test_explicit_id_is_used_as_is(self, tmp_path, service) -> None: + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 0, + "conflicts": [], + } + result = _run( + ["--json", "merge-request", "conflicts", "--project", ALIAS, "--merge-request-id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.list_conflicts.assert_called_once_with(ALIAS, 7) + service.find_merge_request_for_branch.assert_not_called() + data = _json(result)["data"] + assert data["merge_request_id"] == 7 + assert data["resolved_from_branch"] is False + + def test_short_alias_id(self, tmp_path, service) -> None: + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 9, + "count": 0, + "conflicts": [], + } + result = _run( + ["--json", "mr", "conflicts", "--project", ALIAS, "--id", "9"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.list_conflicts.assert_called_once_with(ALIAS, 9) + + def test_omitted_id_resolves_active_branch_then_its_mr(self, tmp_path, service) -> None: + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 0, + "conflicts": [], + } + result = _run( + ["--json", "merge-request", "conflicts", "--project", ALIAS], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 0, result.output + service.find_merge_request_for_branch.assert_called_once_with(ALIAS, 123) + service.list_conflicts.assert_called_once_with(ALIAS, 7) + data = _json(result)["data"] + assert data["resolved_from_branch"] is True + assert data["branch_from_id"] == 123 + + def test_explicit_branch_wins_over_active(self, tmp_path, service) -> None: + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 0, + "conflicts": [], + } + result = _run( + ["--json", "merge-request", "conflicts", "--project", ALIAS, "--branch", "555"], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 0, result.output + service.find_merge_request_for_branch.assert_called_once_with(ALIAS, 555) + + def test_both_flags_is_exit_2_not_silent_precedence(self, tmp_path, service) -> None: + result = _run( + [ + "--json", + "merge-request", + "conflicts", + "--project", + ALIAS, + "--merge-request-id", + "7", + "--branch", + "123", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 2 + assert _json(result)["error"]["code"] == ErrorCode.INVALID_ARGUMENT + service.find_merge_request_for_branch.assert_not_called() + service.list_conflicts.assert_not_called() + + def test_no_id_no_branch_no_active_is_exit_5_with_branch_use_hint( + self, tmp_path, service + ) -> None: + result = _run( + ["--json", "merge-request", "conflicts", "--project", ALIAS], _store(tmp_path), service + ) + assert result.exit_code == 5 + err = _json(result)["error"] + assert err["code"] == ErrorCode.CONFIG_ERROR + assert "branch use" in err["message"] + + def test_human_mode_reports_the_resolution(self, tmp_path, service) -> None: + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 0, + "conflicts": [], + } + result = _run( + ["merge-request", "conflicts", "--project", ALIAS], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 0, result.output + assert "Resolved merge request #7 from branch 123" in result.output + + def test_json_mode_does_not_print_the_info_lines(self, tmp_path, service) -> None: + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 0, + "conflicts": [], + } + result = _run( + ["--json", "merge-request", "conflicts", "--project", ALIAS], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 0, result.output + _json(result) # stdout is pure JSON + + +# --------------------------------------------------------------------------- +# One error handler -- FEATURE_NOT_ENABLED must survive on every command +# --------------------------------------------------------------------------- + + +_READ_COMMANDS = [ + ["merge-request", "detail", "--project", ALIAS], + ["merge-request", "conflicts", "--project", ALIAS], + [ + "merge-request", + "diff", + "--project", + ALIAS, + "--component-id", + "keboola.ex-db", + "--config-id", + "1", + ], +] + + +class TestErrorHandler: + @pytest.mark.parametrize("args", _READ_COMMANDS, ids=lambda a: a[1]) + def test_feature_not_enabled_from_the_resolver_keeps_its_code( + self, tmp_path, service, args + ) -> None: + # The resolver runs the feature pre-flight on its no-match path (PR #703, + # O002), so FeatureNotEnabledError now surfaces from READS with an omitted + # id -- exactly where a copied `except ConfigError -> CONFIG_ERROR` idiom + # would flatten it. + service.find_merge_request_for_branch.side_effect = FeatureNotEnabledError( + "Merge requests are not enabled on this project" + ) + result = _run(["--json", *args], _store(tmp_path, active_branch=123), service) + assert result.exit_code == 5, result.output + assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED + + def test_plain_config_error_is_config_error_exit_5(self, tmp_path, service) -> None: + service.list_merge_requests.side_effect = ConfigError("boom") + result = _run( + ["--json", "merge-request", "list", "--project", ALIAS], _store(tmp_path), service + ) + assert result.exit_code == 5 + assert _json(result)["error"]["code"] == ErrorCode.CONFIG_ERROR + + def test_api_error_maps_through_the_house_table(self, tmp_path, service) -> None: + service.list_merge_requests.side_effect = KeboolaApiError( + message="denied", status_code=403, error_code=ErrorCode.ACCESS_DENIED, retryable=False + ) + result = _run( + ["--json", "merge-request", "list", "--project", ALIAS], _store(tmp_path), service + ) + assert result.exit_code == 1 + assert _json(result)["error"]["code"] == ErrorCode.ACCESS_DENIED + + def test_not_found_from_the_resolver(self, tmp_path, service) -> None: + service.find_merge_request_for_branch.side_effect = KeboolaApiError( + message="Branch 123 has no merge request", + status_code=404, + error_code=ErrorCode.NOT_FOUND, + retryable=False, + ) + result = _run( + ["--json", "merge-request", "detail", "--project", ALIAS], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 1 + assert _json(result)["error"]["code"] == ErrorCode.NOT_FOUND + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +class TestList: + def test_json_passthrough(self, tmp_path, service) -> None: + service.list_merge_requests.return_value = { + "alias": ALIAS, + "count": 1, + "merge_requests": [_row()], + } + result = _run( + ["--json", "merge-request", "list", "--project", ALIAS], _store(tmp_path), service + ) + assert result.exit_code == 0, result.output + service.list_merge_requests.assert_called_once_with(ALIAS, state=None) + assert _json(result)["data"]["count"] == 1 + + def test_state_filter_passes_through(self, tmp_path, service) -> None: + service.list_merge_requests.return_value = { + "alias": ALIAS, + "count": 0, + "merge_requests": [], + "state_filter": "merged", + } + result = _run( + ["--json", "merge-request", "list", "--project", ALIAS, "--state", "merged"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.list_merge_requests.assert_called_once_with(ALIAS, state="merged") + + def test_unknown_state_is_exit_2_before_any_call(self, tmp_path, service) -> None: + result = _run( + ["--json", "merge-request", "list", "--project", ALIAS, "--state", "develpment"], + _store(tmp_path), + service, + ) + assert result.exit_code == 2 + err = _json(result)["error"] + assert err["code"] == ErrorCode.INVALID_ARGUMENT + assert "in_development" in err["message"] # the accepted list is spelled out + service.list_merge_requests.assert_not_called() + + def test_human_table_uses_derived_state_and_server_order(self, tmp_path, service) -> None: + service.list_merge_requests.return_value = { + "alias": ALIAS, + "count": 2, + "merge_requests": [ + _row(9, "published", branch_from=None, title="Newer"), + _row(7, "development", title="Older"), + ], + } + result = _run(["merge-request", "list", "--project", ALIAS], _store(tmp_path), service) + assert result.exit_code == 0, result.output + assert "merged" in result.output and "published" not in result.output + assert result.output.index("Newer") < result.output.index("Older") + assert "—" in result.output # null branchFromId on the merged row + + def test_optional_columns_appear_only_when_populated(self, tmp_path, service) -> None: + service.list_merge_requests.return_value = { + "alias": ALIAS, + "count": 1, + "merge_requests": [_row(createdAt=None)], + } + result = _run(["merge-request", "list", "--project", ALIAS], _store(tmp_path), service) + assert "External ID" not in result.output + assert "Merged by" not in result.output + assert "Created" not in result.output + + def test_markup_in_a_title_is_escaped(self, tmp_path, service) -> None: + service.list_merge_requests.return_value = { + "alias": ALIAS, + "count": 1, + "merge_requests": [_row(title="Fix [bold] parsing [/]")], + } + result = _run(["merge-request", "list", "--project", ALIAS], _store(tmp_path), service) + assert result.exit_code == 0, result.output + assert "[bold]" in result.output + + def test_empty_featureless_project_says_so(self, tmp_path, service) -> None: + service.list_merge_requests.return_value = { + "alias": ALIAS, + "count": 0, + "merge_requests": [], + "feature_enabled": False, + } + result = _run(["merge-request", "list", "--project", ALIAS], _store(tmp_path), service) + assert "not enabled" in result.output + assert "No merge requests" not in result.output + + def test_empty_with_feature_says_no_merge_requests(self, tmp_path, service) -> None: + service.list_merge_requests.return_value = { + "alias": ALIAS, + "count": 0, + "merge_requests": [], + "feature_enabled": True, + } + result = _run(["merge-request", "list", "--project", ALIAS], _store(tmp_path), service) + assert "No merge requests" in result.output + + +# --------------------------------------------------------------------------- +# detail +# --------------------------------------------------------------------------- + + +def _detail(**extra: Any) -> dict[str, Any]: + return { + **_row(), + "merge_blockers": [], + "mergeable": True, + "viewer": {"is_creator": True, "has_approved": False}, + "changeLog": {}, + "conflicts": [], + "conflicts_count": 0, + **extra, + } + + +class TestDetail: + def test_json_and_activity_log_flag(self, tmp_path, service) -> None: + service.get_merge_request.return_value = _detail() + result = _run( + [ + "--json", + "merge-request", + "detail", + "--project", + ALIAS, + "--id", + "7", + "--activity-log", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.get_merge_request.assert_called_once_with(ALIAS, 7, include_activity_log=True) + # explicit id: no row fetch, no branch lookup + service.get_merge_request_row.assert_not_called() + service.find_merge_request_for_branch.assert_not_called() + + def test_human_renders_readiness_viewer_and_next_steps(self, tmp_path, service) -> None: + service.get_merge_request.return_value = _detail() + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert result.exit_code == 0, result.output + assert "Mergeable" in result.output + assert "you created this merge request" in result.output + assert "you have approved" not in result.output # False renders as nothing + assert "merge-request merge" in result.output # hint-next from allowed_actions + assert "empty until the merge request is sent for review" in result.output + + def test_blockers_and_armed_auto_merge_render(self, tmp_path, service) -> None: + service.get_merge_request.return_value = _detail( + mergeable=False, + merge_blockers=["conflicts", "approvals"], + conflicts=[ + { + "componentId": "keboola.ex-db", + "configurationId": "1", + "isDeleted": False, + "message": "changed on both", + } + ], + conflicts_count=1, + autoMergeStrategy="immediately", + ) + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert "Blocked by" in result.output and "conflicts (1)" in result.output + assert "armed" in result.output and "immediately" in result.output + assert "keboola.ex-db" in result.output + + def test_viewer_none_flags_render_nothing(self, tmp_path, service) -> None: + service.get_merge_request.return_value = _detail( + viewer={"is_creator": None, "has_approved": None} + ) + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert "You:" not in result.output + + +# --------------------------------------------------------------------------- +# conflicts +# --------------------------------------------------------------------------- + + +class TestConflicts: + def test_table_and_hint_point_at_the_first_conflict(self, tmp_path, service) -> None: + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 2, + "conflicts": [ + { + "componentId": "keboola.ex-db", + "configurationId": "111", + "isDeleted": True, + "message": "m1", + }, + { + "componentId": "keboola.wr-db", + "configurationId": "222", + "isDeleted": False, + "message": "m2", + }, + ], + } + result = _run( + ["merge-request", "conflicts", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "Deleted in branch" in result.output # the flag is the DEV side's + assert ( + "--component-id keboola.ex-db" in result.output and "--config-id 111" in result.output + ) + + def test_no_conflicts(self, tmp_path, service) -> None: + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 0, + "conflicts": [], + } + result = _run( + ["merge-request", "conflicts", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert "No conflicts" in result.output + + +# --------------------------------------------------------------------------- +# diff +# --------------------------------------------------------------------------- + + +def _diff_result(**extra: Any) -> dict[str, Any]: + return { + "alias": ALIAS, + "merge_request_id": 7, + "component_id": "keboola.ex-db", + "config_id": "111", + "branch_id": 123, + "onto_version": 9, + "ours_deleted": False, + "theirs_deleted": False, + "changes": [ + { + "path": "configuration.limit", + "changed_by": "both", + "agreed": False, + "base": 100, + "ours": 500, + "theirs": 250, + }, + { + "path": "configuration.timeout", + "changed_by": "theirs", + "base": 30, + "ours": 30, + "theirs": 60, + }, + { + "path": "configuration.flag", + "changed_by": "ours", + "base": True, + "ours": False, + "theirs": True, + }, + { + "path": "configuration.x", + "changed_by": "both", + "agreed": True, + "base": 1, + "ours": 2, + "theirs": 2, + }, + ], + "resolution_candidate": { + "name": "My config", + "description": None, + "isDisabled": False, + "configuration": {"limit": 500}, + "rows": [], + }, + "diff": {}, + **extra, + } + + +_DIFF_ARGS = [ + "merge-request", + "diff", + "--project", + ALIAS, + "--id", + "7", + "--component-id", + "keboola.ex-db", + "--config-id", + "111", +] + + +class TestDiff: + def test_three_sections_plus_agreed(self, tmp_path, service) -> None: + service.get_config_diff.return_value = _diff_result() + result = _run(_DIFF_ARGS, _store(tmp_path), service) + assert result.exit_code == 0, result.output + service.get_config_diff.assert_called_once_with(ALIAS, 7, "keboola.ex-db", "111") + for heading in ( + "Both changed -- decide", + "agreed", + "Only you changed", + "Only production changed", + ): + assert heading in result.output + + def test_deleted_side_renders_recommendation_and_no_sections(self, tmp_path, service) -> None: + # Since #703 finding #4 a null side yields zero rows -- the flags ARE the content. + service.get_config_diff.return_value = _diff_result( + changes=[], + theirs_deleted=True, + resolution_candidate={ + "name": "n", + "description": None, + "isDisabled": False, + "configuration": {}, + "rows": [], + }, + ) + result = _run(_DIFF_ARGS, _store(tmp_path), service) + assert result.exit_code == 0, result.output + assert "Production deleted this configuration" in result.output + assert "--take delete" in result.output and "--take ours" in result.output + for heading in ( + "Both changed", + "Only you changed", + "Only production changed", + "No changes", + ): + assert heading not in result.output + + def test_no_rows_and_no_flags_means_the_conflict_cleared(self, tmp_path, service) -> None: + service.get_config_diff.return_value = _diff_result(changes=[]) + result = _run(_DIFF_ARGS, _store(tmp_path), service) + assert "cleared" in result.output + assert "No changes" not in result.output + + def test_output_writes_the_candidate_verbatim(self, tmp_path, service) -> None: + service.get_config_diff.return_value = _diff_result() + target = tmp_path / "resolved.json" + result = _run(["--json", *_DIFF_ARGS, "--output", str(target)], _store(tmp_path), service) + assert result.exit_code == 0, result.output + written = json.loads(target.read_text()) + assert written == _diff_result()["resolution_candidate"] + assert "description" in written and written["description"] is None # explicit null survives + assert _json(result)["data"]["output_path"] == str(target) + + def test_output_refuses_when_nothing_to_prefill(self, tmp_path, service) -> None: + service.get_config_diff.return_value = _diff_result( + changes=[], ours_deleted=True, resolution_candidate=None + ) + target = tmp_path / "resolved.json" + result = _run(["--json", *_DIFF_ARGS, "--output", str(target)], _store(tmp_path), service) + assert result.exit_code == 2 + assert "--take delete" in _json(result)["error"]["message"] + assert not target.exists() + + def test_unknown_format_is_exit_2(self, tmp_path, service) -> None: + result = _run([*_DIFF_ARGS, "--format", "wide"], _store(tmp_path), service) + assert result.exit_code == 2 + service.get_config_diff.assert_not_called() + + def test_long_values_elide_unless_full(self, tmp_path, service) -> None: + long_value = "x" * 200 + changes = [ + { + "path": "configuration.blob", + "changed_by": "ours", + "base": "", + "ours": long_value, + "theirs": "", + } + ] + service.get_config_diff.return_value = _diff_result(changes=changes) + store = _store(tmp_path) + short = _run(_DIFF_ARGS, store, service) + assert "…" in short.output and "--format full" in short.output + full = _run([*_DIFF_ARGS, "--format", "full"], store, service) + assert "Long values elided" not in full.output + # folded, not cropped: every character of the value reaches the terminal + assert full.output.count("x") >= 200 From 7558ec04eefe7fe83e253ae99eb878c2541d138f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 02:27:39 +0200 Subject: [PATCH 04/16] 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 --- .../commands/merge_request.py | 577 +++++++++++++++- tests/test_merge_request_cli.py | 618 ++++++++++++++++++ 2 files changed, 1188 insertions(+), 7 deletions(-) diff --git a/src/keboola_agent_cli/commands/merge_request.py b/src/keboola_agent_cli/commands/merge_request.py index d7af5467..71b2ff3d 100644 --- a/src/keboola_agent_cli/commands/merge_request.py +++ b/src/keboola_agent_cli/commands/merge_request.py @@ -43,13 +43,14 @@ import typer from ..errors import ConfigError, ErrorCode, KeboolaApiError -from ..services.merge_request_service import STATE_FILTER_VOCABULARY +from ..services.merge_request_service import STATE_FILTER_VOCABULARY, TAKE_MODES from ._helpers import ( check_cli_operation, check_cli_permission, get_formatter, get_service, map_error_to_exit_code, + parse_json_arg, resolve_branch, resolve_project_alias, ) @@ -267,6 +268,7 @@ def _require_explicit_target_under_json( branch: int | None, reason: str, suggested_id: int | None = None, + hint: str | None = None, ) -> None: """When an invocation resolves to the destructive class, ``--json`` requires an explicit target. @@ -281,9 +283,12 @@ def _require_explicit_target_under_json( """ if not formatter.json_mode or merge_request_id is not None or branch is not None: return - hint = ( - f"--merge-request-id {suggested_id}" if suggested_id else "--merge-request-id or --branch" - ) + if hint is None: + hint = ( + f"--merge-request-id {suggested_id}" + if suggested_id + else "--merge-request-id or --branch" + ) _usage_error( formatter, f"{reason} Under --json a destructive operation needs an explicit target: pass {hint}.", @@ -327,10 +332,18 @@ def _escalate_if_armed( return target.auto_merge_strategy -def _armed_warning(strategy: str, *, what_happened: str) -> str: +def _armed_warning(strategy: str, result: dict[str, Any]) -> str: + """What an armed MR means right now, phrased from the resulting state: + approved -> the backend merges on its next tick; anything else -> it will, + the moment the MR is approved. Always names the disarm.""" + state = str(result.get("state") or "") + when = ( + "the backend will merge it into production on its next tick" + if state == "approved" + else "the backend will merge it into production as soon as it is approved" + ) return ( - f"Auto-merge is armed ({strategy}) -- {what_happened}, and the backend will merge " - "this merge request into production on its next tick. Disarm with " + f"Auto-merge is armed ({strategy}) -- {when}. Disarm with " "`merge-request update --auto-merge-strategy none` if that is not intended." ) @@ -570,3 +583,553 @@ def merge_request_diff( f"`{resolve_cmd} --take ours|theirs`, or `merge-request diff ... --output FILE` " "to edit a resolution by hand", ) + + +# -- Writes ----------------------------------------------------------------------------- + +_AUTO_MERGE_STRATEGIES = ("immediately", "scheduled", _AUTO_MERGE_DISARMED) +_REASON_MAX_LENGTH = 1000 # MergeRequestRejectRequest::REASON_MAX_LENGTH, server-side cap + +_YES_OPT = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt") +_TITLE_OPT = typer.Option(None, "--title", help="Merge request title") +_DESCRIPTION_OPT = typer.Option( + None, "--description", help="Description (on update: an empty string clears it)" +) +_REVIEWER_OPT = typer.Option( + None, + "--reviewer-id", + help=( + "Reviewer user ID (repeatable; ids from `project member-list`). On update the " + "given set REPLACES the current reviewers -- it never appends" + ), +) +_AUTO_MERGE_STRATEGY_OPT = typer.Option( + None, + "--auto-merge-strategy", + help=( + "immediately | scheduled | none. ARMING (immediately/scheduled) is a destructive " + "operation: once the merge request is approved, the backend merges it into " + "production on its own -- no `merge` call involved. `none` disarms" + ), +) +_AUTO_MERGE_AT_OPT = typer.Option( + None, + "--auto-merge-at", + help="When to auto-merge (ISO 8601); required with --auto-merge-strategy scheduled", +) +_EXTERNAL_ID_OPT = typer.Option( + None, "--external-id", help="Free-form correlation id, e.g. a ticket (max 255 chars)" +) + + +def _validate_auto_merge_flags(formatter: Any, strategy: str | None, at: str | None) -> bool: + """Exit 2 on a bad strategy or a broken strategy/at pairing; return whether + the flags ARM auto-merge (strategy given and not `none`).""" + if strategy is not None and strategy not in _AUTO_MERGE_STRATEGIES: + _usage_error( + formatter, + f"Unknown --auto-merge-strategy {strategy!r}: use {', '.join(_AUTO_MERGE_STRATEGIES)}.", + ) + if strategy == "scheduled" and not at: + _usage_error(formatter, "--auto-merge-strategy scheduled requires --auto-merge-at.") + if at is not None and strategy != "scheduled": + _usage_error( + formatter, + "--auto-merge-at is only meaningful with --auto-merge-strategy scheduled.", + ) + return strategy is not None and strategy != _AUTO_MERGE_DISARMED + + +def _confirm_or_abort(formatter: Any, yes: bool, question: str) -> None: + """The house prompt shape: skipped by --yes and in --json (where consent is + implied and the explicit-target rule stands in for it).""" + if yes or formatter.json_mode: + return + if not typer.confirm(question): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + +def _arming_question(strategy: str, at: str | None, *, subject: str) -> str: + when = f" at {at}" if at else "" + return ( + f"Arm auto-merge ({strategy}{when}) on {subject}? Once it is approved, the backend " + "will merge it into production automatically -- without a `merge` call. Continue?" + ) + + +def _print_row_success(formatter: Any, result: dict[str, Any], headline: str) -> None: + def render(c: Any, d: dict[str, Any]) -> None: + state = str(d.get("derived_state") or d.get("state") or "").replace("_", " ") + c.print(f"[bold green]Success:[/bold green] {headline} -- state: {state}") + + formatter.output(result, render) + + +def _hint_from_actions(formatter: Any, result: dict[str, Any]) -> None: + hints = next_step_hints(result.get("allowed_actions")) + if hints: + _hint_next(formatter, " | ".join(f"`{h}`" for h in hints)) + + +@merge_request_app.command("create") +def merge_request_create( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + title: str = typer.Option(..., "--title", help="Merge request title"), + branch: int | None = typer.Option( + None, + "--branch", + help="Source dev branch ID (default: the active branch set via `branch use`)", + ), + description: str | None = _DESCRIPTION_OPT, + reviewer_id: list[int] | None = _REVIEWER_OPT, + auto_merge_strategy: str | None = _AUTO_MERGE_STRATEGY_OPT, + auto_merge_at: str | None = _AUTO_MERGE_AT_OPT, + external_id: str | None = _EXTERNAL_ID_OPT, + yes: bool = _YES_OPT, +) -> None: + """Open a merge request from a development branch into production. + + The target is always the default branch; the source is --branch or the + active branch. A branch can have one merge request, ever. On a non-SOX + project with 0 required approvals you can `merge` straight from here -- + no `request-review` needed. + """ + formatter = get_formatter(ctx) + arming = _validate_auto_merge_flags(formatter, auto_merge_strategy, auto_merge_at) + if arming: + # Arming IS a (delayed) production merge: destructive, and under + # --json it must name its target -- here the source branch. + check_cli_operation(ctx, "merge-request.create --auto-merge-strategy") + _require_explicit_target_under_json( + formatter, + merge_request_id=None, + branch=branch, + reason="--auto-merge-strategy arms an automatic production merge.", + hint="--branch", + ) + service = get_service(ctx, "merge_request_service") + try: + alias = resolve_project_alias(ctx, formatter, project) + config_store = get_service(ctx, "config_store") + _, branch_id = resolve_branch(config_store, formatter, alias, branch) + if branch_id is None: + formatter.error( + message=( + f"No source branch for project '{alias}': pass --branch or run " + "`kbagent branch use` first." + ), + error_code=ErrorCode.CONFIG_ERROR, + ) + raise typer.Exit(code=5) + if arming: + _confirm_or_abort( + formatter, + yes, + _arming_question( + str(auto_merge_strategy), + auto_merge_at, + subject=f"the new merge request from branch {branch_id}", + ), + ) + result = service.create_merge_request( + alias, + branch_from_id=branch_id, + title=title, + description=description, + reviewer_ids=reviewer_id or None, # never [] -- that REPLACES the set with nothing + auto_merge_strategy=auto_merge_strategy, + auto_merge_at=auto_merge_at, + external_id=external_id, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + result.setdefault("merge_request_id", result.get("id")) + result.setdefault("resolved_from_branch", branch is None) + if arming: + result.setdefault("warnings", []).append(_armed_warning(str(auto_merge_strategy), result)) + _print_row_success( + formatter, + result, + f"Created merge request #{result.get('id')} from branch {branch_id}", + ) + _emit_warnings(formatter, result) + _hint_from_actions(formatter, result) + + +@merge_request_app.command("update") +def merge_request_update( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + title: str | None = _TITLE_OPT, + description: str | None = _DESCRIPTION_OPT, + reviewer_id: list[int] | None = _REVIEWER_OPT, + auto_merge_strategy: str | None = _AUTO_MERGE_STRATEGY_OPT, + auto_merge_at: str | None = _AUTO_MERGE_AT_OPT, + external_id: str | None = _EXTERNAL_ID_OPT, + yes: bool = _YES_OPT, +) -> None: + """Change a merge request's title, description, reviewers, auto-merge or external id. + + Omitted fields stay as they are; an empty string clears --description / + --external-id. --reviewer-id replaces the whole reviewer set. + """ + formatter = get_formatter(ctx) + fields = ( + title, + description, + reviewer_id or None, + auto_merge_strategy, + auto_merge_at, + external_id, + ) + if all(f is None for f in fields): + # PUT {} is a server-side no-op that answers 200 -- refuse instead of + # reporting success having changed nothing. + _usage_error(formatter, "Nothing to update: pass at least one field flag.") + arming = _validate_auto_merge_flags(formatter, auto_merge_strategy, auto_merge_at) + if arming: + check_cli_operation(ctx, "merge-request.update --auto-merge-strategy") + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason="--auto-merge-strategy arms an automatic production merge.", + ) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=False, + ) + if arming: + _confirm_or_abort( + formatter, + yes, + _arming_question( + str(auto_merge_strategy), + auto_merge_at, + subject=f"merge request #{target.merge_request_id}", + ), + ) + result = _stamp_target( + service.update_merge_request( + target.alias, + target.merge_request_id, + title=title, + description=description, + reviewer_ids=reviewer_id or None, + auto_merge_strategy=auto_merge_strategy, + auto_merge_at=auto_merge_at, + external_id=external_id, + ), + target, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + if arming: + result.setdefault("warnings", []).append(_armed_warning(str(auto_merge_strategy), result)) + _print_row_success(formatter, result, f"Updated merge request #{target.merge_request_id}") + _emit_warnings(formatter, result) + _hint_from_actions(formatter, result) + + +def _transition( + ctx: typer.Context, + *, + operation: str, + project: str | None, + merge_request_id: int | None, + branch: int | None, + escalate_when_armed: bool, + call: Any, + headline: str, +) -> None: + """Shared body of request-review / approve / request-changes. + + ``escalate_when_armed`` is True for the two that move an MR toward + ``approved`` (what an armed auto-merge waits for); request-changes moves + it AWAY and deletes approvals, so it never escalates. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=escalate_when_armed, + ) + strategy = ( + _escalate_if_armed( + ctx, + formatter, + target, + operation=operation, + merge_request_id=merge_request_id, + branch=branch, + ) + if escalate_when_armed + else None + ) + result = _stamp_target(call(service, target), target) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + if strategy: + result.setdefault("warnings", []).append(_armed_warning(strategy, result)) + _print_row_success(formatter, result, headline.format(id=target.merge_request_id)) + _emit_warnings(formatter, result) + _hint_from_actions(formatter, result) + + +@merge_request_app.command("request-review") +def merge_request_request_review( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, +) -> None: + """Send the merge request for review. + + On a non-SOX project with 0 required approvals (the default) the backend + finishes the review itself and the merge request lands directly in + `approved` -- so `merge` works straight from `development` and this step + is optional. Note: with no reviewers selected, the review-requested email + goes to every project member. + """ + _transition( + ctx, + operation="request-review", + project=project, + merge_request_id=merge_request_id, + branch=branch, + escalate_when_armed=True, + call=lambda s, t: s.request_review(t.alias, t.merge_request_id), + headline="Review requested for merge request #{id}", + ) + + +@merge_request_app.command("approve") +def merge_request_approve( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, +) -> None: + """Add your approval to a merge request under review. + + Only possible while the merge request is `in_review`. On a non-SOX project + with 0 required approvals (the default) that state is never reached -- + `request-review` jumps straight to `approved` -- so this command answers + 422 there. It exists for projects that require approvals. + """ + _transition( + ctx, + operation="approve", + project=project, + merge_request_id=merge_request_id, + branch=branch, + escalate_when_armed=True, + call=lambda s, t: s.approve(t.alias, t.merge_request_id), + headline="Approved merge request #{id}", + ) + + +@merge_request_app.command("request-changes") +def merge_request_request_changes( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + reason: str | None = typer.Option( + None, "--reason", help=f"Why (max {_REASON_MAX_LENGTH} characters)" + ), +) -> None: + """Send the merge request back to development; existing approvals are removed. + + This is also the closest thing to closing a merge request: the API has no + cancel, and the web UI's "cancel" is exactly this call made by the + creator. The merge request stays open in `development` and can be + resubmitted; deleting the branch is the terminal outcome. + """ + formatter = get_formatter(ctx) + if reason is not None and len(reason) > _REASON_MAX_LENGTH: + _usage_error( + formatter, f"--reason is capped at {_REASON_MAX_LENGTH} characters (got {len(reason)})." + ) + _transition( + ctx, + operation="request-changes", + project=project, + merge_request_id=merge_request_id, + branch=branch, + escalate_when_armed=False, + call=lambda s, t: s.request_changes(t.alias, t.merge_request_id, reason=reason), + headline="Changes requested on merge request #{id}", + ) + + +@merge_request_app.command("merge") +def merge_request_merge( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + yes: bool = _YES_OPT, +) -> None: + """Merge the merge request into production and delete its source branch. + + Waits for the merge job (up to 10 minutes). Works straight from + `development` when approvals are satisfied. The source branch is always + deleted afterwards (a separate async job). Under --json the target must be + explicit: pass --merge-request-id or --branch. + """ + formatter = get_formatter(ctx) + # Statically destructive: the explicit-target rule applies before any lookup. + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason="`merge-request merge` rewrites production and deletes the source branch.", + ) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=True, + ) + title = (target.row or {}).get("title") or "" + _confirm_or_abort( + formatter, + yes, + f"Merge request #{target.merge_request_id} '{title}' will be merged into " + f"production and its source branch {target.branch_id} deleted. Continue?", + ) + result = _stamp_target(service.merge(target.alias, target.merge_request_id), target) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + formatter.output( + result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}") + ) + _emit_warnings(formatter, result) + _hint_next(formatter, "`merge-request list` -- the merged request now shows as merged") + + +@merge_request_app.command("resolve") +def merge_request_resolve( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + component_id: str = typer.Option(..., "--component-id", help="Component ID"), + config_id: str = typer.Option(..., "--config-id", help="Configuration ID"), + take: str | None = typer.Option( + None, + "--take", + help=( + "ours (keep your branch's content) | theirs (adopt production's) | delete. " + "Mutually exclusive with --resolved" + ), + ), + resolved: str | None = typer.Option( + None, + "--resolved", + help=( + "A hand-authored resolution: JSON inline, @file, or - for stdin. Start from " + "`merge-request diff --output FILE`; the body must carry name, description, " + "isDisabled, configuration and rows (rebase REPLACES the whole configuration)" + ), + ), + change_description: str | None = typer.Option( + None, "--change-description", help="Version message for the rebased configuration" + ), +) -> None: + """Resolve one conflicting configuration by rebasing it onto production's version. + + Every mode replaces the configuration in your branch; the previous content + stays in its version history. Rebasing each listed conflict makes the merge + request mergeable -- there is no re-validate step. There is deliberately no + --all: conflicts are meant to be walked, not waved away. + """ + formatter = get_formatter(ctx) + if (take is None) == (resolved is None): + _usage_error(formatter, "Pass exactly one of --take ours|theirs|delete or --resolved.") + if take is not None and take not in TAKE_MODES: + _usage_error(formatter, f"Unknown --take value {take!r}: use {', '.join(TAKE_MODES)}.") + body: dict[str, Any] | None = None + if resolved is not None: + try: + parsed = parse_json_arg(resolved, label="--resolved") + except ValueError as exc: + _usage_error(formatter, str(exc)) + if not isinstance(parsed, dict): + _usage_error( + formatter, "--resolved must be a JSON object (the replaced configuration body)." + ) + body = parsed + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=True, + ) + # Resolving the last conflict on an armed, approved MR unblocks the + # scheduler's retry loop -- it causes the merge as surely as approve does. + strategy = _escalate_if_armed( + ctx, + formatter, + target, + operation="resolve", + merge_request_id=merge_request_id, + branch=branch, + ) + result = _stamp_target( + service.resolve_conflict( + target.alias, + target.merge_request_id, + component_id, + config_id, + take=take, + resolved=body, + change_description=change_description, + ), + target, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + if strategy: + result.setdefault("warnings", []).append(_armed_warning(strategy, result)) + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Success:[/bold green] Resolved {component_id}/{config_id} " + f"({d.get('resolution')}) -- rebased onto production version {d.get('onto_version')}" + ), + ) + _emit_warnings(formatter, result) + _hint_next( + formatter, + "`merge-request conflicts` for what is left, then `merge-request merge`", + ) diff --git a/tests/test_merge_request_cli.py b/tests/test_merge_request_cli.py index 33c4b188..02071590 100644 --- a/tests/test_merge_request_cli.py +++ b/tests/test_merge_request_cli.py @@ -705,3 +705,621 @@ def test_long_values_elide_unless_full(self, tmp_path, service) -> None: assert "Long values elided" not in full.output # folded, not cropped: every character of the value reaches the terminal assert full.output.count("x") >= 200 + + +# --------------------------------------------------------------------------- +# create +# --------------------------------------------------------------------------- + + +def _created(**extra: Any) -> dict[str, Any]: + return {**_row(), "branch_from_id": 123, "branch_into_id": 1, **extra} + + +class TestCreate: + def test_create_from_active_branch(self, tmp_path, service) -> None: + service.create_merge_request.return_value = _created() + result = _run( + ["--json", "merge-request", "create", "--project", ALIAS, "--title", "T"], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 0, result.output + kwargs = service.create_merge_request.call_args.kwargs + assert kwargs["branch_from_id"] == 123 and kwargs["title"] == "T" + assert kwargs["reviewer_ids"] is None # never [] -- that would clear the set + data = _json(result)["data"] + assert data["merge_request_id"] == 7 and data["resolved_from_branch"] is True + + def test_reviewer_ids_pass_through_when_given(self, tmp_path, service) -> None: + service.create_merge_request.return_value = _created() + _run( + [ + "--json", + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + "--reviewer-id", + "5", + "--reviewer-id", + "6", + ], + _store(tmp_path), + service, + ) + assert service.create_merge_request.call_args.kwargs["reviewer_ids"] == [5, 6] + + def test_no_branch_anywhere_is_exit_5(self, tmp_path, service) -> None: + result = _run( + ["--json", "merge-request", "create", "--project", ALIAS, "--title", "T"], + _store(tmp_path), + service, + ) + assert result.exit_code == 5 + assert "branch use" in _json(result)["error"]["message"] + service.create_merge_request.assert_not_called() + + @pytest.mark.parametrize( + "flags", + [ + ["--auto-merge-strategy", "sometimes"], + ["--auto-merge-strategy", "scheduled"], # missing --auto-merge-at + ["--auto-merge-at", "2026-09-04T10:00:00Z"], # at without scheduled + ["--auto-merge-strategy", "immediately", "--auto-merge-at", "2026-09-04T10:00:00Z"], + ], + ) + def test_auto_merge_flag_pairing_is_validated(self, tmp_path, service, flags) -> None: + result = _run( + [ + "--json", + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + *flags, + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 2, result.output + service.create_merge_request.assert_not_called() + + def test_arming_is_destructive_under_deny_destructive(self, tmp_path, service) -> None: + result = _run( + [ + "--json", + "--deny-destructive", + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + "--auto-merge-strategy", + "immediately", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 6, result.output + service.create_merge_request.assert_not_called() + + def test_disarmed_none_is_NOT_destructive(self, tmp_path, service) -> None: + # `none` is the disarm -- escalating it would let --deny-destructive lock + # a dangerous setting in place. + service.create_merge_request.return_value = _created() + result = _run( + [ + "--json", + "--deny-destructive", + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + "--auto-merge-strategy", + "none", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + + def test_arming_under_json_needs_an_explicit_branch(self, tmp_path, service) -> None: + result = _run( + [ + "--json", + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--auto-merge-strategy", + "immediately", + ], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 2 + assert "--branch" in _json(result)["error"]["message"] + service.create_merge_request.assert_not_called() + + def test_arming_prompts_in_human_mode_and_warns_after(self, tmp_path, service) -> None: + service.create_merge_request.return_value = _created(autoMergeStrategy="immediately") + args = [ + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + "--auto-merge-strategy", + "immediately", + ] + store = _store(tmp_path) + aborted = _run(args, store, service, input="n\n") + assert aborted.exit_code == 0 and "Aborted" in aborted.output + service.create_merge_request.assert_not_called() + confirmed = _run(args, store, service, input="y\n") + assert confirmed.exit_code == 0, confirmed.output + assert "Arm auto-merge" in confirmed.output + assert "Auto-merge is armed (immediately)" in confirmed.output + service.create_merge_request.assert_called_once() + + def test_yes_skips_the_arming_prompt(self, tmp_path, service) -> None: + service.create_merge_request.return_value = _created() + result = _run( + [ + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + "--auto-merge-strategy", + "immediately", + "--yes", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "Continue?" not in result.output + + +# --------------------------------------------------------------------------- +# update +# --------------------------------------------------------------------------- + + +class TestUpdate: + def test_no_fields_is_exit_2(self, tmp_path, service) -> None: + result = _run( + ["--json", "merge-request", "update", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 2 + service.update_merge_request.assert_not_called() + + def test_empty_string_clears_description(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row() + result = _run( + [ + "--json", + "merge-request", + "update", + "--project", + ALIAS, + "--id", + "7", + "--description", + "", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + kwargs = service.update_merge_request.call_args.kwargs + assert kwargs["description"] == "" + assert kwargs["reviewer_ids"] is None and kwargs["title"] is None + + def test_arming_on_update_is_destructive_and_needs_explicit_target_under_json( + self, tmp_path, service + ) -> None: + denied = _run( + [ + "--json", + "--deny-destructive", + "merge-request", + "update", + "--project", + ALIAS, + "--id", + "7", + "--auto-merge-strategy", + "immediately", + ], + _store(tmp_path / "a"), + service, + ) + assert denied.exit_code == 6 + implicit = _run( + [ + "--json", + "merge-request", + "update", + "--project", + ALIAS, + "--auto-merge-strategy", + "immediately", + ], + _store(tmp_path / "b", active_branch=123), + service, + ) + assert implicit.exit_code == 2 + service.update_merge_request.assert_not_called() + + def test_disarming_needs_neither(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row() + result = _run( + [ + "--json", + "--deny-destructive", + "merge-request", + "update", + "--project", + ALIAS, + "--auto-merge-strategy", + "none", + ], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 0, result.output + + +# --------------------------------------------------------------------------- +# transitions: request-review / approve / request-changes +# --------------------------------------------------------------------------- + + +class TestTransitions: + def test_request_review_unarmed_is_plain_write(self, tmp_path, service) -> None: + service.request_review.return_value = _row(state="approved", derived_state="approved") + result = _run( + ["--json", "--deny-destructive", "merge-request", "request-review", "--project", ALIAS], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 0, result.output + service.request_review.assert_called_once_with(ALIAS, 7) + # implicit path: the row came from find -- no extra fetch + service.get_merge_request_row.assert_not_called() + + @pytest.mark.parametrize("command", ["request-review", "approve"]) + def test_armed_mr_escalates_to_destructive(self, tmp_path, service, command) -> None: + service.find_merge_request_for_branch.return_value = _row(autoMergeStrategy="immediately") + result = _run( + [ + "--json", + "--deny-destructive", + "merge-request", + command, + "--project", + ALIAS, + "--branch", + "123", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 6, result.output + getattr(service, command.replace("-", "_")).assert_not_called() + + def test_armed_with_explicit_id_fetches_the_row_once(self, tmp_path, service) -> None: + service.get_merge_request_row.return_value = _row(autoMergeStrategy="scheduled") + service.request_review.return_value = _row( + state="approved", derived_state="approved", autoMergeStrategy="scheduled" + ) + result = _run( + ["merge-request", "request-review", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.get_merge_request_row.assert_called_once_with(ALIAS, 7) + service.get_merge_request.assert_not_called() # never the three-call detail + assert "Auto-merge is armed (scheduled)" in result.output + assert "on its next tick" in result.output # state is approved + + def test_armed_implicit_target_under_json_exits_2_after_resolution( + self, tmp_path, service + ) -> None: + # Deliberate: whether the call is destructive is only known from the row. + service.find_merge_request_for_branch.return_value = _row(autoMergeStrategy="immediately") + result = _run( + ["--json", "merge-request", "request-review", "--project", ALIAS], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 2 + msg = _json(result)["error"]["message"] + assert "#7" in msg and "--merge-request-id 7" in msg + service.find_merge_request_for_branch.assert_called_once() + service.request_review.assert_not_called() + + def test_request_changes_never_escalates_and_caps_reason(self, tmp_path, service) -> None: + service.find_merge_request_for_branch.return_value = _row(autoMergeStrategy="immediately") + service.request_changes.return_value = _row() + ok = _run( + [ + "--json", + "--deny-destructive", + "merge-request", + "request-changes", + "--project", + ALIAS, + "--reason", + "nope", + ], + _store(tmp_path / "a", active_branch=123), + service, + ) + assert ok.exit_code == 0, ok.output + service.request_changes.assert_called_once_with(ALIAS, 7, reason="nope") + too_long = _run( + [ + "--json", + "merge-request", + "request-changes", + "--project", + ALIAS, + "--id", + "7", + "--reason", + "x" * 1001, + ], + _store(tmp_path / "b"), + service, + ) + assert too_long.exit_code == 2 + + +# --------------------------------------------------------------------------- +# merge +# --------------------------------------------------------------------------- + + +def _merged() -> dict[str, Any]: + return { + "alias": ALIAS, + "merge_request_id": 7, + "branch_from_id": 123, + "was_active": True, + "job": {"id": 1}, + "state": "published", + "derived_state": "merged", + "message": "Merge request 7 merged into production. Source branch 123 is being deleted.", + } + + +class TestMerge: + def test_json_without_a_target_is_exit_2_before_any_lookup(self, tmp_path, service) -> None: + result = _run( + ["--json", "merge-request", "merge", "--project", ALIAS], + _store(tmp_path, active_branch=123), + service, + ) + assert result.exit_code == 2 + assert "explicit target" in _json(result)["error"]["message"] + service.find_merge_request_for_branch.assert_not_called() + service.merge.assert_not_called() + + def test_json_with_branch_is_an_explicit_target(self, tmp_path, service) -> None: + service.merge.return_value = _merged() + result = _run( + ["--json", "merge-request", "merge", "--project", ALIAS, "--branch", "123"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.merge.assert_called_once_with(ALIAS, 7) + + def test_human_keeps_the_fallback_and_prompts_with_branch_and_title( + self, tmp_path, service + ) -> None: + service.merge.return_value = _merged() + store = _store(tmp_path, active_branch=123) + aborted = _run(["merge-request", "merge", "--project", ALIAS], store, service, input="n\n") + assert aborted.exit_code == 0 and "Aborted" in aborted.output + assert ( + "#7 'Add sales pipeline'" in aborted.output and "branch 123 deleted" in aborted.output + ) + service.merge.assert_not_called() + confirmed = _run( + ["merge-request", "merge", "--project", ALIAS], store, service, input="y\n" + ) + assert confirmed.exit_code == 0, confirmed.output + assert "is being deleted" in confirmed.output + service.merge.assert_called_once_with(ALIAS, 7) + + def test_deny_destructive_blocks_merge(self, tmp_path, service) -> None: + result = _run( + [ + "--json", + "--deny-destructive", + "merge-request", + "merge", + "--project", + ALIAS, + "--id", + "7", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 6 + service.merge.assert_not_called() + + def test_post_merge_warnings_render(self, tmp_path, service) -> None: + service.merge.return_value = { + **_merged(), + "warnings": ["Post-merge active-branch reset failed: disk full"], + } + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "disk full" in result.output + + def test_merge_conflict_error_passes_through(self, tmp_path, service) -> None: + service.merge.side_effect = KeboolaApiError( + message="conflicts", + status_code=409, + error_code=ErrorCode.MR_MERGE_CONFLICT, + retryable=False, + ) + result = _run( + ["--json", "merge-request", "merge", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 1 + assert _json(result)["error"]["code"] == ErrorCode.MR_MERGE_CONFLICT + + +# --------------------------------------------------------------------------- +# resolve +# --------------------------------------------------------------------------- + + +_RESOLVE = [ + "merge-request", + "resolve", + "--project", + ALIAS, + "--id", + "7", + "--component-id", + "keboola.ex-db", + "--config-id", + "111", +] + + +def _resolved(**extra: Any) -> dict[str, Any]: + return { + "alias": ALIAS, + "merge_request_id": 7, + "component_id": "keboola.ex-db", + "config_id": "111", + "branch_id": 123, + "resolution": "ours", + "onto_version": 9, + "configuration": {"id": "111", "version": 10}, + **extra, + } + + +class TestResolve: + def test_take_passes_through(self, tmp_path, service) -> None: + service.resolve_conflict.return_value = _resolved() + result = _run(["--json", *_RESOLVE, "--take", "ours"], _store(tmp_path), service) + assert result.exit_code == 0, result.output + service.resolve_conflict.assert_called_once_with( + ALIAS, 7, "keboola.ex-db", "111", take="ours", resolved=None, change_description=None + ) + + @pytest.mark.parametrize( + "extra", + [ + [], # neither + ["--take", "ours", "--resolved", "{}"], # both + ["--take", "mine"], # unknown mode + ["--resolved", "[1,2]"], # not an object + ["--resolved", "{not json"], # malformed + ], + ) + def test_argument_shape_errors_are_exit_2(self, tmp_path, service, extra) -> None: + result = _run(["--json", *_RESOLVE, *extra], _store(tmp_path), service) + assert result.exit_code == 2, result.output + service.resolve_conflict.assert_not_called() + + def test_resolved_from_file_round_trips_the_diff_output(self, tmp_path, service) -> None: + candidate = { + "name": "n", + "description": None, + "isDisabled": False, + "configuration": {"limit": 5}, + "rows": [], + } + path = tmp_path / "resolved.json" + path.write_text(json.dumps(candidate)) + service.resolve_conflict.return_value = _resolved(resolution="custom") + result = _run( + [ + "--json", + *_RESOLVE, + "--resolved", + f"@{path}", + "--change-description", + "merged by hand", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + kwargs = service.resolve_conflict.call_args.kwargs + assert kwargs["resolved"] == candidate and kwargs["take"] is None + assert kwargs["change_description"] == "merged by hand" + + def test_service_warnings_render(self, tmp_path, service) -> None: + service.resolve_conflict.return_value = _resolved( + resolution="delete", + warnings=["--change-description dropped: the delete tombstone cannot carry one"], + ) + result = _run( + [*_RESOLVE, "--take", "delete", "--change-description", "x"], _store(tmp_path), service + ) + assert result.exit_code == 0, result.output + assert "dropped" in result.output + + def test_resolve_on_an_armed_mr_escalates(self, tmp_path, service) -> None: + service.get_merge_request_row.return_value = _row( + autoMergeStrategy="immediately", state="approved" + ) + result = _run( + ["--json", "--deny-destructive", *_RESOLVE, "--take", "theirs"], + _store(tmp_path), + service, + ) + assert result.exit_code == 6 + service.resolve_conflict.assert_not_called() + + def test_feature_not_enabled_from_resolve_keeps_its_code(self, tmp_path, service) -> None: + service.resolve_conflict.side_effect = FeatureNotEnabledError("not enabled") + result = _run(["--json", *_RESOLVE, "--take", "ours"], _store(tmp_path), service) + assert result.exit_code == 5 + assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED From 59a4b5a7021dfcc98ce1d36299d43c5a719824d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 02:29:30 +0200 Subject: [PATCH 05/16] 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 --- .../commands/_merge_request_common.py | 334 +++++++ .../commands/_merge_request_writes.py | 587 ++++++++++++ .../commands/merge_request.py | 873 +----------------- 3 files changed, 942 insertions(+), 852 deletions(-) create mode 100644 src/keboola_agent_cli/commands/_merge_request_common.py create mode 100644 src/keboola_agent_cli/commands/_merge_request_writes.py diff --git a/src/keboola_agent_cli/commands/_merge_request_common.py b/src/keboola_agent_cli/commands/_merge_request_common.py new file mode 100644 index 00000000..39568601 --- /dev/null +++ b/src/keboola_agent_cli/commands/_merge_request_common.py @@ -0,0 +1,334 @@ +"""Shared machinery of the ``kbagent merge-request`` group. + +Everything ``merge_request.py`` (reads) and ``_merge_request_writes.py`` +(writes) have in common, in one place so the two command modules cannot drift: +the option declarations, the ONE error handler, target resolution, the +destructive-under-json rule and the auto-merge escalation, and the output +helpers. Design record: ``docs/merge-requests-layer1.md``. + +Split out of ``merge_request.py`` when the group crossed the 800-code-line soft +ceiling (eleven commands at ~75 lines each, as the RFC predicted). A third +module rather than reads importing from writes or vice versa: both command +modules import from here and only ``merge_request.py`` imports the writes, so +there is no cycle. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, NoReturn + +import typer + +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ._helpers import ( + check_cli_operation, + get_service, + map_error_to_exit_code, + resolve_branch, + resolve_project_alias, +) +from ._merge_request_render import next_step_hints + +# -- Shared option declarations ------------------------------------------------ +# +# Reused across commands so the help text and the flag names cannot drift +# between them. `--merge-request-id`/`--id`: a merge request is the OBJECT the +# command acts on, hence `---id` like --config-id/--table-id (the bare +# nouns --project/--branch are the CONTEXT you work in); `--id` is the short +# alias the `agent` group already established beside `--task-id`. + +_PROJECT_OPT = typer.Option( + None, + "--project", + help="Project alias (default: KBAGENT_PROJECT, then the `project use` pin, then the sole project)", +) +_MERGE_REQUEST_ID_OPT = typer.Option( + None, + "--merge-request-id", + "--id", + help=( + "Merge request ID. Omit to use the merge request of --branch, or of the " + "active branch (`branch use`)" + ), +) +_BRANCH_OPT = typer.Option( + None, + "--branch", + help=( + "Dev branch ID whose merge request to use (default: the active branch set " + "via `branch use`). Mutually exclusive with --merge-request-id" + ), +) + +_AUTO_MERGE_DISARMED = "none" + + +# -- Error handling --------------------------------------------------------------- + + +def _handle_error(formatter: Any, exc: ConfigError | KeboolaApiError) -> NoReturn: + """The group's ONE ``ConfigError``/``KeboolaApiError`` -> exit-code mapping. + + No command in this module has its own ``except``: eleven inline copies of + the idiom are eleven places to flatten ``FeatureNotEnabledError`` (a + ``ConfigError`` subclass carrying ``FEATURE_NOT_ENABLED``) into a bare + ``CONFIG_ERROR``, which is what ``commands/config.py``'s + ``_handle_config_service_error`` does and what a ``--json`` consumer cannot + recover from. Same shape as that helper, corrected code lookup -- the + pattern ``server/app.py``'s ConfigError handler already uses. + """ + if isinstance(exc, ConfigError): + formatter.error( + message=exc.message, + error_code=getattr(exc, "error_code", ErrorCode.CONFIG_ERROR), + ) + raise typer.Exit(code=5) from None + formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + + +def _usage_error(formatter: Any, message: str) -> NoReturn: + formatter.error(message=message, error_code=ErrorCode.INVALID_ARGUMENT) + raise typer.Exit(code=2) + + +# -- Target resolution ------------------------------------------------------------- + + +@dataclass(frozen=True) +class _Target: + """What a command operates on, and how that was decided. + + ``row`` is the service's enriched MR row (raw + ``derived_state`` + + ``allowed_actions``). It is always present when the target was resolved + from a branch (``find_merge_request_for_branch`` returns it for free) and + fetched on demand (``need_row``) when the id was explicit -- one GET via + ``get_merge_request_row``, never the three-call detail. + """ + + alias: str + merge_request_id: int + row: dict[str, Any] | None + branch_id: int | None + resolved_from_branch: bool + + @property + def auto_merge_strategy(self) -> str: + """``immediately`` | ``scheduled`` | ``none``; ``none`` when unknown.""" + if not self.row: + return _AUTO_MERGE_DISARMED + return str(self.row.get("autoMergeStrategy") or _AUTO_MERGE_DISARMED) + + @property + def armed(self) -> bool: + return self.auto_merge_strategy != _AUTO_MERGE_DISARMED + + +def _branch_from_row(row: dict[str, Any] | None) -> int | None: + raw = ((row or {}).get("branches") or {}).get("branchFromId") + try: + return int(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + +def _resolve_target( + ctx: typer.Context, + formatter: Any, + *, + project: str | None, + merge_request_id: int | None, + branch: int | None, + need_row: bool, +) -> _Target: + """Resolve which merge request a command operates on. + + 1. ``--merge-request-id`` given -> that (``row`` fetched only if ``need_row``). + 2. Else ``resolve_branch()``: explicit ``--branch``, else ``active_branch_id``. + 3. On that branch, ``find_merge_request_for_branch()`` -> the MR (and its row). + + Both flags at once is exit 2, not silent precedence: they are two ways of + naming one target, and a contradiction (MR 7 not being FROM branch 123) is + exactly what a ``--json`` script would never notice. With neither and no + active branch, the house wording: pass ``--branch`` or run ``branch use``. + Service errors propagate -- the caller's ``except`` routes them through + :func:`_handle_error` (a feature-less project surfaces here as + ``FEATURE_NOT_ENABLED``, since the resolver runs the feature pre-flight on + its no-match path). + """ + if merge_request_id is not None and branch is not None: + _usage_error( + formatter, + "Pass either --merge-request-id or --branch, not both -- they are two ways " + "of naming the same merge request.", + ) + alias = resolve_project_alias(ctx, formatter, project) + service = get_service(ctx, "merge_request_service") + + if merge_request_id is not None: + row = service.get_merge_request_row(alias, merge_request_id) if need_row else None + return _Target( + alias=alias, + merge_request_id=merge_request_id, + row=row, + branch_id=_branch_from_row(row), + resolved_from_branch=False, + ) + + config_store = get_service(ctx, "config_store") + _, branch_id = resolve_branch(config_store, formatter, alias, branch) + if branch_id is None: + formatter.error( + message=( + f"No merge request selected for project '{alias}': pass " + "--merge-request-id, or --branch, or run `kbagent branch use` first." + ), + error_code=ErrorCode.CONFIG_ERROR, + ) + raise typer.Exit(code=5) + row = service.find_merge_request_for_branch(alias, branch_id) + resolved_id = int(row["id"]) + if not formatter.json_mode: + formatter.err_console.print( + f"[bold blue]Info:[/bold blue] Resolved merge request #{resolved_id} " + f"from branch {branch_id}" + ) + return _Target( + alias=alias, + merge_request_id=resolved_id, + row=row, + branch_id=branch_id, + resolved_from_branch=True, + ) + + +def _stamp_target(result: dict[str, Any], target: _Target) -> dict[str, Any]: + """Add the target facts every ``--json`` result carries regardless of how + the target was reached -- so a machine caller can always assert on what was + actually operated upon. Never overwrites a key the service already set.""" + result.setdefault("merge_request_id", target.merge_request_id) + result.setdefault("branch_from_id", target.branch_id) + result.setdefault("resolved_from_branch", target.resolved_from_branch) + return result + + +# -- Destructive-under-json rule and auto-merge escalation ---------------------- + + +def _require_explicit_target_under_json( + formatter: Any, + *, + merge_request_id: int | None, + branch: int | None, + reason: str, + suggested_id: int | None = None, + hint: str | None = None, +) -> None: + """When an invocation resolves to the destructive class, ``--json`` requires + an explicit target. + + Every destructive command in kbagent either prompts or is told its target; + none relies on the prompt for machine safety (``--json`` implies consent + in all 48 commands carrying ``--yes``). A bare ``--json merge-request + merge`` would do neither -- the first command where nothing on the command + line identifies what gets destroyed. Humans keep the active-branch + fallback and get the prompt; a script, which received the id in its + previous call's payload, names it. + """ + if not formatter.json_mode or merge_request_id is not None or branch is not None: + return + if hint is None: + hint = ( + f"--merge-request-id {suggested_id}" + if suggested_id + else "--merge-request-id or --branch" + ) + _usage_error( + formatter, + f"{reason} Under --json a destructive operation needs an explicit target: pass {hint}.", + ) + + +def _escalate_if_armed( + ctx: typer.Context, + formatter: Any, + target: _Target, + *, + operation: str, + merge_request_id: int | None, + branch: int | None, +) -> str | None: + """Apply the state-derived destructive escalation for an armed MR. + + Returns the strategy (``immediately`` / ``scheduled``) when the MR is armed + so the caller can say so in its output, or ``None``. Order matters: the + policy check comes first (a denial is the stronger statement -- telling a + denied caller to "pass --merge-request-id" would not help), then the + ``--json`` explicit-target rule. That rule can only fire AFTER resolution + here -- whether the invocation is destructive is only known from the + fetched row; the check cannot move earlier because the information does + not exist earlier. One wasted round trip on the rare path is the price of + a rule with no exceptions. + """ + if not target.armed: + return None + check_cli_operation(ctx, f"merge-request.{operation} --auto-merge-armed") + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason=( + f"Merge request #{target.merge_request_id} has auto-merge armed " + f"({target.auto_merge_strategy}), so `{operation}` will cause a production merge." + ), + suggested_id=target.merge_request_id, + ) + return target.auto_merge_strategy + + +def _armed_warning(strategy: str, result: dict[str, Any]) -> str: + """What an armed MR means right now, phrased from the resulting state: + approved -> the backend merges on its next tick; anything else -> it will, + the moment the MR is approved. Always names the disarm.""" + state = str(result.get("state") or "") + when = ( + "the backend will merge it into production on its next tick" + if state == "approved" + else "the backend will merge it into production as soon as it is approved" + ) + return ( + f"Auto-merge is armed ({strategy}) -- {when}. Disarm with " + "`merge-request update --auto-merge-strategy none` if that is not intended." + ) + + +# -- Output helpers ---------------------------------------------------------------- + + +def _emit_warnings(formatter: Any, result: dict[str, Any]) -> None: + """Render ``warnings[]`` -- the group's one soft-failure key -- in human mode. + In ``--json`` the key is in the payload; ``formatter.warning`` is human-only.""" + for warning in result.get("warnings") or []: + formatter.warning(str(warning)) + + +def _hint_next(formatter: Any, text: str) -> None: + """The one-line next step every command ends with in human mode. Rich-only: + ``--json`` consumers have ``allowed_actions`` as data on every result.""" + if not formatter.json_mode: + formatter.console.print(f"[dim]Next:[/dim] {text}") + + +def _hint_from_actions(formatter: Any, result: dict[str, Any]) -> None: + hints = next_step_hints(result.get("allowed_actions")) + if hints: + _hint_next(formatter, " | ".join(f"`{h}`" for h in hints)) + + +def _print_row_success(formatter: Any, result: dict[str, Any], headline: str) -> None: + def render(c: Any, d: dict[str, Any]) -> None: + state = str(d.get("derived_state") or d.get("state") or "").replace("_", " ") + c.print(f"[bold green]Success:[/bold green] {headline} -- state: {state}") + + formatter.output(result, render) diff --git a/src/keboola_agent_cli/commands/_merge_request_writes.py b/src/keboola_agent_cli/commands/_merge_request_writes.py new file mode 100644 index 00000000..88818496 --- /dev/null +++ b/src/keboola_agent_cli/commands/_merge_request_writes.py @@ -0,0 +1,587 @@ +"""Write commands of the ``kbagent merge-request`` group. + +``create`` / ``update`` / ``request-review`` / ``approve`` / ``request-changes`` / +``merge`` / ``resolve`` -- split out of ``merge_request.py`` when the group +crossed the 800-code-line soft ceiling. Mounted flat onto the group's Typer app +via :func:`register`, so permission keys stay in the ``merge-request.*`` +namespace and ``--help`` lists them with the reads (precedent: +``_storage_describe.register``). Shared machinery -- target resolution, the one +error handler, the destructive-under-json rule, the auto-merge escalation -- +lives in ``_merge_request_common.py``; this module only decides what each write +asks, confirms, and says afterwards. Design record: ``docs/merge-requests-layer1.md``. +""" + +from __future__ import annotations + +from typing import Any + +import typer + +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..services.merge_request_service import TAKE_MODES +from ._helpers import ( + check_cli_operation, + get_formatter, + get_service, + parse_json_arg, + resolve_branch, + resolve_project_alias, +) +from ._merge_request_common import ( + _AUTO_MERGE_DISARMED, + _BRANCH_OPT, + _MERGE_REQUEST_ID_OPT, + _PROJECT_OPT, + _armed_warning, + _emit_warnings, + _escalate_if_armed, + _handle_error, + _hint_from_actions, + _hint_next, + _print_row_success, + _require_explicit_target_under_json, + _resolve_target, + _stamp_target, + _usage_error, +) + +writes_app = typer.Typer() + + +_AUTO_MERGE_STRATEGIES = ("immediately", "scheduled", _AUTO_MERGE_DISARMED) +_REASON_MAX_LENGTH = 1000 # MergeRequestRejectRequest::REASON_MAX_LENGTH, server-side cap + +_YES_OPT = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt") +_TITLE_OPT = typer.Option(None, "--title", help="Merge request title") +_DESCRIPTION_OPT = typer.Option( + None, "--description", help="Description (on update: an empty string clears it)" +) +_REVIEWER_OPT = typer.Option( + None, + "--reviewer-id", + help=( + "Reviewer user ID (repeatable; ids from `project member-list`). On update the " + "given set REPLACES the current reviewers -- it never appends" + ), +) +_AUTO_MERGE_STRATEGY_OPT = typer.Option( + None, + "--auto-merge-strategy", + help=( + "immediately | scheduled | none. ARMING (immediately/scheduled) is a destructive " + "operation: once the merge request is approved, the backend merges it into " + "production on its own -- no `merge` call involved. `none` disarms" + ), +) +_AUTO_MERGE_AT_OPT = typer.Option( + None, + "--auto-merge-at", + help="When to auto-merge (ISO 8601); required with --auto-merge-strategy scheduled", +) +_EXTERNAL_ID_OPT = typer.Option( + None, "--external-id", help="Free-form correlation id, e.g. a ticket (max 255 chars)" +) + + +def _validate_auto_merge_flags(formatter: Any, strategy: str | None, at: str | None) -> bool: + """Exit 2 on a bad strategy or a broken strategy/at pairing; return whether + the flags ARM auto-merge (strategy given and not `none`).""" + if strategy is not None and strategy not in _AUTO_MERGE_STRATEGIES: + _usage_error( + formatter, + f"Unknown --auto-merge-strategy {strategy!r}: use {', '.join(_AUTO_MERGE_STRATEGIES)}.", + ) + if strategy == "scheduled" and not at: + _usage_error(formatter, "--auto-merge-strategy scheduled requires --auto-merge-at.") + if at is not None and strategy != "scheduled": + _usage_error( + formatter, + "--auto-merge-at is only meaningful with --auto-merge-strategy scheduled.", + ) + return strategy is not None and strategy != _AUTO_MERGE_DISARMED + + +def _confirm_or_abort(formatter: Any, yes: bool, question: str) -> None: + """The house prompt shape: skipped by --yes and in --json (where consent is + implied and the explicit-target rule stands in for it).""" + if yes or formatter.json_mode: + return + if not typer.confirm(question): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + +def _arming_question(strategy: str, at: str | None, *, subject: str) -> str: + when = f" at {at}" if at else "" + return ( + f"Arm auto-merge ({strategy}{when}) on {subject}? Once it is approved, the backend " + "will merge it into production automatically -- without a `merge` call. Continue?" + ) + + +@writes_app.command("create") +def merge_request_create( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + title: str = typer.Option(..., "--title", help="Merge request title"), + branch: int | None = typer.Option( + None, + "--branch", + help="Source dev branch ID (default: the active branch set via `branch use`)", + ), + description: str | None = _DESCRIPTION_OPT, + reviewer_id: list[int] | None = _REVIEWER_OPT, + auto_merge_strategy: str | None = _AUTO_MERGE_STRATEGY_OPT, + auto_merge_at: str | None = _AUTO_MERGE_AT_OPT, + external_id: str | None = _EXTERNAL_ID_OPT, + yes: bool = _YES_OPT, +) -> None: + """Open a merge request from a development branch into production. + + The target is always the default branch; the source is --branch or the + active branch. A branch can have one merge request, ever. On a non-SOX + project with 0 required approvals you can `merge` straight from here -- + no `request-review` needed. + """ + formatter = get_formatter(ctx) + arming = _validate_auto_merge_flags(formatter, auto_merge_strategy, auto_merge_at) + if arming: + # Arming IS a (delayed) production merge: destructive, and under + # --json it must name its target -- here the source branch. + check_cli_operation(ctx, "merge-request.create --auto-merge-strategy") + _require_explicit_target_under_json( + formatter, + merge_request_id=None, + branch=branch, + reason="--auto-merge-strategy arms an automatic production merge.", + hint="--branch", + ) + service = get_service(ctx, "merge_request_service") + try: + alias = resolve_project_alias(ctx, formatter, project) + config_store = get_service(ctx, "config_store") + _, branch_id = resolve_branch(config_store, formatter, alias, branch) + if branch_id is None: + formatter.error( + message=( + f"No source branch for project '{alias}': pass --branch or run " + "`kbagent branch use` first." + ), + error_code=ErrorCode.CONFIG_ERROR, + ) + raise typer.Exit(code=5) + if arming: + _confirm_or_abort( + formatter, + yes, + _arming_question( + str(auto_merge_strategy), + auto_merge_at, + subject=f"the new merge request from branch {branch_id}", + ), + ) + result = service.create_merge_request( + alias, + branch_from_id=branch_id, + title=title, + description=description, + reviewer_ids=reviewer_id or None, # never [] -- that REPLACES the set with nothing + auto_merge_strategy=auto_merge_strategy, + auto_merge_at=auto_merge_at, + external_id=external_id, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + result.setdefault("merge_request_id", result.get("id")) + result.setdefault("resolved_from_branch", branch is None) + if arming: + result.setdefault("warnings", []).append(_armed_warning(str(auto_merge_strategy), result)) + _print_row_success( + formatter, + result, + f"Created merge request #{result.get('id')} from branch {branch_id}", + ) + _emit_warnings(formatter, result) + _hint_from_actions(formatter, result) + + +@writes_app.command("update") +def merge_request_update( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + title: str | None = _TITLE_OPT, + description: str | None = _DESCRIPTION_OPT, + reviewer_id: list[int] | None = _REVIEWER_OPT, + auto_merge_strategy: str | None = _AUTO_MERGE_STRATEGY_OPT, + auto_merge_at: str | None = _AUTO_MERGE_AT_OPT, + external_id: str | None = _EXTERNAL_ID_OPT, + yes: bool = _YES_OPT, +) -> None: + """Change a merge request's title, description, reviewers, auto-merge or external id. + + Omitted fields stay as they are; an empty string clears --description / + --external-id. --reviewer-id replaces the whole reviewer set. + """ + formatter = get_formatter(ctx) + fields = ( + title, + description, + reviewer_id or None, + auto_merge_strategy, + auto_merge_at, + external_id, + ) + if all(f is None for f in fields): + # PUT {} is a server-side no-op that answers 200 -- refuse instead of + # reporting success having changed nothing. + _usage_error(formatter, "Nothing to update: pass at least one field flag.") + arming = _validate_auto_merge_flags(formatter, auto_merge_strategy, auto_merge_at) + if arming: + check_cli_operation(ctx, "merge-request.update --auto-merge-strategy") + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason="--auto-merge-strategy arms an automatic production merge.", + ) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=False, + ) + if arming: + _confirm_or_abort( + formatter, + yes, + _arming_question( + str(auto_merge_strategy), + auto_merge_at, + subject=f"merge request #{target.merge_request_id}", + ), + ) + result = _stamp_target( + service.update_merge_request( + target.alias, + target.merge_request_id, + title=title, + description=description, + reviewer_ids=reviewer_id or None, + auto_merge_strategy=auto_merge_strategy, + auto_merge_at=auto_merge_at, + external_id=external_id, + ), + target, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + if arming: + result.setdefault("warnings", []).append(_armed_warning(str(auto_merge_strategy), result)) + _print_row_success(formatter, result, f"Updated merge request #{target.merge_request_id}") + _emit_warnings(formatter, result) + _hint_from_actions(formatter, result) + + +def _transition( + ctx: typer.Context, + *, + operation: str, + project: str | None, + merge_request_id: int | None, + branch: int | None, + escalate_when_armed: bool, + call: Any, + headline: str, +) -> None: + """Shared body of request-review / approve / request-changes. + + ``escalate_when_armed`` is True for the two that move an MR toward + ``approved`` (what an armed auto-merge waits for); request-changes moves + it AWAY and deletes approvals, so it never escalates. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=escalate_when_armed, + ) + strategy = ( + _escalate_if_armed( + ctx, + formatter, + target, + operation=operation, + merge_request_id=merge_request_id, + branch=branch, + ) + if escalate_when_armed + else None + ) + result = _stamp_target(call(service, target), target) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + if strategy: + result.setdefault("warnings", []).append(_armed_warning(strategy, result)) + _print_row_success(formatter, result, headline.format(id=target.merge_request_id)) + _emit_warnings(formatter, result) + _hint_from_actions(formatter, result) + + +@writes_app.command("request-review") +def merge_request_request_review( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, +) -> None: + """Send the merge request for review. + + On a non-SOX project with 0 required approvals (the default) the backend + finishes the review itself and the merge request lands directly in + `approved` -- so `merge` works straight from `development` and this step + is optional. Note: with no reviewers selected, the review-requested email + goes to every project member. + """ + _transition( + ctx, + operation="request-review", + project=project, + merge_request_id=merge_request_id, + branch=branch, + escalate_when_armed=True, + call=lambda s, t: s.request_review(t.alias, t.merge_request_id), + headline="Review requested for merge request #{id}", + ) + + +@writes_app.command("approve") +def merge_request_approve( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, +) -> None: + """Add your approval to a merge request under review. + + Only possible while the merge request is `in_review`. On a non-SOX project + with 0 required approvals (the default) that state is never reached -- + `request-review` jumps straight to `approved` -- so this command answers + 422 there. It exists for projects that require approvals. + """ + _transition( + ctx, + operation="approve", + project=project, + merge_request_id=merge_request_id, + branch=branch, + escalate_when_armed=True, + call=lambda s, t: s.approve(t.alias, t.merge_request_id), + headline="Approved merge request #{id}", + ) + + +@writes_app.command("request-changes") +def merge_request_request_changes( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + reason: str | None = typer.Option( + None, "--reason", help=f"Why (max {_REASON_MAX_LENGTH} characters)" + ), +) -> None: + """Send the merge request back to development; existing approvals are removed. + + This is also the closest thing to closing a merge request: the API has no + cancel, and the web UI's "cancel" is exactly this call made by the + creator. The merge request stays open in `development` and can be + resubmitted; deleting the branch is the terminal outcome. + """ + formatter = get_formatter(ctx) + if reason is not None and len(reason) > _REASON_MAX_LENGTH: + _usage_error( + formatter, f"--reason is capped at {_REASON_MAX_LENGTH} characters (got {len(reason)})." + ) + _transition( + ctx, + operation="request-changes", + project=project, + merge_request_id=merge_request_id, + branch=branch, + escalate_when_armed=False, + call=lambda s, t: s.request_changes(t.alias, t.merge_request_id, reason=reason), + headline="Changes requested on merge request #{id}", + ) + + +@writes_app.command("merge") +def merge_request_merge( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + yes: bool = _YES_OPT, +) -> None: + """Merge the merge request into production and delete its source branch. + + Waits for the merge job (up to 10 minutes). Works straight from + `development` when approvals are satisfied. The source branch is always + deleted afterwards (a separate async job). Under --json the target must be + explicit: pass --merge-request-id or --branch. + """ + formatter = get_formatter(ctx) + # Statically destructive: the explicit-target rule applies before any lookup. + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason="`merge-request merge` rewrites production and deletes the source branch.", + ) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=True, + ) + title = (target.row or {}).get("title") or "" + _confirm_or_abort( + formatter, + yes, + f"Merge request #{target.merge_request_id} '{title}' will be merged into " + f"production and its source branch {target.branch_id} deleted. Continue?", + ) + result = _stamp_target(service.merge(target.alias, target.merge_request_id), target) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + formatter.output( + result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}") + ) + _emit_warnings(formatter, result) + _hint_next(formatter, "`merge-request list` -- the merged request now shows as merged") + + +@writes_app.command("resolve") +def merge_request_resolve( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + component_id: str = typer.Option(..., "--component-id", help="Component ID"), + config_id: str = typer.Option(..., "--config-id", help="Configuration ID"), + take: str | None = typer.Option( + None, + "--take", + help=( + "ours (keep your branch's content) | theirs (adopt production's) | delete. " + "Mutually exclusive with --resolved" + ), + ), + resolved: str | None = typer.Option( + None, + "--resolved", + help=( + "A hand-authored resolution: JSON inline, @file, or - for stdin. Start from " + "`merge-request diff --output FILE`; the body must carry name, description, " + "isDisabled, configuration and rows (rebase REPLACES the whole configuration)" + ), + ), + change_description: str | None = typer.Option( + None, "--change-description", help="Version message for the rebased configuration" + ), +) -> None: + """Resolve one conflicting configuration by rebasing it onto production's version. + + Every mode replaces the configuration in your branch; the previous content + stays in its version history. Rebasing each listed conflict makes the merge + request mergeable -- there is no re-validate step. There is deliberately no + --all: conflicts are meant to be walked, not waved away. + """ + formatter = get_formatter(ctx) + if (take is None) == (resolved is None): + _usage_error(formatter, "Pass exactly one of --take ours|theirs|delete or --resolved.") + if take is not None and take not in TAKE_MODES: + _usage_error(formatter, f"Unknown --take value {take!r}: use {', '.join(TAKE_MODES)}.") + body: dict[str, Any] | None = None + if resolved is not None: + try: + parsed = parse_json_arg(resolved, label="--resolved") + except ValueError as exc: + _usage_error(formatter, str(exc)) + if not isinstance(parsed, dict): + _usage_error( + formatter, "--resolved must be a JSON object (the replaced configuration body)." + ) + body = parsed + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=True, + ) + # Resolving the last conflict on an armed, approved MR unblocks the + # scheduler's retry loop -- it causes the merge as surely as approve does. + strategy = _escalate_if_armed( + ctx, + formatter, + target, + operation="resolve", + merge_request_id=merge_request_id, + branch=branch, + ) + result = _stamp_target( + service.resolve_conflict( + target.alias, + target.merge_request_id, + component_id, + config_id, + take=take, + resolved=body, + change_description=change_description, + ), + target, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + if strategy: + result.setdefault("warnings", []).append(_armed_warning(strategy, result)) + formatter.output( + result, + lambda c, d: c.print( + f"[bold green]Success:[/bold green] Resolved {component_id}/{config_id} " + f"({d.get('resolution')}) -- rebased onto production version {d.get('onto_version')}" + ), + ) + _emit_warnings(formatter, result) + _hint_next( + formatter, + "`merge-request conflicts` for what is left, then `merge-request merge`", + ) + + +def register(app: typer.Typer) -> None: + """Mount the write commands flat onto the group's app (same namespace, same --help).""" + app.registered_commands.extend(writes_app.registered_commands) diff --git a/src/keboola_agent_cli/commands/merge_request.py b/src/keboola_agent_cli/commands/merge_request.py index 71b2ff3d..b118a7ca 100644 --- a/src/keboola_agent_cli/commands/merge_request.py +++ b/src/keboola_agent_cli/commands/merge_request.py @@ -30,29 +30,32 @@ rendered the same way in every command, after the main output, before the hint-next line (:func:`_emit_warnings`). -Renderers live in ``_merge_request_render.py``. +Renderers live in ``_merge_request_render.py``; the shared machinery in +``_merge_request_common.py``; the write commands in ``_merge_request_writes.py`` +(mounted onto this app at the bottom) -- the group crossed the 800-code-line +soft ceiling, as the RFC predicted for eleven commands. """ from __future__ import annotations import json -from dataclasses import dataclass from pathlib import Path -from typing import Any, NoReturn import typer -from ..errors import ConfigError, ErrorCode, KeboolaApiError -from ..services.merge_request_service import STATE_FILTER_VOCABULARY, TAKE_MODES -from ._helpers import ( - check_cli_operation, - check_cli_permission, - get_formatter, - get_service, - map_error_to_exit_code, - parse_json_arg, - resolve_branch, - resolve_project_alias, +from ..errors import ConfigError, KeboolaApiError +from ..services.merge_request_service import STATE_FILTER_VOCABULARY +from ._helpers import check_cli_permission, get_formatter, get_service, resolve_project_alias +from ._merge_request_common import ( + _BRANCH_OPT, + _MERGE_REQUEST_ID_OPT, + _PROJECT_OPT, + _emit_warnings, + _handle_error, + _hint_next, + _resolve_target, + _stamp_target, + _usage_error, ) from ._merge_request_render import ( format_config_diff, @@ -61,6 +64,7 @@ format_merge_requests_table, next_step_hints, ) +from ._merge_request_writes import register as _register_write_commands merge_request_app = typer.Typer( help=( @@ -75,296 +79,6 @@ def _merge_request_permission_check(ctx: typer.Context) -> None: check_cli_permission(ctx, "merge-request") -# -- Shared option declarations ------------------------------------------------ -# -# Reused across commands so the help text and the flag names cannot drift -# between them. `--merge-request-id`/`--id`: a merge request is the OBJECT the -# command acts on, hence `---id` like --config-id/--table-id (the bare -# nouns --project/--branch are the CONTEXT you work in); `--id` is the short -# alias the `agent` group already established beside `--task-id`. - -_PROJECT_OPT = typer.Option( - None, - "--project", - help="Project alias (default: KBAGENT_PROJECT, then the `project use` pin, then the sole project)", -) -_MERGE_REQUEST_ID_OPT = typer.Option( - None, - "--merge-request-id", - "--id", - help=( - "Merge request ID. Omit to use the merge request of --branch, or of the " - "active branch (`branch use`)" - ), -) -_BRANCH_OPT = typer.Option( - None, - "--branch", - help=( - "Dev branch ID whose merge request to use (default: the active branch set " - "via `branch use`). Mutually exclusive with --merge-request-id" - ), -) - -_AUTO_MERGE_DISARMED = "none" - - -# -- Error handling --------------------------------------------------------------- - - -def _handle_error(formatter: Any, exc: ConfigError | KeboolaApiError) -> NoReturn: - """The group's ONE ``ConfigError``/``KeboolaApiError`` -> exit-code mapping. - - No command in this module has its own ``except``: eleven inline copies of - the idiom are eleven places to flatten ``FeatureNotEnabledError`` (a - ``ConfigError`` subclass carrying ``FEATURE_NOT_ENABLED``) into a bare - ``CONFIG_ERROR``, which is what ``commands/config.py``'s - ``_handle_config_service_error`` does and what a ``--json`` consumer cannot - recover from. Same shape as that helper, corrected code lookup -- the - pattern ``server/app.py``'s ConfigError handler already uses. - """ - if isinstance(exc, ConfigError): - formatter.error( - message=exc.message, - error_code=getattr(exc, "error_code", ErrorCode.CONFIG_ERROR), - ) - raise typer.Exit(code=5) from None - formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) - raise typer.Exit(code=map_error_to_exit_code(exc)) from None - - -def _usage_error(formatter: Any, message: str) -> NoReturn: - formatter.error(message=message, error_code=ErrorCode.INVALID_ARGUMENT) - raise typer.Exit(code=2) - - -# -- Target resolution ------------------------------------------------------------- - - -@dataclass(frozen=True) -class _Target: - """What a command operates on, and how that was decided. - - ``row`` is the service's enriched MR row (raw + ``derived_state`` + - ``allowed_actions``). It is always present when the target was resolved - from a branch (``find_merge_request_for_branch`` returns it for free) and - fetched on demand (``need_row``) when the id was explicit -- one GET via - ``get_merge_request_row``, never the three-call detail. - """ - - alias: str - merge_request_id: int - row: dict[str, Any] | None - branch_id: int | None - resolved_from_branch: bool - - @property - def auto_merge_strategy(self) -> str: - """``immediately`` | ``scheduled`` | ``none``; ``none`` when unknown.""" - if not self.row: - return _AUTO_MERGE_DISARMED - return str(self.row.get("autoMergeStrategy") or _AUTO_MERGE_DISARMED) - - @property - def armed(self) -> bool: - return self.auto_merge_strategy != _AUTO_MERGE_DISARMED - - -def _branch_from_row(row: dict[str, Any] | None) -> int | None: - raw = ((row or {}).get("branches") or {}).get("branchFromId") - try: - return int(raw) if raw is not None else None - except (TypeError, ValueError): - return None - - -def _resolve_target( - ctx: typer.Context, - formatter: Any, - *, - project: str | None, - merge_request_id: int | None, - branch: int | None, - need_row: bool, -) -> _Target: - """Resolve which merge request a command operates on. - - 1. ``--merge-request-id`` given -> that (``row`` fetched only if ``need_row``). - 2. Else ``resolve_branch()``: explicit ``--branch``, else ``active_branch_id``. - 3. On that branch, ``find_merge_request_for_branch()`` -> the MR (and its row). - - Both flags at once is exit 2, not silent precedence: they are two ways of - naming one target, and a contradiction (MR 7 not being FROM branch 123) is - exactly what a ``--json`` script would never notice. With neither and no - active branch, the house wording: pass ``--branch`` or run ``branch use``. - Service errors propagate -- the caller's ``except`` routes them through - :func:`_handle_error` (a feature-less project surfaces here as - ``FEATURE_NOT_ENABLED``, since the resolver runs the feature pre-flight on - its no-match path). - """ - if merge_request_id is not None and branch is not None: - _usage_error( - formatter, - "Pass either --merge-request-id or --branch, not both -- they are two ways " - "of naming the same merge request.", - ) - alias = resolve_project_alias(ctx, formatter, project) - service = get_service(ctx, "merge_request_service") - - if merge_request_id is not None: - row = service.get_merge_request_row(alias, merge_request_id) if need_row else None - return _Target( - alias=alias, - merge_request_id=merge_request_id, - row=row, - branch_id=_branch_from_row(row), - resolved_from_branch=False, - ) - - config_store = get_service(ctx, "config_store") - _, branch_id = resolve_branch(config_store, formatter, alias, branch) - if branch_id is None: - formatter.error( - message=( - f"No merge request selected for project '{alias}': pass " - "--merge-request-id, or --branch, or run `kbagent branch use` first." - ), - error_code=ErrorCode.CONFIG_ERROR, - ) - raise typer.Exit(code=5) - row = service.find_merge_request_for_branch(alias, branch_id) - resolved_id = int(row["id"]) - if not formatter.json_mode: - formatter.err_console.print( - f"[bold blue]Info:[/bold blue] Resolved merge request #{resolved_id} " - f"from branch {branch_id}" - ) - return _Target( - alias=alias, - merge_request_id=resolved_id, - row=row, - branch_id=branch_id, - resolved_from_branch=True, - ) - - -def _stamp_target(result: dict[str, Any], target: _Target) -> dict[str, Any]: - """Add the target facts every ``--json`` result carries regardless of how - the target was reached -- so a machine caller can always assert on what was - actually operated upon. Never overwrites a key the service already set.""" - result.setdefault("merge_request_id", target.merge_request_id) - result.setdefault("branch_from_id", target.branch_id) - result.setdefault("resolved_from_branch", target.resolved_from_branch) - return result - - -# -- Destructive-under-json rule and auto-merge escalation ---------------------- - - -def _require_explicit_target_under_json( - formatter: Any, - *, - merge_request_id: int | None, - branch: int | None, - reason: str, - suggested_id: int | None = None, - hint: str | None = None, -) -> None: - """When an invocation resolves to the destructive class, ``--json`` requires - an explicit target. - - Every destructive command in kbagent either prompts or is told its target; - none relies on the prompt for machine safety (``--json`` implies consent - in all 48 commands carrying ``--yes``). A bare ``--json merge-request - merge`` would do neither -- the first command where nothing on the command - line identifies what gets destroyed. Humans keep the active-branch - fallback and get the prompt; a script, which received the id in its - previous call's payload, names it. - """ - if not formatter.json_mode or merge_request_id is not None or branch is not None: - return - if hint is None: - hint = ( - f"--merge-request-id {suggested_id}" - if suggested_id - else "--merge-request-id or --branch" - ) - _usage_error( - formatter, - f"{reason} Under --json a destructive operation needs an explicit target: pass {hint}.", - ) - - -def _escalate_if_armed( - ctx: typer.Context, - formatter: Any, - target: _Target, - *, - operation: str, - merge_request_id: int | None, - branch: int | None, -) -> str | None: - """Apply the state-derived destructive escalation for an armed MR. - - Returns the strategy (``immediately`` / ``scheduled``) when the MR is armed - so the caller can say so in its output, or ``None``. Order matters: the - policy check comes first (a denial is the stronger statement -- telling a - denied caller to "pass --merge-request-id" would not help), then the - ``--json`` explicit-target rule. That rule can only fire AFTER resolution - here -- whether the invocation is destructive is only known from the - fetched row; the check cannot move earlier because the information does - not exist earlier. One wasted round trip on the rare path is the price of - a rule with no exceptions. - """ - if not target.armed: - return None - check_cli_operation(ctx, f"merge-request.{operation} --auto-merge-armed") - _require_explicit_target_under_json( - formatter, - merge_request_id=merge_request_id, - branch=branch, - reason=( - f"Merge request #{target.merge_request_id} has auto-merge armed " - f"({target.auto_merge_strategy}), so `{operation}` will cause a production merge." - ), - suggested_id=target.merge_request_id, - ) - return target.auto_merge_strategy - - -def _armed_warning(strategy: str, result: dict[str, Any]) -> str: - """What an armed MR means right now, phrased from the resulting state: - approved -> the backend merges on its next tick; anything else -> it will, - the moment the MR is approved. Always names the disarm.""" - state = str(result.get("state") or "") - when = ( - "the backend will merge it into production on its next tick" - if state == "approved" - else "the backend will merge it into production as soon as it is approved" - ) - return ( - f"Auto-merge is armed ({strategy}) -- {when}. Disarm with " - "`merge-request update --auto-merge-strategy none` if that is not intended." - ) - - -# -- Output helpers ---------------------------------------------------------------- - - -def _emit_warnings(formatter: Any, result: dict[str, Any]) -> None: - """Render ``warnings[]`` -- the group's one soft-failure key -- in human mode. - In ``--json`` the key is in the payload; ``formatter.warning`` is human-only.""" - for warning in result.get("warnings") or []: - formatter.warning(str(warning)) - - -def _hint_next(formatter: Any, text: str) -> None: - """The one-line next step every command ends with in human mode. Rich-only: - ``--json`` consumers have ``allowed_actions`` as data on every result.""" - if not formatter.json_mode: - formatter.console.print(f"[dim]Next:[/dim] {text}") - - # -- Reads ------------------------------------------------------------------------------ @@ -585,551 +299,6 @@ def merge_request_diff( ) -# -- Writes ----------------------------------------------------------------------------- - -_AUTO_MERGE_STRATEGIES = ("immediately", "scheduled", _AUTO_MERGE_DISARMED) -_REASON_MAX_LENGTH = 1000 # MergeRequestRejectRequest::REASON_MAX_LENGTH, server-side cap - -_YES_OPT = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt") -_TITLE_OPT = typer.Option(None, "--title", help="Merge request title") -_DESCRIPTION_OPT = typer.Option( - None, "--description", help="Description (on update: an empty string clears it)" -) -_REVIEWER_OPT = typer.Option( - None, - "--reviewer-id", - help=( - "Reviewer user ID (repeatable; ids from `project member-list`). On update the " - "given set REPLACES the current reviewers -- it never appends" - ), -) -_AUTO_MERGE_STRATEGY_OPT = typer.Option( - None, - "--auto-merge-strategy", - help=( - "immediately | scheduled | none. ARMING (immediately/scheduled) is a destructive " - "operation: once the merge request is approved, the backend merges it into " - "production on its own -- no `merge` call involved. `none` disarms" - ), -) -_AUTO_MERGE_AT_OPT = typer.Option( - None, - "--auto-merge-at", - help="When to auto-merge (ISO 8601); required with --auto-merge-strategy scheduled", -) -_EXTERNAL_ID_OPT = typer.Option( - None, "--external-id", help="Free-form correlation id, e.g. a ticket (max 255 chars)" -) - - -def _validate_auto_merge_flags(formatter: Any, strategy: str | None, at: str | None) -> bool: - """Exit 2 on a bad strategy or a broken strategy/at pairing; return whether - the flags ARM auto-merge (strategy given and not `none`).""" - if strategy is not None and strategy not in _AUTO_MERGE_STRATEGIES: - _usage_error( - formatter, - f"Unknown --auto-merge-strategy {strategy!r}: use {', '.join(_AUTO_MERGE_STRATEGIES)}.", - ) - if strategy == "scheduled" and not at: - _usage_error(formatter, "--auto-merge-strategy scheduled requires --auto-merge-at.") - if at is not None and strategy != "scheduled": - _usage_error( - formatter, - "--auto-merge-at is only meaningful with --auto-merge-strategy scheduled.", - ) - return strategy is not None and strategy != _AUTO_MERGE_DISARMED - - -def _confirm_or_abort(formatter: Any, yes: bool, question: str) -> None: - """The house prompt shape: skipped by --yes and in --json (where consent is - implied and the explicit-target rule stands in for it).""" - if yes or formatter.json_mode: - return - if not typer.confirm(question): - formatter.console.print("Aborted.") - raise typer.Exit(code=0) - - -def _arming_question(strategy: str, at: str | None, *, subject: str) -> str: - when = f" at {at}" if at else "" - return ( - f"Arm auto-merge ({strategy}{when}) on {subject}? Once it is approved, the backend " - "will merge it into production automatically -- without a `merge` call. Continue?" - ) - - -def _print_row_success(formatter: Any, result: dict[str, Any], headline: str) -> None: - def render(c: Any, d: dict[str, Any]) -> None: - state = str(d.get("derived_state") or d.get("state") or "").replace("_", " ") - c.print(f"[bold green]Success:[/bold green] {headline} -- state: {state}") - - formatter.output(result, render) - - -def _hint_from_actions(formatter: Any, result: dict[str, Any]) -> None: - hints = next_step_hints(result.get("allowed_actions")) - if hints: - _hint_next(formatter, " | ".join(f"`{h}`" for h in hints)) - - -@merge_request_app.command("create") -def merge_request_create( - ctx: typer.Context, - project: str | None = _PROJECT_OPT, - title: str = typer.Option(..., "--title", help="Merge request title"), - branch: int | None = typer.Option( - None, - "--branch", - help="Source dev branch ID (default: the active branch set via `branch use`)", - ), - description: str | None = _DESCRIPTION_OPT, - reviewer_id: list[int] | None = _REVIEWER_OPT, - auto_merge_strategy: str | None = _AUTO_MERGE_STRATEGY_OPT, - auto_merge_at: str | None = _AUTO_MERGE_AT_OPT, - external_id: str | None = _EXTERNAL_ID_OPT, - yes: bool = _YES_OPT, -) -> None: - """Open a merge request from a development branch into production. - - The target is always the default branch; the source is --branch or the - active branch. A branch can have one merge request, ever. On a non-SOX - project with 0 required approvals you can `merge` straight from here -- - no `request-review` needed. - """ - formatter = get_formatter(ctx) - arming = _validate_auto_merge_flags(formatter, auto_merge_strategy, auto_merge_at) - if arming: - # Arming IS a (delayed) production merge: destructive, and under - # --json it must name its target -- here the source branch. - check_cli_operation(ctx, "merge-request.create --auto-merge-strategy") - _require_explicit_target_under_json( - formatter, - merge_request_id=None, - branch=branch, - reason="--auto-merge-strategy arms an automatic production merge.", - hint="--branch", - ) - service = get_service(ctx, "merge_request_service") - try: - alias = resolve_project_alias(ctx, formatter, project) - config_store = get_service(ctx, "config_store") - _, branch_id = resolve_branch(config_store, formatter, alias, branch) - if branch_id is None: - formatter.error( - message=( - f"No source branch for project '{alias}': pass --branch or run " - "`kbagent branch use` first." - ), - error_code=ErrorCode.CONFIG_ERROR, - ) - raise typer.Exit(code=5) - if arming: - _confirm_or_abort( - formatter, - yes, - _arming_question( - str(auto_merge_strategy), - auto_merge_at, - subject=f"the new merge request from branch {branch_id}", - ), - ) - result = service.create_merge_request( - alias, - branch_from_id=branch_id, - title=title, - description=description, - reviewer_ids=reviewer_id or None, # never [] -- that REPLACES the set with nothing - auto_merge_strategy=auto_merge_strategy, - auto_merge_at=auto_merge_at, - external_id=external_id, - ) - except (ConfigError, KeboolaApiError) as exc: - _handle_error(formatter, exc) - - result.setdefault("merge_request_id", result.get("id")) - result.setdefault("resolved_from_branch", branch is None) - if arming: - result.setdefault("warnings", []).append(_armed_warning(str(auto_merge_strategy), result)) - _print_row_success( - formatter, - result, - f"Created merge request #{result.get('id')} from branch {branch_id}", - ) - _emit_warnings(formatter, result) - _hint_from_actions(formatter, result) - - -@merge_request_app.command("update") -def merge_request_update( - ctx: typer.Context, - project: str | None = _PROJECT_OPT, - merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, - branch: int | None = _BRANCH_OPT, - title: str | None = _TITLE_OPT, - description: str | None = _DESCRIPTION_OPT, - reviewer_id: list[int] | None = _REVIEWER_OPT, - auto_merge_strategy: str | None = _AUTO_MERGE_STRATEGY_OPT, - auto_merge_at: str | None = _AUTO_MERGE_AT_OPT, - external_id: str | None = _EXTERNAL_ID_OPT, - yes: bool = _YES_OPT, -) -> None: - """Change a merge request's title, description, reviewers, auto-merge or external id. - - Omitted fields stay as they are; an empty string clears --description / - --external-id. --reviewer-id replaces the whole reviewer set. - """ - formatter = get_formatter(ctx) - fields = ( - title, - description, - reviewer_id or None, - auto_merge_strategy, - auto_merge_at, - external_id, - ) - if all(f is None for f in fields): - # PUT {} is a server-side no-op that answers 200 -- refuse instead of - # reporting success having changed nothing. - _usage_error(formatter, "Nothing to update: pass at least one field flag.") - arming = _validate_auto_merge_flags(formatter, auto_merge_strategy, auto_merge_at) - if arming: - check_cli_operation(ctx, "merge-request.update --auto-merge-strategy") - _require_explicit_target_under_json( - formatter, - merge_request_id=merge_request_id, - branch=branch, - reason="--auto-merge-strategy arms an automatic production merge.", - ) - service = get_service(ctx, "merge_request_service") - try: - target = _resolve_target( - ctx, - formatter, - project=project, - merge_request_id=merge_request_id, - branch=branch, - need_row=False, - ) - if arming: - _confirm_or_abort( - formatter, - yes, - _arming_question( - str(auto_merge_strategy), - auto_merge_at, - subject=f"merge request #{target.merge_request_id}", - ), - ) - result = _stamp_target( - service.update_merge_request( - target.alias, - target.merge_request_id, - title=title, - description=description, - reviewer_ids=reviewer_id or None, - auto_merge_strategy=auto_merge_strategy, - auto_merge_at=auto_merge_at, - external_id=external_id, - ), - target, - ) - except (ConfigError, KeboolaApiError) as exc: - _handle_error(formatter, exc) - - if arming: - result.setdefault("warnings", []).append(_armed_warning(str(auto_merge_strategy), result)) - _print_row_success(formatter, result, f"Updated merge request #{target.merge_request_id}") - _emit_warnings(formatter, result) - _hint_from_actions(formatter, result) - - -def _transition( - ctx: typer.Context, - *, - operation: str, - project: str | None, - merge_request_id: int | None, - branch: int | None, - escalate_when_armed: bool, - call: Any, - headline: str, -) -> None: - """Shared body of request-review / approve / request-changes. - - ``escalate_when_armed`` is True for the two that move an MR toward - ``approved`` (what an armed auto-merge waits for); request-changes moves - it AWAY and deletes approvals, so it never escalates. - """ - formatter = get_formatter(ctx) - service = get_service(ctx, "merge_request_service") - try: - target = _resolve_target( - ctx, - formatter, - project=project, - merge_request_id=merge_request_id, - branch=branch, - need_row=escalate_when_armed, - ) - strategy = ( - _escalate_if_armed( - ctx, - formatter, - target, - operation=operation, - merge_request_id=merge_request_id, - branch=branch, - ) - if escalate_when_armed - else None - ) - result = _stamp_target(call(service, target), target) - except (ConfigError, KeboolaApiError) as exc: - _handle_error(formatter, exc) - - if strategy: - result.setdefault("warnings", []).append(_armed_warning(strategy, result)) - _print_row_success(formatter, result, headline.format(id=target.merge_request_id)) - _emit_warnings(formatter, result) - _hint_from_actions(formatter, result) - - -@merge_request_app.command("request-review") -def merge_request_request_review( - ctx: typer.Context, - project: str | None = _PROJECT_OPT, - merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, - branch: int | None = _BRANCH_OPT, -) -> None: - """Send the merge request for review. - - On a non-SOX project with 0 required approvals (the default) the backend - finishes the review itself and the merge request lands directly in - `approved` -- so `merge` works straight from `development` and this step - is optional. Note: with no reviewers selected, the review-requested email - goes to every project member. - """ - _transition( - ctx, - operation="request-review", - project=project, - merge_request_id=merge_request_id, - branch=branch, - escalate_when_armed=True, - call=lambda s, t: s.request_review(t.alias, t.merge_request_id), - headline="Review requested for merge request #{id}", - ) - - -@merge_request_app.command("approve") -def merge_request_approve( - ctx: typer.Context, - project: str | None = _PROJECT_OPT, - merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, - branch: int | None = _BRANCH_OPT, -) -> None: - """Add your approval to a merge request under review. - - Only possible while the merge request is `in_review`. On a non-SOX project - with 0 required approvals (the default) that state is never reached -- - `request-review` jumps straight to `approved` -- so this command answers - 422 there. It exists for projects that require approvals. - """ - _transition( - ctx, - operation="approve", - project=project, - merge_request_id=merge_request_id, - branch=branch, - escalate_when_armed=True, - call=lambda s, t: s.approve(t.alias, t.merge_request_id), - headline="Approved merge request #{id}", - ) - - -@merge_request_app.command("request-changes") -def merge_request_request_changes( - ctx: typer.Context, - project: str | None = _PROJECT_OPT, - merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, - branch: int | None = _BRANCH_OPT, - reason: str | None = typer.Option( - None, "--reason", help=f"Why (max {_REASON_MAX_LENGTH} characters)" - ), -) -> None: - """Send the merge request back to development; existing approvals are removed. - - This is also the closest thing to closing a merge request: the API has no - cancel, and the web UI's "cancel" is exactly this call made by the - creator. The merge request stays open in `development` and can be - resubmitted; deleting the branch is the terminal outcome. - """ - formatter = get_formatter(ctx) - if reason is not None and len(reason) > _REASON_MAX_LENGTH: - _usage_error( - formatter, f"--reason is capped at {_REASON_MAX_LENGTH} characters (got {len(reason)})." - ) - _transition( - ctx, - operation="request-changes", - project=project, - merge_request_id=merge_request_id, - branch=branch, - escalate_when_armed=False, - call=lambda s, t: s.request_changes(t.alias, t.merge_request_id, reason=reason), - headline="Changes requested on merge request #{id}", - ) - - -@merge_request_app.command("merge") -def merge_request_merge( - ctx: typer.Context, - project: str | None = _PROJECT_OPT, - merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, - branch: int | None = _BRANCH_OPT, - yes: bool = _YES_OPT, -) -> None: - """Merge the merge request into production and delete its source branch. - - Waits for the merge job (up to 10 minutes). Works straight from - `development` when approvals are satisfied. The source branch is always - deleted afterwards (a separate async job). Under --json the target must be - explicit: pass --merge-request-id or --branch. - """ - formatter = get_formatter(ctx) - # Statically destructive: the explicit-target rule applies before any lookup. - _require_explicit_target_under_json( - formatter, - merge_request_id=merge_request_id, - branch=branch, - reason="`merge-request merge` rewrites production and deletes the source branch.", - ) - service = get_service(ctx, "merge_request_service") - try: - target = _resolve_target( - ctx, - formatter, - project=project, - merge_request_id=merge_request_id, - branch=branch, - need_row=True, - ) - title = (target.row or {}).get("title") or "" - _confirm_or_abort( - formatter, - yes, - f"Merge request #{target.merge_request_id} '{title}' will be merged into " - f"production and its source branch {target.branch_id} deleted. Continue?", - ) - result = _stamp_target(service.merge(target.alias, target.merge_request_id), target) - except (ConfigError, KeboolaApiError) as exc: - _handle_error(formatter, exc) - - formatter.output( - result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}") - ) - _emit_warnings(formatter, result) - _hint_next(formatter, "`merge-request list` -- the merged request now shows as merged") - - -@merge_request_app.command("resolve") -def merge_request_resolve( - ctx: typer.Context, - project: str | None = _PROJECT_OPT, - merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, - branch: int | None = _BRANCH_OPT, - component_id: str = typer.Option(..., "--component-id", help="Component ID"), - config_id: str = typer.Option(..., "--config-id", help="Configuration ID"), - take: str | None = typer.Option( - None, - "--take", - help=( - "ours (keep your branch's content) | theirs (adopt production's) | delete. " - "Mutually exclusive with --resolved" - ), - ), - resolved: str | None = typer.Option( - None, - "--resolved", - help=( - "A hand-authored resolution: JSON inline, @file, or - for stdin. Start from " - "`merge-request diff --output FILE`; the body must carry name, description, " - "isDisabled, configuration and rows (rebase REPLACES the whole configuration)" - ), - ), - change_description: str | None = typer.Option( - None, "--change-description", help="Version message for the rebased configuration" - ), -) -> None: - """Resolve one conflicting configuration by rebasing it onto production's version. - - Every mode replaces the configuration in your branch; the previous content - stays in its version history. Rebasing each listed conflict makes the merge - request mergeable -- there is no re-validate step. There is deliberately no - --all: conflicts are meant to be walked, not waved away. - """ - formatter = get_formatter(ctx) - if (take is None) == (resolved is None): - _usage_error(formatter, "Pass exactly one of --take ours|theirs|delete or --resolved.") - if take is not None and take not in TAKE_MODES: - _usage_error(formatter, f"Unknown --take value {take!r}: use {', '.join(TAKE_MODES)}.") - body: dict[str, Any] | None = None - if resolved is not None: - try: - parsed = parse_json_arg(resolved, label="--resolved") - except ValueError as exc: - _usage_error(formatter, str(exc)) - if not isinstance(parsed, dict): - _usage_error( - formatter, "--resolved must be a JSON object (the replaced configuration body)." - ) - body = parsed - service = get_service(ctx, "merge_request_service") - try: - target = _resolve_target( - ctx, - formatter, - project=project, - merge_request_id=merge_request_id, - branch=branch, - need_row=True, - ) - # Resolving the last conflict on an armed, approved MR unblocks the - # scheduler's retry loop -- it causes the merge as surely as approve does. - strategy = _escalate_if_armed( - ctx, - formatter, - target, - operation="resolve", - merge_request_id=merge_request_id, - branch=branch, - ) - result = _stamp_target( - service.resolve_conflict( - target.alias, - target.merge_request_id, - component_id, - config_id, - take=take, - resolved=body, - change_description=change_description, - ), - target, - ) - except (ConfigError, KeboolaApiError) as exc: - _handle_error(formatter, exc) - - if strategy: - result.setdefault("warnings", []).append(_armed_warning(strategy, result)) - formatter.output( - result, - lambda c, d: c.print( - f"[bold green]Success:[/bold green] Resolved {component_id}/{config_id} " - f"({d.get('resolution')}) -- rebased onto production version {d.get('onto_version')}" - ), - ) - _emit_warnings(formatter, result) - _hint_next( - formatter, - "`merge-request conflicts` for what is left, then `merge-request merge`", - ) +# Writes are declared in _merge_request_writes.py (file-size budget) and mounted +# here so `kbagent merge-request --help` lists the whole group in one place. +_register_write_commands(merge_request_app) From c0182344ac9e2c6c631c9139bf18e182edd93ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 02:33:54 +0200 Subject: [PATCH 06/16] 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 --- docs/web-server-endpoints.md | 21 +- src/keboola_agent_cli/server/app.py | 14 + src/keboola_agent_cli/server/dependencies.py | 3 + .../server/routers/merge_requests.py | 349 ++++++++++++++++++ tests/test_server_router_calls.py | 203 ++++++++++ 5 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 src/keboola_agent_cli/server/routers/merge_requests.py diff --git a/docs/web-server-endpoints.md b/docs/web-server-endpoints.md index a7b50443..eb0c5188 100644 --- a/docs/web-server-endpoints.md +++ b/docs/web-server-endpoints.md @@ -9,7 +9,7 @@ auth, and the concepts behind these routes live in [`web-server.md`](web-server.md); a running server serves the same spec interactively at `/docs` (Swagger) and `/openapi.json`. -**235 operations** across **205 paths** and **30 routers**. +**247 operations** across **215 paths** and **31 routers**. Paths are shown as the server registers them. Reaching them through the Node BFF (or single-process `--ui` mode) prefixes every path with `/api`. @@ -353,6 +353,25 @@ Dev branch lifecycle (create / use / reset / delete / merge) and branch metadata | `PUT` | `/branches/{project}/metadata/{key}` | Set a branch metadata value | | `DELETE` | `/branches/{project}/metadata/{metadata_id}` | Delete a branch metadata entry | +### `merge-requests` (12 operations) + +Merge requests (Branches 2.0, non-SOX): list / detail / create / update / review transitions / merge, plus conflict inspection and resolution. Every route enforces the permission policy; `merge` and any operation that arms or completes an auto-merge are destructive. `POST .../merge` is synchronous and may block up to 600 s. Mirrors `kbagent merge-request *`. + +| Method | Path | Summary | +|---|---|---| +| `GET` | `/merge-requests/{project}` | List merge requests | +| `POST` | `/merge-requests/{project}` | Create a merge request | +| `GET` | `/merge-requests/{project}/by-branch/{branch_id}` | Find the merge request of a branch | +| `GET` | `/merge-requests/{project}/{merge_request_id}` | Merge request detail | +| `PUT` | `/merge-requests/{project}/{merge_request_id}` | Update a merge request | +| `GET` | `/merge-requests/{project}/{merge_request_id}/conflicts` | List conflicts | +| `GET` | `/merge-requests/{project}/{merge_request_id}/diff/{component_id}/{config_id}` | Three-way diff of one conflicting configuration | +| `POST` | `/merge-requests/{project}/{merge_request_id}/request-review` | Send for review | +| `POST` | `/merge-requests/{project}/{merge_request_id}/approve` | Approve | +| `POST` | `/merge-requests/{project}/{merge_request_id}/request-changes` | Request changes | +| `POST` | `/merge-requests/{project}/{merge_request_id}/merge` | Merge into production | +| `POST` | `/merge-requests/{project}/{merge_request_id}/resolve/{component_id}/{config_id}` | Resolve one conflict | + ### `lineage` (8 operations) Build and query cross-project data lineage (table-level and column-level). Mirrors `kbagent lineage build|show|info`. diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index 07211ada..de251821 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -56,6 +56,7 @@ kai, lineage, members, + merge_requests, notifications, org, projects, @@ -302,6 +303,18 @@ "Mirrors `kbagent branch *`." ), }, + { + "name": "merge-requests", + "description": ( + "**Development.** " + "Merge requests (Branches 2.0, non-SOX): list / detail / create / " + "update / review transitions / merge, plus conflict inspection and " + "resolution. Every route enforces the permission policy; `merge` and " + "any operation that arms or completes an auto-merge are destructive. " + "`POST .../merge` is synchronous and may block up to 600 s. " + "Mirrors `kbagent merge-request *`." + ), + }, { "name": "lineage", "description": ( @@ -947,6 +960,7 @@ async def _generic_handler(_request, exc: Exception): app.include_router(token.router) app.include_router(jobs.router) app.include_router(branches.router) + app.include_router(merge_requests.router) app.include_router(workspaces.router) app.include_router(flows.router) app.include_router(schedules.router) diff --git a/src/keboola_agent_cli/server/dependencies.py b/src/keboola_agent_cli/server/dependencies.py index 07211ec0..dc67aa76 100644 --- a/src/keboola_agent_cli/server/dependencies.py +++ b/src/keboola_agent_cli/server/dependencies.py @@ -35,6 +35,7 @@ from ..services.kai_service import KaiService from ..services.lineage_service import LineageService from ..services.member_service import MemberService +from ..services.merge_request_service import MergeRequestService from ..services.notification_service import NotificationService from ..services.org_service import OrgService from ..services.project_service import ProjectService @@ -131,6 +132,7 @@ class ServiceRegistry: transformation: TransformationService = field(init=False) billing: BillingService = field(init=False) auth: AuthService = field(init=False) + merge_request: MergeRequestService = field(init=False) def __post_init__(self) -> None: cs = self.config_store @@ -142,6 +144,7 @@ def __post_init__(self) -> None: self.stream = StreamService(config_store=cs) self.job = JobService(config_store=cs) self.branch = BranchService(config_store=cs) + self.merge_request = MergeRequestService(config_store=cs) self.workspace = WorkspaceService(config_store=cs) self.flow = FlowService(config_store=cs) self.schedule = ScheduleService(config_store=cs) diff --git a/src/keboola_agent_cli/server/routers/merge_requests.py b/src/keboola_agent_cli/server/routers/merge_requests.py new file mode 100644 index 00000000..4ea5fd67 --- /dev/null +++ b/src/keboola_agent_cli/server/routers/merge_requests.py @@ -0,0 +1,349 @@ +"""Merge-request endpoints -- the REST mirror of ``kbagent merge-request *``. + +1:1 with the CLI group (CONTRIBUTING: every non-terminal command has a route), +plus one route the CLI hides behind an omitted ``--merge-request-id``: +``GET /{project}/by-branch/{branch_id}`` -- over HTTP there is no active-branch +idiom, so the branch->MR resolver is exposed directly (registered as the +serve-only ``merge-request.by-branch``). Skipped on purpose: ``diff --output +PATH`` writes to the host's disk; ``GET .../diff`` returns the same payload +(``resolution_candidate`` included) and the caller writes its own file. + +**Every route enforces the permission policy** (``Depends(require_permission)``), +which most routers do not yet do. Here it is not optional: the CLI classifies +``merge`` as destructive and escalates arming auto-merge and the transitions +on an armed MR to destructive too (``permissions.FLAG_ESCALATIONS``); without +the same checks over HTTP that whole analysis would be decorative for +``serve`` callers. The static class is a route dependency; the state/flag- +derived escalations are evaluated in the route body, where the request body +(and, via one row GET, the MR's ``autoMergeStrategy``) is known. Design +record: ``docs/merge-requests-layer1.md``. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from ...errors import ErrorCode, KeboolaApiError +from ...permissions import PermissionEngine +from ...services.merge_request_service import STATE_FILTER_VOCABULARY, TAKE_MODES +from ..dependencies import ServiceRegistry, get_permission_engine, get_registry, require_permission + +router = APIRouter(prefix="/merge-requests", tags=["merge-requests"]) + +_AUTO_MERGE_DISARMED = "none" +_AUTO_MERGE_STRATEGIES = ("immediately", "scheduled", _AUTO_MERGE_DISARMED) + + +def _perm(operation: str) -> Any: + return Depends(require_permission(f"merge-request.{operation}")) + + +def _invalid(message: str) -> KeboolaApiError: + # INVALID_ARGUMENT is in app.py's _CALLER_REFUSAL_CODES -> HTTP 400, the + # REST twin of the CLI's exit 2. + return KeboolaApiError( + message=message, status_code=400, error_code=ErrorCode.INVALID_ARGUMENT, retryable=False + ) + + +def _arming(strategy: str | None, at: str | None) -> bool: + """Validate the auto-merge flag pairing (400 on a bad one); return whether it ARMS.""" + if strategy is not None and strategy not in _AUTO_MERGE_STRATEGIES: + raise _invalid( + f"Unknown auto_merge_strategy {strategy!r}: use {', '.join(_AUTO_MERGE_STRATEGIES)}." + ) + if strategy == "scheduled" and not at: + raise _invalid("auto_merge_strategy 'scheduled' requires auto_merge_at.") + if at is not None and strategy != "scheduled": + raise _invalid("auto_merge_at is only meaningful with auto_merge_strategy 'scheduled'.") + return strategy is not None and strategy != _AUTO_MERGE_DISARMED + + +def _escalate_if_armed( + registry: ServiceRegistry, + engine: PermissionEngine, + project: str, + merge_request_id: int, + operation: str, +) -> None: + """request-review / approve / resolve on an MR armed for auto-merge cause a + production merge; apply the same state-derived escalation the CLI does. + One row GET, never the three-call detail.""" + row = registry.merge_request.get_merge_request_row(project, merge_request_id) + if (row.get("autoMergeStrategy") or _AUTO_MERGE_DISARMED) != _AUTO_MERGE_DISARMED: + engine.check_or_raise(f"merge-request.{operation} --auto-merge-armed") + + +# -- Bodies ------------------------------------------------------------------------------------ + + +class MergeRequestCreate(BaseModel): + branch_from_id: int + title: str + description: str | None = None + reviewer_ids: list[int] | None = None + auto_merge_strategy: str | None = None + auto_merge_at: str | None = None + external_id: str | None = None + + +class MergeRequestUpdate(BaseModel): + title: str | None = None + description: str | None = None + reviewer_ids: list[int] | None = None + auto_merge_strategy: str | None = None + auto_merge_at: str | None = None + external_id: str | None = None + + +class RequestChanges(BaseModel): + reason: str | None = None + + +class ResolveConflict(BaseModel): + take: str | None = None + resolved: dict[str, Any] | None = None + change_description: str | None = None + + +# -- Reads ------------------------------------------------------------------------------------- + + +@router.get("/{project}", summary="List merge requests", dependencies=[_perm("list")]) +def list_merge_requests( + project: str, + state: str | None = Query(None, description="Client-side state filter"), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """List the project's merge requests, newest first. Mirrors `kbagent merge-request list`.""" + if state is not None and state.lower() not in STATE_FILTER_VOCABULARY: + raise _invalid( + f"Unknown state {state!r}. Accepted: {', '.join(sorted(STATE_FILTER_VOCABULARY))}." + ) + return registry.merge_request.list_merge_requests(project, state=state) + + +# Declared BEFORE /{project}/{merge_request_id}: FastAPI matches in order, and +# the int path type would only turn 'by-branch' into a 422 instead of a match. +@router.get( + "/{project}/by-branch/{branch_id}", + summary="Find the merge request of a branch", + dependencies=[_perm("by-branch")], +) +def find_for_branch( + project: str, branch_id: int, registry: ServiceRegistry = Depends(get_registry) +) -> dict[str, Any]: + """The branch->MR resolver the CLI hides behind an omitted --merge-request-id + (a branch has at most one merge request, ever). Serve-only.""" + return registry.merge_request.find_merge_request_for_branch(project, branch_id) + + +@router.get( + "/{project}/{merge_request_id}", summary="Merge request detail", dependencies=[_perm("detail")] +) +def get_merge_request( + project: str, + merge_request_id: int, + activity_log: bool = Query(False, description="Include the activity log"), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Detail with derived status, blockers, viewer flags and live conflicts. Mirrors `kbagent merge-request detail`.""" + return registry.merge_request.get_merge_request( + project, merge_request_id, include_activity_log=activity_log + ) + + +@router.get( + "/{project}/{merge_request_id}/conflicts", + summary="List conflicts", + dependencies=[_perm("conflicts")], +) +def list_conflicts( + project: str, merge_request_id: int, registry: ServiceRegistry = Depends(get_registry) +) -> dict[str, Any]: + """Configurations changed on both sides, computed live. Mirrors `kbagent merge-request conflicts`.""" + return registry.merge_request.list_conflicts(project, merge_request_id) + + +@router.get( + "/{project}/{merge_request_id}/diff/{component_id}/{config_id}", + summary="Three-way diff of one conflicting configuration", + dependencies=[_perm("diff")], +) +def get_config_diff( + project: str, + merge_request_id: int, + component_id: str, + config_id: str, + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Per-path classification plus `resolution_candidate` (the CLI's `--output` + content -- write it yourself). Mirrors `kbagent merge-request diff`.""" + return registry.merge_request.get_config_diff( + project, merge_request_id, component_id, config_id + ) + + +# -- Writes ------------------------------------------------------------------------------------ + + +@router.post("/{project}", summary="Create a merge request", dependencies=[_perm("create")]) +def create_merge_request( + project: str, + body: MergeRequestCreate, + registry: ServiceRegistry = Depends(get_registry), + engine: PermissionEngine = Depends(get_permission_engine), +) -> dict[str, Any]: + """Open a merge request from a dev branch into production. Arming auto-merge + is destructive (a delayed production merge). Mirrors `kbagent merge-request create`.""" + if _arming(body.auto_merge_strategy, body.auto_merge_at): + engine.check_or_raise("merge-request.create --auto-merge-strategy") + return registry.merge_request.create_merge_request( + project, + branch_from_id=body.branch_from_id, + title=body.title, + description=body.description, + reviewer_ids=body.reviewer_ids, + auto_merge_strategy=body.auto_merge_strategy, + auto_merge_at=body.auto_merge_at, + external_id=body.external_id, + ) + + +@router.put( + "/{project}/{merge_request_id}", + summary="Update a merge request", + dependencies=[_perm("update")], +) +def update_merge_request( + project: str, + merge_request_id: int, + body: MergeRequestUpdate, + registry: ServiceRegistry = Depends(get_registry), + engine: PermissionEngine = Depends(get_permission_engine), +) -> dict[str, Any]: + """Omitted fields stay; an empty string clears description/external_id; + reviewer_ids replaces the set. Mirrors `kbagent merge-request update`.""" + if all( + v is None + for v in ( + body.title, + body.description, + body.reviewer_ids, + body.auto_merge_strategy, + body.auto_merge_at, + body.external_id, + ) + ): + raise _invalid("Nothing to update: pass at least one field.") + if _arming(body.auto_merge_strategy, body.auto_merge_at): + engine.check_or_raise("merge-request.update --auto-merge-strategy") + return registry.merge_request.update_merge_request( + project, + merge_request_id, + title=body.title, + description=body.description, + reviewer_ids=body.reviewer_ids, + auto_merge_strategy=body.auto_merge_strategy, + auto_merge_at=body.auto_merge_at, + external_id=body.external_id, + ) + + +@router.post( + "/{project}/{merge_request_id}/request-review", + summary="Send for review", + dependencies=[_perm("request-review")], +) +def request_review( + project: str, + merge_request_id: int, + registry: ServiceRegistry = Depends(get_registry), + engine: PermissionEngine = Depends(get_permission_engine), +) -> dict[str, Any]: + """On a 0-approval project this lands directly in `approved`. Destructive + when the MR is armed for auto-merge. Mirrors `kbagent merge-request request-review`.""" + _escalate_if_armed(registry, engine, project, merge_request_id, "request-review") + return registry.merge_request.request_review(project, merge_request_id) + + +@router.post( + "/{project}/{merge_request_id}/approve", summary="Approve", dependencies=[_perm("approve")] +) +def approve( + project: str, + merge_request_id: int, + registry: ServiceRegistry = Depends(get_registry), + engine: PermissionEngine = Depends(get_permission_engine), +) -> dict[str, Any]: + """Only from `in_review`; 422 on a 0-approval project. Destructive when the + MR is armed for auto-merge. Mirrors `kbagent merge-request approve`.""" + _escalate_if_armed(registry, engine, project, merge_request_id, "approve") + return registry.merge_request.approve(project, merge_request_id) + + +@router.post( + "/{project}/{merge_request_id}/request-changes", + summary="Request changes", + dependencies=[_perm("request-changes")], +) +def request_changes( + project: str, + merge_request_id: int, + body: RequestChanges | None = None, + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Back to development, approvals removed; also the closest thing to closing. + Mirrors `kbagent merge-request request-changes`.""" + reason = body.reason if body else None + return registry.merge_request.request_changes(project, merge_request_id, reason=reason) + + +@router.post( + "/{project}/{merge_request_id}/merge", + summary="Merge into production", + dependencies=[_perm("merge")], +) +def merge( + project: str, merge_request_id: int, registry: ServiceRegistry = Depends(get_registry) +) -> dict[str, Any]: + """Merges and deletes the source branch. SYNCHRONOUS: awaits the merge job for + up to MERGE_JOB_MAX_WAIT (600 s) -- set your client/proxy timeout accordingly. + Mirrors `kbagent merge-request merge`.""" + return registry.merge_request.merge(project, merge_request_id) + + +@router.post( + "/{project}/{merge_request_id}/resolve/{component_id}/{config_id}", + summary="Resolve one conflict", + dependencies=[_perm("resolve")], +) +def resolve_conflict( + project: str, + merge_request_id: int, + component_id: str, + config_id: str, + body: ResolveConflict, + registry: ServiceRegistry = Depends(get_registry), + engine: PermissionEngine = Depends(get_permission_engine), +) -> dict[str, Any]: + """Exactly one of `take` (ours|theirs|delete) or `resolved` (the full replaced + body -- start from the diff's `resolution_candidate`). Destructive when the + MR is armed for auto-merge. Mirrors `kbagent merge-request resolve`.""" + if (body.take is None) == (body.resolved is None): + raise _invalid("Pass exactly one of take (ours|theirs|delete) or resolved.") + if body.take is not None and body.take not in TAKE_MODES: + raise _invalid(f"Unknown take {body.take!r}: use {', '.join(TAKE_MODES)}.") + _escalate_if_armed(registry, engine, project, merge_request_id, "resolve") + return registry.merge_request.resolve_conflict( + project, + merge_request_id, + component_id, + config_id, + take=body.take, + resolved=body.resolved, + change_description=body.change_description, + ) diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index 51aa27ff..d1156441 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -2668,3 +2668,206 @@ def test_workspace_load_invalid_load_type_answers_400(tmp_path: Path) -> None: assert res.status_code == 400, res.text assert res.json()["error"]["code"] == "INVALID_ARGUMENT" + + +# --------------------------------------------------------------------------- +# merge_requests.py -- the REST mirror of `kbagent merge-request *` (DMD-1900) +# Service: MergeRequestService.(project, merge_request_id, ...) +# --------------------------------------------------------------------------- + +MR_ID = 7 + + +def _mr_client(tmp_path: Path, svc: MagicMock, **app_kwargs: Any) -> TestClient: + app = create_app(config_dir=str(tmp_path), auth_token="test-token", **app_kwargs) + app.dependency_overrides[get_registry] = lambda: _mock_registry(merge_request=svc) + return TestClient(app) + + +def _armed_row(strategy: str = "immediately") -> dict[str, Any]: + return {"id": MR_ID, "state": "development", "autoMergeStrategy": strategy} + + +def test_merge_request_list_forwards_state_kwarg(tmp_path: Path) -> None: + svc = MagicMock() + svc.list_merge_requests.return_value = {"count": 0, "merge_requests": []} + with _mr_client(tmp_path, svc) as client: + res = client.get(f"/merge-requests/{PROJECT}", params={"state": "merged"}, headers=AUTH) + assert res.status_code == 200, res.text + svc.list_merge_requests.assert_called_once_with(PROJECT, state="merged") + + +def test_merge_request_list_rejects_unknown_state_with_400(tmp_path: Path) -> None: + svc = MagicMock() + with _mr_client(tmp_path, svc) as client: + res = client.get(f"/merge-requests/{PROJECT}", params={"state": "develpment"}, headers=AUTH) + assert res.status_code == 400, res.text + assert res.json()["error"]["code"] == ErrorCode.INVALID_ARGUMENT + svc.list_merge_requests.assert_not_called() + + +def test_merge_request_by_branch_is_matched_before_the_id_route(tmp_path: Path) -> None: + svc = MagicMock() + svc.find_merge_request_for_branch.return_value = {"id": MR_ID} + with _mr_client(tmp_path, svc) as client: + res = client.get(f"/merge-requests/{PROJECT}/by-branch/123", headers=AUTH) + assert res.status_code == 200, res.text + svc.find_merge_request_for_branch.assert_called_once_with(PROJECT, 123) + svc.get_merge_request.assert_not_called() + + +def test_merge_request_detail_forwards_include_activity_log(tmp_path: Path) -> None: + svc = MagicMock() + svc.get_merge_request.return_value = {"id": MR_ID} + with _mr_client(tmp_path, svc) as client: + res = client.get( + f"/merge-requests/{PROJECT}/{MR_ID}", params={"activity_log": "true"}, headers=AUTH + ) + assert res.status_code == 200, res.text + svc.get_merge_request.assert_called_once_with(PROJECT, MR_ID, include_activity_log=True) + + +def test_merge_request_conflicts_and_diff_positional_parity(tmp_path: Path) -> None: + svc = MagicMock() + svc.list_conflicts.return_value = {"count": 0, "conflicts": []} + svc.get_config_diff.return_value = {"changes": []} + with _mr_client(tmp_path, svc) as client: + assert ( + client.get(f"/merge-requests/{PROJECT}/{MR_ID}/conflicts", headers=AUTH).status_code + == 200 + ) + res = client.get( + f"/merge-requests/{PROJECT}/{MR_ID}/diff/{COMPONENT}/{CONFIG_ID}", headers=AUTH + ) + assert res.status_code == 200, res.text + svc.list_conflicts.assert_called_once_with(PROJECT, MR_ID) + svc.get_config_diff.assert_called_once_with(PROJECT, MR_ID, COMPONENT, CONFIG_ID) + + +def test_merge_request_create_forwards_every_kwarg(tmp_path: Path) -> None: + svc = MagicMock() + svc.create_merge_request.return_value = {"id": MR_ID} + with _mr_client(tmp_path, svc) as client: + res = client.post( + f"/merge-requests/{PROJECT}", + json={"branch_from_id": 123, "title": "T", "reviewer_ids": [5], "external_id": "TCK-1"}, + headers=AUTH, + ) + assert res.status_code == 200, res.text + svc.create_merge_request.assert_called_once_with( + PROJECT, + branch_from_id=123, + title="T", + description=None, + reviewer_ids=[5], + auto_merge_strategy=None, + auto_merge_at=None, + external_id="TCK-1", + ) + + +def test_merge_request_update_refuses_an_empty_body(tmp_path: Path) -> None: + svc = MagicMock() + with _mr_client(tmp_path, svc) as client: + res = client.put(f"/merge-requests/{PROJECT}/{MR_ID}", json={}, headers=AUTH) + assert res.status_code == 400, res.text + svc.update_merge_request.assert_not_called() + + +def test_merge_request_update_forwards_kwargs(tmp_path: Path) -> None: + svc = MagicMock() + svc.update_merge_request.return_value = {"id": MR_ID} + with _mr_client(tmp_path, svc) as client: + res = client.put( + f"/merge-requests/{PROJECT}/{MR_ID}", json={"description": ""}, headers=AUTH + ) + assert res.status_code == 200, res.text + kwargs = svc.update_merge_request.call_args.kwargs + assert kwargs["description"] == "" and kwargs["title"] is None + + +def test_merge_request_transitions_and_merge_positional_parity(tmp_path: Path) -> None: + svc = MagicMock() + svc.get_merge_request_row.return_value = {"id": MR_ID, "autoMergeStrategy": "none"} + for method in ("request_review", "approve", "request_changes", "merge"): + getattr(svc, method).return_value = {"id": MR_ID} + with _mr_client(tmp_path, svc) as client: + base = f"/merge-requests/{PROJECT}/{MR_ID}" + assert client.post(f"{base}/request-review", headers=AUTH).status_code == 200 + assert client.post(f"{base}/approve", headers=AUTH).status_code == 200 + assert ( + client.post(f"{base}/request-changes", json={"reason": "no"}, headers=AUTH).status_code + == 200 + ) + assert client.post(f"{base}/merge", headers=AUTH).status_code == 200 + svc.request_review.assert_called_once_with(PROJECT, MR_ID) + svc.approve.assert_called_once_with(PROJECT, MR_ID) + svc.request_changes.assert_called_once_with(PROJECT, MR_ID, reason="no") + svc.merge.assert_called_once_with(PROJECT, MR_ID) + + +def test_merge_request_resolve_forwards_kwargs_and_validates_shape(tmp_path: Path) -> None: + svc = MagicMock() + svc.get_merge_request_row.return_value = {"id": MR_ID, "autoMergeStrategy": "none"} + svc.resolve_conflict.return_value = {"resolution": "ours"} + url = f"/merge-requests/{PROJECT}/{MR_ID}/resolve/{COMPONENT}/{CONFIG_ID}" + with _mr_client(tmp_path, svc) as client: + assert client.post(url, json={}, headers=AUTH).status_code == 400 # neither + assert ( + client.post(url, json={"take": "ours", "resolved": {}}, headers=AUTH).status_code == 400 + ) + assert client.post(url, json={"take": "mine"}, headers=AUTH).status_code == 400 + res = client.post(url, json={"take": "ours", "change_description": "x"}, headers=AUTH) + assert res.status_code == 200, res.text + svc.resolve_conflict.assert_called_once_with( + PROJECT, MR_ID, COMPONENT, CONFIG_ID, take="ours", resolved=None, change_description="x" + ) + + +# -- permissions are enforced on every route, static class and escalations alike -- + + +def test_merge_request_merge_is_destructive_over_http(tmp_path: Path) -> None: + svc = MagicMock() + with _mr_client(tmp_path, svc, deny_destructive=True) as client: + res = client.post(f"/merge-requests/{PROJECT}/{MR_ID}/merge", headers=AUTH) + assert res.status_code == 403, res.text + assert res.json()["error"]["code"] == ErrorCode.PERMISSION_DENIED + svc.merge.assert_not_called() + + +def test_merge_request_arming_auto_merge_is_destructive_over_http(tmp_path: Path) -> None: + svc = MagicMock() + svc.create_merge_request.return_value = {"id": MR_ID} + with _mr_client(tmp_path, svc, deny_destructive=True) as client: + armed = client.post( + f"/merge-requests/{PROJECT}", + json={"branch_from_id": 123, "title": "T", "auto_merge_strategy": "immediately"}, + headers=AUTH, + ) + disarmed = client.post( + f"/merge-requests/{PROJECT}", + json={"branch_from_id": 123, "title": "T", "auto_merge_strategy": "none"}, + headers=AUTH, + ) + assert armed.status_code == 403, armed.text + assert disarmed.status_code == 200, disarmed.text # `none` is the disarm, never escalates + svc.create_merge_request.assert_called_once() + + +def test_merge_request_transition_on_armed_mr_is_destructive_over_http(tmp_path: Path) -> None: + svc = MagicMock() + svc.get_merge_request_row.return_value = _armed_row() + with _mr_client(tmp_path, svc, deny_destructive=True) as client: + res = client.post(f"/merge-requests/{PROJECT}/{MR_ID}/request-review", headers=AUTH) + assert res.status_code == 403, res.text + svc.get_merge_request_row.assert_called_once_with(PROJECT, MR_ID) # the row tier, one GET + svc.get_merge_request.assert_not_called() + svc.request_review.assert_not_called() + + +def test_merge_request_reads_pass_under_deny_destructive(tmp_path: Path) -> None: + svc = MagicMock() + svc.list_merge_requests.return_value = {"count": 0, "merge_requests": []} + with _mr_client(tmp_path, svc, deny_destructive=True) as client: + assert client.get(f"/merge-requests/{PROJECT}", headers=AUTH).status_code == 200 From a6ac128283b40979c9a97bb1fffcac7cffecf4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 02:41:18 +0200 Subject: [PATCH 07/16] 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 --- CLAUDE.md | 54 ++++++ plugins/kbagent/agents/keboola-expert.md | 1 + plugins/kbagent/skills/kbagent/SKILL.md | 29 ++- .../kbagent/references/branch-workflow.md | 4 +- .../kbagent/references/commands-reference.md | 18 +- .../skills/kbagent/references/gotchas.md | 53 ++++++ .../references/merge-request-workflow.md | 140 ++++++++++++++ src/keboola_agent_cli/commands/branch.py | 26 ++- src/keboola_agent_cli/commands/context.py | 62 +++++- tests/test_e2e.py | 178 ++++++++++++++++++ 10 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 plugins/kbagent/skills/kbagent/references/merge-request-workflow.md diff --git a/CLAUDE.md b/CLAUDE.md index 3f2a7a61..434c5f10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -737,6 +737,60 @@ kbagent branch metadata-list --project NAME [--branch ID|default] kbagent branch metadata-get --project NAME --key KEY [--branch ID|default] kbagent branch metadata-set --project NAME --key KEY [--text STR | --file PATH | --stdin] [--branch ID|default] kbagent branch metadata-delete --project NAME --metadata-id ID [--branch ID|default] +# branch merge is DEPRECATED (since vNEXT): it only builds a UI URL and resets the active branch. On a +# project with `branches-merge-requests` use the merge-request group below; the command keeps working +# (it also serves projects without the feature) and now carries `deprecation` in --json. + +# merge-request (since vNEXT, DMD-1900): the non-SOX Branches 2.0 lifecycle. Hidden alias `mr`. Every +# command except list/create takes `[--merge-request-id N | --id N] [--branch B]`: omitted, the target is +# the merge request OF the active branch (`branch use`) -- a branch has at most one MR, ever. Both flags at +# once -> exit 2. `--project` is single-project (never fans out). Status is the DERIVED state the web UI +# shows (in_development|in_review|approved|in_merge|merged|closed|rejected), never the raw one. +kbagent merge-request list [--project A] [--state in_development|in_review|approved|in_merge|merged|closed|rejected] +kbagent merge-request detail [--project A] [--merge-request-id N | --branch B] [--activity-log] +kbagent merge-request create --title T [--project A] [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes] +kbagent merge-request update [--project A] [--merge-request-id N | --branch B] [--title T] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy S] [--auto-merge-at TS] [--external-id X] [--yes] +kbagent merge-request request-review [--project A] [--merge-request-id N | --branch B] +kbagent merge-request approve [--project A] [--merge-request-id N | --branch B] +kbagent merge-request request-changes [--project A] [--merge-request-id N | --branch B] [--reason TEXT] +kbagent merge-request merge [--project A] [--merge-request-id N | --branch B] [--yes] +kbagent merge-request conflicts [--project A] [--merge-request-id N | --branch B] +kbagent merge-request diff --component-id C --config-id I [--project A] [--merge-request-id N | --branch B] [--format short|full] [--output PATH] +kbagent merge-request resolve --component-id C --config-id I (--take ours|theirs|delete | --resolved JSON|@file|-) [--project A] [--merge-request-id N | --branch B] [--change-description TEXT] +# WHAT MAY HAPPEN WITHOUT A HUMAN SAYING SO -- read before automating this group: +# `merge` is DESTRUCTIVE (deletes the source branch, rewrites production). Under --json it REQUIRES an +# explicit target (--merge-request-id or --branch): every destructive kbagent command either prompts or is +# told its target, and --json has no prompt. In human mode the active-branch fallback stays and a prompt +# names the MR and the branch (--yes skips it). +# ARMING AUTO-MERGE IS A PRODUCTION MERGE, just delayed: a backend scheduler runs every `approved` MR whose +# autoMergeStrategy is immediately/scheduled through the same MergeProcessor, on its own, retrying every +# tick until it lands -- `merge` is never called. So `create`/`update --auto-merge-strategy immediately| +# scheduled` escalate to destructive (blocked by --deny-destructive; explicit target under --json; prompt +# in human mode), and `request-review` / `approve` / `resolve` on an ALREADY-armed MR escalate too -- +# they are what moves it into `approved`. `--auto-merge-strategy none` is the disarm and never escalates. +# The escalation is deliberately conservative (the required-approvals count is unreadable with a Storage +# token, DMD-1969). An agent under --deny-destructive can run the whole flow and cannot complete a +# merge by any route. +# On a non-SOX project with the default 0 required approvals: `merge` works straight from `development`; +# `request-review` lands directly in `approved` (in_review is unreachable); `approve` answers 422 in every +# state. There is NO `close`: `request-changes` by the creator is the UI's cancel and leaves the MR in +# `development` (the `closed`/`rejected` derivations depend on reviewer status a 0-approval project never +# populates -- same blind spot as the web UI, DMD-1988). Requesting review with no reviewers selected +# emails EVERY project member. +# Conflicts: `conflicts` lists what changed on both sides (isDeleted = the DEV side's flag); `diff` +# classifies per path (both / only you / only production) and, when a side deleted the config wholesale, +# recommends the --take; `diff --output FILE` writes `resolution_candidate` (your branch's content, all +# five keys: name/description/isDisabled/configuration/rows -- rebase REPLACES, a missing key wipes data, +# the service refuses a partial body) to edit and hand back via `resolve --resolved @FILE`. No `--all`. +# Errors: a project without the feature answers FEATURE_NOT_ENABLED (exit 5) from every command whose +# target was resolved implicitly, reads included -- two wordings, missing feature vs SOX project. A +# scoped Storage token 403s (ACCESS_DENIED) on everything but `list`. `MR_MERGE_CONFLICT` / +# `MR_NOT_READY_TO_MERGE` (exit 1; the latter retryable) come from the merge 409; a truncated conflict +# list in the error carries `details.api_error_params_truncated: true` -- run `conflicts` for the full set. +# `merge` blocks for up to 10 minutes (no --wait/--timeout). Every result may carry `warnings[]` +# (post-merge cleanup failures, a dropped --change-description on a delete resolution). On a project +# where the feature was later switched OFF, `allowed_actions` (and the hint-next line) still recommend +# writes that will fail FEATURE_NOT_ENABLED -- state-derived, feature-blind by Layer 2's decision. kbagent workspace create --project ALIAS [--name NAME] [--backend TYPE] [--ui] [--read-only/--no-read-only] kbagent workspace list [--project NAME ...] [--orphaned] [--branch ID] [--qs-compatible] diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 0a6a6e2a..be4bcb34 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -129,6 +129,7 @@ been retired, so its absence is NOT a promise (see §1 Rule 6). | Export a FILTERED or INCREMENTAL slice of a table (no workspace) | `kbagent storage download-table --table-id ... --where-column status --where-value active [--where-operator eq\|neq] [--changed-since "-2 days"]` -- server-side filter on the credential-only export path | `kbagent workspace query` with a `WHERE` clause when you need real SQL | downloading the whole table then filtering locally | | Run Keboola SQL / read-write Storage Files from INSIDE a Python process you control | `from keboola_agent_cli import Client` -- stateless `Client(url, token)`; `.query(workspace_id, sql)`, `.files.upload/.read_bytes/.list`; no subprocess, no `serve`, no config-dir. See [library-workflow.md](../skills/kbagent/references/library-workflow.md) | the CLI or `kbagent serve` REST when you are NOT already inside Python | shelling out to the `kbagent` binary from Python you control; using it for open-ended exploration (fixed set of typed ops) | | Inspect dev branch | `kbagent branch list --project P`, `kbagent branch use --project P --branch ID` | -- | acting on `main` when a dev branch exists | +| Merge a dev branch into production (review, conflicts) | `kbagent merge-request create --title T` from the active branch, `merge-request detail` for readiness, `merge-request merge` (vNEXT+; project feature `branches-merge-requests`). Conflicts: `conflicts` -> `diff --component-id C --config-id I` -> `resolve --take ours\|theirs\|delete`. See [merge-request-workflow.md](../skills/kbagent/references/merge-request-workflow.md) -- read the auto-merge section before touching `--auto-merge-strategy` | `branch merge` (deprecated URL builder) on a project WITHOUT the feature | `--auto-merge-strategy immediately\|scheduled` without treating it as a production merge (a backend scheduler merges on its own once approved; blocked by `--deny-destructive`, needs an explicit target under `--json`); `--json merge` with no `--merge-request-id`/`--branch` (exit 2 by design); `resolve --resolved` with a partial body (rebase REPLACES -- all five keys or refused); reading `allowed_actions` as feature-aware; `approve` on a 0-approval project (422 in every state) | | Audit project capabilities / features | `kbagent project info --project P` -- project id, name, backend, enabled features, quota limits, metrics | -- | inspecting the UI project settings manually | | Manage feature flags (stack / project / user) | `kbagent feature list\|project-show\|project-add\|project-remove\|user-show\|user-add\|user-remove --project P [--email E] [--feature NAME] [--dry-run]` -- Manage API, needs a SUPER-ADMIN token (interactive prompt; `--allow-env-manage-token` for CI) | `kbagent project info` for a project's *enabled* features (read-only, no super-admin) | raw `/manage/...` calls; a manage token passed as a CLI flag | | Create a new config (one-shot remote, no scaffold to disk) | `kbagent config new --project P --component-id C --name N --push --no-files [--configuration @body.json]` -- default body `{}` skips validation; an explicit body is schema-validated (`--no-validate` opts out); works for every component type. `--output-dir` + `--push` together is safe only on 0.89.0+ (scaffold records `_keboola.config_id`, lands in the created branch's subtree); older kbagent writes an ID-less scaffold that the next `sync push` DUPLICATES (issue #644) -- there, scaffold and push in two steps | `kbagent config new --output-dir D` then edit + `kbagent sync push` | raw `POST /v2/storage/components/.../configs` (no schema validation, no encryption) | diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 5b70f8ad..e3289159 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -3,7 +3,8 @@ name: kbagent description: > Use when working with Keboola Connection projects via the kbagent CLI. Covers: exploring and searching configurations, job history, data - lineage, dev branches, workspace SQL debugging, GitOps config sync, + lineage, dev branches, merge requests (branch -> production with review), + workspace SQL debugging, GitOps config sync, bucket sharing and linking, encrypting secrets, Storage tables, files, and snapshots, data apps, flows and schedules, invitations, @@ -12,6 +13,7 @@ description: > first-time setup and logout in any client. Triggers: kbagent, Keboola, keboola config, keboola job, keboola lineage, keboola sync, gitops, dev branch, + merge request, mr, merge branch, auto-merge, review request, data app, streamlit deploy, semantic layer, sl, dev-portal, data stream, OTLP, scoped token, encrypt secrets, feature flag, flow schedule, invite member, SQL transformation edit, @@ -238,11 +240,33 @@ When working inside a git repository or project directory, run `kbagent init` (o | Set an existing development branch as active | `kbagent branch use --project PROJECT --branch BRANCH` | | Reset the active branch back to main/production | `kbagent branch reset --project PROJECT` | | Delete a development branch | `kbagent branch delete --project PROJECT --branch BRANCH` | -| Get the KBC UI merge URL for a development branch | `kbagent branch merge --project PROJECT` | +| [DEPRECATED] Get the KBC UI merge URL for a development branch | `kbagent branch merge --project PROJECT` | | List all metadata entries on a branch | `kbagent branch metadata-list --project PROJECT` | | Read a single metadata value by key | `kbagent branch metadata-get --project PROJECT --key KEY` | | Set a metadata key/value on a branch | `kbagent branch metadata-set --project PROJECT --key KEY` | | Delete a branch metadata entry by its numeric ID | `kbagent branch metadata-delete --project PROJECT --metadata-id METADATA-ID` | +| List the project's merge requests, newest first | `kbagent merge-request list` | +| Show one merge request: readiness, blockers, reviewers, change log, conflicts | `kbagent merge-request detail` | +| List the configurations changed on both sides (computed live by the backend) | `kbagent merge-request conflicts` | +| Three-way diff of one conflicting configuration, classified per path | `kbagent merge-request diff --component-id COMPONENT-ID --config-id CONFIG-ID` | +| Open a merge request from a development branch into production | `kbagent merge-request create --title TITLE` | +| Change a merge request's title, description, reviewers, auto-merge or external id | `kbagent merge-request update` | +| Send the merge request for review | `kbagent merge-request request-review` | +| Add your approval to a merge request under review | `kbagent merge-request approve` | +| Send the merge request back to development; existing approvals are removed | `kbagent merge-request request-changes` | +| Merge the merge request into production and delete its source branch | `kbagent merge-request merge` | +| Resolve one conflicting configuration by rebasing it onto production's version | `kbagent merge-request resolve --component-id COMPONENT-ID --config-id CONFIG-ID` | +| List the project's merge requests, newest first | `kbagent mr list` | +| Show one merge request: readiness, blockers, reviewers, change log, conflicts | `kbagent mr detail` | +| List the configurations changed on both sides (computed live by the backend) | `kbagent mr conflicts` | +| Three-way diff of one conflicting configuration, classified per path | `kbagent mr diff --component-id COMPONENT-ID --config-id CONFIG-ID` | +| Open a merge request from a development branch into production | `kbagent mr create --title TITLE` | +| Change a merge request's title, description, reviewers, auto-merge or external id | `kbagent mr update` | +| Send the merge request for review | `kbagent mr request-review` | +| Add your approval to a merge request under review | `kbagent mr approve` | +| Send the merge request back to development; existing approvals are removed | `kbagent mr request-changes` | +| Merge the merge request into production and delete its source branch | `kbagent mr merge` | +| Resolve one conflicting configuration by rebasing it onto production's version | `kbagent mr resolve --component-id COMPONENT-ID --config-id CONFIG-ID` | | Create a new workspace | `kbagent workspace create --project PROJECT` | | List workspaces from connected projects | `kbagent workspace list` | | Show workspace details (password NOT included) | `kbagent workspace detail --project PROJECT --workspace-id WORKSPACE-ID` | @@ -411,6 +435,7 @@ For detailed response parsing rules and common pitfalls, see [gotchas](reference | **Project members & invitations** (single + bulk via CSV, role change, remove) | [member-workflow](references/member-workflow.md) | | **Billing / PAYG credits** (balance only; the shape of the invoice-history gap; PAYG_NOT_AVAILABLE; units) | [billing-workflow](references/billing-workflow.md) | | Dev branches | [branch-workflow](references/branch-workflow.md) | +| **Merge requests** (dev branch -> production with review; conflicts + resolve; auto-merge is destructive; `--json merge` needs an explicit target) | [merge-request-workflow](references/merge-request-workflow.md) | | Encrypting secrets before a config write | [encrypt-workflow](references/encrypt-workflow.md) | | Sync & Git-branching (GitOps) | [sync-workflow](references/sync-workflow.md) | | Sync row-level internals (manifest v3, hoist, encryption) | [sync-rows-workflow](references/sync-rows-workflow.md) | diff --git a/plugins/kbagent/skills/kbagent/references/branch-workflow.md b/plugins/kbagent/skills/kbagent/references/branch-workflow.md index 3d5968a1..484b6287 100644 --- a/plugins/kbagent/skills/kbagent/references/branch-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/branch-workflow.md @@ -30,12 +30,12 @@ kbagent --json branch merge --project ALIAS | `branch use --branch ID` | Switch to existing branch | | `branch reset` | Switch back to main/production | | `branch delete --branch ID` | Delete branch (resets if it was active) | -| `branch merge` | Get merge URL, reset to main | +| `branch merge` | DEPRECATED (since vNEXT): get merge URL, reset to main. On a project with `branches-merge-requests` use `merge-request` -- see [merge-request-workflow.md](merge-request-workflow.md) | ## Key details - **Async operations**: `branch create` and `branch delete` are async on the API. kbagent waits for completion (typically 1-3s). No need to poll. -- **Merge is manual**: `branch merge` returns a URL for the Keboola UI. It does NOT merge via API. This is intentional for safe review. +- **Merge from the CLI needs the merge-request group** *(since vNEXT)*: `branch merge` only returns a URL for the Keboola UI (and is deprecated). On a project with the `branches-merge-requests` feature, `kbagent merge-request create` + `merge-request merge` merge via the API with review and conflict resolution -- see [merge-request-workflow.md](merge-request-workflow.md). - **Active branch persistence**: stored in kbagent config. Survives between sessions. - **Config commands respect active branch**: `config list`, `config detail`, and `config search` auto-scope to the active branch. Use `--branch ID` to override. - **Workspaces respect active branch**: `workspace create` and `workspace delete` operate in the active branch context. diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index e1dfa2a5..9d88ae71 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -237,12 +237,28 @@ Bucket sharing + linking across projects in the same organization. `sharing edge - `branch use --project ALIAS --branch ID` -- switch active branch - `branch reset --project ALIAS` -- reset to main/production - `branch delete --project ALIAS --branch ID` -- delete branch (resets if active) -- `branch merge --project ALIAS [--branch ID]` -- get merge URL (does NOT merge via API) +- `branch merge --project ALIAS [--branch ID]` -- DEPRECATED (since vNEXT): get merge URL (does NOT merge via API), resets the active branch. Prefer `merge-request` on projects with the feature - `branch metadata-list --project NAME [--branch ID|default]` -- list all metadata entries on a branch (id, key, value, provider, timestamp). `--branch` defaults to `default` (main branch) - `branch metadata-get --project NAME --key KEY [--branch ID|default]` -- read a single metadata value by key. Exits with `NOT_FOUND` (exit 1) if absent - `branch metadata-set --project NAME --key KEY [--text STR | --file PATH | --stdin] [--branch ID|default]` -- set a key/value. Useful for `KBC.projectDescription` and similar dashboard-visible fields. Pass exactly one of `--text`, `--file`, or `--stdin` - `branch metadata-delete --project NAME --metadata-id ID [--branch ID|default]` -- delete a metadata entry by its numeric ID (from `metadata-list`) +## Merge Requests (since vNEXT) +Non-SOX Branches 2.0: merge a dev branch into production with review. Alias `mr`. Every command except `list`/`create` takes `[--merge-request-id N | --id N] [--branch B]`; omitted, the target is the merge request of the active branch. A branch has at most one MR, ever. Both flags at once -> exit 2. Status is the derived state the web UI shows. See `merge-request-workflow.md`. +- `merge-request list [--project A] [--state STATE]` -- newest first; `--state` filters client-side (unknown -> exit 2); an empty list on a feature-less project says so (`feature_enabled: false`) +- `merge-request detail [--merge-request-id N | --branch B] [--activity-log]` -- readiness (`mergeable`/`merge_blockers`), `viewer`, `allowed_actions`, reviewers, approvals, change log (empty until sent for review, by design), live conflicts +- `merge-request create --title T [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes]` -- from `--branch` or the active branch into production +- `merge-request update [--merge-request-id N | --branch B] [--title] [--description] [--reviewer-id ...] [--auto-merge-strategy] [--auto-merge-at] [--external-id] [--yes]` -- omitted fields stay; `""` clears description/external-id; `--reviewer-id` REPLACES the set; no fields -> exit 2 +- `merge-request request-review [...]` -- on a 0-approval project lands directly in `approved` (`merge` works without it); no reviewers selected = email to every project member +- `merge-request approve [...]` -- only from `in_review`; 422 on a 0-approval project +- `merge-request request-changes [...] [--reason TEXT]` -- back to development, approvals removed; the closest thing to "close" (no cancel endpoint) +- `merge-request merge [...] [--yes]` -- DESTRUCTIVE: merges into production, deletes the source branch, blocks up to 10 min; under `--json` an explicit target is REQUIRED +- `merge-request conflicts [...]` -- configs changed on both sides (live); `isDeleted` is the dev side's flag +- `merge-request diff --component-id C --config-id I [...] [--format short|full] [--output PATH]` -- per-path both/only-you/only-production; a wholesale-deleted side is reported with the `--take` to pick; `--output` writes `resolution_candidate` (all five keys) for `resolve --resolved @PATH` +- `merge-request resolve --component-id C --config-id I (--take ours|theirs|delete | --resolved JSON|@file|-) [...] [--change-description TEXT]` -- rebase onto production's version; rebase REPLACES, a `--resolved` body needs name/description/isDisabled/configuration/rows; no `--all` +- **Auto-merge is destructive**: arming (`immediately|scheduled`) makes the backend merge on its own once approved. Arming on create/update and request-review/approve/resolve on an already-armed MR are blocked by `--deny-destructive` and need an explicit target under `--json`; `none` disarms +- Errors: `FEATURE_NOT_ENABLED` (exit 5, two wordings: feature missing vs SOX) from any command whose target resolved implicitly; `MR_MERGE_CONFLICT` / `MR_NOT_READY_TO_MERGE` from merge (a truncated conflict list carries `details.api_error_params_truncated`); scoped token 403s on everything but `list`. Every result may carry `warnings[]` + ## Workspaces (SQL Debugging) - `workspace create --project ALIAS [--name NAME] [--ui] [--read-only]` -- create workspace (headless ~1s, `--ui` ~15s). Since v0.47.1: Snowflake headless workspaces return a `private_key` PEM field; `password` is empty. BigQuery workspaces keep the default password credential shape. - `workspace list [--project NAME ...] [--orphaned] [--branch ID] [--qs-compatible]` -- list workspaces. `--project` repeatable; `--orphaned` filters to workspaces whose backing `keboola.sandboxes` config is missing. **Since v0.42.0 (#304)**: each entry carries `login_type`, `read_only`, `qs_compatible`, `database`, `warehouse`. New `Login Type` / `RO` / `QS` columns in human mode. `--qs-compatible` pre-filters to RO + whitelisted-loginType workspaces (the canonical data-app shape). **Updated v0.58.0**: `qs_compatible` is keyed by `(backend, loginType)` -- BigQuery workspaces (loginType `default`) now report `qs_compatible: true` and pass `--qs-compatible`; pre-0.58.0 every BigQuery workspace was wrongly excluded (Snowflake's own legacy `default` stays `false`). `--branch` requires exactly one `--project`; without `--branch`, the command behaves like `storage buckets` and uses production with an `Info: Using production branch for read (active dev branch X ignored; pass --branch X to override)` banner when an alias is pinned to a dev branch diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 4694a383..f0774534 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -5043,3 +5043,56 @@ It carries the command name, the outcome, and the duration -- never argument val signed out or no session, which the command's own help documents as normal. The event reads `type=info`, so a consumer never counts a routine signed-out check as an error. Keep this list aligned when another command documents a non-zero exit as an expected result. + +## `merge-request` group: arming auto-merge IS a production merge (since vNEXT) + +`kbagent merge-request` (alias `mr`) merges a dev branch into production with review +(non-SOX "Branches 2.0", project feature `branches-merge-requests`). Full playbook: +`merge-request-workflow.md`. The parts that bite: + +- **`--auto-merge-strategy immediately|scheduled` is not metadata.** A backend scheduler runs + every `approved` MR armed with it through the same merge processor `merge` uses -- on its own, + retrying every tick, `merge` never called, the arming call answering 200 with nothing to say + so. kbagent therefore classifies arming (on `create`/`update`) AND `request-review` / + `approve` / `resolve` on an already-armed MR as **destructive**: `--deny-destructive` blocks + them, `--json` requires an explicit target, human mode prompts at the arming. `none` disarms + and never escalates. Deliberately conservative -- the required-approvals count is unreadable + with a Storage token (DMD-1969), so every armed operation escalates even where it would not + yet merge. +- **`merge` under `--json` needs `--merge-request-id` or `--branch`.** Every destructive + kbagent command either prompts or is told its target; `--json` has no prompt. In human mode + the active-branch fallback stays and the prompt names the MR and the branch it will delete. +- **Targets are implicit everywhere else**: omit the id and the command uses the merge request + OF the active branch (`branch use`). Both `--merge-request-id` and `--branch` at once -> exit 2. +- **`FEATURE_NOT_ENABLED` (exit 5) comes from reads too** -- from any command whose target was + resolved implicitly on a project without the feature (the resolver runs the feature check when + the branch has no MR). Two wordings: the feature is missing vs. the project is SOX + (`protected-default-branch`), which kbagent does not support -- do not tell a SOX project to + "enable the feature". +- **A scoped Storage token gets `ACCESS_DENIED` on everything but `list`** -- the + detail/conflicts endpoints require an admin identity. +- **On a 0-approval project** (the non-SOX default): `merge` works straight from + `development`; `request-review` lands directly in `approved`; `approve` answers 422 in every + state. Requesting review with no reviewers selected emails EVERY project member. +- **There is no `close`.** `request-changes` by the creator is the UI's cancel; the MR stays in + `development`. The `closed`/`rejected` derived states depend on reviewer status a 0-approval + project never populates -- the same blind spot the web UI has (DMD-1988). +- **The change log is empty until the MR is sent for review** (backend behaviour); "what will + this merge" is unavailable in `development`. +- **`--reviewer-id` REPLACES the reviewer set** (never appends); `--description ""` / + `--external-id ""` CLEAR on update; `update` with no fields is exit 2. +- **`diff --output FILE` writes all five keys** (`name`, `description` as explicit `null`, + `isDisabled`, `configuration`, `rows`). Rebase REPLACES: `resolve --resolved` refuses a body + missing any of them (a defaulted `isDisabled` would silently re-enable a disabled config). + Edit values, never delete keys. A side that deleted the config wholesale is reported as a + sentence recommending the `--take`, not as an empty table. No `--all`. +- **The source branch is deleted asynchronously** after a merge ("is being deleted", never + "is deleted"); `merge` itself blocks up to 10 minutes, no `--wait`/`--timeout`. +- **Every result may carry `warnings[]`** (post-merge cleanup failure, a dropped + `--change-description` on a delete resolution). A truncated conflict list inside + `MR_MERGE_CONFLICT` carries `details.api_error_params_truncated: true` -- run `conflicts`. +- **`allowed_actions` / the hint-next line are feature-blind**: on a project where the feature + was later switched off they recommend writes that fail `FEATURE_NOT_ENABLED` (Layer 2's + documented decision; server-side fix is DMD-1988). +- **`branch merge` is deprecated** (still works, carries `deprecation` in `--json`): it only + builds a UI URL and resets the active branch. diff --git a/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md new file mode 100644 index 00000000..9065315e --- /dev/null +++ b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md @@ -0,0 +1,140 @@ +# Merge Request Workflow -- merging a dev branch into production with review (since vNEXT) + +`kbagent merge-request` (alias `mr`) is the non-SOX "Branches 2.0" lifecycle: open a merge +request from a development branch, optionally get it reviewed, inspect and resolve conflicts, +merge. Requires the project feature `branches-merge-requests`. Read this before automating any +of it -- **two things in this group merge production without a human saying so** if you let them. + +## The short path (0 required approvals, the non-SOX default) + +```bash +kbagent branch create --project P --name "fix-x" # auto-activates the branch +# ... edit configs on the branch (config update / sync push --branch) ... +kbagent mr create --project P --title "Fix X" # from the active branch +kbagent mr detail --project P # readiness, blockers, conflicts +kbagent mr merge --project P # prompt -> merge -> branch deleted +``` + +Every command except `list` / `create` finds its merge request **from the active branch** when +`--merge-request-id` (or `--id`) is omitted -- a branch has at most one merge request, ever. +`--branch B` names another branch; `--merge-request-id N` names the MR directly; both at once +is exit 2. `--project` is single-project (the `project use` pin works). + +`merge` works **straight from `development`** on a 0-approval project: the backend skips the +review itself. `request-review` is optional there (and lands directly in `approved`); +`approve` answers **422 in every state** because `in_review` is never reached. Both commands +exist for projects that require approvals. + +## From a script or an agent (`--json`) + +```bash +MR=$(kbagent --json mr create --project P --title "Fix X" --branch 123 | jq .data.id) +kbagent --json mr detail --project P --merge-request-id "$MR" | jq '.data | {mergeable, merge_blockers}' +kbagent --json mr merge --project P --merge-request-id "$MR" # explicit target REQUIRED +``` + +**Under `--json`, `merge` requires an explicit target** (`--merge-request-id` or `--branch`). +Every destructive kbagent command either prompts or is told its target; `--json` has no +prompt, so a bare `--json mr merge` would be the one command where nothing on the command line +says what gets destroyed. The same rule applies to every invocation that *escalates* to +destructive (next section). Every `--json` result carries `merge_request_id`, +`branch_from_id` and `resolved_from_branch` so you can assert on what was operated upon. + +## Auto-merge is a production merge -- treat it as one + +`--auto-merge-strategy immediately|scheduled` (on `create` or `update`) is not metadata. A +backend scheduler runs every `approved` merge request armed with it through the **same merge +processor** the `merge` command uses -- on its own, retrying every tick until it lands, with +`merge` never called and nothing in the arming call's response saying so. Consequences kbagent +enforces: + +| operation | class | why | +|---|---|---| +| `merge` | destructive | deletes the source branch, rewrites production | +| `create` / `update` with `--auto-merge-strategy immediately\|scheduled` | destructive | arming IS a delayed merge | +| `request-review` / `approve` / `resolve` on an **already-armed** MR | destructive | they move it into `approved`, which is what the scheduler waits for (`resolve` unblocks a merge stuck on a conflict) | +| `--auto-merge-strategy none` | write | the disarm -- never escalates, so `--deny-destructive` can always disarm | + +So an agent run with `--deny-destructive` can open, review, inspect and resolve, and **cannot +complete a merge by any route** -- direct or armed. Human mode prompts at the two decision +points (`merge`, arming); the armed transitions print a warning saying the merge is now +imminent. The escalation is deliberately conservative: on a 2-approval project `request-review` +lands in `in_review` and merges nothing, but the required count is unreadable with a Storage +token (DMD-1969), so every armed operation escalates. + +## Conflicts + +A conflict = a configuration changed in the branch **and** in production since the branch was +created. The backend computes them live on every `conflicts` call and every `merge` attempt; +rebasing each listed configuration is sufficient (no re-validate step). + +```bash +kbagent mr conflicts --project P # what is in conflict +kbagent mr diff --project P --component-id C --config-id I # per path: both / only you / only production +kbagent mr resolve --project P --component-id C --config-id I --take ours # keep your content +kbagent mr resolve ... --take theirs # adopt production's +kbagent mr resolve ... --take delete # drop the configuration +``` + +For a hand-made three-way merge, the git-mergetool loop with a file as the third pane: + +```bash +kbagent mr diff --project P --component-id C --config-id I --output resolved.json # your content, prefilled +$EDITOR resolved.json +kbagent mr resolve --project P --component-id C --config-id I --resolved @resolved.json +``` + +- `diff` reports a side that deleted the configuration wholesale **as a sentence with the + `--take` to pick**, not as a table: "Production deleted this configuration; your branch + changed it. Resolve with `--take delete` or `--take ours`." +- The `--output` file carries **all five keys** -- `name`, `description` (an explicit `null` + when empty), `isDisabled`, `configuration`, `rows`. Rebase REPLACES the whole configuration: + a body missing any of them is refused, because a defaulted `isDisabled` would silently + re-enable a disabled configuration and the merge would push that to production. Edit + values, do not delete keys. +- There is no `--all`. Conflicts are meant to be walked; loop over `conflicts --json` yourself + if you truly want to take one side everywhere. +- `--change-description` on a `--take delete` is dropped with a warning -- the delete + tombstone has nowhere to carry it. + +## What the outputs mean + +- **Status** is the *derived* state the web UI shows: `in_development`, `in_review`, + `approved`, `in_merge`, `merged`, `closed`, `rejected`. `closed` / `rejected` depend on + reviewer status a 0-approval project never populates -- the same blind spot the UI has + (DMD-1988). **There is no `close` command**: `request-changes` by the creator is the UI's + cancel and leaves the MR in `development`. +- `detail.mergeable` / `merge_blockers` (`conflicts`, `approvals`, `state`) are + informational; the merge itself is the authority (409 → `MR_MERGE_CONFLICT` or + `MR_NOT_READY_TO_MERGE`, the latter retryable). +- The **change log is empty until the MR is sent for review** -- the backend writes it then. + Not a gap; "what will this merge" is unavailable in `development`. +- `allowed_actions` (and the human hint-next line) are state-derived and **feature-blind**: on + a project where the feature was later switched off they recommend writes that will fail + `FEATURE_NOT_ENABLED`. +- Every result may carry `warnings[]` (a failed post-merge cleanup, a dropped + change-description). Render or log it. +- A **truncated conflict list** inside `MR_MERGE_CONFLICT` carries + `details.api_error_params_truncated: true` -- run `conflicts` for the full set. + +## Errors you will meet + +| error | cause | do | +|---|---|---| +| `FEATURE_NOT_ENABLED` (exit 5) | project lacks `branches-merge-requests` -- surfaces from ANY command whose target was resolved implicitly, reads included; second wording = SOX project (`protected-default-branch`), which kbagent does not support | enable the feature / use the UI on SOX | +| `ACCESS_DENIED` on everything but `list` | scoped Storage token; the detail/conflicts endpoints require an admin identity | use a master token | +| exit 2 `INVALID_ARGUMENT` | both `--merge-request-id` and `--branch`; unknown `--state`/`--take`; `--json` destructive without a target; `update` with no fields; `--auto-merge-at` without `scheduled` | fix the flags | +| `NOT_FOUND` from the resolver | the branch has no merge request | `mr create` | +| `MR_NOT_READY_TO_MERGE` (retryable) | merge lock, wrong state, another MR merging | retry | +| `STORAGE_JOB_TIMEOUT` (exit 4) | merge ran past 10 min; it continues server-side | poll `mr detail` | + +## Requesting review emails people + +With no `--reviewer-id` and no project-designated reviewers, the review-requested notification +goes to **every project member**. Pass reviewer ids (from `project member-list`) or, on a +0-approval project, just `merge`. + +## `branch merge` is deprecated + +It only builds a UI URL (and resets the active branch). It keeps working -- it also serves +projects without the feature -- but on a project with merge requests enabled use this group. diff --git a/src/keboola_agent_cli/commands/branch.py b/src/keboola_agent_cli/commands/branch.py index 2ac143ce..7a000944 100644 --- a/src/keboola_agent_cli/commands/branch.py +++ b/src/keboola_agent_cli/commands/branch.py @@ -21,6 +21,12 @@ branch_app = typer.Typer(help="Manage development branches") +_MERGE_DEPRECATION = ( + "`branch merge` is deprecated: it only builds a UI URL (and resets the active branch). " + "If this project has merge requests enabled ('branches-merge-requests'), use " + "`kbagent merge-request create` + `kbagent merge-request merge` to merge from the CLI." +) + @branch_app.callback(invoke_without_command=True) def _branch_permission_check(ctx: typer.Context) -> None: @@ -223,17 +229,27 @@ def branch_merge( help="Branch ID to merge (uses active branch if not set)", ), ) -> None: - """Get the KBC UI merge URL for a development branch. - - Does NOT perform the merge via API. Instead, generates the URL - to the Keboola UI where you can review and merge safely. - After displaying the URL, resets the active branch to main. + """[DEPRECATED] Get the KBC UI merge URL for a development branch. + + Does NOT perform the merge via API -- it builds the URL to the Keboola + UI and then resets the active branch to main. Deprecated since vNEXT: + on a project with merge requests enabled ('branches-merge-requests'), + use `kbagent merge-request create` and `kbagent merge-request merge`, + which merge from the CLI and delete the source branch. This command + keeps working unchanged (it also serves projects without that feature). """ formatter = get_formatter(ctx) service = get_service(ctx, "branch_service") try: result = service.get_merge_url(alias=project, branch_id=branch) + # Deprecate-with-pointer (precedent: the #390 tool-group removal): + # the pointer is CONDITIONAL because this command is not a 1:1 + # replacement -- it works on any project, merge-request needs the + # feature. Behaviour is unchanged, including the active-branch reset. + result["deprecation"] = _MERGE_DEPRECATION + if not formatter.json_mode: + formatter.warning(_MERGE_DEPRECATION) formatter.output( result, lambda c, d: ( diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 012f6466..b6d09b4c 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1203,7 +1203,9 @@ Delete branch (async). Auto-resets to main if it was active. kbagent branch merge --project ALIAS [--branch ID] - Get KBC UI merge URL (does NOT merge via API). Resets active branch. + DEPRECATED (since vNEXT): builds a KBC UI merge URL (does NOT merge via API) and + resets the active branch. On a project with merge requests enabled use + `merge-request create` + `merge-request merge` below. kbagent branch metadata-list --project NAME [--branch ID|default] List all metadata entries on a branch (id, key, value, provider, timestamp). @@ -1218,6 +1220,64 @@ kbagent branch metadata-delete --project NAME --metadata-id ID [--branch ID|default] Delete a metadata entry by its numeric ID (from metadata-list). +### Merge Requests (Branches 2.0, non-SOX) + + Merge a dev branch into production with review. Hidden alias: `mr`. Every command + except list/create takes [--merge-request-id N | --id N] [--branch B]; omitted, the + target is the merge request OF the active branch (`branch use`). A branch has at most + one merge request, ever. Both flags at once -> exit 2. Status is the DERIVED state the + web UI shows (in_development|in_review|approved|in_merge|merged|closed|rejected). + + kbagent merge-request list [--project A] [--state STATE] + List merge requests, newest first. --state filters client-side (unknown value -> exit 2). + Empty list on a project without the feature says so (feature_enabled: false). + + kbagent merge-request detail [--project A] [--merge-request-id N | --branch B] [--activity-log] + Readiness (mergeable / merge_blockers), viewer flags, allowed_actions, reviewers, + approvals, change log (EMPTY until sent for review -- by design), live conflicts. + + kbagent merge-request create --title T [--project A] [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes] + Open a merge request from --branch (or the active branch) into production. + + kbagent merge-request update [--project A] [--merge-request-id N | --branch B] [--title T] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy S] [--auto-merge-at TS] [--external-id X] [--yes] + Omitted fields stay; "" clears description/external-id; --reviewer-id REPLACES the set. + No fields -> exit 2. + + kbagent merge-request request-review [--project A] [--merge-request-id N | --branch B] + On a 0-approval project lands directly in `approved`; `merge` works without it. + With no reviewers selected the email goes to EVERY project member. + + kbagent merge-request approve [--project A] [--merge-request-id N | --branch B] + Only from in_review -- 422 on a 0-approval project (in_review is unreachable there). + + kbagent merge-request request-changes [--project A] [--merge-request-id N | --branch B] [--reason TEXT] + Back to development, approvals removed. Also the closest thing to "close" (no cancel + endpoint; the MR stays in development). --reason max 1000 chars. + + kbagent merge-request merge [--project A] [--merge-request-id N | --branch B] [--yes] + DESTRUCTIVE: merges into production and deletes the source branch. Blocks up to 10 min. + Under --json an explicit target (--merge-request-id or --branch) is REQUIRED. + + kbagent merge-request conflicts [--project A] [--merge-request-id N | --branch B] + Configurations changed on both sides (live). isDeleted = the DEV branch side's flag. + + kbagent merge-request diff --component-id C --config-id I [--project A] [--merge-request-id N | --branch B] [--format short|full] [--output PATH] + Per-path classification: both changed / only you / only production. A side deleted + wholesale is reported with the --take to pick. --output writes resolution_candidate + (your content, all five keys) to edit and hand back with `resolve --resolved @PATH`. + + kbagent merge-request resolve --component-id C --config-id I (--take ours|theirs|delete | --resolved JSON|@file|-) [--project A] [--merge-request-id N | --branch B] [--change-description TEXT] + Rebase one conflicting config onto production's version. Rebase REPLACES: a --resolved + body must carry name, description, isDisabled, configuration, rows. No --all. + + AUTO-MERGE IS DESTRUCTIVE: arming (--auto-merge-strategy immediately|scheduled) makes the + backend merge the MR on its own once approved -- `merge` is never called. create/update + that arm, and request-review/approve/resolve on an already-armed MR, are blocked by + --deny-destructive and need an explicit target under --json. `none` disarms. + Errors: FEATURE_NOT_ENABLED (exit 5) from any command whose target was resolved implicitly + on a project without the feature; MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE from merge; + a scoped token 403s on everything but list. Every result may carry warnings[]. + ### Workspaces (SQL Debugging) kbagent workspace create --project ALIAS [--name NAME] [--backend TYPE] [--ui] [--read-only/--no-read-only] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index a9d7be8a..dd871156 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -14409,3 +14409,181 @@ def test_set_guard_rejects_state_prefix_exit_2(self) -> None: "parameters.foo=1", )["data"] assert data["configuration"]["parameters"]["foo"] == 1 + + +@skip_without_credentials +@pytest.mark.e2e +class TestE2EMergeRequestLifecycle: + """End-to-end tests for the `merge-request` group (DMD-1900). + + GATED ON THE PROJECT FEATURE, not on credentials: the E2E project does not + carry ``branches-merge-requests`` today and kbagent cannot provision one + (no project-create in ManageClient). So ``setup`` runs ``merge-request + list`` and ``pytest.skip``s on ``feature_enabled: false`` -- the same shape + the conditional-flows tests use. The suite stays green and starts covering + the group the moment the flag lands (one-time, super-admin manage token: + ``kbagent feature project-add --project kbagent-e2e --feature + branches-merge-requests``). The skip is explicit and reported, never silent + (docs/merge-requests-layer1.md, Bookkeeping / E2E). + + Two properties of the scenario worth knowing before reading on: + + - **The happy path merges into production.** There is no dry-run merge, so + the test creates a throwaway ``ex-generic-v2`` config IN A DEV BRANCH, + merges the branch, and then deletes the config from production in + ``cleanup``. That is inside the blast radius the flow/config E2E tests + already have, but it is an explicit teardown -- never left for the next run. + - **``merge`` takes a project-wide lock** and refuses while another MR in + the project is processing (``MR_NOT_READY_TO_MERGE``). Two concurrent runs + collide; treat that error here as a known flake source, not a regression. + + ``approve`` has no happy path on a 0-approval project (422 in every state, + ``in_review`` is unreachable) -- the test asserts the refusal. + """ + + @pytest.fixture(autouse=True) + def setup(self, tmp_path: Path) -> None: + self.token = os.environ[ENV_TOKEN] + raw_url = os.environ.get(ENV_URL, "connection.keboola.com") + self.url = raw_url if raw_url.startswith("https://") else f"https://{raw_url}" + self.alias = f"{RUN_ID}-mr"[:60] + self.component_id = "ex-generic-v2" + + self.config_dir = tmp_path / "config" + self.config_dir.mkdir() + result = _invoke( + self.config_dir, + [ + "--json", + "project", + "add", + "--project", + self.alias, + "--url", + self.url, + "--token", + self.token, + ], + ) + assert result.exit_code == 0, f"project add failed: {result.output}" + + self.client = KeboolaClient(stack_url=self.url, token=self.token) + self._created_branch_ids: list[int] = [] + self._production_config_ids: list[str] = [] + + # The feature gate. `list` is ungated server-side, so on a project + # without the feature it answers 200 + [] and the service adds + # feature_enabled: false -- that, not an error, is the skip signal. + listing = self._run("merge-request", "list", "--project", self.alias) + if listing.exit_code != 0: + pytest.skip(f"merge-request list failed on the E2E project: {listing.output}") + if json.loads(listing.output)["data"].get("feature_enabled") is False: + pytest.skip( + "E2E project lacks the `branches-merge-requests` feature; enable it once with " + "`kbagent feature project-add --project kbagent-e2e --feature branches-merge-requests`" + ) + + @pytest.fixture(autouse=True) + def cleanup(self) -> Any: + yield + # The merged config lives in PRODUCTION after a successful merge. + for cfg_id in self._production_config_ids: + with contextlib.suppress(Exception): + self.client.delete_config(component_id=self.component_id, config_id=cfg_id) + # A merged branch is deleted by the backend; an unmerged one (failed test) is ours. + for branch_id in self._created_branch_ids: + with contextlib.suppress(Exception): + self.client.delete_dev_branch(branch_id) + self.client.close() + + def _run(self, *args: str) -> Any: + return _invoke(self.config_dir, ["--json", *args]) + + def _run_ok(self, *args: str) -> dict[str, Any]: + return _json_ok(self._run(*args)) + + def test_lifecycle_create_inspect_merge(self) -> None: + """branch -> config on the branch -> create MR -> detail/conflicts -> approve 422 -> merge.""" + _step(1, "branch create", "the merge request's source") + branch = self._run_ok( + "branch", "create", "--project", self.alias, "--name", f"{RUN_ID}-mr-src" + )["data"] + branch_id = int(branch["branch_id"]) + self._created_branch_ids.append(branch_id) + + _step(2, "create a throwaway config IN the branch", "something for the merge to carry") + cfg = self.client.create_config( + component_id=self.component_id, + name=f"{RUN_ID}-mr-config", + configuration={"parameters": {"e2e": RUN_ID}}, + description="E2E throwaway -- DMD-1900 merge-request lifecycle", + branch_id=branch_id, + ) + config_id = str(cfg["id"]) + # After the merge this id exists in production -- schedule its deletion now. + self._production_config_ids.append(config_id) + + _step(3, "merge-request create --branch", "explicit branch, no active-branch state") + created = self._run_ok( + "merge-request", + "create", + "--project", + self.alias, + "--branch", + str(branch_id), + "--title", + f"{RUN_ID} lifecycle", + "--description", + "E2E", + )["data"] + mr_id = int(created["id"]) + assert created["branch_from_id"] == branch_id + assert created["derived_state"] == "in_development" + assert created["merge_request_id"] == mr_id and created["resolved_from_branch"] is False + + _step(4, "list shows it newest-first with the derived state") + rows = self._run_ok("merge-request", "list", "--project", self.alias)["data"][ + "merge_requests" + ] + assert any(int(r["id"]) == mr_id for r in rows) + + _step(5, "detail: readiness, viewer, allowed_actions, empty change log in development") + detail = self._run_ok( + "merge-request", "detail", "--project", self.alias, "--merge-request-id", str(mr_id) + )["data"] + assert detail["conflicts_count"] == 0 and detail["mergeable"] is True + assert detail["viewer"]["is_creator"] is True + assert "merge" in detail["allowed_actions"] + assert not (detail.get("changeLog") or {}).get("configurations") + + _step(5.1, "by-branch resolution via --branch instead of the id") + via_branch = self._run_ok( + "merge-request", "conflicts", "--project", self.alias, "--branch", str(branch_id) + )["data"] + assert via_branch["merge_request_id"] == mr_id and via_branch["count"] == 0 + + _step(6, "approve is 422 on a 0-approval project", "the refusal IS the expected outcome") + approve = self._run( + "merge-request", "approve", "--project", self.alias, "--merge-request-id", str(mr_id) + ) + assert approve.exit_code != 0, approve.output + assert json.loads(approve.output)["error"]["code"] not in (ErrorCode.FEATURE_NOT_ENABLED,) + + _step(7, "--json merge without an explicit target is exit 2 before any call") + bare = self._run("merge-request", "merge", "--project", self.alias) + assert bare.exit_code == 2, bare.output + + _step(8, "merge --merge-request-id", "straight from development; blocks on the Storage job") + merged = self._run_ok( + "merge-request", "merge", "--project", self.alias, "--merge-request-id", str(mr_id) + )["data"] + assert merged["branch_from_id"] == branch_id + assert "is being deleted" in merged["message"] + assert merged.get("derived_state") in (None, "merged") + + _step(9, "the config now exists in production") + prod = self.client.get_config_detail(component_id=self.component_id, config_id=config_id) + assert prod["id"] == config_id + # The backend deletes the source branch asynchronously; nothing to assert + # about its existence at this instant (the RFC's wording rule exists for + # exactly this reason). From 347511fbfa5a79ced9857d1d9c4b61bfca4bf6e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 02:44:13 +0200 Subject: [PATCH 08/16] 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 --- plugins/kbagent/skills/kbagent/SKILL.md | 13 ++++++------- .../skills/kbagent/references/commands-reference.md | 4 +++- .../kbagent/skills/kbagent/references/gotchas.md | 5 ++++- .../kbagent/references/merge-request-workflow.md | 4 +++- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index e3289159..6f900d09 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -3,17 +3,16 @@ name: kbagent description: > Use when working with Keboola Connection projects via the kbagent CLI. Covers: exploring and searching configurations, job history, data - lineage, dev branches, merge requests (branch -> production with review), - workspace SQL debugging, GitOps config sync, - bucket sharing and linking, encrypting secrets, - Storage tables, files, and snapshots, data apps, - flows and schedules, invitations, - feature flags, OTLP data streams, scoped Storage tokens, the semantic + lineage, dev branches, merge requests, workspace SQL debugging, GitOps sync, + bucket sharing/linking, encrypting secrets, + Storage tables/files/snapshots, data apps, + flows/schedules, invitations, + feature flags, OTLP data streams, scoped Storage tokens, semantic layer, Developer Portal, browser login, first-time setup and logout in any client. Triggers: kbagent, Keboola, keboola config, keboola job, keboola lineage, keboola sync, gitops, dev branch, - merge request, mr, merge branch, auto-merge, review request, + merge request, data app, streamlit deploy, semantic layer, sl, dev-portal, data stream, OTLP, scoped token, encrypt secrets, feature flag, flow schedule, invite member, SQL transformation edit, diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 9d88ae71..253b1a2f 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -243,7 +243,9 @@ Bucket sharing + linking across projects in the same organization. `sharing edge - `branch metadata-set --project NAME --key KEY [--text STR | --file PATH | --stdin] [--branch ID|default]` -- set a key/value. Useful for `KBC.projectDescription` and similar dashboard-visible fields. Pass exactly one of `--text`, `--file`, or `--stdin` - `branch metadata-delete --project NAME --metadata-id ID [--branch ID|default]` -- delete a metadata entry by its numeric ID (from `metadata-list`) -## Merge Requests (since vNEXT) +## Merge Requests +*(since vNEXT)* + Non-SOX Branches 2.0: merge a dev branch into production with review. Alias `mr`. Every command except `list`/`create` takes `[--merge-request-id N | --id N] [--branch B]`; omitted, the target is the merge request of the active branch. A branch has at most one MR, ever. Both flags at once -> exit 2. Status is the derived state the web UI shows. See `merge-request-workflow.md`. - `merge-request list [--project A] [--state STATE]` -- newest first; `--state` filters client-side (unknown -> exit 2); an empty list on a feature-less project says so (`feature_enabled: false`) - `merge-request detail [--merge-request-id N | --branch B] [--activity-log]` -- readiness (`mergeable`/`merge_blockers`), `viewer`, `allowed_actions`, reviewers, approvals, change log (empty until sent for review, by design), live conflicts diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index f0774534..45796c18 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -5044,7 +5044,10 @@ It carries the command name, the outcome, and the duration -- never argument val reads `type=info`, so a consumer never counts a routine signed-out check as an error. Keep this list aligned when another command documents a non-zero exit as an expected result. -## `merge-request` group: arming auto-merge IS a production merge (since vNEXT) + +## `merge-request` group: arming auto-merge IS a production merge + +*(since vNEXT, DMD-1900)* `kbagent merge-request` (alias `mr`) merges a dev branch into production with review (non-SOX "Branches 2.0", project feature `branches-merge-requests`). Full playbook: diff --git a/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md index 9065315e..290f2277 100644 --- a/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md @@ -1,4 +1,6 @@ -# Merge Request Workflow -- merging a dev branch into production with review (since vNEXT) +# Merge Request Workflow -- merging a dev branch into production with review + +*(since vNEXT, DMD-1900)* `kbagent merge-request` (alias `mr`) is the non-SOX "Branches 2.0" lifecycle: open a merge request from a development branch, optionally get it reviewed, inspect and resolve conflicts, From 3d8a6eb50be125a77fefbc2cf55c53af225cd3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 08:24:49 +0200 Subject: [PATCH 09/16] 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 --- CLAUDE.md | 2 +- src/keboola_agent_cli/commands/_helpers.py | 14 +- .../commands/_merge_request_common.py | 82 +++++- .../commands/_merge_request_render.py | 5 +- .../commands/_merge_request_writes.py | 53 ++-- .../commands/merge_request.py | 46 +-- src/keboola_agent_cli/server/app.py | 32 ++- .../server/routers/merge_requests.py | 31 ++- .../services/merge_request_service.py | 64 ++++- tests/test_merge_request_cli.py | 263 +++++++++++++++++- tests/test_merge_request_service.py | 41 ++- tests/test_server_router_calls.py | 49 ++++ 12 files changed, 588 insertions(+), 94 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 434c5f10..6a48df01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -746,7 +746,7 @@ kbagent branch metadata-delete --project NAME --metadata-id ID [--branch ID|defa # the merge request OF the active branch (`branch use`) -- a branch has at most one MR, ever. Both flags at # once -> exit 2. `--project` is single-project (never fans out). Status is the DERIVED state the web UI # shows (in_development|in_review|approved|in_merge|merged|closed|rejected), never the raw one. -kbagent merge-request list [--project A] [--state in_development|in_review|approved|in_merge|merged|closed|rejected] +kbagent merge-request list [--project A] [--state STATE] # derived (in_development|in_review|approved|in_merge|merged|closed|rejected) or raw (development|published|canceled) states; `--help` lists them kbagent merge-request detail [--project A] [--merge-request-id N | --branch B] [--activity-log] kbagent merge-request create --title T [--project A] [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes] kbagent merge-request update [--project A] [--merge-request-id N | --branch B] [--title T] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy S] [--auto-merge-at TS] [--external-id X] [--yes] diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index 83a50118..e72a7241 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -81,13 +81,14 @@ def parse_json_arg(raw: str, *, label: str) -> Any: The house input contract for structured flags (``config update --configuration``, ``transformation edit --op``, ``merge-request resolve - --resolved``). Lived as a private copy in ``transformation.py`` (and a - dict-only variant in ``config.py``); hoisted here so the third consumer - does not become a third copy. + --resolved``). Hoisted from ``transformation.py``'s private copy so the + merge-request group did not add another; ``config.py`` still carries an + older dict-only variant (``_parse_json_input``) -- folding it in is a + follow-up, not this PR. Raises: - ValueError: On a missing file or malformed JSON -- the message names - ``label`` (the flag) so the caller can print it at exit 2 as-is. + ValueError: On a missing/unreadable file or malformed JSON -- the message + names ``label`` (the flag) so the caller can print it at exit 2 as-is. """ try: if raw == "-": @@ -100,6 +101,9 @@ def parse_json_arg(raw: str, *, label: str) -> Any: return json.loads(raw) except json.JSONDecodeError as exc: raise ValueError(f"{label}: invalid JSON: {exc}") from exc + except OSError as exc: + # a directory, a permission problem -- a usage error, not a crash + raise ValueError(f"{label}: cannot read file: {exc}") from exc def read_password_stdin() -> str: diff --git a/src/keboola_agent_cli/commands/_merge_request_common.py b/src/keboola_agent_cli/commands/_merge_request_common.py index 39568601..e2e75777 100644 --- a/src/keboola_agent_cli/commands/_merge_request_common.py +++ b/src/keboola_agent_cli/commands/_merge_request_common.py @@ -19,8 +19,10 @@ from typing import Any, NoReturn import typer +from rich.markup import escape from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..services.merge_request_service import AUTO_MERGE_DISARMED from ._helpers import ( check_cli_operation, get_service, @@ -61,8 +63,6 @@ ), ) -_AUTO_MERGE_DISARMED = "none" - # -- Error handling --------------------------------------------------------------- @@ -84,10 +84,49 @@ def _handle_error(formatter: Any, exc: ConfigError | KeboolaApiError) -> NoRetur error_code=getattr(exc, "error_code", ErrorCode.CONFIG_ERROR), ) raise typer.Exit(code=5) from None - formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + if exc.error_code == ErrorCode.MR_MERGE_CONFLICT: + _print_conflicting_configurations(formatter, exc.details) raise typer.Exit(code=map_error_to_exit_code(exc)) from None +def _print_conflicting_configurations(formatter: Any, details: dict[str, Any]) -> None: + """Human render of the conflict list the merge 409 carries. + + ``details.api_error_params.errors`` lists the conflicting configurations + -- kept on the error so the caller need not call `conflicts` again. It is + BOUNDED (PR #703 review, O003): over a size cap the list is cut to a + fixed number of entries, or dropped entirely, and either way + ``details.api_error_params_truncated`` is set. A renderer that printed + the list and stopped would show 20 conflicts to a user who has 300 -- + fix 20, merge again, 20 more, never the total. So the line naming the + truncation is not optional, and it names no number (the cap is a Layer + 2 constant that may move). ``--json`` gets nothing extra: the marker is + already in the envelope. + """ + if formatter.json_mode: + return + params = details.get("api_error_params") or {} + errors = params.get("errors") if isinstance(params, dict) else None + entries = errors if isinstance(errors, list) else [] + for entry in entries: + if isinstance(entry, dict): + component = escape(str(entry.get("componentId", "?"))) + config = escape(str(entry.get("configurationId", "?"))) + formatter.err_console.print(f" - {component}/{config}") + else: + formatter.err_console.print(f" - {escape(str(entry))}") + if details.get("api_error_params_truncated"): + formatter.err_console.print( + " … list truncated -- run `merge-request conflicts` for the full set." + ) + + def _usage_error(formatter: Any, message: str) -> NoReturn: formatter.error(message=message, error_code=ErrorCode.INVALID_ARGUMENT) raise typer.Exit(code=2) @@ -117,12 +156,12 @@ class _Target: def auto_merge_strategy(self) -> str: """``immediately`` | ``scheduled`` | ``none``; ``none`` when unknown.""" if not self.row: - return _AUTO_MERGE_DISARMED - return str(self.row.get("autoMergeStrategy") or _AUTO_MERGE_DISARMED) + return AUTO_MERGE_DISARMED + return str(self.row.get("autoMergeStrategy") or AUTO_MERGE_DISARMED) @property def armed(self) -> bool: - return self.auto_merge_strategy != _AUTO_MERGE_DISARMED + return self.auto_merge_strategy != AUTO_MERGE_DISARMED def _branch_from_row(row: dict[str, Any] | None) -> int | None: @@ -208,11 +247,28 @@ def _stamp_target(result: dict[str, Any], target: _Target) -> dict[str, Any]: the target was reached -- so a machine caller can always assert on what was actually operated upon. Never overwrites a key the service already set.""" result.setdefault("merge_request_id", target.merge_request_id) - result.setdefault("branch_from_id", target.branch_id) + # On the explicit-id path the target may not know the branch (no row was + # fetched); most results carry it anyway -- the enriched row's + # `branches.branchFromId`, the diff's `branch_id` -- so read it from there + # before falling back, and never write a null beside a payload that says + # 123 (two keys disagreeing about one fact). + branch = target.branch_id + if branch is None: + branch = _branch_from_row(result) + if branch is None and result.get("branch_id") is not None: + branch = _coerce_int(result.get("branch_id")) + result.setdefault("branch_from_id", branch) result.setdefault("resolved_from_branch", target.resolved_from_branch) return result +def _coerce_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + # -- Destructive-under-json rule and auto-merge escalation ---------------------- @@ -287,17 +343,19 @@ def _escalate_if_armed( return target.auto_merge_strategy -def _armed_warning(strategy: str, result: dict[str, Any]) -> str: - """What an armed MR means right now, phrased from the resulting state: - approved -> the backend merges on its next tick; anything else -> it will, - the moment the MR is approved. Always names the disarm.""" +def _warn_armed(formatter: Any, strategy: str, result: dict[str, Any]) -> None: + """Say what an armed MR means right now -- human mode only, never injected + into the payload (Layer 1 does not manufacture data the service did not + produce; a --json consumer reads `autoMergeStrategy` off the row). Phrased + from the resulting state: approved -> the backend merges on its next tick; + anything else -> it will, the moment the MR is approved.""" state = str(result.get("state") or "") when = ( "the backend will merge it into production on its next tick" if state == "approved" else "the backend will merge it into production as soon as it is approved" ) - return ( + formatter.warning( f"Auto-merge is armed ({strategy}) -- {when}. Disarm with " "`merge-request update --auto-merge-strategy none` if that is not intended." ) diff --git a/src/keboola_agent_cli/commands/_merge_request_render.py b/src/keboola_agent_cli/commands/_merge_request_render.py index 815db614..66dc5a63 100644 --- a/src/keboola_agent_cli/commands/_merge_request_render.py +++ b/src/keboola_agent_cli/commands/_merge_request_render.py @@ -292,7 +292,10 @@ def next_step_hints(allowed_actions: list[str] | None) -> list[str]: State-derived and feature-blind by Layer 2's decision: on a project where the feature was later switched off these recommend writes that end in FEATURE_NOT_ENABLED (RFC, Known gaps).""" - return [_ACTION_COMMAND[a] for a in (allowed_actions or []) if a in _ACTION_COMMAND] + # Unknown names (e.g. a server-serialised camelCase vocabulary once + # DMD-1988 lands) fall back to the raw action rather than vanishing -- the + # hint line is a per-command guarantee, not best-effort. + return [_ACTION_COMMAND.get(a, f"action: {escape(str(a))}") for a in (allowed_actions or [])] # -- conflicts ------------------------------------------------------------------------------- diff --git a/src/keboola_agent_cli/commands/_merge_request_writes.py b/src/keboola_agent_cli/commands/_merge_request_writes.py index 88818496..95326a21 100644 --- a/src/keboola_agent_cli/commands/_merge_request_writes.py +++ b/src/keboola_agent_cli/commands/_merge_request_writes.py @@ -16,9 +16,14 @@ from typing import Any import typer +from rich.markup import escape from ..errors import ConfigError, ErrorCode, KeboolaApiError -from ..services.merge_request_service import TAKE_MODES +from ..services.merge_request_service import ( + TAKE_MODES, + arms_auto_merge, + validate_auto_merge_flags, +) from ._helpers import ( check_cli_operation, get_formatter, @@ -28,11 +33,9 @@ resolve_project_alias, ) from ._merge_request_common import ( - _AUTO_MERGE_DISARMED, _BRANCH_OPT, _MERGE_REQUEST_ID_OPT, _PROJECT_OPT, - _armed_warning, _emit_warnings, _escalate_if_armed, _handle_error, @@ -43,12 +46,12 @@ _resolve_target, _stamp_target, _usage_error, + _warn_armed, ) writes_app = typer.Typer() -_AUTO_MERGE_STRATEGIES = ("immediately", "scheduled", _AUTO_MERGE_DISARMED) _REASON_MAX_LENGTH = 1000 # MergeRequestRejectRequest::REASON_MAX_LENGTH, server-side cap _YES_OPT = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt") @@ -84,21 +87,12 @@ def _validate_auto_merge_flags(formatter: Any, strategy: str | None, at: str | None) -> bool: - """Exit 2 on a bad strategy or a broken strategy/at pairing; return whether - the flags ARM auto-merge (strategy given and not `none`).""" - if strategy is not None and strategy not in _AUTO_MERGE_STRATEGIES: - _usage_error( - formatter, - f"Unknown --auto-merge-strategy {strategy!r}: use {', '.join(_AUTO_MERGE_STRATEGIES)}.", - ) - if strategy == "scheduled" and not at: - _usage_error(formatter, "--auto-merge-strategy scheduled requires --auto-merge-at.") - if at is not None and strategy != "scheduled": - _usage_error( - formatter, - "--auto-merge-at is only meaningful with --auto-merge-strategy scheduled.", - ) - return strategy is not None and strategy != _AUTO_MERGE_DISARMED + """Exit 2 on a bad strategy or pairing (the service owns the vocabulary and + the rule, so the router and the CLI cannot drift); return whether the flags ARM.""" + problem = validate_auto_merge_flags(strategy, at) + if problem: + _usage_error(formatter, problem) + return arms_auto_merge(strategy) def _confirm_or_abort(formatter: Any, yes: bool, question: str) -> None: @@ -196,7 +190,7 @@ def merge_request_create( result.setdefault("merge_request_id", result.get("id")) result.setdefault("resolved_from_branch", branch is None) if arming: - result.setdefault("warnings", []).append(_armed_warning(str(auto_merge_strategy), result)) + _warn_armed(formatter, str(auto_merge_strategy), result) _print_row_success( formatter, result, @@ -284,7 +278,7 @@ def merge_request_update( _handle_error(formatter, exc) if arming: - result.setdefault("warnings", []).append(_armed_warning(str(auto_merge_strategy), result)) + _warn_armed(formatter, str(auto_merge_strategy), result) _print_row_success(formatter, result, f"Updated merge request #{target.merge_request_id}") _emit_warnings(formatter, result) _hint_from_actions(formatter, result) @@ -335,7 +329,7 @@ def _transition( _handle_error(formatter, exc) if strategy: - result.setdefault("warnings", []).append(_armed_warning(strategy, result)) + _warn_armed(formatter, strategy, result) _print_row_success(formatter, result, headline.format(id=target.merge_request_id)) _emit_warnings(formatter, result) _hint_from_actions(formatter, result) @@ -452,6 +446,9 @@ def merge_request_merge( reason="`merge-request merge` rewrites production and deletes the source branch.", ) service = get_service(ctx, "merge_request_service") + # The row is only for the prompt (title + branch); merge()'s own result + # already carries branch_from_id. Skip the GET when no prompt will show. + will_prompt = not yes and not formatter.json_mode try: target = _resolve_target( ctx, @@ -459,7 +456,7 @@ def merge_request_merge( project=project, merge_request_id=merge_request_id, branch=branch, - need_row=True, + need_row=will_prompt, ) title = (target.row or {}).get("title") or "" _confirm_or_abort( @@ -473,7 +470,8 @@ def merge_request_merge( _handle_error(formatter, exc) formatter.output( - result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}") + result, + lambda c, d: c.print(f"[bold green]Success:[/bold green] {escape(str(d['message']))}"), ) _emit_warnings(formatter, result) _hint_next(formatter, "`merge-request list` -- the merged request now shows as merged") @@ -567,12 +565,13 @@ def merge_request_resolve( _handle_error(formatter, exc) if strategy: - result.setdefault("warnings", []).append(_armed_warning(strategy, result)) + _warn_armed(formatter, strategy, result) formatter.output( result, lambda c, d: c.print( - f"[bold green]Success:[/bold green] Resolved {component_id}/{config_id} " - f"({d.get('resolution')}) -- rebased onto production version {d.get('onto_version')}" + f"[bold green]Success:[/bold green] Resolved {escape(component_id)}/{escape(config_id)} " + f"({escape(str(d.get('resolution')))}) -- rebased onto production version " + f"{escape(str(d.get('onto_version')))}" ), ) _emit_warnings(formatter, result) diff --git a/src/keboola_agent_cli/commands/merge_request.py b/src/keboola_agent_cli/commands/merge_request.py index b118a7ca..5c66a930 100644 --- a/src/keboola_agent_cli/commands/merge_request.py +++ b/src/keboola_agent_cli/commands/merge_request.py @@ -42,8 +42,9 @@ from pathlib import Path import typer +from rich.markup import escape -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..services.merge_request_service import STATE_FILTER_VOCABULARY from ._helpers import check_cli_permission, get_formatter, get_service, resolve_project_alias from ._merge_request_common import ( @@ -193,7 +194,7 @@ def merge_request_conflicts( project=project, merge_request_id=merge_request_id, branch=branch, - need_row=False, + need_row=True, ) result = _stamp_target( service.list_conflicts(target.alias, target.merge_request_id), target @@ -208,9 +209,9 @@ def merge_request_conflicts( first = conflicts[0] _hint_next( formatter, - f"`merge-request diff --component-id {first.get('componentId')} " - f"--config-id {first.get('configurationId')}` to see what differs, then " - "`merge-request resolve --take ours|theirs|delete`", + f"`merge-request diff --component-id {escape(str(first.get('componentId')))} " + f"--config-id {escape(str(first.get('configurationId')))}` to see what differs, " + "then `merge-request resolve --take ours|theirs|delete`", ) else: _hint_next(formatter, "`merge-request merge` -- nothing blocks it on the conflict side") @@ -268,22 +269,35 @@ def merge_request_diff( if output is not None: candidate = result.get("resolution_candidate") if candidate is None: - # Nothing to prefill: the configuration is deleted (or absent) in - # the branch. A skeleton here would be a misleading file kbagent - # then refuses; the resolution for this shape is --take delete. - _usage_error( - formatter, - "--output has nothing to write: the configuration is deleted in your " - "branch, so there is no content to edit. Resolve with " - "`merge-request resolve --take delete` (or `--take theirs` to keep " - "production's version).", + # Nothing to prefill. Three shapes, said apart: deleted in the + # branch (resolve with --take delete/theirs), absent from the + # branch entirely, or a hole in the server envelope (the service + # explains in warnings). A skeleton here would be a misleading + # file kbagent then refuses. + if result.get("ours_deleted") is True: + why = ( + "the configuration is deleted in your branch, so there is no content " + "to edit. Resolve with `merge-request resolve --take delete` (or " + "`--take theirs` to keep production's version)." + ) + elif result.get("ours_deleted") is None: + why = "the configuration does not exist in your branch." + else: + why = "; ".join(result.get("warnings") or ["no candidate could be composed."]) + _usage_error(formatter, f"--output has nothing to write: {why}") + try: + output.write_text(json.dumps(candidate, indent=2, ensure_ascii=False) + "\n") + except OSError as exc: + formatter.error( + message=f"Cannot write --output {output}: {exc}", + error_code=ErrorCode.INVALID_ARGUMENT, ) - output.write_text(json.dumps(candidate, indent=2, ensure_ascii=False) + "\n") + raise typer.Exit(code=2) from None result["output_path"] = str(output) formatter.output(result, lambda c, d: format_config_diff(c, d, full=output_format == "full")) _emit_warnings(formatter, result) - resolve_cmd = f"merge-request resolve --component-id {component_id} --config-id {config_id}" + resolve_cmd = f"merge-request resolve --component-id {escape(component_id)} --config-id {escape(config_id)}" if output is not None: _hint_next( formatter, diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index de251821..af284010 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -492,7 +492,11 @@ def custom_openapi() -> dict: def _format_error( - message: str, error_code: ErrorCode | str, *, http_status: int = 400 + message: str, + error_code: ErrorCode | str, + *, + http_status: int = 400, + details: dict[str, Any] | None = None, ) -> JSONResponse: """Render a kbagent-style error envelope at the given HTTP status. @@ -502,16 +506,11 @@ def _format_error( code is not yet in the enum). The :class:`ErrorCode` mixes in ``str``, so both shapes serialise as plain strings in the JSON body. """ - return JSONResponse( - status_code=http_status, - content={ - "status": "error", - "error": { - "code": str(error_code), - "message": message, - }, - }, - ) + error: dict[str, Any] = {"code": str(error_code), "message": message} + if details: + # Only when non-empty, matching the CLI envelope's presence contract. + error["details"] = details + return JSONResponse(status_code=http_status, content={"status": "error", "error": error}) # A browser-login session backing a session-registered project is USER-scoped @@ -536,6 +535,15 @@ def _format_error( {ErrorCode.WORKSPACE_LOAD_COPY_TOO_LARGE, ErrorCode.INVALID_ARGUMENT} ) +# The merge-request merge 409s: a statement about the merge request's state +# (conflicts / lock / another merge running), not about the gateway -- and the +# conflict list in `details.api_error_params` is the whole point of the +# remapping, so it must survive into the REST envelope. 502 would drop it and +# invite a retry of a request that cannot succeed until the conflicts clear. +_MERGE_REQUEST_CONFLICT_CODES = frozenset( + {ErrorCode.MR_MERGE_CONFLICT, ErrorCode.MR_NOT_READY_TO_MERGE} +) + _SESSION_REMEDY_ON_HOST = ( "Complete `kbagent auth login` on the host running `kbagent serve` -- this server " "cannot open a browser login for a remote caller." @@ -921,6 +929,8 @@ async def _api_error_handler(_request, exc: KeboolaApiError): return _format_error(f"{msg} {_SESSION_REMEDY_ON_HOST}", code, http_status=401) if code in _CALLER_REFUSAL_CODES: return _format_error(msg, code, http_status=400) + if code in _MERGE_REQUEST_CONFLICT_CODES: + return _format_error(msg, code, http_status=409, details=getattr(exc, "details", None)) if code == ErrorCode.NOT_FOUND: # An upstream 404 is a statement about the requested resource, not # about the gateway: reporting it as 502 made callers retry (and diff --git a/src/keboola_agent_cli/server/routers/merge_requests.py b/src/keboola_agent_cli/server/routers/merge_requests.py index 4ea5fd67..95dc9083 100644 --- a/src/keboola_agent_cli/server/routers/merge_requests.py +++ b/src/keboola_agent_cli/server/routers/merge_requests.py @@ -28,13 +28,18 @@ from ...errors import ErrorCode, KeboolaApiError from ...permissions import PermissionEngine -from ...services.merge_request_service import STATE_FILTER_VOCABULARY, TAKE_MODES +from ...services.merge_request_service import ( + AUTO_MERGE_DISARMED, + STATE_FILTER_VOCABULARY, + TAKE_MODES, + arms_auto_merge, + validate_auto_merge_flags, +) from ..dependencies import ServiceRegistry, get_permission_engine, get_registry, require_permission router = APIRouter(prefix="/merge-requests", tags=["merge-requests"]) -_AUTO_MERGE_DISARMED = "none" -_AUTO_MERGE_STRATEGIES = ("immediately", "scheduled", _AUTO_MERGE_DISARMED) +_REASON_MAX_LENGTH = 1000 # MergeRequestRejectRequest::REASON_MAX_LENGTH, same cap as the CLI def _perm(operation: str) -> Any: @@ -50,16 +55,12 @@ def _invalid(message: str) -> KeboolaApiError: def _arming(strategy: str | None, at: str | None) -> bool: - """Validate the auto-merge flag pairing (400 on a bad one); return whether it ARMS.""" - if strategy is not None and strategy not in _AUTO_MERGE_STRATEGIES: - raise _invalid( - f"Unknown auto_merge_strategy {strategy!r}: use {', '.join(_AUTO_MERGE_STRATEGIES)}." - ) - if strategy == "scheduled" and not at: - raise _invalid("auto_merge_strategy 'scheduled' requires auto_merge_at.") - if at is not None and strategy != "scheduled": - raise _invalid("auto_merge_at is only meaningful with auto_merge_strategy 'scheduled'.") - return strategy is not None and strategy != _AUTO_MERGE_DISARMED + """400 on a bad strategy / pairing (the service owns the rule, so the CLI and + this router cannot drift); return whether the body ARMS auto-merge.""" + problem = validate_auto_merge_flags(strategy, at) + if problem: + raise _invalid(problem) + return arms_auto_merge(strategy) def _escalate_if_armed( @@ -73,7 +74,7 @@ def _escalate_if_armed( production merge; apply the same state-derived escalation the CLI does. One row GET, never the three-call detail.""" row = registry.merge_request.get_merge_request_row(project, merge_request_id) - if (row.get("autoMergeStrategy") or _AUTO_MERGE_DISARMED) != _AUTO_MERGE_DISARMED: + if (row.get("autoMergeStrategy") or AUTO_MERGE_DISARMED) != AUTO_MERGE_DISARMED: engine.check_or_raise(f"merge-request.{operation} --auto-merge-armed") @@ -299,6 +300,8 @@ def request_changes( """Back to development, approvals removed; also the closest thing to closing. Mirrors `kbagent merge-request request-changes`.""" reason = body.reason if body else None + if reason is not None and len(reason) > _REASON_MAX_LENGTH: + raise _invalid(f"reason is capped at {_REASON_MAX_LENGTH} characters (got {len(reason)}).") return registry.merge_request.request_changes(project, merge_request_id, reason=reason) diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py index 8adc2e49..12bde7c8 100644 --- a/src/keboola_agent_cli/services/merge_request_service.py +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -86,6 +86,31 @@ # The resolve_conflict take modes. Public for the same reason. TAKE_MODES: tuple[str, ...] = ("ours", "theirs", "delete") +# The auto-merge vocabulary (AutoMergeStrategy enum, wire-exact). Public and +# validated HERE so the CLI and the serve router cannot drift from each other: +# an arming value one surface recognises and the other does not is a safety +# divergence (--deny-destructive would stop escalating on one of them). +AUTO_MERGE_DISARMED = "none" +AUTO_MERGE_STRATEGIES: tuple[str, ...] = ("immediately", "scheduled", AUTO_MERGE_DISARMED) + + +def validate_auto_merge_flags(strategy: str | None, at: str | None) -> str | None: + """Return the usage-error message for a bad strategy / strategy-at pairing, or + None when the pair is acceptable. Pure: the caller decides how to fail + (exit 2 on the CLI, 400 over REST).""" + if strategy is not None and strategy not in AUTO_MERGE_STRATEGIES: + return f"Unknown auto-merge strategy {strategy!r}: use {', '.join(AUTO_MERGE_STRATEGIES)}." + if strategy == "scheduled" and not at: + return "Auto-merge strategy 'scheduled' requires an auto-merge time (auto_merge_at)." + if at is not None and strategy != "scheduled": + return "An auto-merge time is only meaningful with the 'scheduled' strategy." + return None + + +def arms_auto_merge(strategy: str | None) -> bool: + """True when the value ARMS auto-merge (anything but absent or the disarm).""" + return strategy is not None and strategy != AUTO_MERGE_DISARMED + def _same_id(a: Any, b: Any) -> bool: """Compare two ids that may arrive as int or str (approverId is a string @@ -898,7 +923,7 @@ def get_config_diff( client.close() theirs = diff.get("theirs") or {} ours = diff.get("ours") - return { + result: dict[str, Any] = { "alias": alias, "merge_request_id": merge_request_id, "component_id": component_id, @@ -913,6 +938,10 @@ def get_config_diff( "resolution_candidate": self._resolution_candidate(ours), "diff": diff, } + candidate_warnings = self._candidate_warnings(ours) + if candidate_warnings: + result["warnings"] = candidate_warnings + return result def _resolution_candidate(self, ours: dict[str, Any] | None) -> dict[str, Any] | None: """The ours-prefilled body a caller edits and hands back to ``resolve_conflict``. @@ -934,8 +963,33 @@ def _resolution_candidate(self, ours: dict[str, Any] | None) -> dict[str, Any] | if ours is None or ours.get("isDeleted"): return None envelope = ours.get("diff") or {} + # A hole in the server-produced envelope is a backend contract + # violation (the schema marks every content key required). Writing it + # as an explicit null would make `resolve --resolved @file` blame the + # CALLER for a file kbagent wrote -- so there is no candidate, and the + # caller learns why from `warnings` (the same message the --take path + # raises as VALIDATION_ERROR). + missing = [ + key for key in ("name", "rows", "configuration", "isDisabled") if key not in envelope + ] + if missing: + return None return {key: envelope.get(key) for key in self._DIFF_CONTENT_KEYS} + def _candidate_warnings(self, ours: dict[str, Any] | None) -> list[str]: + if ours is None or ours.get("isDeleted"): + return [] + envelope = ours.get("diff") or {} + missing = [ + key for key in ("name", "rows", "configuration", "isDisabled") if key not in envelope + ] + if not missing: + return [] + return [ + f"The diff's ours side carries no {', '.join(missing)} -- no resolution candidate " + "could be prefilled (backend envelope hole). Author the resolution manually." + ] + def _classify_three_way(self, diff: dict[str, Any]) -> list[dict[str, Any]]: """Intersect the two pairwise diffs (base->ours, base->theirs) per path. @@ -1237,7 +1291,10 @@ def _branch_from_id_of(self, client: KeboolaClient, merge_request_id: int) -> in raise KeboolaApiError( message=message, status_code=0, - error_code=ErrorCode.VALIDATION_ERROR, + # A caller mistake (resolving/diffing a finished MR), not a backend fault: + # INVALID_ARGUMENT is what app.py maps to HTTP 400 over `serve` + # (VALIDATION_ERROR would answer 502 and invite a retry). + error_code=ErrorCode.INVALID_ARGUMENT, retryable=False, ) return branch_from_id @@ -1258,6 +1315,7 @@ def _require_in_conflict_set( "See `kbagent merge-request conflicts` for the current set." ), status_code=0, - error_code=ErrorCode.VALIDATION_ERROR, + # Caller mistake -> INVALID_ARGUMENT (HTTP 400 over `serve`, see above). + error_code=ErrorCode.INVALID_ARGUMENT, retryable=False, ) diff --git a/tests/test_merge_request_cli.py b/tests/test_merge_request_cli.py index 02071590..628c31b8 100644 --- a/tests/test_merge_request_cli.py +++ b/tests/test_merge_request_cli.py @@ -1267,7 +1267,9 @@ def test_argument_shape_errors_are_exit_2(self, tmp_path, service, extra) -> Non assert result.exit_code == 2, result.output service.resolve_conflict.assert_not_called() - def test_resolved_from_file_round_trips_the_diff_output(self, tmp_path, service) -> None: + def test_resolved_from_file_is_parsed_and_forwarded(self, tmp_path, service) -> None: + # The real round trip (diff --output -> resolve) is pinned one layer down: + # test_merge_request_service.py::TestLayer1RfcWalkFollowUps. Here: argument parsing only. candidate = { "name": "n", "description": None, @@ -1323,3 +1325,262 @@ def test_feature_not_enabled_from_resolve_keeps_its_code(self, tmp_path, service result = _run(["--json", *_RESOLVE, "--take", "ours"], _store(tmp_path), service) assert result.exit_code == 5 assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED + + +class TestDiffOutputErrors: + def test_unwritable_output_path_is_a_readable_exit_2(self, tmp_path, service) -> None: + service.get_config_diff.return_value = _diff_result() + target = tmp_path / "no-such-dir" / "resolved.json" + result = _run(["--json", *_DIFF_ARGS, "--output", str(target)], _store(tmp_path), service) + assert result.exit_code == 2, result.output + assert "Cannot write --output" in _json(result)["error"]["message"] + + +class TestMergeRowFetch: + def test_json_merge_with_explicit_id_does_not_fetch_the_row(self, tmp_path, service) -> None: + service.merge.return_value = _merged() + result = _run( + ["--json", "merge-request", "merge", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.get_merge_request_row.assert_not_called() + assert _json(result)["data"]["branch_from_id"] == 123 # from merge()'s own result + + def test_human_merge_with_explicit_id_fetches_the_row_for_the_prompt( + self, tmp_path, service + ) -> None: + service.merge.return_value = _merged() + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + input="y\n", + ) + assert result.exit_code == 0, result.output + service.get_merge_request_row.assert_called_once_with(ALIAS, 7) + assert "'Add sales pipeline'" in result.output + + +class TestMergeConflictDetails: + def _conflict_error(self, truncated: bool) -> KeboolaApiError: + details: dict[str, Any] = { + "api_error_code": "storage.mergeRequests.validation", + "api_error_params": { + "errors": [ + {"componentId": "keboola.ex-db", "configurationId": "111"}, + {"componentId": "keboola.wr-db", "configurationId": "[bold]2"}, + ] + }, + } + if truncated: + details["api_error_params_truncated"] = True + return KeboolaApiError( + message="Configurations changed on both branches.", + status_code=409, + error_code=ErrorCode.MR_MERGE_CONFLICT, + retryable=False, + details=details, + ) + + def test_json_carries_details_through(self, tmp_path, service) -> None: + service.merge.side_effect = self._conflict_error(truncated=True) + result = _run( + ["--json", "merge-request", "merge", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 1 + err = _json(result)["error"] + assert err["details"]["api_error_params_truncated"] is True + assert err["details"]["api_error_params"]["errors"][0]["configurationId"] == "111" + + def test_human_lists_conflicts_and_says_truncated(self, tmp_path, service) -> None: + service.merge.side_effect = self._conflict_error(truncated=True) + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, + ) + assert result.exit_code == 1 + assert "keboola.ex-db/111" in result.output + assert "[bold]2" in result.output # escaped, not interpreted + assert "list truncated" in result.output and "merge-request conflicts" in result.output + + def test_human_untruncated_has_no_truncation_line(self, tmp_path, service) -> None: + service.merge.side_effect = self._conflict_error(truncated=False) + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, + ) + assert "keboola.wr-db" in result.output + assert "list truncated" not in result.output + + +class TestOpusReviewFollowUps: + """Pins for the Phase-5 self-review findings (docs/merge-requests-layer1.md).""" + + def test_explicit_id_detail_carries_branch_from_id_from_the_payload( + self, tmp_path, service + ) -> None: + # M1: never `branch_from_id: null` beside `branches.branchFromId: 123`. + service.get_merge_request.return_value = _detail() + result = _run( + ["--json", "merge-request", "detail", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert _json(result)["data"]["branch_from_id"] == 123 + + def test_explicit_id_diff_and_conflicts_carry_branch_from_id(self, tmp_path, service) -> None: + service.get_config_diff.return_value = _diff_result() + diff = _run(["--json", *_DIFF_ARGS], _store(tmp_path / "a"), service) + assert _json(diff)["data"]["branch_from_id"] == 123 # from the diff's branch_id + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 0, + "conflicts": [], + } + conflicts = _run( + ["--json", "merge-request", "conflicts", "--project", ALIAS, "--id", "7"], + _store(tmp_path / "b"), + service, + ) + assert _json(conflicts)["data"]["branch_from_id"] == 123 # via the row tier + service.get_merge_request_row.assert_called_once_with(ALIAS, 7) + + def test_armed_warning_is_human_only_and_not_in_the_payload(self, tmp_path, service) -> None: + # L4: Layer 1 does not manufacture payload; --json reads autoMergeStrategy off the row. + service.create_merge_request.return_value = _created(autoMergeStrategy="immediately") + result = _run( + [ + "--json", + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + "--auto-merge-strategy", + "immediately", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "warnings" not in _json(result)["data"] + + def test_hint_falls_back_to_the_raw_action_for_unknown_names(self, tmp_path, service) -> None: + # M3: a server-serialised vocabulary (DMD-1988) must not make hint-next vanish. + service.get_merge_request.return_value = _detail(allowed_actions=["requestReview"]) + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert "requestReview" in result.output + + def test_output_wording_for_a_backend_envelope_hole(self, tmp_path, service) -> None: + # L3/L5: a null candidate on a NON-deleted side is the service's warning, not "deleted in your branch". + service.get_config_diff.return_value = _diff_result( + resolution_candidate=None, + warnings=[ + "The diff's ours side carries no isDisabled -- no resolution candidate could be prefilled." + ], + ) + result = _run( + ["--json", *_DIFF_ARGS, "--output", str(tmp_path / "r.json")], _store(tmp_path), service + ) + assert result.exit_code == 2 + msg = _json(result)["error"]["message"] + assert "carries no isDisabled" in msg and "deleted in your branch" not in msg + + def test_resolved_pointing_at_a_directory_is_exit_2_not_a_traceback( + self, tmp_path, service + ) -> None: + # L7: OSError from the file read is a usage error. + result = _run( + ["--json", *_RESOLVE, "--resolved", f"@{tmp_path}"], _store(tmp_path), service + ) + assert result.exit_code == 2, result.output + service.resolve_conflict.assert_not_called() + + def test_markup_in_wire_ids_does_not_crash_the_resolve_success_line( + self, tmp_path, service + ) -> None: + # H2: the operation already landed server-side; a MarkupError afterwards would report failure. + service.resolve_conflict.return_value = _resolved(component_id="k", config_id="[/x]") + result = _run( + [ + "merge-request", + "resolve", + "--project", + ALIAS, + "--id", + "7", + "--component-id", + "k", + "--config-id", + "[/x]", + "--take", + "ours", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + + @pytest.mark.parametrize( + "args", + [ + ["merge-request", "update", "--project", ALIAS, "--title", "T"], + ["merge-request", "request-review", "--project", ALIAS], + ["merge-request", "approve", "--project", ALIAS], + ["merge-request", "request-changes", "--project", ALIAS], + ["merge-request", "merge", "--project", ALIAS, "--branch", "123"], + [ + "merge-request", + "resolve", + "--project", + ALIAS, + "--component-id", + "c", + "--config-id", + "1", + "--take", + "ours", + ], + ], + ids=lambda a: a[1], + ) + def test_feature_not_enabled_keeps_its_code_on_every_write( + self, tmp_path, service, args + ) -> None: + # L2: one case per command, as the RFC promised. + service.find_merge_request_for_branch.side_effect = FeatureNotEnabledError("not enabled") + result = _run(["--json", *args], _store(tmp_path, active_branch=123), service) + assert result.exit_code == 5, result.output + assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED + + def test_create_feature_not_enabled_keeps_its_code(self, tmp_path, service) -> None: + service.create_merge_request.side_effect = FeatureNotEnabledError("not enabled") + result = _run( + [ + "--json", + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 5 + assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED diff --git a/tests/test_merge_request_service.py b/tests/test_merge_request_service.py index 4ee0a609..8f24c5b7 100644 --- a/tests/test_merge_request_service.py +++ b/tests/test_merge_request_service.py @@ -803,7 +803,7 @@ def test_closed_mr_cannot_be_resolved(self, store, client_factory) -> None: mock.merge_requests.get.return_value = _wire_mr(7, "published", branch_from=None) with pytest.raises(KeboolaApiError) as exc_info: _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="ours") - assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + assert exc_info.value.error_code == ErrorCode.INVALID_ARGUMENT mock.rebase_config.assert_not_called() def test_take_ours_rebases_dev_content_onto_theirs_version(self, store, client_factory) -> None: @@ -924,7 +924,7 @@ def test_config_outside_conflict_set_is_refused(self, store, client_factory) -> self._arm(mock) with pytest.raises(KeboolaApiError) as exc_info: _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.other", "999", take="ours") - assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + assert exc_info.value.error_code == ErrorCode.INVALID_ARGUMENT mock.rebase_config.assert_not_called() @@ -1145,7 +1145,7 @@ def test_diff_on_a_closed_mr_is_refused(self, store, client_factory) -> None: mock.merge_requests.get.return_value = _wire_mr(7, "published", branch_from=None) with pytest.raises(KeboolaApiError) as exc_info: _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") - assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + assert exc_info.value.error_code == ErrorCode.INVALID_ARGUMENT mock.get_config_diff.assert_not_called() @@ -1592,3 +1592,38 @@ def test_candidate_round_trips_through_resolve_unmodified(self, store, client_fa assert kwargs["configuration"] == {"limit": 500} assert kwargs["is_disabled"] is False assert kwargs["description"] is None + + def test_envelope_hole_yields_no_candidate_and_a_warning(self, store, client_factory) -> None: + # A hole in the server-produced ours envelope is a backend contract + # violation; writing it as an explicit null would make the resolve guard + # blame the caller for a file kbagent wrote. So: no candidate, and why. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + ours = _side({"limit": 500}, version=4) + del ours["diff"]["isDisabled"] + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=ours, + theirs=_side({"limit": 250}, version=7), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert result["resolution_candidate"] is None + assert result["ours_deleted"] is False + assert any("isDisabled" in w for w in result["warnings"]) + + def test_auto_merge_vocabulary_is_validated_in_one_place(self) -> None: + from keboola_agent_cli.services.merge_request_service import ( + AUTO_MERGE_STRATEGIES, + arms_auto_merge, + validate_auto_merge_flags, + ) + + assert set(AUTO_MERGE_STRATEGIES) == {"immediately", "scheduled", "none"} + assert validate_auto_merge_flags(None, None) is None + assert validate_auto_merge_flags("none", None) is None + assert validate_auto_merge_flags("scheduled", "2026-09-04T10:00:00Z") is None + assert validate_auto_merge_flags("sometimes", None) + assert validate_auto_merge_flags("scheduled", None) + assert validate_auto_merge_flags("immediately", "2026-09-04T10:00:00Z") + assert arms_auto_merge("immediately") and arms_auto_merge("scheduled") + assert not arms_auto_merge("none") and not arms_auto_merge(None) diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index d1156441..49e27c10 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -2871,3 +2871,52 @@ def test_merge_request_reads_pass_under_deny_destructive(tmp_path: Path) -> None svc.list_merge_requests.return_value = {"count": 0, "merge_requests": []} with _mr_client(tmp_path, svc, deny_destructive=True) as client: assert client.get(f"/merge-requests/{PROJECT}", headers=AUTH).status_code == 200 + + +def test_merge_request_request_changes_caps_reason_like_the_cli(tmp_path: Path) -> None: + svc = MagicMock() + with _mr_client(tmp_path, svc) as client: + res = client.post( + f"/merge-requests/{PROJECT}/{MR_ID}/request-changes", + json={"reason": "x" * 1001}, + headers=AUTH, + ) + assert res.status_code == 400, res.text + svc.request_changes.assert_not_called() + + +def test_merge_request_merge_conflict_is_409_with_details_over_http(tmp_path: Path) -> None: + # The whole point of the MR_MERGE_CONFLICT remapping is the conflict list in + # details; a 502 would drop it and invite a retry that cannot succeed. + svc = MagicMock() + svc.merge.side_effect = KeboolaApiError( + message="conflicts", + status_code=409, + error_code=ErrorCode.MR_MERGE_CONFLICT, + retryable=False, + details={"api_error_params": {"errors": [{"componentId": "c", "configurationId": "1"}]}}, + ) + with _mr_client(tmp_path, svc) as client: + res = client.post(f"/merge-requests/{PROJECT}/{MR_ID}/merge", headers=AUTH) + assert res.status_code == 409, res.text + body = res.json()["error"] + assert body["code"] == ErrorCode.MR_MERGE_CONFLICT + assert body["details"]["api_error_params"]["errors"][0]["configurationId"] == "1" + + +def test_merge_request_caller_mistakes_from_the_service_are_400_over_http(tmp_path: Path) -> None: + svc = MagicMock() + svc.get_merge_request_row.return_value = {"id": MR_ID, "autoMergeStrategy": "none"} + svc.resolve_conflict.side_effect = KeboolaApiError( + message="not in the conflict set", + status_code=0, + error_code=ErrorCode.INVALID_ARGUMENT, + retryable=False, + ) + with _mr_client(tmp_path, svc) as client: + res = client.post( + f"/merge-requests/{PROJECT}/{MR_ID}/resolve/{COMPONENT}/{CONFIG_ID}", + json={"take": "ours"}, + headers=AUTH, + ) + assert res.status_code == 400, res.text From c127bc229caac0d1e9c0f53b439f77d99d042d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 11:37:28 +0200 Subject: [PATCH 10/16] 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 --- .../commands/_merge_request_render.py | 9 +++++++++ .../services/merge_request_service.py | 12 ++++++++---- tests/test_merge_request_cli.py | 17 +++++++++++++++++ tests/test_merge_request_service.py | 16 ++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/keboola_agent_cli/commands/_merge_request_render.py b/src/keboola_agent_cli/commands/_merge_request_render.py index 66dc5a63..6868bba7 100644 --- a/src/keboola_agent_cli/commands/_merge_request_render.py +++ b/src/keboola_agent_cli/commands/_merge_request_render.py @@ -383,6 +383,15 @@ def format_config_diff(console: Console, data: dict[str, Any], *, full: bool = F changes: list[dict[str, Any]] = data.get("changes") or [] if not changes: + if data.get("warnings"): + # The service produced no per-path rows AND said why (an empty or + # holed envelope on one side) -- do not claim the conflict cleared; + # the warning line that follows carries the reason. + console.print( + "No per-path classification could be produced for this configuration " + "(see the warning below)." + ) + return # Both sides exist, neither deleted, nothing differs: the conflict # cleared between `conflicts` and `diff` -- say that, not "nothing changed". console.print( diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py index 12bde7c8..ea79d6fb 100644 --- a/src/keboola_agent_cli/services/merge_request_service.py +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -1291,10 +1291,14 @@ def _branch_from_id_of(self, client: KeboolaClient, merge_request_id: int) -> in raise KeboolaApiError( message=message, status_code=0, - # A caller mistake (resolving/diffing a finished MR), not a backend fault: - # INVALID_ARGUMENT is what app.py maps to HTTP 400 over `serve` - # (VALIDATION_ERROR would answer 502 and invite a retry). - error_code=ErrorCode.INVALID_ARGUMENT, + # The two faults blame different parties, and the code carries + # that over `serve`: an absent id means the caller is resolving + # a finished MR -> INVALID_ARGUMENT (HTTP 400); a present but + # non-numeric id is the server's payload -> VALIDATION_ERROR + # (a gateway-side fault, not something to fix in the request). + error_code=( + ErrorCode.INVALID_ARGUMENT if raw is None else ErrorCode.VALIDATION_ERROR + ), retryable=False, ) return branch_from_id diff --git a/tests/test_merge_request_cli.py b/tests/test_merge_request_cli.py index 628c31b8..afdba4ae 100644 --- a/tests/test_merge_request_cli.py +++ b/tests/test_merge_request_cli.py @@ -1584,3 +1584,20 @@ def test_create_feature_not_enabled_keeps_its_code(self, tmp_path, service) -> N ) assert result.exit_code == 5 assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED + + +class TestDiffEmptyEnvelope: + def test_no_rows_with_a_service_warning_does_not_claim_the_conflict_cleared( + self, tmp_path, service + ) -> None: + service.get_config_diff.return_value = _diff_result( + changes=[], + resolution_candidate=None, + warnings=[ + "The diff's ours side carries no name -- no resolution candidate could be prefilled." + ], + ) + result = _run(_DIFF_ARGS, _store(tmp_path), service) + assert result.exit_code == 0, result.output + assert "cleared" not in result.output + assert "carries no name" in result.output diff --git a/tests/test_merge_request_service.py b/tests/test_merge_request_service.py index 8f24c5b7..6085055d 100644 --- a/tests/test_merge_request_service.py +++ b/tests/test_merge_request_service.py @@ -1627,3 +1627,19 @@ def test_auto_merge_vocabulary_is_validated_in_one_place(self) -> None: assert validate_auto_merge_flags("immediately", "2026-09-04T10:00:00Z") assert arms_auto_merge("immediately") and arms_auto_merge("scheduled") assert not arms_auto_merge("none") and not arms_auto_merge(None) + + def test_branch_from_id_faults_blame_the_right_party(self, store, client_factory) -> None: + # absent id = the caller is resolving a finished MR (INVALID_ARGUMENT -> 400 over serve); + # a non-numeric id = the server's payload (VALIDATION_ERROR). + factory, mock = client_factory + svc = _svc(store, factory) + mock.merge_requests.get.return_value = _wire_mr(7, "published", branch_from=None) + with pytest.raises(KeboolaApiError) as absent: + svc.get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert absent.value.error_code == ErrorCode.INVALID_ARGUMENT + garbage_row = _wire_mr(7, "development") + garbage_row["branches"]["branchFromId"] = "main" # a non-numeric wire id + mock.merge_requests.get.return_value = garbage_row + with pytest.raises(KeboolaApiError) as garbage: + svc.get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert garbage.value.error_code == ErrorCode.VALIDATION_ERROR From 97c55c54495309449bd530c73d0b5519b9b2a901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Thu, 3 Sep 2026 23:56:14 +0200 Subject: [PATCH 11/16] 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 --- CLAUDE.md | 9 +- plugins/kbagent/agents/keboola-expert.md | 2 +- .../kbagent/references/commands-reference.md | 2 +- .../skills/kbagent/references/gotchas.md | 10 +- .../references/merge-request-workflow.md | 10 +- .../commands/_merge_request_render.py | 10 ++ .../commands/_merge_request_writes.py | 27 +++++- src/keboola_agent_cli/commands/context.py | 2 +- .../commands/merge_request.py | 9 ++ src/keboola_agent_cli/services/base.py | 6 ++ .../services/config_service.py | 2 +- .../services/merge_request_service.py | 95 ++++++++++++++----- tests/test_merge_request_cli.py | 41 ++++++++ tests/test_merge_request_service.py | 80 ++++++++++++++++ 14 files changed, 262 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a48df01..6498686b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -788,9 +788,12 @@ kbagent merge-request resolve --component-id C --config-id I (--take ours|theirs # `MR_NOT_READY_TO_MERGE` (exit 1; the latter retryable) come from the merge 409; a truncated conflict # list in the error carries `details.api_error_params_truncated: true` -- run `conflicts` for the full set. # `merge` blocks for up to 10 minutes (no --wait/--timeout). Every result may carry `warnings[]` -# (post-merge cleanup failures, a dropped --change-description on a delete resolution). On a project -# where the feature was later switched OFF, `allowed_actions` (and the hint-next line) still recommend -# writes that will fail FEATURE_NOT_ENABLED -- state-derived, feature-blind by Layer 2's decision. +# (post-merge cleanup failures, a dropped --change-description on a delete resolution). A merge whose +# source branch id could not be read carries `cleanup_skipped: true` + `branch_from_id_raw` (the local +# active-branch reset / sync unlink did NOT run -- key on the flag, not on warning text). On a project +# where the feature was later switched OFF, `list` rows' `allowed_actions` still recommend writes that +# will fail FEATURE_NOT_ENABLED (state-derived, feature-blind); `detail` carries `feature_enabled` and +# its hint-next respects it. kbagent workspace create --project ALIAS [--name NAME] [--backend TYPE] [--ui] [--read-only/--no-read-only] kbagent workspace list [--project NAME ...] [--orphaned] [--branch ID] [--qs-compatible] diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index be4bcb34..3cbcb04c 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -129,7 +129,7 @@ been retired, so its absence is NOT a promise (see §1 Rule 6). | Export a FILTERED or INCREMENTAL slice of a table (no workspace) | `kbagent storage download-table --table-id ... --where-column status --where-value active [--where-operator eq\|neq] [--changed-since "-2 days"]` -- server-side filter on the credential-only export path | `kbagent workspace query` with a `WHERE` clause when you need real SQL | downloading the whole table then filtering locally | | Run Keboola SQL / read-write Storage Files from INSIDE a Python process you control | `from keboola_agent_cli import Client` -- stateless `Client(url, token)`; `.query(workspace_id, sql)`, `.files.upload/.read_bytes/.list`; no subprocess, no `serve`, no config-dir. See [library-workflow.md](../skills/kbagent/references/library-workflow.md) | the CLI or `kbagent serve` REST when you are NOT already inside Python | shelling out to the `kbagent` binary from Python you control; using it for open-ended exploration (fixed set of typed ops) | | Inspect dev branch | `kbagent branch list --project P`, `kbagent branch use --project P --branch ID` | -- | acting on `main` when a dev branch exists | -| Merge a dev branch into production (review, conflicts) | `kbagent merge-request create --title T` from the active branch, `merge-request detail` for readiness, `merge-request merge` (vNEXT+; project feature `branches-merge-requests`). Conflicts: `conflicts` -> `diff --component-id C --config-id I` -> `resolve --take ours\|theirs\|delete`. See [merge-request-workflow.md](../skills/kbagent/references/merge-request-workflow.md) -- read the auto-merge section before touching `--auto-merge-strategy` | `branch merge` (deprecated URL builder) on a project WITHOUT the feature | `--auto-merge-strategy immediately\|scheduled` without treating it as a production merge (a backend scheduler merges on its own once approved; blocked by `--deny-destructive`, needs an explicit target under `--json`); `--json merge` with no `--merge-request-id`/`--branch` (exit 2 by design); `resolve --resolved` with a partial body (rebase REPLACES -- all five keys or refused); reading `allowed_actions` as feature-aware; `approve` on a 0-approval project (422 in every state) | +| Merge a dev branch into production (review, conflicts) | `kbagent merge-request create --title T` from the active branch, `merge-request detail` for readiness, `merge-request merge` (vNEXT+; project feature `branches-merge-requests`). Conflicts: `conflicts` -> `diff --component-id C --config-id I` -> `resolve --take ours\|theirs\|delete`. See [merge-request-workflow.md](../skills/kbagent/references/merge-request-workflow.md) -- read the auto-merge section before touching `--auto-merge-strategy` | `branch merge` (deprecated URL builder) on a project WITHOUT the feature | `--auto-merge-strategy immediately\|scheduled` without treating it as a production merge (a backend scheduler merges on its own once approved; blocked by `--deny-destructive`, needs an explicit target under `--json`); `--json merge` with no `--merge-request-id`/`--branch` (exit 2 by design); `resolve --resolved` with a partial body (rebase REPLACES -- all five keys or refused); reading a `list` row's `allowed_actions` as feature-aware (only `detail` carries `feature_enabled`); `approve` on a 0-approval project (422 in every state) | | Audit project capabilities / features | `kbagent project info --project P` -- project id, name, backend, enabled features, quota limits, metrics | -- | inspecting the UI project settings manually | | Manage feature flags (stack / project / user) | `kbagent feature list\|project-show\|project-add\|project-remove\|user-show\|user-add\|user-remove --project P [--email E] [--feature NAME] [--dry-run]` -- Manage API, needs a SUPER-ADMIN token (interactive prompt; `--allow-env-manage-token` for CI) | `kbagent project info` for a project's *enabled* features (read-only, no super-admin) | raw `/manage/...` calls; a manage token passed as a CLI flag | | Create a new config (one-shot remote, no scaffold to disk) | `kbagent config new --project P --component-id C --name N --push --no-files [--configuration @body.json]` -- default body `{}` skips validation; an explicit body is schema-validated (`--no-validate` opts out); works for every component type. `--output-dir` + `--push` together is safe only on 0.89.0+ (scaffold records `_keboola.config_id`, lands in the created branch's subtree); older kbagent writes an ID-less scaffold that the next `sync push` DUPLICATES (issue #644) -- there, scaffold and push in two steps | `kbagent config new --output-dir D` then edit + `kbagent sync push` | raw `POST /v2/storage/components/.../configs` (no schema validation, no encryption) | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 253b1a2f..b263d700 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -248,7 +248,7 @@ Bucket sharing + linking across projects in the same organization. `sharing edge Non-SOX Branches 2.0: merge a dev branch into production with review. Alias `mr`. Every command except `list`/`create` takes `[--merge-request-id N | --id N] [--branch B]`; omitted, the target is the merge request of the active branch. A branch has at most one MR, ever. Both flags at once -> exit 2. Status is the derived state the web UI shows. See `merge-request-workflow.md`. - `merge-request list [--project A] [--state STATE]` -- newest first; `--state` filters client-side (unknown -> exit 2); an empty list on a feature-less project says so (`feature_enabled: false`) -- `merge-request detail [--merge-request-id N | --branch B] [--activity-log]` -- readiness (`mergeable`/`merge_blockers`), `viewer`, `allowed_actions`, reviewers, approvals, change log (empty until sent for review, by design), live conflicts +- `merge-request detail [--merge-request-id N | --branch B] [--activity-log]` -- readiness (`mergeable`/`merge_blockers`), `viewer`, `allowed_actions`, `feature_enabled`, reviewers, approvals, change log (empty until sent for review, by design), live conflicts - `merge-request create --title T [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes]` -- from `--branch` or the active branch into production - `merge-request update [--merge-request-id N | --branch B] [--title] [--description] [--reviewer-id ...] [--auto-merge-strategy] [--auto-merge-at] [--external-id] [--yes]` -- omitted fields stay; `""` clears description/external-id; `--reviewer-id` REPLACES the set; no fields -> exit 2 - `merge-request request-review [...]` -- on a 0-approval project lands directly in `approved` (`merge` works without it); no reviewers selected = email to every project member diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 45796c18..3f434c31 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -5094,8 +5094,12 @@ It carries the command name, the outcome, and the duration -- never argument val - **Every result may carry `warnings[]`** (post-merge cleanup failure, a dropped `--change-description` on a delete resolution). A truncated conflict list inside `MR_MERGE_CONFLICT` carries `details.api_error_params_truncated: true` -- run `conflicts`. -- **`allowed_actions` / the hint-next line are feature-blind**: on a project where the feature - was later switched off they recommend writes that fail `FEATURE_NOT_ENABLED` (Layer 2's - documented decision; server-side fix is DMD-1988). +- **`allowed_actions` are feature-blind in `list`, feature-aware in `detail`**: on a project + where the feature was later switched off, list rows still recommend writes that fail + `FEATURE_NOT_ENABLED` (state-derived; Layer 2 declined the per-list GET). `detail` carries + `feature_enabled` and its hint respects it. Server-side fix is DMD-1988. +- **`cleanup_skipped: true` on a merge result** means the source branch id could not be read and + the local active-branch reset / sync unlink did NOT run (`branch_from_id_raw` echoes the value). + Key on the flag; recover with `kbagent branch reset` + `kbagent sync branch-unlink`. - **`branch merge` is deprecated** (still works, carries `deprecation` in `--json`): it only builds a UI URL and resets the active branch. diff --git a/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md index 290f2277..59f0df9f 100644 --- a/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md @@ -111,9 +111,13 @@ kbagent mr resolve --project P --component-id C --config-id I --resolved @resolv `MR_NOT_READY_TO_MERGE`, the latter retryable). - The **change log is empty until the MR is sent for review** -- the backend writes it then. Not a gap; "what will this merge" is unavailable in `development`. -- `allowed_actions` (and the human hint-next line) are state-derived and **feature-blind**: on - a project where the feature was later switched off they recommend writes that will fail - `FEATURE_NOT_ENABLED`. +- `allowed_actions` are state-derived and **feature-blind** in `list`: on a project where the + feature was later switched off, rows recommend writes that will fail `FEATURE_NOT_ENABLED`. + `detail` carries `feature_enabled` (free there) and its hint-next respects it -- read the flag + before acting on a detail's actions. +- A merge whose source branch id could not be read carries **`cleanup_skipped: true`** and + `branch_from_id_raw`: the local active-branch reset and sync unlink did **not** run. Key on the + flag, not on warning text; recover with `branch reset` + `sync branch-unlink`. - Every result may carry `warnings[]` (a failed post-merge cleanup, a dropped change-description). Render or log it. - A **truncated conflict list** inside `MR_MERGE_CONFLICT` carries diff --git a/src/keboola_agent_cli/commands/_merge_request_render.py b/src/keboola_agent_cli/commands/_merge_request_render.py index 6868bba7..6cdcc1bb 100644 --- a/src/keboola_agent_cli/commands/_merge_request_render.py +++ b/src/keboola_agent_cli/commands/_merge_request_render.py @@ -190,6 +190,16 @@ def format_merge_request_detail(console: Console, data: dict[str, Any]) -> None: pairs: list[tuple[str, str]] = [ ("Readiness", _blockers_line(data)), ] + if data.get("feature_enabled") is False: + # The detail tier knows the feature state for free (followups F5); + # every write action below would end in FEATURE_NOT_ENABLED. + pairs.append( + ( + "Feature", + "[red]merge requests are not enabled on this project[/red] -- writes will be " + "refused (FEATURE_NOT_ENABLED)", + ) + ) viewer = _viewer_line(data.get("viewer")) if viewer: pairs.append(("", viewer)) diff --git a/src/keboola_agent_cli/commands/_merge_request_writes.py b/src/keboola_agent_cli/commands/_merge_request_writes.py index 95326a21..026cf26d 100644 --- a/src/keboola_agent_cli/commands/_merge_request_writes.py +++ b/src/keboola_agent_cli/commands/_merge_request_writes.py @@ -469,12 +469,29 @@ def merge_request_merge( except (ConfigError, KeboolaApiError) as exc: _handle_error(formatter, exc) - formatter.output( - result, - lambda c, d: c.print(f"[bold green]Success:[/bold green] {escape(str(d['message']))}"), - ) + formatter.output(result, _render_merge_result) _emit_warnings(formatter, result) - _hint_next(formatter, "`merge-request list` -- the merged request now shows as merged") + if result.get("cleanup_skipped"): + _hint_next( + formatter, + "`kbagent branch reset` and `kbagent sync branch-unlink` if this project's active " + "branch pointed at the merged branch", + ) + else: + _hint_next(formatter, "`merge-request list` -- the merged request now shows as merged") + + +def _render_merge_result(console: Any, data: dict[str, Any]) -> None: + console.print(f"[bold green]Success:[/bold green] {escape(str(data['message']))}") + if data.get("cleanup_skipped"): + # Keyed on the structured flag (followups F3), never on warning text: + # the local cleanup did NOT run, so active_branch_id and the sync + # mapping may still point at the branch the merge just doomed. + console.print( + "[yellow]Local cleanup skipped[/yellow]: the source branch id could not be read " + f"({escape(str(data.get('branch_from_id_raw')))}); active branch and sync mapping " + "were left untouched." + ) @writes_app.command("resolve") diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index b6d09b4c..5c5ab5d6 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1233,7 +1233,7 @@ Empty list on a project without the feature says so (feature_enabled: false). kbagent merge-request detail [--project A] [--merge-request-id N | --branch B] [--activity-log] - Readiness (mergeable / merge_blockers), viewer flags, allowed_actions, reviewers, + Readiness (mergeable / merge_blockers), viewer flags, allowed_actions, feature_enabled, reviewers, approvals, change log (EMPTY until sent for review -- by design), live conflicts. kbagent merge-request create --title T [--project A] [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes] diff --git a/src/keboola_agent_cli/commands/merge_request.py b/src/keboola_agent_cli/commands/merge_request.py index 5c66a930..7d2c9740 100644 --- a/src/keboola_agent_cli/commands/merge_request.py +++ b/src/keboola_agent_cli/commands/merge_request.py @@ -167,6 +167,15 @@ def merge_request_detail( formatter.output(result, format_merge_request_detail) _emit_warnings(formatter, result) + if result.get("feature_enabled") is False: + # State-derived actions are feature-blind; the detail knows better + # (followups F5) and must not recommend a write that cannot succeed. + _hint_next( + formatter, + "none of the write actions can succeed here -- 'branches-merge-requests' is not " + "enabled on this project", + ) + return hints = next_step_hints(result.get("allowed_actions")) if hints: _hint_next(formatter, " | ".join(f"`{h}`" for h in hints)) diff --git a/src/keboola_agent_cli/services/base.py b/src/keboola_agent_cli/services/base.py index def780e4..766eddf8 100644 --- a/src/keboola_agent_cli/services/base.py +++ b/src/keboola_agent_cli/services/base.py @@ -135,6 +135,12 @@ def find_default_branch_id(branches: list[dict[str, Any]]) -> int | None: try: return int(branch["id"]) except (KeyError, TypeError, ValueError): + # Skipping is the right recovery, but not a silent one: the + # callers then word "no default branch" for a project that DID + # report one (followups F4). The log line is where the truth goes. + logger.warning( + "isDefault branch carries a non-numeric id %r -- skipped", branch.get("id") + ) continue return None diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index e820849e..205e65fd 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -169,7 +169,7 @@ def _fetch_project_configs( if not folder_branch_id: # Fetch default branch ID from dev-branches endpoint folder_branch_id = find_default_branch_id(client.list_dev_branches()) - if folder_branch_id: + if folder_branch_id is not None: result = client.list_config_folder_metadata(branch_id=folder_branch_id) folder_map = result if isinstance(result, dict) else {} except Exception: diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py index ea79d6fb..55740889 100644 --- a/src/keboola_agent_cli/services/merge_request_service.py +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -152,7 +152,8 @@ def derive_state(mr: dict[str, Any]) -> str: - otherwise the raw state mapped through ``_DERIVED_STATE_BY_RAW`` (an unknown raw state passes through unchanged, defensively). - Reliability caveat (verified against Connection): ``reviewers[].status`` is populated only within a review + Reliability caveat (verified against Connection): ``reviewers[].status`` is + populated only within a review round anchored by an actual ``request_review`` event, and a non-reviewer's decision (the creator's included -- the creator can never BE a reviewer) is dropped whenever explicit reviewers exist. ``skip_review`` writes no @@ -501,6 +502,12 @@ def get_merge_request( admin_id: int | None = None if _server_viewer(mr) is None: admin_id = client.verify_token().admin_id + # The detail tier already pays verify_token (above) in the common + # case, so the features cache is warm and this is free -- which is + # what the list tier could not afford (followups F5). A project that + # lost the feature keeps its MRs; the state-derived actions would + # advertise writes the pre-flight refuses. + feature_enabled = client.has_feature(BRANCHES_MERGE_REQUESTS_FEATURE) finally: client.close() @@ -513,6 +520,7 @@ def get_merge_request( "mergeable": not blockers and conflicts is not None, "allowed_actions": derive_allowed_actions(mr), "viewer": derive_viewer(mr, admin_id), + "feature_enabled": feature_enabled, } if conflicts is not None: detail["conflicts"] = conflicts @@ -739,11 +747,15 @@ def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: was_active = branch_from_id is not None and project.active_branch_id == branch_from_id mapping_cleanup: dict[str, Any] | None = None warnings: list[str] = [] - if raw_branch_from is not None and branch_from_id is None: + cleanup_skipped = raw_branch_from is not None and branch_from_id is None + if cleanup_skipped: # "Absent" and "present but not numeric" must not collapse # silently: with no usable id the local cleanup below is skipped, # leaving active_branch_id and the sync mapping pointing at the # branch the merge just doomed -- and the caller must hear that. + # Recorded STRUCTURALLY too (`cleanup_skipped`, `branch_from_id_raw`, + # followups F3): the prose alone left this result byte-identical + # to the legitimate published-MR null for a --json consumer. logger.warning("branchFromId %r is not a numeric branch id", raw_branch_from) warnings.append( f"branchFromId {raw_branch_from!r} is not a numeric branch id -- local " @@ -782,6 +794,8 @@ def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: f"Source branch {branch_from_id} is being deleted (a separate async " "job -- it may still briefly exist)." ) + elif cleanup_skipped: + message_parts.append("Source branch id could not be read; see warnings.") if branch_reset_done: message_parts.append("Active branch reset to main.") if mapping_cleanup: @@ -802,6 +816,9 @@ def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: result["allowed_actions"] = derive_allowed_actions(mr_after) if mapping_cleanup: result["mapping_cleanup"] = mapping_cleanup + if cleanup_skipped: + result["cleanup_skipped"] = True + result["branch_from_id_raw"] = raw_branch_from if warnings: # `warnings` is the group's one soft-failure key (resolve_conflict # uses the same name): "the operation landed, something secondary @@ -938,9 +955,9 @@ def get_config_diff( "resolution_candidate": self._resolution_candidate(ours), "diff": diff, } - candidate_warnings = self._candidate_warnings(ours) - if candidate_warnings: - result["warnings"] = candidate_warnings + diff_warnings = self._diff_warnings(diff) + if diff_warnings: + result["warnings"] = diff_warnings return result def _resolution_candidate(self, ours: dict[str, Any] | None) -> dict[str, Any] | None: @@ -976,32 +993,60 @@ def _resolution_candidate(self, ours: dict[str, Any] | None) -> dict[str, Any] | return None return {key: envelope.get(key) for key in self._DIFF_CONTENT_KEYS} - def _candidate_warnings(self, ours: dict[str, Any] | None) -> list[str]: - if ours is None or ours.get("isDeleted"): - return [] - envelope = ours.get("diff") or {} - missing = [ - key for key in ("name", "rows", "configuration", "isDisabled") if key not in envelope - ] - if not missing: - return [] - return [ - f"The diff's ours side carries no {', '.join(missing)} -- no resolution candidate " - "could be prefilled (backend envelope hole). Author the resolution manually." - ] + def _diff_warnings(self, diff: dict[str, Any]) -> list[str]: + """Say why a side yielded no classification / no candidate. + + Two shapes, both backend contract violations (the OA schema marks + every content key required): an EMPTY envelope on either side -- the + classifier then emits no rows and a renderer must not read that as + "the conflict cleared" -- and a HOLED ours envelope, which yields no + resolution candidate rather than an explicit-null file the resolve + guard would blame the caller for. + """ + warnings: list[str] = [] + for label in ("ours", "theirs"): + side = diff.get(label) + if side is None or side.get("isDeleted"): + continue + envelope = side.get("diff") or {} + if not envelope: + warnings.append( + f"The diff's {label} side has an empty content envelope -- no per-path " + "classification is possible (backend envelope hole)." + ) + continue + if label == "ours": + missing = [ + key + for key in ("name", "rows", "configuration", "isDisabled") + if key not in envelope + ] + if missing: + warnings.append( + f"The diff's ours side carries no {', '.join(missing)} -- no resolution " + "candidate could be prefilled (backend envelope hole). Author the " + "resolution manually." + ) + return warnings def _classify_three_way(self, diff: dict[str, Any]) -> list[dict[str, Any]]: """Intersect the two pairwise diffs (base->ours, base->theirs) per path. Only meaningful when BOTH sides carry comparable content. A null side (the config never existed there), a tombstoned one - (``isDeleted`` -- the criterion ``resolve_conflict`` already treats - as "no content to take") and an empty envelope are all side-level - facts carried by the ``ours_deleted`` / ``theirs_deleted`` flags; - fabricating per-path rows against an empty stand-in would emit - contradictions -- every base key rendered as "that side removed it" - next to a flag saying the side does not exist. Any of them yields no - ``changes`` at all. + (``isDeleted``) and an empty envelope (``diff: {}``) all yield no + ``changes`` at all: fabricating per-path rows against an empty + stand-in would emit contradictions -- every base key rendered as + "that side removed it" next to a flag saying the side does not exist. + + The classifier is deliberately STRICTER than ``resolve_conflict``: + the resolver shares the ``isDeleted`` criterion but treats an empty + envelope on a take side as a VALIDATION_ERROR (missing content keys), + never as "nothing to take" -- collapsing it to the delete resolution + would destroy a configuration, so refusing is the safe direction + there, while here silence is (followups F2). The side-level facts a + renderer needs live on ``ours_deleted`` / ``theirs_deleted`` and, for + the empty-envelope case, in ``warnings``. """ def classifiable(side: dict[str, Any] | None) -> bool: diff --git a/tests/test_merge_request_cli.py b/tests/test_merge_request_cli.py index afdba4ae..51f6b6d9 100644 --- a/tests/test_merge_request_cli.py +++ b/tests/test_merge_request_cli.py @@ -1601,3 +1601,44 @@ def test_no_rows_with_a_service_warning_does_not_claim_the_conflict_cleared( assert result.exit_code == 0, result.output assert "cleared" not in result.output assert "carries no name" in result.output + + +class TestLayer2Followups: + def test_merge_render_keys_on_cleanup_skipped(self, tmp_path, service) -> None: + # F3: the renderer reads the structured flag, never the warning text. + service.merge.return_value = { + **_merged(), + "branch_from_id": None, + "was_active": False, + "cleanup_skipped": True, + "branch_from_id_raw": "0123x", + "message": "Merge request 7 merged into production. Source branch id could not be read; see warnings.", + "warnings": [ + "branchFromId '0123x' is not a numeric branch id -- local cleanup was skipped." + ], + } + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "Local cleanup skipped" in result.output and "0123x" in result.output + assert "branch reset" in result.output and "sync branch-unlink" in result.output + + def test_detail_hint_respects_feature_enabled(self, tmp_path, service) -> None: + # F5: never recommend a write that cannot succeed. + service.get_merge_request.return_value = _detail(feature_enabled=False) + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert result.exit_code == 0, result.output + assert "not enabled on this project" in result.output + assert "merge-request merge" not in result.output + + def test_detail_hint_unchanged_when_feature_enabled(self, tmp_path, service) -> None: + service.get_merge_request.return_value = _detail(feature_enabled=True) + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert "merge-request merge" in result.output diff --git a/tests/test_merge_request_service.py b/tests/test_merge_request_service.py index 6085055d..d43a047a 100644 --- a/tests/test_merge_request_service.py +++ b/tests/test_merge_request_service.py @@ -1643,3 +1643,83 @@ def test_branch_from_id_faults_blame_the_right_party(self, store, client_factory with pytest.raises(KeboolaApiError) as garbage: svc.get_config_diff(ALIAS, 7, "keboola.ex-db", "111") assert garbage.value.error_code == ErrorCode.VALIDATION_ERROR + + +class TestLayer2FollowupsInheritedByLayer1: + """docs/merge-requests-layer2-followups.md -- the non-blocking leftovers of PR #703 + that go through the Layer 1 PR (F2, F3, F4, F5, F7).""" + + def _arm_merge(self, mock: MagicMock, branch_from: Any) -> None: + row = _wire_mr(7, "approved") + row["branches"]["branchFromId"] = branch_from + mock.merge_requests.get.return_value = row + mock.merge_requests.merge.return_value = {"id": 1, "status": "success", "results": {}} + + # F2 -- the empty-envelope half of the classifier had no test + def test_empty_envelope_side_yields_no_rows_and_a_warning(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs={"version": 7, "isDeleted": False, "diff": {}}, + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert result["changes"] == [] + assert result["theirs_deleted"] is False and result["ours_deleted"] is False + # ...and the renderer must not read that as "cleared": the reason is recorded. + assert any("theirs side has an empty content envelope" in w for w in result["warnings"]) + # the ours side is intact, so the candidate is still composed + assert result["resolution_candidate"]["configuration"] == {"limit": 500} + + # F3 -- the branch-id degradation is recorded structurally, not in prose only + def test_non_numeric_branch_from_id_is_recorded_structurally( + self, store, client_factory + ) -> None: + factory, mock = client_factory + self._arm_merge(mock, "0123x") + result = _svc(store, factory).merge(ALIAS, 7) + assert result["cleanup_skipped"] is True + assert result["branch_from_id_raw"] == "0123x" + assert result["branch_from_id"] is None + assert "Source branch id could not be read; see warnings." in result["message"] + assert "is being deleted" not in result["message"] + + def test_legitimate_null_branch_carries_no_degradation_flag( + self, store, client_factory + ) -> None: + factory, mock = client_factory + self._arm_merge(mock, None) + result = _svc(store, factory).merge(ALIAS, 7) + assert "cleanup_skipped" not in result and "branch_from_id_raw" not in result + + # F7 -- the positive assertion for the outcome-gated sentence + def test_successful_reset_says_so(self, store, client_factory) -> None: + factory, mock = client_factory + store.set_project_branch(ALIAS, 123) + self._arm_merge(mock, 123) + result = _svc(store, factory).merge(ALIAS, 7) + assert result["was_active"] is True + assert "Active branch reset to main." in result["message"] + assert store.get_project(ALIAS).active_branch_id is None + + # F5 -- the detail tier is feature-aware for free + def test_detail_carries_feature_enabled(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [] + mock.has_feature.return_value = False + detail = _svc(store, factory).get_merge_request(ALIAS, 7) + assert detail["feature_enabled"] is False + # state-derived actions stay as they are -- the CONSUMER gates on the flag + assert "merge" in detail["allowed_actions"] + + # F4 -- the shared helper logs the skip instead of folding it into None silently + def test_find_default_branch_id_logs_the_skipped_entry(self, caplog) -> None: + import logging + + from keboola_agent_cli.services.base import find_default_branch_id + + with caplog.at_level(logging.WARNING, logger="keboola_agent_cli.services.base"): + assert find_default_branch_id([{"isDefault": True, "id": "main"}]) is None + assert any("non-numeric id 'main'" in r.getMessage() for r in caplog.records) From 4bf828e05ea567cba4763e07897c2a6c0d19756f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Fri, 4 Sep 2026 17:16:24 +0200 Subject: [PATCH 12/16] 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 --- .../commands/_merge_request_render.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/keboola_agent_cli/commands/_merge_request_render.py b/src/keboola_agent_cli/commands/_merge_request_render.py index 6cdcc1bb..0de10fdb 100644 --- a/src/keboola_agent_cli/commands/_merge_request_render.py +++ b/src/keboola_agent_cli/commands/_merge_request_render.py @@ -196,8 +196,10 @@ def format_merge_request_detail(console: Console, data: dict[str, Any]) -> None: pairs.append( ( "Feature", - "[red]merge requests are not enabled on this project[/red] -- writes will be " - "refused (FEATURE_NOT_ENABLED)", + ( + "[red]merge requests are not enabled on this project[/red] -- writes will be " + "refused (FEATURE_NOT_ENABLED)" + ), ) ) viewer = _viewer_line(data.get("viewer")) @@ -209,8 +211,10 @@ def format_merge_request_detail(console: Console, data: dict[str, Any]) -> None: pairs.append( ( "Auto-merge", - f"[red]armed[/red] ({escape(str(strategy))}{when}) -- the backend merges this " - "MR on its next tick once it is approved", + ( + f"[red]armed[/red] ({escape(str(strategy))}{when}) -- the backend merges this " + "MR on its next tick once it is approved" + ), ) ) pairs.append( From 6d6a45e766065a676374a0fa231f8de0eb7656f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Fri, 4 Sep 2026 17:22:58 +0200 Subject: [PATCH 13/16] 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 --- .../server/_serve_command_map.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/keboola_agent_cli/server/_serve_command_map.py b/src/keboola_agent_cli/server/_serve_command_map.py index fffe1b31..725323eb 100644 --- a/src/keboola_agent_cli/server/_serve_command_map.py +++ b/src/keboola_agent_cli/server/_serve_command_map.py @@ -104,6 +104,15 @@ ("GET", "/lineage/walk"): "", ("GET", "/members/{project}"): "project member-list", ("GET", "/members/{project}/invitations"): "project invitation-list", + ("GET", "/merge-requests/{project}"): "merge-request list", + # serve-only: the branch->MR resolver the CLI hides behind an omitted --merge-request-id + ("GET", "/merge-requests/{project}/by-branch/{branch_id}"): "", + ("GET", "/merge-requests/{project}/{merge_request_id}"): "merge-request detail", + ("GET", "/merge-requests/{project}/{merge_request_id}/conflicts"): "merge-request conflicts", + ( + "GET", + "/merge-requests/{project}/{merge_request_id}/diff/{component_id}/{config_id}", + ): "merge-request diff", ("GET", "/notifications"): "notification list", ("GET", "/notifications/{project}/{subscription_id}"): "notification detail", ("GET", "/projects"): "project list", @@ -201,6 +210,21 @@ ("POST", "/kai/chat"): "kai chat", ("POST", "/lineage/build"): "lineage build", ("POST", "/lineage/show"): "lineage show", + ("POST", "/merge-requests/{project}"): "merge-request create", + ("POST", "/merge-requests/{project}/{merge_request_id}/approve"): "merge-request approve", + ("POST", "/merge-requests/{project}/{merge_request_id}/merge"): "merge-request merge", + ( + "POST", + "/merge-requests/{project}/{merge_request_id}/request-changes", + ): "merge-request request-changes", + ( + "POST", + "/merge-requests/{project}/{merge_request_id}/request-review", + ): "merge-request request-review", + ( + "POST", + "/merge-requests/{project}/{merge_request_id}/resolve/{component_id}/{config_id}", + ): "merge-request resolve", ("POST", "/members/{project}/invitations/cancel"): "project invitation-cancel", ("POST", "/members/{project}/invite"): "project invite", ("POST", "/members/{project}/remove"): "project member-remove", @@ -257,6 +281,7 @@ ("POST", "/workspaces/{project}/{workspace_id}/query"): "workspace query", ("PUT", "/branches/{project}/metadata/{key}"): "branch metadata-set", ("PUT", "/configs/{project}/{component_id}/{config_id}/metadata/{key}"): "config set-metadata", + ("PUT", "/merge-requests/{project}/{merge_request_id}"): "merge-request update", ("PUT", "/configs/{project}/{component_id}/{config_id}/state"): "config state-set", ("PUT", "/configs/{project}/{component_id}/{config_id}/variables"): "config variables-set", ("PUT", "/data-apps/{project}/{app_id}/secrets"): "data-app secrets-set", From d2993cdc38de7ea1c03f67a8262ad3bf451bee5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Sat, 5 Sep 2026 15:13:56 +0200 Subject: [PATCH 14/16] 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 --- .../commands/_merge_request_common.py | 4 +- .../commands/merge_request.py | 10 +- .../services/merge_request_service.py | 69 ++++--- tests/test_e2e.py | 193 ++++++++++++------ tests/test_merge_request_cli.py | 30 +++ tests/test_merge_request_service.py | 19 ++ 6 files changed, 230 insertions(+), 95 deletions(-) diff --git a/src/keboola_agent_cli/commands/_merge_request_common.py b/src/keboola_agent_cli/commands/_merge_request_common.py index e2e75777..975eebfd 100644 --- a/src/keboola_agent_cli/commands/_merge_request_common.py +++ b/src/keboola_agent_cli/commands/_merge_request_common.py @@ -368,7 +368,9 @@ def _emit_warnings(formatter: Any, result: dict[str, Any]) -> None: """Render ``warnings[]`` -- the group's one soft-failure key -- in human mode. In ``--json`` the key is in the payload; ``formatter.warning`` is human-only.""" for warning in result.get("warnings") or []: - formatter.warning(str(warning)) + # Backend / exception text rides in here; an unbalanced `[/x]` would + # raise MarkupError AFTER the irreversible operation succeeded. + formatter.warning(escape(str(warning))) def _hint_next(formatter: Any, text: str) -> None: diff --git a/src/keboola_agent_cli/commands/merge_request.py b/src/keboola_agent_cli/commands/merge_request.py index 7d2c9740..b8ea2e87 100644 --- a/src/keboola_agent_cli/commands/merge_request.py +++ b/src/keboola_agent_cli/commands/merge_request.py @@ -295,7 +295,9 @@ def merge_request_diff( why = "; ".join(result.get("warnings") or ["no candidate could be composed."]) _usage_error(formatter, f"--output has nothing to write: {why}") try: - output.write_text(json.dumps(candidate, indent=2, ensure_ascii=False) + "\n") + output.write_text( + json.dumps(candidate, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) except OSError as exc: formatter.error( message=f"Cannot write --output {output}: {exc}", @@ -308,10 +310,8 @@ def merge_request_diff( _emit_warnings(formatter, result) resolve_cmd = f"merge-request resolve --component-id {escape(component_id)} --config-id {escape(config_id)}" if output is not None: - _hint_next( - formatter, - f"edit {output}, then `{resolve_cmd} --resolved @{output}`", - ) + shown = escape(str(output)) + _hint_next(formatter, f"edit {shown}, then `{resolve_cmd} --resolved @{shown}`") elif result.get("ours_deleted") or result.get("theirs_deleted"): _hint_next(formatter, f"`{resolve_cmd} --take delete|ours|theirs` as recommended above") else: diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py index 55740889..612fe6c1 100644 --- a/src/keboola_agent_cli/services/merge_request_service.py +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -86,6 +86,19 @@ # The resolve_conflict take modes. Public for the same reason. TAKE_MODES: tuple[str, ...] = ("ours", "theirs", "delete") +# The content keys a diff side's envelope must carry for the side to be +# classifiable / composable (`description` is nullable and may be absent on a +# take side -- see resolve_conflict; it is not required here). +_REQUIRED_CONTENT_KEYS: tuple[str, ...] = ("name", "rows", "configuration", "isDisabled") + + +def _envelope_holes(side: dict[str, Any]) -> list[str]: + """Required content keys ABSENT from a (non-null, non-deleted) side's envelope. + An empty envelope reports all of them.""" + envelope = side.get("diff") or {} + return [key for key in _REQUIRED_CONTENT_KEYS if key not in envelope] + + # The auto-merge vocabulary (AutoMergeStrategy enum, wire-exact). Public and # validated HERE so the CLI and the serve router cannot drift from each other: # an arming value one surface recognises and the other does not is a safety @@ -979,54 +992,46 @@ def _resolution_candidate(self, ours: dict[str, Any] | None) -> dict[str, Any] | """ if ours is None or ours.get("isDeleted"): return None - envelope = ours.get("diff") or {} # A hole in the server-produced envelope is a backend contract # violation (the schema marks every content key required). Writing it # as an explicit null would make `resolve --resolved @file` blame the # CALLER for a file kbagent wrote -- so there is no candidate, and the # caller learns why from `warnings` (the same message the --take path # raises as VALIDATION_ERROR). - missing = [ - key for key in ("name", "rows", "configuration", "isDisabled") if key not in envelope - ] - if missing: + if _envelope_holes(ours): return None + envelope = ours.get("diff") or {} return {key: envelope.get(key) for key in self._DIFF_CONTENT_KEYS} def _diff_warnings(self, diff: dict[str, Any]) -> list[str]: """Say why a side yielded no classification / no candidate. - Two shapes, both backend contract violations (the OA schema marks - every content key required): an EMPTY envelope on either side -- the - classifier then emits no rows and a renderer must not read that as - "the conflict cleared" -- and a HOLED ours envelope, which yields no - resolution candidate rather than an explicit-null file the resolve - guard would blame the caller for. + Both shapes are backend contract violations (the OA schema marks every + content key required): an EMPTY envelope, or a HOLED one missing a + required key, on EITHER side. The classifier emits no rows for such a + side -- fabricating them would report the hole as a real removal -- + and a renderer must not read "no rows" as "the conflict cleared". On + the ours side the same hole also means no resolution candidate. """ warnings: list[str] = [] for label in ("ours", "theirs"): side = diff.get(label) if side is None or side.get("isDeleted"): continue - envelope = side.get("diff") or {} - if not envelope: - warnings.append( - f"The diff's {label} side has an empty content envelope -- no per-path " - "classification is possible (backend envelope hole)." - ) + holes = _envelope_holes(side) + if not holes: continue - if label == "ours": - missing = [ - key - for key in ("name", "rows", "configuration", "isDisabled") - if key not in envelope - ] - if missing: - warnings.append( - f"The diff's ours side carries no {', '.join(missing)} -- no resolution " - "candidate could be prefilled (backend envelope hole). Author the " - "resolution manually." - ) + if holes == list(_REQUIRED_CONTENT_KEYS): + what = "has an empty content envelope" + else: + what = f"carries no {', '.join(holes)}" + tail = ( + " -- no per-path classification is possible, and no resolution candidate " + "could be prefilled; author the resolution manually" + if label == "ours" + else " -- no per-path classification is possible" + ) + warnings.append(f"The diff's {label} side {what}{tail} (backend envelope hole).") return warnings def _classify_three_way(self, diff: dict[str, Any]) -> list[dict[str, Any]]: @@ -1050,7 +1055,11 @@ def _classify_three_way(self, diff: dict[str, Any]) -> list[dict[str, Any]]: """ def classifiable(side: dict[str, Any] | None) -> bool: - return side is not None and not side.get("isDeleted") and bool(side.get("diff")) + # A side with a HOLED envelope (a required content key absent) is as + # unclassifiable as an empty one: `content()` would omit the key and + # the intersection would then report it as a real removal by that + # side. Same criterion `_diff_warnings` reports on. + return side is not None and not side.get("isDeleted") and not _envelope_holes(side) if not classifiable(diff.get("ours")) or not classifiable(diff.get("theirs")): return [] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index dd871156..592bc77e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -14414,31 +14414,28 @@ def test_set_guard_rejects_state_prefix_exit_2(self) -> None: @skip_without_credentials @pytest.mark.e2e class TestE2EMergeRequestLifecycle: - """End-to-end tests for the `merge-request` group (DMD-1900). + """End-to-end tests for the `merge-request` group (DMD-1900) -- all eleven commands. GATED ON THE PROJECT FEATURE, not on credentials: the E2E project does not carry ``branches-merge-requests`` today and kbagent cannot provision one - (no project-create in ManageClient). So ``setup`` runs ``merge-request - list`` and ``pytest.skip``s on ``feature_enabled: false`` -- the same shape - the conditional-flows tests use. The suite stays green and starts covering - the group the moment the flag lands (one-time, super-admin manage token: - ``kbagent feature project-add --project kbagent-e2e --feature - branches-merge-requests``). The skip is explicit and reported, never silent - (docs/merge-requests-layer1.md, Bookkeeping / E2E). - - Two properties of the scenario worth knowing before reading on: - - - **The happy path merges into production.** There is no dry-run merge, so - the test creates a throwaway ``ex-generic-v2`` config IN A DEV BRANCH, - merges the branch, and then deletes the config from production in - ``cleanup``. That is inside the blast radius the flow/config E2E tests - already have, but it is an explicit teardown -- never left for the next run. - - **``merge`` takes a project-wide lock** and refuses while another MR in - the project is processing (``MR_NOT_READY_TO_MERGE``). Two concurrent runs - collide; treat that error here as a known flake source, not a regression. + (no project-create in ManageClient). ``setup`` runs ``merge-request list`` + -- which must SUCCEED (a crash or an auth regression is a failure, never a + skip) -- and ``pytest.skip``s only on ``feature_enabled: false``. The suite + stays green and starts covering the group the moment the flag lands + (one-time, super-admin manage token: ``kbagent feature project-add + --project kbagent-e2e --feature branches-merge-requests``). + + The scenario manufactures a REAL conflict so `conflicts` / `diff` / + `resolve` have something to work on: a throwaway ``ex-generic-v2`` config is + created in PRODUCTION, a branch is created (it inherits the config), the + config is then changed on BOTH sides. After the merge the config lives in + production with the branch's content and ``cleanup`` deletes it -- an + explicit teardown, never left for the next run. ``merge`` takes a + project-wide lock, so two concurrent runs collide with + ``MR_NOT_READY_TO_MERGE``: a known flake source, not a regression. ``approve`` has no happy path on a 0-approval project (422 in every state, - ``in_review`` is unreachable) -- the test asserts the refusal. + ``in_review`` is unreachable) -- the test asserts THAT refusal precisely. """ @pytest.fixture(autouse=True) @@ -14471,22 +14468,22 @@ def setup(self, tmp_path: Path) -> None: self._created_branch_ids: list[int] = [] self._production_config_ids: list[str] = [] - # The feature gate. `list` is ungated server-side, so on a project - # without the feature it answers 200 + [] and the service adds - # feature_enabled: false -- that, not an error, is the skip signal. + # The feature gate -- and ONLY the feature gate. `list` is ungated + # server-side, so on a project without the feature it answers 200 + [] + # and the service adds feature_enabled: false; anything else failing + # here is a real failure and must be reported as one. listing = self._run("merge-request", "list", "--project", self.alias) - if listing.exit_code != 0: - pytest.skip(f"merge-request list failed on the E2E project: {listing.output}") + assert listing.exit_code == 0, f"merge-request list failed: {listing.output}" if json.loads(listing.output)["data"].get("feature_enabled") is False: pytest.skip( "E2E project lacks the `branches-merge-requests` feature; enable it once with " - "`kbagent feature project-add --project kbagent-e2e --feature branches-merge-requests`" + "`kbagent feature project-add --project kbagent-e2e " + "--feature branches-merge-requests`" ) @pytest.fixture(autouse=True) def cleanup(self) -> Any: yield - # The merged config lives in PRODUCTION after a successful merge. for cfg_id in self._production_config_ids: with contextlib.suppress(Exception): self.client.delete_config(component_id=self.component_id, config_id=cfg_id) @@ -14502,28 +14499,51 @@ def _run(self, *args: str) -> Any: def _run_ok(self, *args: str) -> dict[str, Any]: return _json_ok(self._run(*args)) - def test_lifecycle_create_inspect_merge(self) -> None: - """branch -> config on the branch -> create MR -> detail/conflicts -> approve 422 -> merge.""" - _step(1, "branch create", "the merge request's source") - branch = self._run_ok( - "branch", "create", "--project", self.alias, "--name", f"{RUN_ID}-mr-src" + def _mr(self, command: str, mr_id: int, *args: str) -> dict[str, Any]: + return self._run_ok( + "merge-request", + command, + "--project", + self.alias, + "--merge-request-id", + str(mr_id), + *args, )["data"] - branch_id = int(branch["branch_id"]) - self._created_branch_ids.append(branch_id) - _step(2, "create a throwaway config IN the branch", "something for the merge to carry") + def test_full_lifecycle_with_a_real_conflict(self, tmp_path: Path) -> None: + _step(1, "config in PRODUCTION", "so the branch inherits it and a conflict is possible") cfg = self.client.create_config( component_id=self.component_id, name=f"{RUN_ID}-mr-config", - configuration={"parameters": {"e2e": RUN_ID}}, + configuration={"parameters": {"side": "base", "e2e": RUN_ID}}, description="E2E throwaway -- DMD-1900 merge-request lifecycle", - branch_id=branch_id, ) config_id = str(cfg["id"]) - # After the merge this id exists in production -- schedule its deletion now. self._production_config_ids.append(config_id) - _step(3, "merge-request create --branch", "explicit branch, no active-branch state") + _step(2, "branch create", "the merge request's source; inherits the config") + branch = self._run_ok( + "branch", "create", "--project", self.alias, "--name", f"{RUN_ID}-mr-src" + )["data"] + branch_id = int(branch["branch_id"]) + self._created_branch_ids.append(branch_id) + + _step(3, "change the config on BOTH sides", "branch says ours, production says theirs") + self.client.update_config( + self.component_id, + config_id, + configuration={"parameters": {"side": "ours", "e2e": RUN_ID}}, + change_description="E2E branch change", + branch_id=branch_id, + ) + self.client.update_config( + self.component_id, + config_id, + configuration={"parameters": {"side": "theirs", "e2e": RUN_ID}}, + change_description="E2E production change", + ) + + _step(4, "merge-request create --branch") created = self._run_ok( "merge-request", "create", @@ -14541,49 +14561,104 @@ def test_lifecycle_create_inspect_merge(self) -> None: assert created["derived_state"] == "in_development" assert created["merge_request_id"] == mr_id and created["resolved_from_branch"] is False - _step(4, "list shows it newest-first with the derived state") + _step(5, "update --title / --external-id", "omitted fields stay") + updated = self._mr( + "update", mr_id, "--title", f"{RUN_ID} lifecycle (edited)", "--external-id", "E2E-1" + ) + assert updated["title"].endswith("(edited)") and updated["externalId"] == "E2E-1" + assert updated["description"] == "E2E" + + _step(6, "list shows it with the derived state") rows = self._run_ok("merge-request", "list", "--project", self.alias)["data"][ "merge_requests" ] assert any(int(r["id"]) == mr_id for r in rows) - _step(5, "detail: readiness, viewer, allowed_actions, empty change log in development") - detail = self._run_ok( - "merge-request", "detail", "--project", self.alias, "--merge-request-id", str(mr_id) - )["data"] - assert detail["conflicts_count"] == 0 and detail["mergeable"] is True + _step(7, "detail: blocked by the conflict; viewer; feature_enabled; empty change log") + detail = self._mr("detail", mr_id) + assert detail["conflicts_count"] == 1 and detail["mergeable"] is False + assert "conflicts" in detail["merge_blockers"] assert detail["viewer"]["is_creator"] is True - assert "merge" in detail["allowed_actions"] + assert detail["feature_enabled"] is True assert not (detail.get("changeLog") or {}).get("configurations") - _step(5.1, "by-branch resolution via --branch instead of the id") - via_branch = self._run_ok( + _step(8, "conflicts via --branch resolution", "the same MR, named by its branch") + conflicts = self._run_ok( "merge-request", "conflicts", "--project", self.alias, "--branch", str(branch_id) )["data"] - assert via_branch["merge_request_id"] == mr_id and via_branch["count"] == 0 + assert conflicts["merge_request_id"] == mr_id and conflicts["count"] == 1 + assert conflicts["conflicts"][0]["configurationId"] == config_id + + _step( + 9, + "diff: both sides changed configuration.parameters.side; --output writes the candidate", + ) + candidate_path = tmp_path / "resolved.json" + diff = self._mr( + "diff", + mr_id, + "--component-id", + self.component_id, + "--config-id", + config_id, + "--output", + str(candidate_path), + ) + by_path = {c["path"]: c for c in diff["changes"]} + assert by_path["configuration.parameters.side"]["changed_by"] == "both" + assert by_path["configuration.parameters.side"]["ours"] == "ours" + assert by_path["configuration.parameters.side"]["theirs"] == "theirs" + assert diff["branch_id"] == branch_id and diff["branch_from_id"] == branch_id + candidate = json.loads(candidate_path.read_text(encoding="utf-8")) + assert set(candidate) == {"name", "description", "isDisabled", "configuration", "rows"} + assert candidate["configuration"]["parameters"]["side"] == "ours" + + _step(10, "resolve --take ours", "rebase onto production's version; conflict set empties") + resolved = self._mr( + "resolve", + mr_id, + "--component-id", + self.component_id, + "--config-id", + config_id, + "--take", + "ours", + "--change-description", + "E2E resolved", + ) + assert resolved["resolution"] == "ours" + assert self._mr("conflicts", mr_id)["count"] == 0 + + _step(11, "request-review lands directly in approved (0 required approvals)") + reviewed = self._mr("request-review", mr_id) + assert reviewed["state"] == "approved" - _step(6, "approve is 422 on a 0-approval project", "the refusal IS the expected outcome") + _step(12, "request-changes sends it back to development") + changed = self._mr("request-changes", mr_id, "--reason", "E2E round trip") + assert changed["state"] == "development" + + _step(13, "approve is 422 on a 0-approval project", "assert THAT refusal, nothing looser") approve = self._run( "merge-request", "approve", "--project", self.alias, "--merge-request-id", str(mr_id) ) - assert approve.exit_code != 0, approve.output - assert json.loads(approve.output)["error"]["code"] not in (ErrorCode.FEATURE_NOT_ENABLED,) + assert approve.exit_code == 1, approve.output + err = json.loads(approve.output)["error"] + assert err["code"] == ErrorCode.API_ERROR and "422" in err["message"], err - _step(7, "--json merge without an explicit target is exit 2 before any call") + _step(14, "--json merge without an explicit target is exit 2 before any call") bare = self._run("merge-request", "merge", "--project", self.alias) assert bare.exit_code == 2, bare.output - _step(8, "merge --merge-request-id", "straight from development; blocks on the Storage job") - merged = self._run_ok( - "merge-request", "merge", "--project", self.alias, "--merge-request-id", str(mr_id) - )["data"] + _step(15, "merge --merge-request-id", "straight from development; blocks on the job") + merged = self._mr("merge", mr_id) assert merged["branch_from_id"] == branch_id assert "is being deleted" in merged["message"] + assert "cleanup_skipped" not in merged assert merged.get("derived_state") in (None, "merged") - _step(9, "the config now exists in production") + _step(16, "production now holds the branch's content") prod = self.client.get_config_detail(component_id=self.component_id, config_id=config_id) - assert prod["id"] == config_id + assert prod["configuration"]["parameters"]["side"] == "ours" # The backend deletes the source branch asynchronously; nothing to assert # about its existence at this instant (the RFC's wording rule exists for # exactly this reason). diff --git a/tests/test_merge_request_cli.py b/tests/test_merge_request_cli.py index 51f6b6d9..a2e7b199 100644 --- a/tests/test_merge_request_cli.py +++ b/tests/test_merge_request_cli.py @@ -1642,3 +1642,33 @@ def test_detail_hint_unchanged_when_feature_enabled(self, tmp_path, service) -> ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service ) assert "merge-request merge" in result.output + + +class TestCopilotBalancedFollowUps: + def test_warning_text_with_markup_does_not_crash(self, tmp_path, service) -> None: + service.merge.return_value = { + **_merged(), + "warnings": ["Post-merge cleanup failed: [/x] bad tag"], + } + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "[/x] bad tag" in result.output + + def test_output_is_utf8_and_a_bracketed_path_does_not_crash(self, tmp_path, service) -> None: + candidate = { + "name": "Příliš žluťoučký kůň", + "description": None, + "isDisabled": False, + "configuration": {}, + "rows": [], + } + service.get_config_diff.return_value = _diff_result(resolution_candidate=candidate) + target = tmp_path / "[x] resolved.json" + result = _run([*_DIFF_ARGS, "--output", str(target)], _store(tmp_path), service) + assert result.exit_code == 0, result.output + assert json.loads(target.read_bytes().decode("utf-8")) == candidate + assert "[x] resolved.json" in result.output diff --git a/tests/test_merge_request_service.py b/tests/test_merge_request_service.py index d43a047a..4a322cba 100644 --- a/tests/test_merge_request_service.py +++ b/tests/test_merge_request_service.py @@ -1723,3 +1723,22 @@ def test_find_default_branch_id_logs_the_skipped_entry(self, caplog) -> None: with caplog.at_level(logging.WARNING, logger="keboola_agent_cli.services.base"): assert find_default_branch_id([{"isDefault": True, "id": "main"}]) is None assert any("non-numeric id 'main'" in r.getMessage() for r in caplog.records) + + # Copilot (Balanced) on #736: a HOLED envelope must not classify either + def test_holed_theirs_envelope_yields_no_rows_and_a_warning( + self, store, client_factory + ) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + theirs = _side({"limit": 250}, version=7) + del theirs["diff"]["configuration"] # a partial envelope, not an empty one + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=theirs, + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + # before the fix: a fabricated "theirs removed configuration.limit" row + assert result["changes"] == [] + assert any("theirs side carries no configuration" in w for w in result["warnings"]) + assert result["resolution_candidate"]["configuration"] == {"limit": 500} # ours intact From 4d41272ef4f08215d1e262cb26eec6d9e4f0ae63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Fri, 11 Sep 2026 00:07:42 +0200 Subject: [PATCH 15/16] 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 --- CLAUDE.md | 36 +- docs/web-server-endpoints.md | 5 +- plugins/kbagent/agents/keboola-expert.md | 2 +- plugins/kbagent/skills/kbagent/SKILL.md | 18 +- .../kbagent/references/commands-reference.md | 13 +- .../skills/kbagent/references/gotchas.md | 39 +- .../references/merge-request-workflow.md | 58 +- .../commands/_merge_request_common.py | 86 +- .../commands/_merge_request_render.py | 19 +- .../commands/_merge_request_writes.py | 386 +++--- src/keboola_agent_cli/commands/context.py | 41 +- .../commands/merge_request.py | 26 +- src/keboola_agent_cli/constants.py | 5 + src/keboola_agent_cli/permissions.py | 54 +- .../server/_serve_command_map.py | 1 + .../server/routers/merge_requests.py | 127 +- .../services/merge_request_service.py | 35 +- tests/test_merge_request_cli.py | 1084 ++++++++--------- tests/test_merge_request_service.py | 86 +- tests/test_server_router_calls.py | 159 ++- 20 files changed, 1193 insertions(+), 1087 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6498686b..c1e71aa1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -748,29 +748,33 @@ kbagent branch metadata-delete --project NAME --metadata-id ID [--branch ID|defa # shows (in_development|in_review|approved|in_merge|merged|closed|rejected), never the raw one. kbagent merge-request list [--project A] [--state STATE] # derived (in_development|in_review|approved|in_merge|merged|closed|rejected) or raw (development|published|canceled) states; `--help` lists them kbagent merge-request detail [--project A] [--merge-request-id N | --branch B] [--activity-log] -kbagent merge-request create --title T [--project A] [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes] -kbagent merge-request update [--project A] [--merge-request-id N | --branch B] [--title T] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy S] [--auto-merge-at TS] [--external-id X] [--yes] +kbagent merge-request create --title T [--project A] [--branch B] [--description D] [--reviewer-id ID ...] [--external-id X] +kbagent merge-request update [--project A] [--merge-request-id N | --branch B] [--title T] [--description D] [--reviewer-id ID ...] [--external-id X] kbagent merge-request request-review [--project A] [--merge-request-id N | --branch B] kbagent merge-request approve [--project A] [--merge-request-id N | --branch B] kbagent merge-request request-changes [--project A] [--merge-request-id N | --branch B] [--reason TEXT] +kbagent merge-request auto-merge --strategy immediately|scheduled|none [--at TS] [--project A] [--merge-request-id N | --branch B] [--yes] kbagent merge-request merge [--project A] [--merge-request-id N | --branch B] [--yes] kbagent merge-request conflicts [--project A] [--merge-request-id N | --branch B] kbagent merge-request diff --component-id C --config-id I [--project A] [--merge-request-id N | --branch B] [--format short|full] [--output PATH] kbagent merge-request resolve --component-id C --config-id I (--take ours|theirs|delete | --resolved JSON|@file|-) [--project A] [--merge-request-id N | --branch B] [--change-description TEXT] -# WHAT MAY HAPPEN WITHOUT A HUMAN SAYING SO -- read before automating this group: -# `merge` is DESTRUCTIVE (deletes the source branch, rewrites production). Under --json it REQUIRES an -# explicit target (--merge-request-id or --branch): every destructive kbagent command either prompts or is -# told its target, and --json has no prompt. In human mode the active-branch fallback stays and a prompt -# names the MR and the branch (--yes skips it). -# ARMING AUTO-MERGE IS A PRODUCTION MERGE, just delayed: a backend scheduler runs every `approved` MR whose -# autoMergeStrategy is immediately/scheduled through the same MergeProcessor, on its own, retrying every -# tick until it lands -- `merge` is never called. So `create`/`update --auto-merge-strategy immediately| -# scheduled` escalate to destructive (blocked by --deny-destructive; explicit target under --json; prompt -# in human mode), and `request-review` / `approve` / `resolve` on an ALREADY-armed MR escalate too -- -# they are what moves it into `approved`. `--auto-merge-strategy none` is the disarm and never escalates. -# The escalation is deliberately conservative (the required-approvals count is unreadable with a Storage -# token, DMD-1969). An agent under --deny-destructive can run the whole flow and cannot complete a -# merge by any route. +# WHAT IS DESTRUCTIVE -- a property of the COMMAND, never of a flag or of the MR's state, so a policy is +# evaluated from the command name alone, before any network call. Destructive = anything that moves a +# merge request toward or into production: `merge` (deletes the source branch, rewrites production), +# `request-review` (on the non-SOX default of 0 approvals it lands the MR directly in `approved`), +# `approve` (the last approval is what a merge waits for), `resolve` (removes the blocker a merge waits +# on) and `auto-merge` (arms a backend scheduler that runs every `approved` MR whose strategy is +# immediately/scheduled through the same MergeProcessor, on its own, retrying every tick -- a DELAYED +# PRODUCTION MERGE with `merge` never called; `--strategy none` disarms and rides the same command, same +# class). Write = shapes the MR without moving it: `create`, `update` (title/description/reviewers/ +# external id -- auto-merge is NOT a field here), `request-changes` (moves it AWAY from approved). +# So `--deny-destructive` yields an agent that can observe and shape merge requests but never move one. +# Under --json every destructive command REQUIRES an explicit target (--merge-request-id or --branch), +# checked before anything is resolved: every destructive kbagent command either prompts or is told its +# target, and --json has no prompt. In human mode the active-branch fallback stays; `merge` and arming +# `auto-merge` prompt (--yes skips), the other destructive commands do not. After request-review/ +# approve/resolve on an MR that IS armed, human mode warns that the backend will now merge (read off the +# write's own result, no extra GET). # On a non-SOX project with the default 0 required approvals: `merge` works straight from `development`; # `request-review` lands directly in `approved` (in_review is unreachable); `approve` answers 422 in every # state. There is NO `close`: `request-changes` by the creator is the UI's cancel and leaves the MR in diff --git a/docs/web-server-endpoints.md b/docs/web-server-endpoints.md index eb0c5188..ea052642 100644 --- a/docs/web-server-endpoints.md +++ b/docs/web-server-endpoints.md @@ -9,7 +9,7 @@ auth, and the concepts behind these routes live in [`web-server.md`](web-server.md); a running server serves the same spec interactively at `/docs` (Swagger) and `/openapi.json`. -**247 operations** across **215 paths** and **31 routers**. +**248 operations** across **216 paths** and **31 routers**. Paths are shown as the server registers them. Reaching them through the Node BFF (or single-process `--ui` mode) prefixes every path with `/api`. @@ -353,7 +353,7 @@ Dev branch lifecycle (create / use / reset / delete / merge) and branch metadata | `PUT` | `/branches/{project}/metadata/{key}` | Set a branch metadata value | | `DELETE` | `/branches/{project}/metadata/{metadata_id}` | Delete a branch metadata entry | -### `merge-requests` (12 operations) +### `merge-requests` (13 operations) Merge requests (Branches 2.0, non-SOX): list / detail / create / update / review transitions / merge, plus conflict inspection and resolution. Every route enforces the permission policy; `merge` and any operation that arms or completes an auto-merge are destructive. `POST .../merge` is synchronous and may block up to 600 s. Mirrors `kbagent merge-request *`. @@ -366,6 +366,7 @@ Merge requests (Branches 2.0, non-SOX): list / detail / create / update / review | `PUT` | `/merge-requests/{project}/{merge_request_id}` | Update a merge request | | `GET` | `/merge-requests/{project}/{merge_request_id}/conflicts` | List conflicts | | `GET` | `/merge-requests/{project}/{merge_request_id}/diff/{component_id}/{config_id}` | Three-way diff of one conflicting configuration | +| `PUT` | `/merge-requests/{project}/{merge_request_id}/auto-merge` | Arm or disarm auto-merge | | `POST` | `/merge-requests/{project}/{merge_request_id}/request-review` | Send for review | | `POST` | `/merge-requests/{project}/{merge_request_id}/approve` | Approve | | `POST` | `/merge-requests/{project}/{merge_request_id}/request-changes` | Request changes | diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 3cbcb04c..c59d7a79 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -129,7 +129,7 @@ been retired, so its absence is NOT a promise (see §1 Rule 6). | Export a FILTERED or INCREMENTAL slice of a table (no workspace) | `kbagent storage download-table --table-id ... --where-column status --where-value active [--where-operator eq\|neq] [--changed-since "-2 days"]` -- server-side filter on the credential-only export path | `kbagent workspace query` with a `WHERE` clause when you need real SQL | downloading the whole table then filtering locally | | Run Keboola SQL / read-write Storage Files from INSIDE a Python process you control | `from keboola_agent_cli import Client` -- stateless `Client(url, token)`; `.query(workspace_id, sql)`, `.files.upload/.read_bytes/.list`; no subprocess, no `serve`, no config-dir. See [library-workflow.md](../skills/kbagent/references/library-workflow.md) | the CLI or `kbagent serve` REST when you are NOT already inside Python | shelling out to the `kbagent` binary from Python you control; using it for open-ended exploration (fixed set of typed ops) | | Inspect dev branch | `kbagent branch list --project P`, `kbagent branch use --project P --branch ID` | -- | acting on `main` when a dev branch exists | -| Merge a dev branch into production (review, conflicts) | `kbagent merge-request create --title T` from the active branch, `merge-request detail` for readiness, `merge-request merge` (vNEXT+; project feature `branches-merge-requests`). Conflicts: `conflicts` -> `diff --component-id C --config-id I` -> `resolve --take ours\|theirs\|delete`. See [merge-request-workflow.md](../skills/kbagent/references/merge-request-workflow.md) -- read the auto-merge section before touching `--auto-merge-strategy` | `branch merge` (deprecated URL builder) on a project WITHOUT the feature | `--auto-merge-strategy immediately\|scheduled` without treating it as a production merge (a backend scheduler merges on its own once approved; blocked by `--deny-destructive`, needs an explicit target under `--json`); `--json merge` with no `--merge-request-id`/`--branch` (exit 2 by design); `resolve --resolved` with a partial body (rebase REPLACES -- all five keys or refused); reading a `list` row's `allowed_actions` as feature-aware (only `detail` carries `feature_enabled`); `approve` on a 0-approval project (422 in every state) | +| Merge a dev branch into production (review, conflicts) | `kbagent merge-request create --title T` from the active branch, `merge-request detail` for readiness, `merge-request merge` (vNEXT+; project feature `branches-merge-requests`). Conflicts: `conflicts` -> `diff --component-id C --config-id I` -> `resolve --take ours\|theirs\|delete`. See [merge-request-workflow.md](../skills/kbagent/references/merge-request-workflow.md) -- read the "What is destructive" section before automating | `branch merge` (deprecated URL builder) on a project WITHOUT the feature | treating `request-review`/`approve`/`resolve` as plain writes (they move the MR toward production and are destructive by command, always -- `--deny-destructive` blocks them); `merge-request auto-merge` without treating it as a production merge (a backend scheduler merges on its own once approved); passing `--auto-merge-strategy` to `create`/`update` (no such option -- arming is its own command); any destructive `--json` call with no `--merge-request-id`/`--branch` (exit 2 by design); `resolve --resolved` with a partial body (rebase REPLACES -- all five keys or refused); reading a `list` row's `allowed_actions` as feature-aware (only `detail` carries `feature_enabled`); `approve` on a 0-approval project (422 in every state) | | Audit project capabilities / features | `kbagent project info --project P` -- project id, name, backend, enabled features, quota limits, metrics | -- | inspecting the UI project settings manually | | Manage feature flags (stack / project / user) | `kbagent feature list\|project-show\|project-add\|project-remove\|user-show\|user-add\|user-remove --project P [--email E] [--feature NAME] [--dry-run]` -- Manage API, needs a SUPER-ADMIN token (interactive prompt; `--allow-env-manage-token` for CI) | `kbagent project info` for a project's *enabled* features (read-only, no super-admin) | raw `/manage/...` calls; a manage token passed as a CLI flag | | Create a new config (one-shot remote, no scaffold to disk) | `kbagent config new --project P --component-id C --name N --push --no-files [--configuration @body.json]` -- default body `{}` skips validation; an explicit body is schema-validated (`--no-validate` opts out); works for every component type. `--output-dir` + `--push` together is safe only on 0.89.0+ (scaffold records `_keboola.config_id`, lands in the created branch's subtree); older kbagent writes an ID-less scaffold that the next `sync push` DUPLICATES (issue #644) -- there, scaffold and push in two steps | `kbagent config new --output-dir D` then edit + `kbagent sync push` | raw `POST /v2/storage/components/.../configs` (no schema validation, no encryption) | diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 6f900d09..4720e2dd 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -249,23 +249,25 @@ When working inside a git repository or project directory, run `kbagent init` (o | List the configurations changed on both sides (computed live by the backend) | `kbagent merge-request conflicts` | | Three-way diff of one conflicting configuration, classified per path | `kbagent merge-request diff --component-id COMPONENT-ID --config-id CONFIG-ID` | | Open a merge request from a development branch into production | `kbagent merge-request create --title TITLE` | -| Change a merge request's title, description, reviewers, auto-merge or external id | `kbagent merge-request update` | -| Send the merge request for review | `kbagent merge-request request-review` | -| Add your approval to a merge request under review | `kbagent merge-request approve` | +| Change a merge request's title, description, reviewers or external id | `kbagent merge-request update` | +| Send the merge request for review (destructive: it moves the MR toward production) | `kbagent merge-request request-review` | +| Add your approval (destructive: the last approval is what a merge waits for) | `kbagent merge-request approve` | | Send the merge request back to development; existing approvals are removed | `kbagent merge-request request-changes` | +| Arm or disarm automatic merging of this merge request (destructive) | `kbagent merge-request auto-merge --strategy STRATEGY` | | Merge the merge request into production and delete its source branch | `kbagent merge-request merge` | -| Resolve one conflicting configuration by rebasing it onto production's version | `kbagent merge-request resolve --component-id COMPONENT-ID --config-id CONFIG-ID` | +| Resolve one conflicting configuration (destructive: it removes a merge blocker) | `kbagent merge-request resolve --component-id COMPONENT-ID --config-id CONFIG-ID` | | List the project's merge requests, newest first | `kbagent mr list` | | Show one merge request: readiness, blockers, reviewers, change log, conflicts | `kbagent mr detail` | | List the configurations changed on both sides (computed live by the backend) | `kbagent mr conflicts` | | Three-way diff of one conflicting configuration, classified per path | `kbagent mr diff --component-id COMPONENT-ID --config-id CONFIG-ID` | | Open a merge request from a development branch into production | `kbagent mr create --title TITLE` | -| Change a merge request's title, description, reviewers, auto-merge or external id | `kbagent mr update` | -| Send the merge request for review | `kbagent mr request-review` | -| Add your approval to a merge request under review | `kbagent mr approve` | +| Change a merge request's title, description, reviewers or external id | `kbagent mr update` | +| Send the merge request for review (destructive: it moves the MR toward production) | `kbagent mr request-review` | +| Add your approval (destructive: the last approval is what a merge waits for) | `kbagent mr approve` | | Send the merge request back to development; existing approvals are removed | `kbagent mr request-changes` | +| Arm or disarm automatic merging of this merge request (destructive) | `kbagent mr auto-merge --strategy STRATEGY` | | Merge the merge request into production and delete its source branch | `kbagent mr merge` | -| Resolve one conflicting configuration by rebasing it onto production's version | `kbagent mr resolve --component-id COMPONENT-ID --config-id CONFIG-ID` | +| Resolve one conflicting configuration (destructive: it removes a merge blocker) | `kbagent mr resolve --component-id COMPONENT-ID --config-id CONFIG-ID` | | Create a new workspace | `kbagent workspace create --project PROJECT` | | List workspaces from connected projects | `kbagent workspace list` | | Show workspace details (password NOT included) | `kbagent workspace detail --project PROJECT --workspace-id WORKSPACE-ID` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index b263d700..96035903 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -249,16 +249,17 @@ Bucket sharing + linking across projects in the same organization. `sharing edge Non-SOX Branches 2.0: merge a dev branch into production with review. Alias `mr`. Every command except `list`/`create` takes `[--merge-request-id N | --id N] [--branch B]`; omitted, the target is the merge request of the active branch. A branch has at most one MR, ever. Both flags at once -> exit 2. Status is the derived state the web UI shows. See `merge-request-workflow.md`. - `merge-request list [--project A] [--state STATE]` -- newest first; `--state` filters client-side (unknown -> exit 2); an empty list on a feature-less project says so (`feature_enabled: false`) - `merge-request detail [--merge-request-id N | --branch B] [--activity-log]` -- readiness (`mergeable`/`merge_blockers`), `viewer`, `allowed_actions`, `feature_enabled`, reviewers, approvals, change log (empty until sent for review, by design), live conflicts -- `merge-request create --title T [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes]` -- from `--branch` or the active branch into production -- `merge-request update [--merge-request-id N | --branch B] [--title] [--description] [--reviewer-id ...] [--auto-merge-strategy] [--auto-merge-at] [--external-id] [--yes]` -- omitted fields stay; `""` clears description/external-id; `--reviewer-id` REPLACES the set; no fields -> exit 2 -- `merge-request request-review [...]` -- on a 0-approval project lands directly in `approved` (`merge` works without it); no reviewers selected = email to every project member -- `merge-request approve [...]` -- only from `in_review`; 422 on a 0-approval project +- `merge-request create --title T [--branch B] [--description D] [--reviewer-id ID ...] [--external-id X]` -- from `--branch` or the active branch into production (write) +- `merge-request update [--merge-request-id N | --branch B] [--title] [--description] [--reviewer-id ...] [--external-id]` -- omitted fields stay; `""` clears description/external-id; `--reviewer-id` REPLACES the set; no fields -> exit 2 (write; auto-merge is not a field here) +- `merge-request request-review [...]` -- DESTRUCTIVE (moves the MR toward production): on a 0-approval project lands directly in `approved` (`merge` works without it); no reviewers selected = email to every project member; `--json` needs an explicit target +- `merge-request approve [...]` -- DESTRUCTIVE (the last approval is what a merge waits for); only from `in_review`; 422 on a 0-approval project; `--json` needs an explicit target +- `merge-request auto-merge --strategy immediately|scheduled|none [--at TS] [...] [--yes]` -- DESTRUCTIVE: `immediately`/`scheduled` arm a backend scheduler that merges the MR on its own once approved (a delayed production merge, `merge` never called); `none` disarms, same class; prompts in human mode when arming; `--json` needs an explicit target - `merge-request request-changes [...] [--reason TEXT]` -- back to development, approvals removed; the closest thing to "close" (no cancel endpoint) - `merge-request merge [...] [--yes]` -- DESTRUCTIVE: merges into production, deletes the source branch, blocks up to 10 min; under `--json` an explicit target is REQUIRED - `merge-request conflicts [...]` -- configs changed on both sides (live); `isDeleted` is the dev side's flag - `merge-request diff --component-id C --config-id I [...] [--format short|full] [--output PATH]` -- per-path both/only-you/only-production; a wholesale-deleted side is reported with the `--take` to pick; `--output` writes `resolution_candidate` (all five keys) for `resolve --resolved @PATH` -- `merge-request resolve --component-id C --config-id I (--take ours|theirs|delete | --resolved JSON|@file|-) [...] [--change-description TEXT]` -- rebase onto production's version; rebase REPLACES, a `--resolved` body needs name/description/isDisabled/configuration/rows; no `--all` -- **Auto-merge is destructive**: arming (`immediately|scheduled`) makes the backend merge on its own once approved. Arming on create/update and request-review/approve/resolve on an already-armed MR are blocked by `--deny-destructive` and need an explicit target under `--json`; `none` disarms +- `merge-request resolve --component-id C --config-id I (--take ours|theirs|delete | --resolved JSON|@file|-) [...] [--change-description TEXT]` -- DESTRUCTIVE (removes a merge blocker); rebase onto production's version; rebase REPLACES, a `--resolved` body needs name/description/isDisabled/configuration/rows; no `--all`; `--json` needs an explicit target +- **Destructive is a property of the command**, never of a flag or of the MR's state: `request-review`, `approve`, `resolve`, `merge`, `auto-merge` are always destructive (they move an MR toward or into production); `create`, `update`, `request-changes` are writes. `--deny-destructive` = observe and shape, never move. A policy is evaluated from the command name alone, before any network call - Errors: `FEATURE_NOT_ENABLED` (exit 5, two wordings: feature missing vs SOX) from any command whose target resolved implicitly; `MR_MERGE_CONFLICT` / `MR_NOT_READY_TO_MERGE` from merge (a truncated conflict list carries `details.api_error_params_truncated`); scoped token 403s on everything but `list`. Every result may carry `warnings[]` ## Workspaces (SQL Debugging) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 3f434c31..e75d42d0 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -5053,18 +5053,26 @@ It carries the command name, the outcome, and the duration -- never argument val (non-SOX "Branches 2.0", project feature `branches-merge-requests`). Full playbook: `merge-request-workflow.md`. The parts that bite: -- **`--auto-merge-strategy immediately|scheduled` is not metadata.** A backend scheduler runs - every `approved` MR armed with it through the same merge processor `merge` uses -- on its own, - retrying every tick, `merge` never called, the arming call answering 200 with nothing to say - so. kbagent therefore classifies arming (on `create`/`update`) AND `request-review` / - `approve` / `resolve` on an already-armed MR as **destructive**: `--deny-destructive` blocks - them, `--json` requires an explicit target, human mode prompts at the arming. `none` disarms - and never escalates. Deliberately conservative -- the required-approvals count is unreadable - with a Storage token (DMD-1969), so every armed operation escalates even where it would not - yet merge. -- **`merge` under `--json` needs `--merge-request-id` or `--branch`.** Every destructive - kbagent command either prompts or is told its target; `--json` has no prompt. In human mode - the active-branch fallback stays and the prompt names the MR and the branch it will delete. +- **Destructive is a property of the COMMAND -- never of a flag or of the MR's state.** + `request-review`, `approve`, `resolve`, `merge` and `auto-merge` are always destructive: each + moves a merge request toward or into production (on the non-SOX default of 0 approvals, + `request-review` lands the MR directly in `approved`; the last `approve` is what a merge waits + for; `resolve` removes a merge blocker). `create`, `update`, `request-changes` are writes. So a + policy is evaluated from the command name alone, before any network call, and + `--deny-destructive` yields an agent that can observe and shape merge requests but never move + one. Nothing about `--deny-destructive` depends on whether an MR happens to be armed. +- **Auto-merge is its own command, not a flag.** `merge-request auto-merge --strategy + immediately|scheduled` arms a backend scheduler that runs every `approved` MR through the same + merge processor `merge` uses -- on its own, retrying every tick, `merge` never called, the + arming call answering 200 with nothing to say so. A delayed production merge, hence destructive + and a consciously separate step (prompts in human mode). `--strategy none` disarms and rides + the same command, same class -- a caller who could not arm never needs to disarm. `create` and + `update` no longer take `--auto-merge-strategy`; a Typer "no such option" is the answer. +- **Every destructive command under `--json` needs `--merge-request-id` or `--branch`**, checked + before anything is resolved. Every destructive kbagent command either prompts or is told its + target; `--json` has no prompt. In human mode the active-branch fallback stays; `merge` and + arming prompt, the other destructive commands do not (they warn afterwards when the MR turns + out to be armed, read off the write's own result). - **Targets are implicit everywhere else**: omit the id and the command uses the merge request OF the active branch (`branch use`). Both `--merge-request-id` and `--branch` at once -> exit 2. - **`FEATURE_NOT_ENABLED` (exit 5) comes from reads too** -- from any command whose target was @@ -5090,7 +5098,12 @@ It carries the command name, the outcome, and the duration -- never argument val Edit values, never delete keys. A side that deleted the config wholesale is reported as a sentence recommending the `--take`, not as an empty table. No `--all`. - **The source branch is deleted asynchronously** after a merge ("is being deleted", never - "is deleted"); `merge` itself blocks up to 10 minutes, no `--wait`/`--timeout`. + "is deleted"); `merge` itself blocks up to 10 minutes, no `--wait`/`--timeout`. A timeout + (`STORAGE_JOB_TIMEOUT`, exit 4) or `MR_NOT_READY_TO_MERGE` is reported **retryable** even + though a merge is not idempotent -- accepted on purpose: the merge job keeps running + server-side and the project-wide merge lock makes a premature retry answer + `MR_NOT_READY_TO_MERGE` rather than start a second merge, so the retry is harmless. Wait + and re-check with `merge-request detail` (state `in_merge` -> `merged`) before retrying. - **Every result may carry `warnings[]`** (post-merge cleanup failure, a dropped `--change-description` on a delete resolution). A truncated conflict list inside `MR_MERGE_CONFLICT` carries `details.api_error_params_truncated: true` -- run `conflicts`. diff --git a/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md index 59f0df9f..589f8404 100644 --- a/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md @@ -35,34 +35,48 @@ kbagent --json mr detail --project P --merge-request-id "$MR" | jq '.data | {mer kbagent --json mr merge --project P --merge-request-id "$MR" # explicit target REQUIRED ``` -**Under `--json`, `merge` requires an explicit target** (`--merge-request-id` or `--branch`). -Every destructive kbagent command either prompts or is told its target; `--json` has no -prompt, so a bare `--json mr merge` would be the one command where nothing on the command line -says what gets destroyed. The same rule applies to every invocation that *escalates* to -destructive (next section). Every `--json` result carries `merge_request_id`, +**Under `--json`, every destructive command requires an explicit target** (`--merge-request-id` +or `--branch`) -- `merge`, `request-review`, `approve`, `resolve`, `auto-merge`. Every destructive +kbagent command either prompts or is told its target; `--json` has no prompt, so a bare +`--json mr merge` would be the one command where nothing on the command line says what gets +destroyed. The check runs before anything is resolved (next section says why the class is +known up front). Every `--json` result carries `merge_request_id`, `branch_from_id` and `resolved_from_branch` so you can assert on what was operated upon. -## Auto-merge is a production merge -- treat it as one +## What is destructive -- a property of the command, never of a flag or of the MR's state -`--auto-merge-strategy immediately|scheduled` (on `create` or `update`) is not metadata. A -backend scheduler runs every `approved` merge request armed with it through the **same merge -processor** the `merge` command uses -- on its own, retrying every tick until it lands, with -`merge` never called and nothing in the arming call's response saying so. Consequences kbagent -enforces: +Anything that moves a merge request **toward or into production** is destructive, always. That +makes the classification static: a policy is evaluated from the command name alone, before any +network call, and nothing about it depends on whether the MR happens to be armed. -| operation | class | why | +| command | class | why | |---|---|---| | `merge` | destructive | deletes the source branch, rewrites production | -| `create` / `update` with `--auto-merge-strategy immediately\|scheduled` | destructive | arming IS a delayed merge | -| `request-review` / `approve` / `resolve` on an **already-armed** MR | destructive | they move it into `approved`, which is what the scheduler waits for (`resolve` unblocks a merge stuck on a conflict) | -| `--auto-merge-strategy none` | write | the disarm -- never escalates, so `--deny-destructive` can always disarm | +| `request-review` | destructive | on the non-SOX default of 0 approvals it lands the MR directly in `approved` -- where `merge` needs nothing more and an armed auto-merge fires | +| `approve` | destructive | the last approval is what a merge (or an armed auto-merge) waits for | +| `resolve` | destructive | removes the blocker a merge is waiting on | +| `auto-merge` | destructive | see below; `--strategy none` disarms and rides the same command, same class | +| `create` / `update` / `request-changes` | write | shape the MR without moving it (`request-changes` moves it *away* from approved) | -So an agent run with `--deny-destructive` can open, review, inspect and resolve, and **cannot -complete a merge by any route** -- direct or armed. Human mode prompts at the two decision -points (`merge`, arming); the armed transitions print a warning saying the merge is now -imminent. The escalation is deliberately conservative: on a 2-approval project `request-review` -lands in `in_review` and merges nothing, but the required count is unreadable with a Storage -token (DMD-1969), so every armed operation escalates. +So an agent run with `--deny-destructive` can **observe and shape** merge requests -- list, +inspect, diff, open, retitle, send back -- and **cannot move one** by any route. Human mode +prompts at the two decision points a human must consciously take (`merge`, arming +`auto-merge`); the other destructive commands warn afterwards when the MR turns out to be armed +(read off the write's own result -- no extra request). + +### Auto-merge is its own command + +```bash +kbagent mr auto-merge --project P --strategy immediately # arm +kbagent mr auto-merge --project P --strategy scheduled --at 2026-10-01T09:00:00Z +kbagent mr auto-merge --project P --strategy none # disarm +``` + +A backend scheduler runs every `approved` merge request whose strategy is `immediately` (or +`scheduled` and due) through the **same merge processor** the `merge` command uses -- on its +own, retrying every tick until it lands, with `merge` never called and nothing in the arming +call's response saying so. Arming is therefore a delayed production merge and a consciously +separate step: `create` and `update` do not take an auto-merge flag at all. ## Conflicts @@ -129,7 +143,7 @@ kbagent mr resolve --project P --component-id C --config-id I --resolved @resolv |---|---|---| | `FEATURE_NOT_ENABLED` (exit 5) | project lacks `branches-merge-requests` -- surfaces from ANY command whose target was resolved implicitly, reads included; second wording = SOX project (`protected-default-branch`), which kbagent does not support | enable the feature / use the UI on SOX | | `ACCESS_DENIED` on everything but `list` | scoped Storage token; the detail/conflicts endpoints require an admin identity | use a master token | -| exit 2 `INVALID_ARGUMENT` | both `--merge-request-id` and `--branch`; unknown `--state`/`--take`; `--json` destructive without a target; `update` with no fields; `--auto-merge-at` without `scheduled` | fix the flags | +| exit 2 `INVALID_ARGUMENT` | both `--merge-request-id` and `--branch`; unknown `--state`/`--take`/`--strategy`; `--json` destructive without a target; `update` with no fields; `--at` without `--strategy scheduled` | fix the flags | | `NOT_FOUND` from the resolver | the branch has no merge request | `mr create` | | `MR_NOT_READY_TO_MERGE` (retryable) | merge lock, wrong state, another MR merging | retry | | `STORAGE_JOB_TIMEOUT` (exit 4) | merge ran past 10 min; it continues server-side | poll `mr detail` | diff --git a/src/keboola_agent_cli/commands/_merge_request_common.py b/src/keboola_agent_cli/commands/_merge_request_common.py index 975eebfd..7fee3dbf 100644 --- a/src/keboola_agent_cli/commands/_merge_request_common.py +++ b/src/keboola_agent_cli/commands/_merge_request_common.py @@ -22,9 +22,7 @@ from rich.markup import escape from ..errors import ConfigError, ErrorCode, KeboolaApiError -from ..services.merge_request_service import AUTO_MERGE_DISARMED from ._helpers import ( - check_cli_operation, get_service, map_error_to_exit_code, resolve_branch, @@ -143,7 +141,10 @@ class _Target: ``allowed_actions``). It is always present when the target was resolved from a branch (``find_merge_request_for_branch`` returns it for free) and fetched on demand (``need_row``) when the id was explicit -- one GET via - ``get_merge_request_row``, never the three-call detail. + ``get_merge_request_row``, never the three-call detail. Callers ask for + it only when they render something from it (the merge prompt's title, the + ``branch_from_id`` a result would otherwise lack) -- never to decide + permissions, which are static. """ alias: str @@ -152,17 +153,6 @@ class _Target: branch_id: int | None resolved_from_branch: bool - @property - def auto_merge_strategy(self) -> str: - """``immediately`` | ``scheduled`` | ``none``; ``none`` when unknown.""" - if not self.row: - return AUTO_MERGE_DISARMED - return str(self.row.get("autoMergeStrategy") or AUTO_MERGE_DISARMED) - - @property - def armed(self) -> bool: - return self.auto_merge_strategy != AUTO_MERGE_DISARMED - def _branch_from_row(row: dict[str, Any] | None) -> int | None: raw = ((row or {}).get("branches") or {}).get("branchFromId") @@ -269,7 +259,7 @@ def _coerce_int(value: Any) -> int | None: return None -# -- Destructive-under-json rule and auto-merge escalation ---------------------- +# -- Destructive-under-json rule --------------------------------------------------- def _require_explicit_target_under_json( @@ -281,8 +271,7 @@ def _require_explicit_target_under_json( suggested_id: int | None = None, hint: str | None = None, ) -> None: - """When an invocation resolves to the destructive class, ``--json`` requires - an explicit target. + """A destructive command under ``--json`` requires an explicit target. Every destructive command in kbagent either prompts or is told its target; none relies on the prompt for machine safety (``--json`` implies consent @@ -291,6 +280,10 @@ def _require_explicit_target_under_json( line identifies what gets destroyed. Humans keep the active-branch fallback and get the prompt; a script, which received the id in its previous call's payload, names it. + + Which commands are destructive is a STATIC fact (OPERATION_REGISTRY), so + this runs before any network call -- it never depends on a flag's value + or on the MR's state. """ if not formatter.json_mode or merge_request_id is not None or branch is not None: return @@ -306,61 +299,6 @@ def _require_explicit_target_under_json( ) -def _escalate_if_armed( - ctx: typer.Context, - formatter: Any, - target: _Target, - *, - operation: str, - merge_request_id: int | None, - branch: int | None, -) -> str | None: - """Apply the state-derived destructive escalation for an armed MR. - - Returns the strategy (``immediately`` / ``scheduled``) when the MR is armed - so the caller can say so in its output, or ``None``. Order matters: the - policy check comes first (a denial is the stronger statement -- telling a - denied caller to "pass --merge-request-id" would not help), then the - ``--json`` explicit-target rule. That rule can only fire AFTER resolution - here -- whether the invocation is destructive is only known from the - fetched row; the check cannot move earlier because the information does - not exist earlier. One wasted round trip on the rare path is the price of - a rule with no exceptions. - """ - if not target.armed: - return None - check_cli_operation(ctx, f"merge-request.{operation} --auto-merge-armed") - _require_explicit_target_under_json( - formatter, - merge_request_id=merge_request_id, - branch=branch, - reason=( - f"Merge request #{target.merge_request_id} has auto-merge armed " - f"({target.auto_merge_strategy}), so `{operation}` will cause a production merge." - ), - suggested_id=target.merge_request_id, - ) - return target.auto_merge_strategy - - -def _warn_armed(formatter: Any, strategy: str, result: dict[str, Any]) -> None: - """Say what an armed MR means right now -- human mode only, never injected - into the payload (Layer 1 does not manufacture data the service did not - produce; a --json consumer reads `autoMergeStrategy` off the row). Phrased - from the resulting state: approved -> the backend merges on its next tick; - anything else -> it will, the moment the MR is approved.""" - state = str(result.get("state") or "") - when = ( - "the backend will merge it into production on its next tick" - if state == "approved" - else "the backend will merge it into production as soon as it is approved" - ) - formatter.warning( - f"Auto-merge is armed ({strategy}) -- {when}. Disarm with " - "`merge-request update --auto-merge-strategy none` if that is not intended." - ) - - # -- Output helpers ---------------------------------------------------------------- @@ -388,7 +326,9 @@ def _hint_from_actions(formatter: Any, result: dict[str, Any]) -> None: def _print_row_success(formatter: Any, result: dict[str, Any], headline: str) -> None: def render(c: Any, d: dict[str, Any]) -> None: - state = str(d.get("derived_state") or d.get("state") or "").replace("_", " ") + # derived_state is wire-controlled once DMD-1988 serialises it (derive_state + # returns the server field verbatim) -- escape like every other wire string. + state = escape(str(d.get("derived_state") or d.get("state") or "").replace("_", " ")) c.print(f"[bold green]Success:[/bold green] {headline} -- state: {state}") formatter.output(result, render) diff --git a/src/keboola_agent_cli/commands/_merge_request_render.py b/src/keboola_agent_cli/commands/_merge_request_render.py index 0de10fdb..c453dc09 100644 --- a/src/keboola_agent_cli/commands/_merge_request_render.py +++ b/src/keboola_agent_cli/commands/_merge_request_render.py @@ -359,6 +359,18 @@ def _deleted_side_message(data: dict[str, Any]) -> str | None: This is the one place the user faces a binary choice the command already knows, so the sentence recommends the resolution.""" ours, theirs = data.get("ours_deleted"), data.get("theirs_deleted") + # `None` means the side does not exist at all -- which a conflict should + # never produce (it requires the config on both sides). It must be checked + # FIRST: `theirs is True and not ours` is also true for ours=None, and + # would then claim "your branch changed it" and recommend `--take ours` + # -- which resolve_conflict collapses into the DELETE resolution on a + # missing side. Defensive wording, no recommendation, for both. + if ours is None and theirs is None: + return "This configuration is not present on either side." + if ours is None: + return "This configuration is not present in the development branch." + if theirs is None: + return "This configuration is not present on the production side." if theirs is True and not ours: return ( "[red]Production deleted this configuration; your branch changed it.[/red]\n" @@ -373,13 +385,6 @@ def _deleted_side_message(data: dict[str, Any]) -> str | None: ) if ours is True and theirs is True: return "Both sides deleted this configuration -- there is nothing to reconcile." - # A None flag means the side does not exist at all, which a conflict should - # never produce (it requires the config on both sides): render defensively, - # no recommendation. - if theirs is None: - return "This configuration is not present on the production side." - if ours is None: - return "This configuration is not present in the development branch." return None diff --git a/src/keboola_agent_cli/commands/_merge_request_writes.py b/src/keboola_agent_cli/commands/_merge_request_writes.py index 026cf26d..5833fcb3 100644 --- a/src/keboola_agent_cli/commands/_merge_request_writes.py +++ b/src/keboola_agent_cli/commands/_merge_request_writes.py @@ -1,12 +1,25 @@ """Write commands of the ``kbagent merge-request`` group. ``create`` / ``update`` / ``request-review`` / ``approve`` / ``request-changes`` / -``merge`` / ``resolve`` -- split out of ``merge_request.py`` when the group -crossed the 800-code-line soft ceiling. Mounted flat onto the group's Typer app -via :func:`register`, so permission keys stay in the ``merge-request.*`` -namespace and ``--help`` lists them with the reads (precedent: -``_storage_describe.register``). Shared machinery -- target resolution, the one -error handler, the destructive-under-json rule, the auto-merge escalation -- +``merge`` / ``resolve`` / ``auto-merge`` -- split out of ``merge_request.py`` when +the group crossed the 800-code-line soft ceiling. Mounted flat onto the group's +Typer app via :func:`register`, so permission keys stay in the ``merge-request.*`` +namespace and ``--help`` lists them with the reads. Registration goes through +Typer's public ``app.command(name)(fn)``; ``_storage_describe.register`` reaches +the same flat mount by declaring its commands inside ``register`` -- either way, +no Typer internals. + +Every command here is in one of two static classes and behaves accordingly: + +- **write** (``create``, ``update``, ``request-changes``): resolve the target, + call the service, report. No prompt, no target rule. +- **destructive** (``request-review``, ``approve``, ``resolve``, ``merge``, + ``auto-merge``): the ``--json`` explicit-target rule runs FIRST, before any + network call, because the class is known from the command name alone; the + two that a human must consciously choose (``merge``, arming ``auto-merge``) + additionally prompt in human mode. + +Shared machinery -- target resolution, the one error handler, the target rule -- lives in ``_merge_request_common.py``; this module only decides what each write asks, confirms, and says afterwards. Design record: ``docs/merge-requests-layer1.md``. """ @@ -18,14 +31,16 @@ import typer from rich.markup import escape +from ..constants import MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH, MERGE_REQUEST_REASON_MAX_LENGTH from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..services.merge_request_service import ( + AUTO_MERGE_DISARMED, + AUTO_MERGE_STRATEGIES, TAKE_MODES, arms_auto_merge, validate_auto_merge_flags, ) from ._helpers import ( - check_cli_operation, get_formatter, get_service, parse_json_arg, @@ -37,7 +52,6 @@ _MERGE_REQUEST_ID_OPT, _PROJECT_OPT, _emit_warnings, - _escalate_if_armed, _handle_error, _hint_from_actions, _hint_next, @@ -46,14 +60,8 @@ _resolve_target, _stamp_target, _usage_error, - _warn_armed, ) -writes_app = typer.Typer() - - -_REASON_MAX_LENGTH = 1000 # MergeRequestRejectRequest::REASON_MAX_LENGTH, server-side cap - _YES_OPT = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt") _TITLE_OPT = typer.Option(None, "--title", help="Merge request title") _DESCRIPTION_OPT = typer.Option( @@ -67,34 +75,13 @@ "given set REPLACES the current reviewers -- it never appends" ), ) -_AUTO_MERGE_STRATEGY_OPT = typer.Option( - None, - "--auto-merge-strategy", - help=( - "immediately | scheduled | none. ARMING (immediately/scheduled) is a destructive " - "operation: once the merge request is approved, the backend merges it into " - "production on its own -- no `merge` call involved. `none` disarms" - ), -) -_AUTO_MERGE_AT_OPT = typer.Option( - None, - "--auto-merge-at", - help="When to auto-merge (ISO 8601); required with --auto-merge-strategy scheduled", -) _EXTERNAL_ID_OPT = typer.Option( - None, "--external-id", help="Free-form correlation id, e.g. a ticket (max 255 chars)" + None, + "--external-id", + help=f"Free-form correlation id, e.g. a ticket (max {MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH} chars)", ) -def _validate_auto_merge_flags(formatter: Any, strategy: str | None, at: str | None) -> bool: - """Exit 2 on a bad strategy or pairing (the service owns the vocabulary and - the rule, so the router and the CLI cannot drift); return whether the flags ARM.""" - problem = validate_auto_merge_flags(strategy, at) - if problem: - _usage_error(formatter, problem) - return arms_auto_merge(strategy) - - def _confirm_or_abort(formatter: Any, yes: bool, question: str) -> None: """The house prompt shape: skipped by --yes and in --json (where consent is implied and the explicit-target rule stands in for it).""" @@ -105,15 +92,31 @@ def _confirm_or_abort(formatter: Any, yes: bool, question: str) -> None: raise typer.Exit(code=0) -def _arming_question(strategy: str, at: str | None, *, subject: str) -> str: - when = f" at {at}" if at else "" - return ( - f"Arm auto-merge ({strategy}{when}) on {subject}? Once it is approved, the backend " - "will merge it into production automatically -- without a `merge` call. Continue?" +def _warn_if_armed(formatter: Any, result: dict[str, Any]) -> None: + """After a destructive transition: if the MR is armed for auto-merge, say what + that means right now. Read off the RESULT the service returned (the enriched + row carries ``autoMergeStrategy``) -- no extra GET, no payload injection; + a --json consumer reads the same field. Phrased from the resulting state: + approved -> the backend merges on its next tick; anything else -> it will, + the moment the MR is approved.""" + strategy = result.get("autoMergeStrategy") + if not arms_auto_merge(strategy): + return + state = str(result.get("state") or "") + when = ( + "the backend will merge it into production on its next tick" + if state == "approved" + else "the backend will merge it into production as soon as it is approved" ) + formatter.warning( + f"Auto-merge is armed ({strategy}) -- {when}. Disarm with " + f"`merge-request auto-merge --strategy {AUTO_MERGE_DISARMED}` if that is not intended." + ) + + +# -- Writes: shape the MR without moving it ------------------------------------------- -@writes_app.command("create") def merge_request_create( ctx: typer.Context, project: str | None = _PROJECT_OPT, @@ -125,31 +128,17 @@ def merge_request_create( ), description: str | None = _DESCRIPTION_OPT, reviewer_id: list[int] | None = _REVIEWER_OPT, - auto_merge_strategy: str | None = _AUTO_MERGE_STRATEGY_OPT, - auto_merge_at: str | None = _AUTO_MERGE_AT_OPT, external_id: str | None = _EXTERNAL_ID_OPT, - yes: bool = _YES_OPT, ) -> None: """Open a merge request from a development branch into production. The target is always the default branch; the source is --branch or the active branch. A branch can have one merge request, ever. On a non-SOX project with 0 required approvals you can `merge` straight from here -- - no `request-review` needed. + no `request-review` needed. Auto-merge is a separate, destructive step: + `merge-request auto-merge`. """ formatter = get_formatter(ctx) - arming = _validate_auto_merge_flags(formatter, auto_merge_strategy, auto_merge_at) - if arming: - # Arming IS a (delayed) production merge: destructive, and under - # --json it must name its target -- here the source branch. - check_cli_operation(ctx, "merge-request.create --auto-merge-strategy") - _require_explicit_target_under_json( - formatter, - merge_request_id=None, - branch=branch, - reason="--auto-merge-strategy arms an automatic production merge.", - hint="--branch", - ) service = get_service(ctx, "merge_request_service") try: alias = resolve_project_alias(ctx, formatter, project) @@ -164,24 +153,12 @@ def merge_request_create( error_code=ErrorCode.CONFIG_ERROR, ) raise typer.Exit(code=5) - if arming: - _confirm_or_abort( - formatter, - yes, - _arming_question( - str(auto_merge_strategy), - auto_merge_at, - subject=f"the new merge request from branch {branch_id}", - ), - ) result = service.create_merge_request( alias, branch_from_id=branch_id, title=title, description=description, reviewer_ids=reviewer_id or None, # never [] -- that REPLACES the set with nothing - auto_merge_strategy=auto_merge_strategy, - auto_merge_at=auto_merge_at, external_id=external_id, ) except (ConfigError, KeboolaApiError) as exc: @@ -189,8 +166,6 @@ def merge_request_create( result.setdefault("merge_request_id", result.get("id")) result.setdefault("resolved_from_branch", branch is None) - if arming: - _warn_armed(formatter, str(auto_merge_strategy), result) _print_row_success( formatter, result, @@ -200,7 +175,6 @@ def merge_request_create( _hint_from_actions(formatter, result) -@writes_app.command("update") def merge_request_update( ctx: typer.Context, project: str | None = _PROJECT_OPT, @@ -209,38 +183,19 @@ def merge_request_update( title: str | None = _TITLE_OPT, description: str | None = _DESCRIPTION_OPT, reviewer_id: list[int] | None = _REVIEWER_OPT, - auto_merge_strategy: str | None = _AUTO_MERGE_STRATEGY_OPT, - auto_merge_at: str | None = _AUTO_MERGE_AT_OPT, external_id: str | None = _EXTERNAL_ID_OPT, - yes: bool = _YES_OPT, ) -> None: - """Change a merge request's title, description, reviewers, auto-merge or external id. + """Change a merge request's title, description, reviewers or external id. Omitted fields stay as they are; an empty string clears --description / - --external-id. --reviewer-id replaces the whole reviewer set. + --external-id. --reviewer-id replaces the whole reviewer set. Auto-merge + is not a field here -- it is the destructive `merge-request auto-merge`. """ formatter = get_formatter(ctx) - fields = ( - title, - description, - reviewer_id or None, - auto_merge_strategy, - auto_merge_at, - external_id, - ) - if all(f is None for f in fields): + if all(f is None for f in (title, description, reviewer_id or None, external_id)): # PUT {} is a server-side no-op that answers 200 -- refuse instead of # reporting success having changed nothing. _usage_error(formatter, "Nothing to update: pass at least one field flag.") - arming = _validate_auto_merge_flags(formatter, auto_merge_strategy, auto_merge_at) - if arming: - check_cli_operation(ctx, "merge-request.update --auto-merge-strategy") - _require_explicit_target_under_json( - formatter, - merge_request_id=merge_request_id, - branch=branch, - reason="--auto-merge-strategy arms an automatic production merge.", - ) service = get_service(ctx, "merge_request_service") try: target = _resolve_target( @@ -251,16 +206,6 @@ def merge_request_update( branch=branch, need_row=False, ) - if arming: - _confirm_or_abort( - formatter, - yes, - _arming_question( - str(auto_merge_strategy), - auto_merge_at, - subject=f"merge request #{target.merge_request_id}", - ), - ) result = _stamp_target( service.update_merge_request( target.alias, @@ -268,8 +213,6 @@ def merge_request_update( title=title, description=description, reviewer_ids=reviewer_id or None, - auto_merge_strategy=auto_merge_strategy, - auto_merge_at=auto_merge_at, external_id=external_id, ), target, @@ -277,31 +220,39 @@ def merge_request_update( except (ConfigError, KeboolaApiError) as exc: _handle_error(formatter, exc) - if arming: - _warn_armed(formatter, str(auto_merge_strategy), result) _print_row_success(formatter, result, f"Updated merge request #{target.merge_request_id}") _emit_warnings(formatter, result) _hint_from_actions(formatter, result) +# -- Transitions ---------------------------------------------------------------------------- + + def _transition( ctx: typer.Context, *, - operation: str, + destructive_reason: str | None, project: str | None, merge_request_id: int | None, branch: int | None, - escalate_when_armed: bool, call: Any, headline: str, ) -> None: """Shared body of request-review / approve / request-changes. - ``escalate_when_armed`` is True for the two that move an MR toward - ``approved`` (what an armed auto-merge waits for); request-changes moves - it AWAY and deletes approvals, so it never escalates. + ``destructive_reason`` is set for the two that move an MR toward + ``approved`` -- they are in the destructive class, so under ``--json`` the + target must be explicit, checked here BEFORE any network call. + request-changes moves the MR away from approved and is a plain write. """ formatter = get_formatter(ctx) + if destructive_reason: + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason=destructive_reason, + ) service = get_service(ctx, "merge_request_service") try: target = _resolve_target( @@ -310,92 +261,83 @@ def _transition( project=project, merge_request_id=merge_request_id, branch=branch, - need_row=escalate_when_armed, - ) - strategy = ( - _escalate_if_armed( - ctx, - formatter, - target, - operation=operation, - merge_request_id=merge_request_id, - branch=branch, - ) - if escalate_when_armed - else None + need_row=False, ) result = _stamp_target(call(service, target), target) except (ConfigError, KeboolaApiError) as exc: _handle_error(formatter, exc) - if strategy: - _warn_armed(formatter, strategy, result) _print_row_success(formatter, result, headline.format(id=target.merge_request_id)) + if destructive_reason: + _warn_if_armed(formatter, result) _emit_warnings(formatter, result) _hint_from_actions(formatter, result) -@writes_app.command("request-review") def merge_request_request_review( ctx: typer.Context, project: str | None = _PROJECT_OPT, merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, branch: int | None = _BRANCH_OPT, ) -> None: - """Send the merge request for review. + """Send the merge request for review (destructive: it moves the MR toward production). On a non-SOX project with 0 required approvals (the default) the backend finishes the review itself and the merge request lands directly in - `approved` -- so `merge` works straight from `development` and this step - is optional. Note: with no reviewers selected, the review-requested email - goes to every project member. + `approved` -- where an armed auto-merge fires, and `merge` needs nothing + more. `merge` works straight from `development` there, so this step is + optional. Note: with no reviewers selected, the review-requested email + goes to every project member. Under --json the target must be explicit. """ _transition( ctx, - operation="request-review", + destructive_reason=( + "`merge-request request-review` moves the merge request toward production " + "(on a 0-approval project it lands directly in `approved`)." + ), project=project, merge_request_id=merge_request_id, branch=branch, - escalate_when_armed=True, call=lambda s, t: s.request_review(t.alias, t.merge_request_id), headline="Review requested for merge request #{id}", ) -@writes_app.command("approve") def merge_request_approve( ctx: typer.Context, project: str | None = _PROJECT_OPT, merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, branch: int | None = _BRANCH_OPT, ) -> None: - """Add your approval to a merge request under review. + """Add your approval (destructive: the last approval is what a merge waits for). Only possible while the merge request is `in_review`. On a non-SOX project with 0 required approvals (the default) that state is never reached -- `request-review` jumps straight to `approved` -- so this command answers - 422 there. It exists for projects that require approvals. + 422 there. It exists for projects that require approvals. Under --json the + target must be explicit. """ _transition( ctx, - operation="approve", + destructive_reason=( + "`merge-request approve` moves the merge request toward production " + "(the last approval is what a merge -- or an armed auto-merge -- waits for)." + ), project=project, merge_request_id=merge_request_id, branch=branch, - escalate_when_armed=True, call=lambda s, t: s.approve(t.alias, t.merge_request_id), headline="Approved merge request #{id}", ) -@writes_app.command("request-changes") def merge_request_request_changes( ctx: typer.Context, project: str | None = _PROJECT_OPT, merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, branch: int | None = _BRANCH_OPT, reason: str | None = typer.Option( - None, "--reason", help=f"Why (max {_REASON_MAX_LENGTH} characters)" + None, "--reason", help=f"Why (max {MERGE_REQUEST_REASON_MAX_LENGTH} characters)" ), ) -> None: """Send the merge request back to development; existing approvals are removed. @@ -406,23 +348,111 @@ def merge_request_request_changes( resubmitted; deleting the branch is the terminal outcome. """ formatter = get_formatter(ctx) - if reason is not None and len(reason) > _REASON_MAX_LENGTH: + # The service validates the cap (one constant, one rule); this pre-check + # exists only so the flag error carries exit 2 like every other bad flag. + if reason is not None and len(reason) > MERGE_REQUEST_REASON_MAX_LENGTH: _usage_error( - formatter, f"--reason is capped at {_REASON_MAX_LENGTH} characters (got {len(reason)})." + formatter, + f"--reason is capped at {MERGE_REQUEST_REASON_MAX_LENGTH} characters (got {len(reason)}).", ) _transition( ctx, - operation="request-changes", + destructive_reason=None, project=project, merge_request_id=merge_request_id, branch=branch, - escalate_when_armed=False, call=lambda s, t: s.request_changes(t.alias, t.merge_request_id, reason=reason), headline="Changes requested on merge request #{id}", ) -@writes_app.command("merge") +# -- Auto-merge ------------------------------------------------------------------------------ + + +def merge_request_auto_merge( + ctx: typer.Context, + project: str | None = _PROJECT_OPT, + merge_request_id: int | None = _MERGE_REQUEST_ID_OPT, + branch: int | None = _BRANCH_OPT, + strategy: str = typer.Option( + ..., + "--strategy", + help=( + f"{' | '.join(AUTO_MERGE_STRATEGIES)}. `immediately` and `scheduled` ARM: once " + "the merge request is approved, the backend merges it into production on its own " + f"-- no `merge` call involved. `{AUTO_MERGE_DISARMED}` disarms" + ), + ), + at: str | None = typer.Option( + None, "--at", help="When to auto-merge (ISO 8601); required with --strategy scheduled" + ), + yes: bool = _YES_OPT, +) -> None: + """Arm or disarm automatic merging of this merge request (destructive). + + A backend scheduler runs every `approved` merge request whose strategy is + `immediately` (or `scheduled` and due) through the same merge processor + `merge` uses -- on its own, retrying until it lands, with `merge` never + called. Arming is therefore a delayed production merge and its own, + consciously taken step: it is not a flag on `create` or `update`. Under + --json the target must be explicit. + """ + formatter = get_formatter(ctx) + problem = validate_auto_merge_flags(strategy, at) + if problem: + _usage_error(formatter, problem) + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason="`merge-request auto-merge` arms (or disarms) an automatic production merge.", + ) + service = get_service(ctx, "merge_request_service") + try: + target = _resolve_target( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=False, + ) + if arms_auto_merge(strategy): + when = f" at {at}" if at else "" + _confirm_or_abort( + formatter, + yes, + f"Arm auto-merge ({strategy}{when}) on merge request #{target.merge_request_id}? " + "Once it is approved, the backend will merge it into production automatically " + "-- without a `merge` call. Continue?", + ) + result = _stamp_target( + service.update_merge_request( + target.alias, + target.merge_request_id, + auto_merge_strategy=strategy, + auto_merge_at=at, + ), + target, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + verb = "Armed" if arms_auto_merge(strategy) else "Disarmed" + _print_row_success( + formatter, + result, + f"{verb} auto-merge ({strategy}) on merge request #{target.merge_request_id}", + ) + if arms_auto_merge(strategy): + _warn_if_armed(formatter, result) + _emit_warnings(formatter, result) + _hint_from_actions(formatter, result) + + +# -- Merge ------------------------------------------------------------------------------------ + + def merge_request_merge( ctx: typer.Context, project: str | None = _PROJECT_OPT, @@ -438,7 +468,6 @@ def merge_request_merge( explicit: pass --merge-request-id or --branch. """ formatter = get_formatter(ctx) - # Statically destructive: the explicit-target rule applies before any lookup. _require_explicit_target_under_json( formatter, merge_request_id=merge_request_id, @@ -484,9 +513,9 @@ def merge_request_merge( def _render_merge_result(console: Any, data: dict[str, Any]) -> None: console.print(f"[bold green]Success:[/bold green] {escape(str(data['message']))}") if data.get("cleanup_skipped"): - # Keyed on the structured flag (followups F3), never on warning text: - # the local cleanup did NOT run, so active_branch_id and the sync - # mapping may still point at the branch the merge just doomed. + # Keyed on the structured flag, never on warning text: the local + # cleanup did NOT run, so active_branch_id and the sync mapping may + # still point at the branch the merge just doomed. console.print( "[yellow]Local cleanup skipped[/yellow]: the source branch id could not be read " f"({escape(str(data.get('branch_from_id_raw')))}); active branch and sync mapping " @@ -494,7 +523,9 @@ def _render_merge_result(console: Any, data: dict[str, Any]) -> None: ) -@writes_app.command("resolve") +# -- Resolve -------------------------------------------------------------------------------- + + def merge_request_resolve( ctx: typer.Context, project: str | None = _PROJECT_OPT, @@ -523,12 +554,14 @@ def merge_request_resolve( None, "--change-description", help="Version message for the rebased configuration" ), ) -> None: - """Resolve one conflicting configuration by rebasing it onto production's version. + """Resolve one conflicting configuration (destructive: it removes a merge blocker). Every mode replaces the configuration in your branch; the previous content stays in its version history. Rebasing each listed conflict makes the merge - request mergeable -- there is no re-validate step. There is deliberately no - --all: conflicts are meant to be walked, not waved away. + request mergeable -- there is no re-validate step, and on an armed MR the + last resolution is what lets the backend merge. There is deliberately no + --all: conflicts are meant to be walked, not waved away. Under --json the + target must be explicit. """ formatter = get_formatter(ctx) if (take is None) == (resolved is None): @@ -546,6 +579,12 @@ def merge_request_resolve( formatter, "--resolved must be a JSON object (the replaced configuration body)." ) body = parsed + _require_explicit_target_under_json( + formatter, + merge_request_id=merge_request_id, + branch=branch, + reason="`merge-request resolve` removes a blocker the merge is waiting on.", + ) service = get_service(ctx, "merge_request_service") try: target = _resolve_target( @@ -554,17 +593,7 @@ def merge_request_resolve( project=project, merge_request_id=merge_request_id, branch=branch, - need_row=True, - ) - # Resolving the last conflict on an armed, approved MR unblocks the - # scheduler's retry loop -- it causes the merge as surely as approve does. - strategy = _escalate_if_armed( - ctx, - formatter, - target, - operation="resolve", - merge_request_id=merge_request_id, - branch=branch, + need_row=False, ) result = _stamp_target( service.resolve_conflict( @@ -581,8 +610,6 @@ def merge_request_resolve( except (ConfigError, KeboolaApiError) as exc: _handle_error(formatter, exc) - if strategy: - _warn_armed(formatter, strategy, result) formatter.output( result, lambda c, d: c.print( @@ -599,5 +626,16 @@ def merge_request_resolve( def register(app: typer.Typer) -> None: - """Mount the write commands flat onto the group's app (same namespace, same --help).""" - app.registered_commands.extend(writes_app.registered_commands) + """Mount the write commands flat onto the group's app -- same permission + namespace, same --help. Through Typer's PUBLIC API: ``app.command(name)`` is + a decorator factory, applied here to already-defined functions, so no + module-level Typer instance and no private attribute is involved. + """ + app.command("create")(merge_request_create) + app.command("update")(merge_request_update) + app.command("request-review")(merge_request_request_review) + app.command("approve")(merge_request_approve) + app.command("request-changes")(merge_request_request_changes) + app.command("auto-merge")(merge_request_auto_merge) + app.command("merge")(merge_request_merge) + app.command("resolve")(merge_request_resolve) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 5c5ab5d6..f98b374c 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1236,23 +1236,31 @@ Readiness (mergeable / merge_blockers), viewer flags, allowed_actions, feature_enabled, reviewers, approvals, change log (EMPTY until sent for review -- by design), live conflicts. - kbagent merge-request create --title T [--project A] [--branch B] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy immediately|scheduled|none] [--auto-merge-at TS] [--external-id X] [--yes] - Open a merge request from --branch (or the active branch) into production. + kbagent merge-request create --title T [--project A] [--branch B] [--description D] [--reviewer-id ID ...] [--external-id X] + Open a merge request from --branch (or the active branch) into production. Write. - kbagent merge-request update [--project A] [--merge-request-id N | --branch B] [--title T] [--description D] [--reviewer-id ID ...] [--auto-merge-strategy S] [--auto-merge-at TS] [--external-id X] [--yes] + kbagent merge-request update [--project A] [--merge-request-id N | --branch B] [--title T] [--description D] [--reviewer-id ID ...] [--external-id X] Omitted fields stay; "" clears description/external-id; --reviewer-id REPLACES the set. - No fields -> exit 2. + No fields -> exit 2. Write; auto-merge is NOT a field here (see auto-merge). kbagent merge-request request-review [--project A] [--merge-request-id N | --branch B] - On a 0-approval project lands directly in `approved`; `merge` works without it. - With no reviewers selected the email goes to EVERY project member. + DESTRUCTIVE (moves the MR toward production): on a 0-approval project it lands directly + in `approved`, where an armed auto-merge fires. `merge` works without it. With no + reviewers selected the email goes to EVERY project member. --json needs an explicit target. kbagent merge-request approve [--project A] [--merge-request-id N | --branch B] - Only from in_review -- 422 on a 0-approval project (in_review is unreachable there). + DESTRUCTIVE (the last approval is what a merge waits for). Only from in_review -- 422 on + a 0-approval project (in_review is unreachable there). --json needs an explicit target. kbagent merge-request request-changes [--project A] [--merge-request-id N | --branch B] [--reason TEXT] Back to development, approvals removed. Also the closest thing to "close" (no cancel - endpoint; the MR stays in development). --reason max 1000 chars. + endpoint; the MR stays in development). --reason max 1000 chars. Write. + + kbagent merge-request auto-merge --strategy immediately|scheduled|none [--at TS] [--project A] [--merge-request-id N | --branch B] [--yes] + DESTRUCTIVE: immediately/scheduled ARM a backend scheduler that merges the MR into + production on its own once approved -- a delayed production merge, `merge` never called. + `none` disarms (same command, same class). Prompts in human mode when arming; --json needs + an explicit target. Not a flag on create/update -- arming is its own conscious step. kbagent merge-request merge [--project A] [--merge-request-id N | --branch B] [--yes] DESTRUCTIVE: merges into production and deletes the source branch. Blocks up to 10 min. @@ -1267,13 +1275,16 @@ (your content, all five keys) to edit and hand back with `resolve --resolved @PATH`. kbagent merge-request resolve --component-id C --config-id I (--take ours|theirs|delete | --resolved JSON|@file|-) [--project A] [--merge-request-id N | --branch B] [--change-description TEXT] - Rebase one conflicting config onto production's version. Rebase REPLACES: a --resolved - body must carry name, description, isDisabled, configuration, rows. No --all. - - AUTO-MERGE IS DESTRUCTIVE: arming (--auto-merge-strategy immediately|scheduled) makes the - backend merge the MR on its own once approved -- `merge` is never called. create/update - that arm, and request-review/approve/resolve on an already-armed MR, are blocked by - --deny-destructive and need an explicit target under --json. `none` disarms. + DESTRUCTIVE (removes a blocker the merge waits on). Rebase one conflicting config onto + production's version. Rebase REPLACES: a --resolved body must carry name, description, + isDisabled, configuration, rows. No --all. --json needs an explicit target. + + DESTRUCTIVE IS A PROPERTY OF THE COMMAND -- never of a flag or of the MR's state -- so a + policy is evaluated from the command name alone, before any network call. Destructive: + request-review, approve, resolve, merge, auto-merge (anything that moves an MR toward or + into production). Write: create, update, request-changes. --deny-destructive = an agent + that can observe and shape merge requests but never move one. Under --json every + destructive command requires an explicit target (--merge-request-id or --branch). Errors: FEATURE_NOT_ENABLED (exit 5) from any command whose target was resolved implicitly on a project without the feature; MR_MERGE_CONFLICT / MR_NOT_READY_TO_MERGE from merge; a scoped token 403s on everything but list. Every result may carry warnings[]. diff --git a/src/keboola_agent_cli/commands/merge_request.py b/src/keboola_agent_cli/commands/merge_request.py index b8ea2e87..71850909 100644 --- a/src/keboola_agent_cli/commands/merge_request.py +++ b/src/keboola_agent_cli/commands/merge_request.py @@ -12,16 +12,19 @@ (``--branch`` -> ``active_branch_id``) and the service maps it to its MR. A branch has at most one MR ever, so this cannot be ambiguous. See :func:`_resolve_target`. -- **Nothing irreversible happens without a human saying so.** ``merge`` is - destructive; arming auto-merge IS a merge (a backend scheduler runs every - approved MR armed with it through the same MergeProcessor), so arming - escalates ``create``/``update`` to destructive, and ``request-review`` / - ``approve`` / ``resolve`` on an already-armed MR escalate too -- they are - what moves it into ``approved``. Confirmation sits where a human CHOOSES the - outcome (``merge``, arming); escalation wherever one is CAUSED. Under - ``--json`` a destructive invocation must name its target explicitly -- the - prompt is gone there, and no other destructive command in kbagent lets the - command line identify nothing (see :func:`_require_explicit_target_under_json`). +- **Destructive is a property of the command, never of a flag or of the MR's + state.** Anything that moves a merge request toward or into production is + destructive, always: ``merge``, ``request-review`` (on the 0-approval default + it lands directly in ``approved``), ``approve``, ``resolve`` (removes a merge + blocker) and ``auto-merge`` (arms the backend scheduler that merges on its + own -- a delayed production merge; the disarm rides the same command). So a + policy is evaluated from the command name alone, before any network call, + and ``--deny-destructive`` yields an agent that can observe and shape merge + requests (``list``/``detail``/``conflicts``/``diff``/``create``/``update``/ + ``request-changes``) but never move one. Under ``--json`` a destructive + command must name its target explicitly -- the prompt is gone there, and no + other destructive command in kbagent lets the command line identify nothing + (see :func:`_require_explicit_target_under_json`). - **One error handler** (:func:`_handle_error`). ``FeatureNotEnabledError`` is a ``ConfigError`` with its own code and surfaces from the resolver behind every omitted id -- reads included -- exactly where a copied ``except ConfigError -> @@ -299,6 +302,9 @@ def merge_request_diff( json.dumps(candidate, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) except OSError as exc: + # The diff itself succeeded and may carry warnings (an envelope + # hole) -- surface them before failing on the file write. + _emit_warnings(formatter, result) formatter.error( message=f"Cannot write --output {output}: {exc}", error_code=ErrorCode.INVALID_ARGUMENT, diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index eaf4d56a..8861b0f6 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -205,6 +205,11 @@ def _resolve_app_name() -> str: STORAGE_JOB_MAX_WAIT: float = 60.0 # max seconds to wait for a storage job IMPORT_JOB_MAX_WAIT: float = 600.0 # 10 min for table import jobs (large files) MERGE_JOB_MAX_WAIT: float = 600.0 # 10 min for merge-request merge jobs (many-config branches) +# Server-side caps on merge-request fields (MergeRequestRejectRequest::REASON_MAX_LENGTH, +# Assert\Length(max: 255) on externalId in the create/update DTOs). Validated ONCE, in +# MergeRequestService, so the CLI and the serve router cannot drift on the number. +MERGE_REQUEST_REASON_MAX_LENGTH = 1000 +MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH = 255 # --- Workspace Table Loading --- # A workspace load is NOT fire-and-forget: when the local poller gives up, the diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 4ffa3b3f..57304831 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -129,24 +129,33 @@ "branch.metadata-get": "read", "branch.metadata-set": "write", "branch.metadata-delete": "destructive", - # Merge requests (non-SOX Branches 2.0). Reads are ungated on the server; - # `merge` irreversibly deletes the source branch and rewrites production - # (the class `branch.delete` occupies). `resolve` stays write despite - # replacing config content: a rebase adds a configuration version, it does - # not destroy the previous one. Five of the writes escalate to destructive - # per invocation via FLAG_ESCALATIONS below -- arming auto-merge IS a - # production merge, just a delayed one (docs/merge-requests-layer1.md). + # Merge requests (non-SOX Branches 2.0). Classification is STATIC -- a + # property of the command, never of a flag or of the MR's state -- so a + # policy can be evaluated from the command name alone, before any network + # call (docs/merge-requests-layer1.md, "What is destructive"). + # Destructive = moves a merge request toward, or into, production: + # `merge` (deletes the source branch, rewrites production); `request-review` + # (on the non-SOX default of 0 approvals it lands the MR directly in + # `approved`, where an armed auto-merge fires); `approve` (the last approval + # is what an armed auto-merge waits for); `resolve` (removes the blocker a + # merge is waiting on); `auto-merge` (arms the backend scheduler that merges + # on its own -- a delayed production merge, and the disarm rides the same + # command). Write = shapes the MR without moving it: `create`, `update` + # (title/description/reviewers/external id -- auto-merge is NOT a field + # here), `request-changes` (moves it AWAY from approved). Reads are + # ungated on the server. "merge-request.list": "read", "merge-request.detail": "read", "merge-request.conflicts": "read", "merge-request.diff": "read", "merge-request.create": "write", "merge-request.update": "write", - "merge-request.request-review": "write", - "merge-request.approve": "write", "merge-request.request-changes": "write", - "merge-request.resolve": "write", + "merge-request.request-review": "destructive", + "merge-request.approve": "destructive", + "merge-request.resolve": "destructive", "merge-request.merge": "destructive", + "merge-request.auto-merge": "destructive", # Serve-only: `GET /merge-requests/{project}/by-branch/{branch_id}` exposes # the branch->MR resolver that the CLI hides behind an omitted # --merge-request-id (there is no active-branch idiom over HTTP). No CLI @@ -421,31 +430,6 @@ # `auth`. FLAG_ESCALATIONS: dict[str, str] = { "auth.logout --remove-projects": "admin", - # A key here is an OPERATION STRING, not necessarily a literal flag: the - # engine looks the string up verbatim (`_matches_pattern`), so a condition - # the command derives from state works exactly like one it reads off a - # flag. The merge-request entries are the proof: - # - # `autoMergeStrategy` is not metadata. A backend scheduler runs every - # `approved` MR armed with it through the same MergeProcessor the merge - # endpoint uses (AutoMergeCandidateRepository.php:44-47, - # AutoMergeTickHandler.php:86) -- polling, retrying every tick until it - # lands. Arming it on create/update is therefore a production merge, just - # a delayed one; and request-review/approve/resolve on an ALREADY-armed MR - # are what move it into `approved`, i.e. what cause the merge. Classifying - # only `merge` as destructive would let `--deny-destructive` be bypassed by - # two write-class commands. `--auto-merge-strategy none` is the disarm and - # must NOT escalate (guard on the value, not the flag's presence), or the - # safety flag would lock the hazard in place. Deliberately conservative: - # on a 2-approval project request-review lands in in_review and merges - # nothing, but the required count is unreadable with a Storage token - # (DMD-1969), so every armed operation escalates. See - # docs/merge-requests-layer1.md, "Auto-merge is a destructive act". - "merge-request.create --auto-merge-strategy": "destructive", - "merge-request.update --auto-merge-strategy": "destructive", - "merge-request.request-review --auto-merge-armed": "destructive", - "merge-request.approve --auto-merge-armed": "destructive", - "merge-request.resolve --auto-merge-armed": "destructive", } # Operations that exist ONLY on the `kbagent serve` REST surface. They are real diff --git a/src/keboola_agent_cli/server/_serve_command_map.py b/src/keboola_agent_cli/server/_serve_command_map.py index 725323eb..42eb22bd 100644 --- a/src/keboola_agent_cli/server/_serve_command_map.py +++ b/src/keboola_agent_cli/server/_serve_command_map.py @@ -282,6 +282,7 @@ ("PUT", "/branches/{project}/metadata/{key}"): "branch metadata-set", ("PUT", "/configs/{project}/{component_id}/{config_id}/metadata/{key}"): "config set-metadata", ("PUT", "/merge-requests/{project}/{merge_request_id}"): "merge-request update", + ("PUT", "/merge-requests/{project}/{merge_request_id}/auto-merge"): "merge-request auto-merge", ("PUT", "/configs/{project}/{component_id}/{config_id}/state"): "config state-set", ("PUT", "/configs/{project}/{component_id}/{config_id}/variables"): "config variables-set", ("PUT", "/data-apps/{project}/{app_id}/secrets"): "data-app secrets-set", diff --git a/src/keboola_agent_cli/server/routers/merge_requests.py b/src/keboola_agent_cli/server/routers/merge_requests.py index 95dc9083..0ff80705 100644 --- a/src/keboola_agent_cli/server/routers/merge_requests.py +++ b/src/keboola_agent_cli/server/routers/merge_requests.py @@ -10,13 +10,12 @@ **Every route enforces the permission policy** (``Depends(require_permission)``), which most routers do not yet do. Here it is not optional: the CLI classifies -``merge`` as destructive and escalates arming auto-merge and the transitions -on an armed MR to destructive too (``permissions.FLAG_ESCALATIONS``); without -the same checks over HTTP that whole analysis would be decorative for -``serve`` callers. The static class is a route dependency; the state/flag- -derived escalations are evaluated in the route body, where the request body -(and, via one row GET, the MR's ``autoMergeStrategy``) is known. Design -record: ``docs/merge-requests-layer1.md``. +``merge``, ``request-review``, ``approve``, ``resolve`` and ``auto-merge`` as +destructive -- statically, by command, never by flag or MR state -- and +without the same checks over HTTP that classification would be decorative +for ``serve`` callers. Because the class is static, the route dependency is +the whole check; nothing is evaluated from the request body or a prior GET. +Design record: ``docs/merge-requests-layer1.md``. """ from __future__ import annotations @@ -27,20 +26,15 @@ from pydantic import BaseModel from ...errors import ErrorCode, KeboolaApiError -from ...permissions import PermissionEngine from ...services.merge_request_service import ( - AUTO_MERGE_DISARMED, STATE_FILTER_VOCABULARY, TAKE_MODES, - arms_auto_merge, validate_auto_merge_flags, ) -from ..dependencies import ServiceRegistry, get_permission_engine, get_registry, require_permission +from ..dependencies import ServiceRegistry, get_registry, require_permission router = APIRouter(prefix="/merge-requests", tags=["merge-requests"]) -_REASON_MAX_LENGTH = 1000 # MergeRequestRejectRequest::REASON_MAX_LENGTH, same cap as the CLI - def _perm(operation: str) -> Any: return Depends(require_permission(f"merge-request.{operation}")) @@ -54,30 +48,6 @@ def _invalid(message: str) -> KeboolaApiError: ) -def _arming(strategy: str | None, at: str | None) -> bool: - """400 on a bad strategy / pairing (the service owns the rule, so the CLI and - this router cannot drift); return whether the body ARMS auto-merge.""" - problem = validate_auto_merge_flags(strategy, at) - if problem: - raise _invalid(problem) - return arms_auto_merge(strategy) - - -def _escalate_if_armed( - registry: ServiceRegistry, - engine: PermissionEngine, - project: str, - merge_request_id: int, - operation: str, -) -> None: - """request-review / approve / resolve on an MR armed for auto-merge cause a - production merge; apply the same state-derived escalation the CLI does. - One row GET, never the three-call detail.""" - row = registry.merge_request.get_merge_request_row(project, merge_request_id) - if (row.get("autoMergeStrategy") or AUTO_MERGE_DISARMED) != AUTO_MERGE_DISARMED: - engine.check_or_raise(f"merge-request.{operation} --auto-merge-armed") - - # -- Bodies ------------------------------------------------------------------------------------ @@ -86,8 +56,6 @@ class MergeRequestCreate(BaseModel): title: str description: str | None = None reviewer_ids: list[int] | None = None - auto_merge_strategy: str | None = None - auto_merge_at: str | None = None external_id: str | None = None @@ -95,11 +63,14 @@ class MergeRequestUpdate(BaseModel): title: str | None = None description: str | None = None reviewer_ids: list[int] | None = None - auto_merge_strategy: str | None = None - auto_merge_at: str | None = None external_id: str | None = None +class AutoMerge(BaseModel): + strategy: str + at: str | None = None + + class RequestChanges(BaseModel): reason: str | None = None @@ -196,20 +167,15 @@ def create_merge_request( project: str, body: MergeRequestCreate, registry: ServiceRegistry = Depends(get_registry), - engine: PermissionEngine = Depends(get_permission_engine), ) -> dict[str, Any]: - """Open a merge request from a dev branch into production. Arming auto-merge - is destructive (a delayed production merge). Mirrors `kbagent merge-request create`.""" - if _arming(body.auto_merge_strategy, body.auto_merge_at): - engine.check_or_raise("merge-request.create --auto-merge-strategy") + """Open a merge request from a dev branch into production. Auto-merge is a + separate, destructive route (PUT .../auto-merge). Mirrors `kbagent merge-request create`.""" return registry.merge_request.create_merge_request( project, branch_from_id=body.branch_from_id, title=body.title, description=body.description, reviewer_ids=body.reviewer_ids, - auto_merge_strategy=body.auto_merge_strategy, - auto_merge_at=body.auto_merge_at, external_id=body.external_id, ) @@ -224,36 +190,44 @@ def update_merge_request( merge_request_id: int, body: MergeRequestUpdate, registry: ServiceRegistry = Depends(get_registry), - engine: PermissionEngine = Depends(get_permission_engine), ) -> dict[str, Any]: """Omitted fields stay; an empty string clears description/external_id; - reviewer_ids replaces the set. Mirrors `kbagent merge-request update`.""" - if all( - v is None - for v in ( - body.title, - body.description, - body.reviewer_ids, - body.auto_merge_strategy, - body.auto_merge_at, - body.external_id, - ) - ): + reviewer_ids replaces the set. Auto-merge is not a field here (PUT + .../auto-merge). Mirrors `kbagent merge-request update`.""" + if all(v is None for v in (body.title, body.description, body.reviewer_ids, body.external_id)): raise _invalid("Nothing to update: pass at least one field.") - if _arming(body.auto_merge_strategy, body.auto_merge_at): - engine.check_or_raise("merge-request.update --auto-merge-strategy") return registry.merge_request.update_merge_request( project, merge_request_id, title=body.title, description=body.description, reviewer_ids=body.reviewer_ids, - auto_merge_strategy=body.auto_merge_strategy, - auto_merge_at=body.auto_merge_at, external_id=body.external_id, ) +@router.put( + "/{project}/{merge_request_id}/auto-merge", + summary="Arm or disarm auto-merge", + dependencies=[_perm("auto-merge")], +) +def set_auto_merge( + project: str, + merge_request_id: int, + body: AutoMerge, + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """DESTRUCTIVE: `immediately`/`scheduled` arm a backend scheduler that merges the + MR into production on its own once approved -- a delayed production merge. + `none` disarms (same route, same class). Mirrors `kbagent merge-request auto-merge`.""" + problem = validate_auto_merge_flags(body.strategy, body.at) + if problem: + raise _invalid(problem) + return registry.merge_request.update_merge_request( + project, merge_request_id, auto_merge_strategy=body.strategy, auto_merge_at=body.at + ) + + @router.post( "/{project}/{merge_request_id}/request-review", summary="Send for review", @@ -263,11 +237,9 @@ def request_review( project: str, merge_request_id: int, registry: ServiceRegistry = Depends(get_registry), - engine: PermissionEngine = Depends(get_permission_engine), ) -> dict[str, Any]: - """On a 0-approval project this lands directly in `approved`. Destructive - when the MR is armed for auto-merge. Mirrors `kbagent merge-request request-review`.""" - _escalate_if_armed(registry, engine, project, merge_request_id, "request-review") + """DESTRUCTIVE: moves the MR toward production -- on a 0-approval project it + lands directly in `approved`. Mirrors `kbagent merge-request request-review`.""" return registry.merge_request.request_review(project, merge_request_id) @@ -278,11 +250,9 @@ def approve( project: str, merge_request_id: int, registry: ServiceRegistry = Depends(get_registry), - engine: PermissionEngine = Depends(get_permission_engine), ) -> dict[str, Any]: - """Only from `in_review`; 422 on a 0-approval project. Destructive when the - MR is armed for auto-merge. Mirrors `kbagent merge-request approve`.""" - _escalate_if_armed(registry, engine, project, merge_request_id, "approve") + """DESTRUCTIVE: the last approval is what a merge waits for. Only from + `in_review`; 422 on a 0-approval project. Mirrors `kbagent merge-request approve`.""" return registry.merge_request.approve(project, merge_request_id) @@ -299,9 +269,8 @@ def request_changes( ) -> dict[str, Any]: """Back to development, approvals removed; also the closest thing to closing. Mirrors `kbagent merge-request request-changes`.""" + # The cap is validated in the service (one constant, one rule); INVALID_ARGUMENT -> 400. reason = body.reason if body else None - if reason is not None and len(reason) > _REASON_MAX_LENGTH: - raise _invalid(f"reason is capped at {_REASON_MAX_LENGTH} characters (got {len(reason)}).") return registry.merge_request.request_changes(project, merge_request_id, reason=reason) @@ -331,16 +300,14 @@ def resolve_conflict( config_id: str, body: ResolveConflict, registry: ServiceRegistry = Depends(get_registry), - engine: PermissionEngine = Depends(get_permission_engine), ) -> dict[str, Any]: - """Exactly one of `take` (ours|theirs|delete) or `resolved` (the full replaced - body -- start from the diff's `resolution_candidate`). Destructive when the - MR is armed for auto-merge. Mirrors `kbagent merge-request resolve`.""" + """DESTRUCTIVE: removes a blocker the merge is waiting on. Exactly one of `take` + (ours|theirs|delete) or `resolved` (the full replaced body -- start from the + diff's `resolution_candidate`). Mirrors `kbagent merge-request resolve`.""" if (body.take is None) == (body.resolved is None): raise _invalid("Pass exactly one of take (ours|theirs|delete) or resolved.") if body.take is not None and body.take not in TAKE_MODES: raise _invalid(f"Unknown take {body.take!r}: use {', '.join(TAKE_MODES)}.") - _escalate_if_armed(registry, engine, project, merge_request_id, "resolve") return registry.merge_request.resolve_conflict( project, merge_request_id, diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py index 612fe6c1..318c8129 100644 --- a/src/keboola_agent_cli/services/merge_request_service.py +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -26,7 +26,12 @@ from typing import Any from ..client import KeboolaClient -from ..constants import BRANCHES_MERGE_REQUESTS_FEATURE, PROTECTED_DEFAULT_BRANCH_FEATURE +from ..constants import ( + BRANCHES_MERGE_REQUESTS_FEATURE, + MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH, + MERGE_REQUEST_REASON_MAX_LENGTH, + PROTECTED_DEFAULT_BRANCH_FEATURE, +) from ..errors import ConfigError, ErrorCode, FeatureNotEnabledError, KeboolaApiError from ..json_utils import DiffEntry, compute_diff_entries from ..models import ProjectConfig @@ -120,6 +125,19 @@ def validate_auto_merge_flags(strategy: str | None, at: str | None) -> str | Non return None +def _too_long(label: str, value: str | None, cap: int) -> None: + """Refuse a field over its server-side cap with INVALID_ARGUMENT -- the code + app.py maps to HTTP 400 and the CLI pre-checks to exit 2 (one constant, one + rule, two surfaces mapping the result).""" + if value is not None and len(value) > cap: + raise KeboolaApiError( + message=f"{label} is capped at {cap} characters (got {len(value)}).", + status_code=400, + error_code=ErrorCode.INVALID_ARGUMENT, + retryable=False, + ) + + def arms_auto_merge(strategy: str | None) -> bool: """True when the value ARMS auto-merge (anything but absent or the disarm).""" return strategy is not None and strategy != AUTO_MERGE_DISARMED @@ -473,7 +491,17 @@ def get_merge_request_row(self, alias: str, merge_request_id: int) -> dict[str, project = self._project(alias) client = self._client_factory(project.stack_url, project.token) try: - mr = client.merge_requests.get(merge_request_id) + try: + mr = client.merge_requests.get(merge_request_id) + except KeboolaApiError as exc: + # A 403 here is byte-identical for "role denied" and "feature + # missing"; only the pre-flight can word the latter. Run it + # LAZILY -- only on the 403 -- so the happy path stays one GET + # (the same treatment find_merge_request_for_branch gives its + # no-match path). If the feature IS there, the 403 was real. + if exc.status_code == 403: + self._require_merge_requests_feature(client) + raise finally: client.close() return {"alias": alias, **_enrich_row(mr)} @@ -570,6 +598,7 @@ def create_merge_request( create+submit alone can end in a production merge and the source branch's deletion. See the notes doc, *Auto-merge*. """ + _too_long("external_id", external_id, MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH) project = self._project(alias) client = self._client_factory(project.stack_url, project.token) try: @@ -620,6 +649,7 @@ def update_merge_request( enough for the backend's auto-merge tick to merge it -- no ``merge()`` call involved (see the notes doc, *Auto-merge*). Not just metadata. """ + _too_long("external_id", external_id, MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH) project = self._project(alias) client = self._client_factory(project.stack_url, project.token) try: @@ -675,6 +705,7 @@ def request_changes( creator on their own MR (rendered as Closed; derived_state mirrors that). ``reason`` is capped at 1000 characters server-side. """ + _too_long("reason", reason, MERGE_REQUEST_REASON_MAX_LENGTH) project = self._project(alias) client = self._client_factory(project.stack_url, project.token) try: diff --git a/tests/test_merge_request_cli.py b/tests/test_merge_request_cli.py index a2e7b199..d6427ded 100644 --- a/tests/test_merge_request_cli.py +++ b/tests/test_merge_request_cli.py @@ -230,6 +230,36 @@ def test_json_mode_does_not_print_the_info_lines(self, tmp_path, service) -> Non assert result.exit_code == 0, result.output _json(result) # stdout is pure JSON + def test_explicit_id_detail_carries_branch_from_id_from_the_payload( + self, tmp_path, service + ) -> None: + # never `branch_from_id: null` beside `branches.branchFromId: 123`. + service.get_merge_request.return_value = _detail() + result = _run( + ["--json", "merge-request", "detail", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert _json(result)["data"]["branch_from_id"] == 123 + + def test_explicit_id_diff_and_conflicts_carry_branch_from_id(self, tmp_path, service) -> None: + service.get_config_diff.return_value = _diff_result() + diff = _run(["--json", *_DIFF_ARGS], _store(tmp_path / "a"), service) + assert _json(diff)["data"]["branch_from_id"] == 123 # from the diff's branch_id + service.list_conflicts.return_value = { + "alias": ALIAS, + "merge_request_id": 7, + "count": 0, + "conflicts": [], + } + conflicts = _run( + ["--json", "merge-request", "conflicts", "--project", ALIAS, "--id", "7"], + _store(tmp_path / "b"), + service, + ) + assert _json(conflicts)["data"]["branch_from_id"] == 123 # via the row tier + service.get_merge_request_row.assert_called_once_with(ALIAS, 7) + # --------------------------------------------------------------------------- # One error handler -- FEATURE_NOT_ENABLED must survive on every command @@ -301,6 +331,70 @@ def test_not_found_from_the_resolver(self, tmp_path, service) -> None: assert result.exit_code == 1 assert _json(result)["error"]["code"] == ErrorCode.NOT_FOUND + @pytest.mark.parametrize( + "args", + [ + ["merge-request", "update", "--project", ALIAS, "--title", "T"], + ["merge-request", "request-review", "--project", ALIAS, "--branch", "123"], + ["merge-request", "approve", "--project", ALIAS, "--branch", "123"], + [ + "merge-request", + "auto-merge", + "--project", + ALIAS, + "--branch", + "123", + "--strategy", + "none", + ], + ["merge-request", "request-changes", "--project", ALIAS], + ["merge-request", "merge", "--project", ALIAS, "--branch", "123"], + [ + "merge-request", + "resolve", + "--project", + ALIAS, + "--branch", + "123", + "--component-id", + "c", + "--config-id", + "1", + "--take", + "ours", + ], + ], + ids=lambda a: a[1], + ) + def test_feature_not_enabled_keeps_its_code_on_every_write( + self, tmp_path, service, args + ) -> None: + # one case per command, as the RFC promised. + service.find_merge_request_for_branch.side_effect = FeatureNotEnabledError("not enabled") + result = _run(["--json", *args], _store(tmp_path, active_branch=123), service) + assert result.exit_code == 5, result.output + assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED + + def test_create_feature_not_enabled_keeps_its_code(self, tmp_path, service) -> None: + service.create_merge_request.side_effect = FeatureNotEnabledError("not enabled") + result = _run( + [ + "--json", + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 5 + assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED + # --------------------------------------------------------------------------- # list @@ -490,6 +584,31 @@ def test_viewer_none_flags_render_nothing(self, tmp_path, service) -> None: ) assert "You:" not in result.output + def test_hint_falls_back_to_the_raw_action_for_unknown_names(self, tmp_path, service) -> None: + # a server-serialised vocabulary (DMD-1988) must not make hint-next vanish. + service.get_merge_request.return_value = _detail(allowed_actions=["requestReview"]) + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert "requestReview" in result.output + + def test_detail_hint_respects_feature_enabled(self, tmp_path, service) -> None: + # never recommend a write that cannot succeed. + service.get_merge_request.return_value = _detail(feature_enabled=False) + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert result.exit_code == 0, result.output + assert "not enabled on this project" in result.output + assert "merge-request merge" not in result.output + + def test_detail_hint_unchanged_when_feature_enabled(self, tmp_path, service) -> None: + service.get_merge_request.return_value = _detail(feature_enabled=True) + result = _run( + ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ) + assert "merge-request merge" in result.output + # --------------------------------------------------------------------------- # conflicts @@ -706,6 +825,62 @@ def test_long_values_elide_unless_full(self, tmp_path, service) -> None: # folded, not cropped: every character of the value reaches the terminal assert full.output.count("x") >= 200 + def test_no_rows_with_a_service_warning_does_not_claim_the_conflict_cleared( + self, tmp_path, service + ) -> None: + service.get_config_diff.return_value = _diff_result( + changes=[], + resolution_candidate=None, + warnings=[ + "The diff's ours side carries no name -- no resolution candidate could be prefilled." + ], + ) + result = _run(_DIFF_ARGS, _store(tmp_path), service) + assert result.exit_code == 0, result.output + assert "cleared" not in result.output + assert "carries no name" in result.output + + +class TestDiffOutput: + """`diff --output PATH` -- the resolution-candidate file.""" + + def test_unwritable_output_path_is_a_readable_exit_2(self, tmp_path, service) -> None: + service.get_config_diff.return_value = _diff_result() + target = tmp_path / "no-such-dir" / "resolved.json" + result = _run(["--json", *_DIFF_ARGS, "--output", str(target)], _store(tmp_path), service) + assert result.exit_code == 2, result.output + assert "Cannot write --output" in _json(result)["error"]["message"] + + def test_output_wording_for_a_backend_envelope_hole(self, tmp_path, service) -> None: + # L3/L5: a null candidate on a NON-deleted side is the service's warning, not "deleted in your branch". + service.get_config_diff.return_value = _diff_result( + resolution_candidate=None, + warnings=[ + "The diff's ours side carries no isDisabled -- no resolution candidate could be prefilled." + ], + ) + result = _run( + ["--json", *_DIFF_ARGS, "--output", str(tmp_path / "r.json")], _store(tmp_path), service + ) + assert result.exit_code == 2 + msg = _json(result)["error"]["message"] + assert "carries no isDisabled" in msg and "deleted in your branch" not in msg + + def test_output_is_utf8_and_a_bracketed_path_does_not_crash(self, tmp_path, service) -> None: + candidate = { + "name": "Příliš žluťoučký kůň", + "description": None, + "isDisabled": False, + "configuration": {}, + "rows": [], + } + service.get_config_diff.return_value = _diff_result(resolution_candidate=candidate) + target = tmp_path / "[x] resolved.json" + result = _run([*_DIFF_ARGS, "--output", str(target)], _store(tmp_path), service) + assert result.exit_code == 0, result.output + assert json.loads(target.read_bytes().decode("utf-8")) == candidate + assert "[x] resolved.json" in result.output + # --------------------------------------------------------------------------- # create @@ -764,369 +939,57 @@ def test_no_branch_anywhere_is_exit_5(self, tmp_path, service) -> None: assert "branch use" in _json(result)["error"]["message"] service.create_merge_request.assert_not_called() - @pytest.mark.parametrize( - "flags", - [ - ["--auto-merge-strategy", "sometimes"], - ["--auto-merge-strategy", "scheduled"], # missing --auto-merge-at - ["--auto-merge-at", "2026-09-04T10:00:00Z"], # at without scheduled - ["--auto-merge-strategy", "immediately", "--auto-merge-at", "2026-09-04T10:00:00Z"], - ], - ) - def test_auto_merge_flag_pairing_is_validated(self, tmp_path, service, flags) -> None: - result = _run( - [ - "--json", - "merge-request", - "create", - "--project", - ALIAS, - "--title", - "T", - "--branch", - "123", - *flags, - ], - _store(tmp_path), - service, - ) - assert result.exit_code == 2, result.output - service.create_merge_request.assert_not_called() - def test_arming_is_destructive_under_deny_destructive(self, tmp_path, service) -> None: +class TestUpdate: + def test_no_fields_is_exit_2(self, tmp_path, service) -> None: result = _run( - [ - "--json", - "--deny-destructive", - "merge-request", - "create", - "--project", - ALIAS, - "--title", - "T", - "--branch", - "123", - "--auto-merge-strategy", - "immediately", - ], + ["--json", "merge-request", "update", "--project", ALIAS, "--id", "7"], _store(tmp_path), service, ) - assert result.exit_code == 6, result.output - service.create_merge_request.assert_not_called() + assert result.exit_code == 2 + service.update_merge_request.assert_not_called() - def test_disarmed_none_is_NOT_destructive(self, tmp_path, service) -> None: - # `none` is the disarm -- escalating it would let --deny-destructive lock - # a dangerous setting in place. - service.create_merge_request.return_value = _created() + def test_empty_string_clears_description(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row() result = _run( [ "--json", - "--deny-destructive", "merge-request", - "create", + "update", "--project", ALIAS, - "--title", - "T", - "--branch", - "123", - "--auto-merge-strategy", - "none", + "--id", + "7", + "--description", + "", ], _store(tmp_path), service, ) assert result.exit_code == 0, result.output + kwargs = service.update_merge_request.call_args.kwargs + assert kwargs["description"] == "" + assert kwargs["reviewer_ids"] is None and kwargs["title"] is None + + +def _merged() -> dict[str, Any]: + return { + "alias": ALIAS, + "merge_request_id": 7, + "branch_from_id": 123, + "was_active": True, + "job": {"id": 1}, + "state": "published", + "derived_state": "merged", + "message": "Merge request 7 merged into production. Source branch 123 is being deleted.", + } - def test_arming_under_json_needs_an_explicit_branch(self, tmp_path, service) -> None: + +class TestMerge: + def test_json_without_a_target_is_exit_2_before_any_lookup(self, tmp_path, service) -> None: result = _run( - [ - "--json", - "merge-request", - "create", - "--project", - ALIAS, - "--title", - "T", - "--auto-merge-strategy", - "immediately", - ], - _store(tmp_path, active_branch=123), - service, - ) - assert result.exit_code == 2 - assert "--branch" in _json(result)["error"]["message"] - service.create_merge_request.assert_not_called() - - def test_arming_prompts_in_human_mode_and_warns_after(self, tmp_path, service) -> None: - service.create_merge_request.return_value = _created(autoMergeStrategy="immediately") - args = [ - "merge-request", - "create", - "--project", - ALIAS, - "--title", - "T", - "--branch", - "123", - "--auto-merge-strategy", - "immediately", - ] - store = _store(tmp_path) - aborted = _run(args, store, service, input="n\n") - assert aborted.exit_code == 0 and "Aborted" in aborted.output - service.create_merge_request.assert_not_called() - confirmed = _run(args, store, service, input="y\n") - assert confirmed.exit_code == 0, confirmed.output - assert "Arm auto-merge" in confirmed.output - assert "Auto-merge is armed (immediately)" in confirmed.output - service.create_merge_request.assert_called_once() - - def test_yes_skips_the_arming_prompt(self, tmp_path, service) -> None: - service.create_merge_request.return_value = _created() - result = _run( - [ - "merge-request", - "create", - "--project", - ALIAS, - "--title", - "T", - "--branch", - "123", - "--auto-merge-strategy", - "immediately", - "--yes", - ], - _store(tmp_path), - service, - ) - assert result.exit_code == 0, result.output - assert "Continue?" not in result.output - - -# --------------------------------------------------------------------------- -# update -# --------------------------------------------------------------------------- - - -class TestUpdate: - def test_no_fields_is_exit_2(self, tmp_path, service) -> None: - result = _run( - ["--json", "merge-request", "update", "--project", ALIAS, "--id", "7"], - _store(tmp_path), - service, - ) - assert result.exit_code == 2 - service.update_merge_request.assert_not_called() - - def test_empty_string_clears_description(self, tmp_path, service) -> None: - service.update_merge_request.return_value = _row() - result = _run( - [ - "--json", - "merge-request", - "update", - "--project", - ALIAS, - "--id", - "7", - "--description", - "", - ], - _store(tmp_path), - service, - ) - assert result.exit_code == 0, result.output - kwargs = service.update_merge_request.call_args.kwargs - assert kwargs["description"] == "" - assert kwargs["reviewer_ids"] is None and kwargs["title"] is None - - def test_arming_on_update_is_destructive_and_needs_explicit_target_under_json( - self, tmp_path, service - ) -> None: - denied = _run( - [ - "--json", - "--deny-destructive", - "merge-request", - "update", - "--project", - ALIAS, - "--id", - "7", - "--auto-merge-strategy", - "immediately", - ], - _store(tmp_path / "a"), - service, - ) - assert denied.exit_code == 6 - implicit = _run( - [ - "--json", - "merge-request", - "update", - "--project", - ALIAS, - "--auto-merge-strategy", - "immediately", - ], - _store(tmp_path / "b", active_branch=123), - service, - ) - assert implicit.exit_code == 2 - service.update_merge_request.assert_not_called() - - def test_disarming_needs_neither(self, tmp_path, service) -> None: - service.update_merge_request.return_value = _row() - result = _run( - [ - "--json", - "--deny-destructive", - "merge-request", - "update", - "--project", - ALIAS, - "--auto-merge-strategy", - "none", - ], - _store(tmp_path, active_branch=123), - service, - ) - assert result.exit_code == 0, result.output - - -# --------------------------------------------------------------------------- -# transitions: request-review / approve / request-changes -# --------------------------------------------------------------------------- - - -class TestTransitions: - def test_request_review_unarmed_is_plain_write(self, tmp_path, service) -> None: - service.request_review.return_value = _row(state="approved", derived_state="approved") - result = _run( - ["--json", "--deny-destructive", "merge-request", "request-review", "--project", ALIAS], - _store(tmp_path, active_branch=123), - service, - ) - assert result.exit_code == 0, result.output - service.request_review.assert_called_once_with(ALIAS, 7) - # implicit path: the row came from find -- no extra fetch - service.get_merge_request_row.assert_not_called() - - @pytest.mark.parametrize("command", ["request-review", "approve"]) - def test_armed_mr_escalates_to_destructive(self, tmp_path, service, command) -> None: - service.find_merge_request_for_branch.return_value = _row(autoMergeStrategy="immediately") - result = _run( - [ - "--json", - "--deny-destructive", - "merge-request", - command, - "--project", - ALIAS, - "--branch", - "123", - ], - _store(tmp_path), - service, - ) - assert result.exit_code == 6, result.output - getattr(service, command.replace("-", "_")).assert_not_called() - - def test_armed_with_explicit_id_fetches_the_row_once(self, tmp_path, service) -> None: - service.get_merge_request_row.return_value = _row(autoMergeStrategy="scheduled") - service.request_review.return_value = _row( - state="approved", derived_state="approved", autoMergeStrategy="scheduled" - ) - result = _run( - ["merge-request", "request-review", "--project", ALIAS, "--id", "7"], - _store(tmp_path), - service, - ) - assert result.exit_code == 0, result.output - service.get_merge_request_row.assert_called_once_with(ALIAS, 7) - service.get_merge_request.assert_not_called() # never the three-call detail - assert "Auto-merge is armed (scheduled)" in result.output - assert "on its next tick" in result.output # state is approved - - def test_armed_implicit_target_under_json_exits_2_after_resolution( - self, tmp_path, service - ) -> None: - # Deliberate: whether the call is destructive is only known from the row. - service.find_merge_request_for_branch.return_value = _row(autoMergeStrategy="immediately") - result = _run( - ["--json", "merge-request", "request-review", "--project", ALIAS], - _store(tmp_path, active_branch=123), - service, - ) - assert result.exit_code == 2 - msg = _json(result)["error"]["message"] - assert "#7" in msg and "--merge-request-id 7" in msg - service.find_merge_request_for_branch.assert_called_once() - service.request_review.assert_not_called() - - def test_request_changes_never_escalates_and_caps_reason(self, tmp_path, service) -> None: - service.find_merge_request_for_branch.return_value = _row(autoMergeStrategy="immediately") - service.request_changes.return_value = _row() - ok = _run( - [ - "--json", - "--deny-destructive", - "merge-request", - "request-changes", - "--project", - ALIAS, - "--reason", - "nope", - ], - _store(tmp_path / "a", active_branch=123), - service, - ) - assert ok.exit_code == 0, ok.output - service.request_changes.assert_called_once_with(ALIAS, 7, reason="nope") - too_long = _run( - [ - "--json", - "merge-request", - "request-changes", - "--project", - ALIAS, - "--id", - "7", - "--reason", - "x" * 1001, - ], - _store(tmp_path / "b"), - service, - ) - assert too_long.exit_code == 2 - - -# --------------------------------------------------------------------------- -# merge -# --------------------------------------------------------------------------- - - -def _merged() -> dict[str, Any]: - return { - "alias": ALIAS, - "merge_request_id": 7, - "branch_from_id": 123, - "was_active": True, - "job": {"id": 1}, - "state": "published", - "derived_state": "merged", - "message": "Merge request 7 merged into production. Source branch 123 is being deleted.", - } - - -class TestMerge: - def test_json_without_a_target_is_exit_2_before_any_lookup(self, tmp_path, service) -> None: - result = _run( - ["--json", "merge-request", "merge", "--project", ALIAS], + ["--json", "merge-request", "merge", "--project", ALIAS], _store(tmp_path, active_branch=123), service, ) @@ -1209,6 +1072,66 @@ def test_merge_conflict_error_passes_through(self, tmp_path, service) -> None: assert result.exit_code == 1 assert _json(result)["error"]["code"] == ErrorCode.MR_MERGE_CONFLICT + def test_json_merge_with_explicit_id_does_not_fetch_the_row(self, tmp_path, service) -> None: + service.merge.return_value = _merged() + result = _run( + ["--json", "merge-request", "merge", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.get_merge_request_row.assert_not_called() + assert _json(result)["data"]["branch_from_id"] == 123 # from merge()'s own result + + def test_human_merge_with_explicit_id_fetches_the_row_for_the_prompt( + self, tmp_path, service + ) -> None: + service.merge.return_value = _merged() + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + input="y\n", + ) + assert result.exit_code == 0, result.output + service.get_merge_request_row.assert_called_once_with(ALIAS, 7) + assert "'Add sales pipeline'" in result.output + + def test_merge_render_keys_on_cleanup_skipped(self, tmp_path, service) -> None: + # the renderer reads the structured flag, never the warning text. + service.merge.return_value = { + **_merged(), + "branch_from_id": None, + "was_active": False, + "cleanup_skipped": True, + "branch_from_id_raw": "0123x", + "message": "Merge request 7 merged into production. Source branch id could not be read; see warnings.", + "warnings": [ + "branchFromId '0123x' is not a numeric branch id -- local cleanup was skipped." + ], + } + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "Local cleanup skipped" in result.output and "0123x" in result.output + assert "branch reset" in result.output and "sync branch-unlink" in result.output + + def test_warning_text_with_markup_does_not_crash(self, tmp_path, service) -> None: + service.merge.return_value = { + **_merged(), + "warnings": ["Post-merge cleanup failed: [/x] bad tag"], + } + result = _run( + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "[/x] bad tag" in result.output + # --------------------------------------------------------------------------- # resolve @@ -1326,44 +1249,43 @@ def test_feature_not_enabled_from_resolve_keeps_its_code(self, tmp_path, service assert result.exit_code == 5 assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED - -class TestDiffOutputErrors: - def test_unwritable_output_path_is_a_readable_exit_2(self, tmp_path, service) -> None: - service.get_config_diff.return_value = _diff_result() - target = tmp_path / "no-such-dir" / "resolved.json" - result = _run(["--json", *_DIFF_ARGS, "--output", str(target)], _store(tmp_path), service) - assert result.exit_code == 2, result.output - assert "Cannot write --output" in _json(result)["error"]["message"] - - -class TestMergeRowFetch: - def test_json_merge_with_explicit_id_does_not_fetch_the_row(self, tmp_path, service) -> None: - service.merge.return_value = _merged() + def test_resolved_pointing_at_a_directory_is_exit_2_not_a_traceback( + self, tmp_path, service + ) -> None: + # OSError from the file read is a usage error. result = _run( - ["--json", "merge-request", "merge", "--project", ALIAS, "--id", "7"], - _store(tmp_path), - service, + ["--json", *_RESOLVE, "--resolved", f"@{tmp_path}"], _store(tmp_path), service ) - assert result.exit_code == 0, result.output - service.get_merge_request_row.assert_not_called() - assert _json(result)["data"]["branch_from_id"] == 123 # from merge()'s own result + assert result.exit_code == 2, result.output + service.resolve_conflict.assert_not_called() - def test_human_merge_with_explicit_id_fetches_the_row_for_the_prompt( + def test_markup_in_wire_ids_does_not_crash_the_resolve_success_line( self, tmp_path, service ) -> None: - service.merge.return_value = _merged() + # the operation already landed server-side; a MarkupError afterwards would report failure. + service.resolve_conflict.return_value = _resolved(component_id="k", config_id="[/x]") result = _run( - ["merge-request", "merge", "--project", ALIAS, "--id", "7"], + [ + "merge-request", + "resolve", + "--project", + ALIAS, + "--id", + "7", + "--component-id", + "k", + "--config-id", + "[/x]", + "--take", + "ours", + ], _store(tmp_path), service, - input="y\n", ) assert result.exit_code == 0, result.output - service.get_merge_request_row.assert_called_once_with(ALIAS, 7) - assert "'Add sales pipeline'" in result.output -class TestMergeConflictDetails: +class TestMergeConflictRender: def _conflict_error(self, truncated: bool) -> KeboolaApiError: details: dict[str, Any] = { "api_error_code": "storage.mergeRequests.validation", @@ -1391,129 +1313,43 @@ def test_json_carries_details_through(self, tmp_path, service) -> None: _store(tmp_path), service, ) - assert result.exit_code == 1 - err = _json(result)["error"] - assert err["details"]["api_error_params_truncated"] is True - assert err["details"]["api_error_params"]["errors"][0]["configurationId"] == "111" - - def test_human_lists_conflicts_and_says_truncated(self, tmp_path, service) -> None: - service.merge.side_effect = self._conflict_error(truncated=True) - result = _run( - ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], - _store(tmp_path), - service, - ) - assert result.exit_code == 1 - assert "keboola.ex-db/111" in result.output - assert "[bold]2" in result.output # escaped, not interpreted - assert "list truncated" in result.output and "merge-request conflicts" in result.output - - def test_human_untruncated_has_no_truncation_line(self, tmp_path, service) -> None: - service.merge.side_effect = self._conflict_error(truncated=False) - result = _run( - ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], - _store(tmp_path), - service, - ) - assert "keboola.wr-db" in result.output - assert "list truncated" not in result.output - - -class TestOpusReviewFollowUps: - """Pins for the Phase-5 self-review findings (docs/merge-requests-layer1.md).""" - - def test_explicit_id_detail_carries_branch_from_id_from_the_payload( - self, tmp_path, service - ) -> None: - # M1: never `branch_from_id: null` beside `branches.branchFromId: 123`. - service.get_merge_request.return_value = _detail() - result = _run( - ["--json", "merge-request", "detail", "--project", ALIAS, "--id", "7"], - _store(tmp_path), - service, - ) - assert _json(result)["data"]["branch_from_id"] == 123 - - def test_explicit_id_diff_and_conflicts_carry_branch_from_id(self, tmp_path, service) -> None: - service.get_config_diff.return_value = _diff_result() - diff = _run(["--json", *_DIFF_ARGS], _store(tmp_path / "a"), service) - assert _json(diff)["data"]["branch_from_id"] == 123 # from the diff's branch_id - service.list_conflicts.return_value = { - "alias": ALIAS, - "merge_request_id": 7, - "count": 0, - "conflicts": [], - } - conflicts = _run( - ["--json", "merge-request", "conflicts", "--project", ALIAS, "--id", "7"], - _store(tmp_path / "b"), - service, - ) - assert _json(conflicts)["data"]["branch_from_id"] == 123 # via the row tier - service.get_merge_request_row.assert_called_once_with(ALIAS, 7) - - def test_armed_warning_is_human_only_and_not_in_the_payload(self, tmp_path, service) -> None: - # L4: Layer 1 does not manufacture payload; --json reads autoMergeStrategy off the row. - service.create_merge_request.return_value = _created(autoMergeStrategy="immediately") - result = _run( - [ - "--json", - "merge-request", - "create", - "--project", - ALIAS, - "--title", - "T", - "--branch", - "123", - "--auto-merge-strategy", - "immediately", - ], - _store(tmp_path), - service, - ) - assert result.exit_code == 0, result.output - assert "warnings" not in _json(result)["data"] + assert result.exit_code == 1 + err = _json(result)["error"] + assert err["details"]["api_error_params_truncated"] is True + assert err["details"]["api_error_params"]["errors"][0]["configurationId"] == "111" - def test_hint_falls_back_to_the_raw_action_for_unknown_names(self, tmp_path, service) -> None: - # M3: a server-serialised vocabulary (DMD-1988) must not make hint-next vanish. - service.get_merge_request.return_value = _detail(allowed_actions=["requestReview"]) + def test_human_lists_conflicts_and_says_truncated(self, tmp_path, service) -> None: + service.merge.side_effect = self._conflict_error(truncated=True) result = _run( - ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, ) - assert "requestReview" in result.output + assert result.exit_code == 1 + assert "keboola.ex-db/111" in result.output + assert "[bold]2" in result.output # escaped, not interpreted + assert "list truncated" in result.output and "merge-request conflicts" in result.output - def test_output_wording_for_a_backend_envelope_hole(self, tmp_path, service) -> None: - # L3/L5: a null candidate on a NON-deleted side is the service's warning, not "deleted in your branch". - service.get_config_diff.return_value = _diff_result( - resolution_candidate=None, - warnings=[ - "The diff's ours side carries no isDisabled -- no resolution candidate could be prefilled." - ], - ) + def test_human_untruncated_has_no_truncation_line(self, tmp_path, service) -> None: + service.merge.side_effect = self._conflict_error(truncated=False) result = _run( - ["--json", *_DIFF_ARGS, "--output", str(tmp_path / "r.json")], _store(tmp_path), service + ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + _store(tmp_path), + service, ) - assert result.exit_code == 2 - msg = _json(result)["error"]["message"] - assert "carries no isDisabled" in msg and "deleted in your branch" not in msg + assert "keboola.wr-db" in result.output + assert "list truncated" not in result.output - def test_resolved_pointing_at_a_directory_is_exit_2_not_a_traceback( - self, tmp_path, service - ) -> None: - # L7: OSError from the file read is a usage error. - result = _run( - ["--json", *_RESOLVE, "--resolved", f"@{tmp_path}"], _store(tmp_path), service - ) - assert result.exit_code == 2, result.output - service.resolve_conflict.assert_not_called() - def test_markup_in_wire_ids_does_not_crash_the_resolve_success_line( - self, tmp_path, service - ) -> None: - # H2: the operation already landed server-side; a MarkupError afterwards would report failure. - service.resolve_conflict.return_value = _resolved(component_id="k", config_id="[/x]") - result = _run( +class TestStaticDestructiveClass: + """Destructive is a property of the command -- never of a flag or of the MR's state. + Checked against the real PermissionEngine (exit 6), not a mocked check.""" + + @pytest.mark.parametrize( + "args", + [ + ["merge-request", "request-review", "--project", ALIAS, "--id", "7"], + ["merge-request", "approve", "--project", ALIAS, "--id", "7"], [ "merge-request", "resolve", @@ -1522,25 +1358,61 @@ def test_markup_in_wire_ids_does_not_crash_the_resolve_success_line( "--id", "7", "--component-id", - "k", + "c", "--config-id", - "[/x]", + "1", "--take", "ours", ], - _store(tmp_path), - service, - ) + ["merge-request", "merge", "--project", ALIAS, "--id", "7"], + [ + "merge-request", + "auto-merge", + "--project", + ALIAS, + "--id", + "7", + "--strategy", + "immediately", + ], + ["merge-request", "auto-merge", "--project", ALIAS, "--id", "7", "--strategy", "none"], + ], + ids=[ + "request-review", + "approve", + "resolve", + "merge", + "auto-merge arm", + "auto-merge disarm", + ], + ) + def test_destructive_commands_are_denied_before_any_call(self, tmp_path, service, args) -> None: + result = _run(["--json", "--deny-destructive", *args], _store(tmp_path), service) + assert result.exit_code == 6, result.output + # denied by the group callback: no service method of any kind was reached + assert not [c for c in service.method_calls if not str(c).startswith("call.__")] + + @pytest.mark.parametrize( + "args", + [ + ["merge-request", "create", "--project", ALIAS, "--title", "T", "--branch", "123"], + ["merge-request", "update", "--project", ALIAS, "--id", "7", "--title", "T"], + ["merge-request", "request-changes", "--project", ALIAS, "--id", "7"], + ], + ids=["create", "update", "request-changes"], + ) + def test_write_commands_pass_under_deny_destructive(self, tmp_path, service, args) -> None: + service.create_merge_request.return_value = _created() + service.update_merge_request.return_value = _row() + service.request_changes.return_value = _row() + result = _run(["--json", "--deny-destructive", *args], _store(tmp_path), service) assert result.exit_code == 0, result.output @pytest.mark.parametrize( "args", [ - ["merge-request", "update", "--project", ALIAS, "--title", "T"], ["merge-request", "request-review", "--project", ALIAS], ["merge-request", "approve", "--project", ALIAS], - ["merge-request", "request-changes", "--project", ALIAS], - ["merge-request", "merge", "--project", ALIAS, "--branch", "123"], [ "merge-request", "resolve", @@ -1553,122 +1425,190 @@ def test_markup_in_wire_ids_does_not_crash_the_resolve_success_line( "--take", "ours", ], + ["merge-request", "auto-merge", "--project", ALIAS, "--strategy", "immediately"], ], - ids=lambda a: a[1], + ids=["request-review", "approve", "resolve", "auto-merge"], ) - def test_feature_not_enabled_keeps_its_code_on_every_write( + def test_destructive_under_json_needs_an_explicit_target_before_any_call( self, tmp_path, service, args ) -> None: - # L2: one case per command, as the RFC promised. - service.find_merge_request_for_branch.side_effect = FeatureNotEnabledError("not enabled") + # The class is known from the command name, so the rule fires BEFORE the + # active-branch fallback would have resolved anything. result = _run(["--json", *args], _store(tmp_path, active_branch=123), service) - assert result.exit_code == 5, result.output - assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED + assert result.exit_code == 2, result.output + assert "explicit target" in _json(result)["error"]["message"] + service.find_merge_request_for_branch.assert_not_called() - def test_create_feature_not_enabled_keeps_its_code(self, tmp_path, service) -> None: - service.create_merge_request.side_effect = FeatureNotEnabledError("not enabled") + def test_transitions_no_longer_fetch_the_row(self, tmp_path, service) -> None: + # Nothing to decide from the MR's state -> no GET before the write. + service.request_review.return_value = _row(state="approved", derived_state="approved") + result = _run( + ["--json", "merge-request", "request-review", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + service.get_merge_request_row.assert_not_called() + service.get_merge_request.assert_not_called() + + def test_request_changes_caps_reason(self, tmp_path, service) -> None: result = _run( [ "--json", "merge-request", - "create", + "request-changes", "--project", ALIAS, - "--title", - "T", - "--branch", - "123", + "--id", + "7", + "--reason", + "x" * 1001, ], _store(tmp_path), service, ) - assert result.exit_code == 5 - assert _json(result)["error"]["code"] == ErrorCode.FEATURE_NOT_ENABLED + assert result.exit_code == 2 + service.request_changes.assert_not_called() -class TestDiffEmptyEnvelope: - def test_no_rows_with_a_service_warning_does_not_claim_the_conflict_cleared( - self, tmp_path, service - ) -> None: - service.get_config_diff.return_value = _diff_result( - changes=[], - resolution_candidate=None, - warnings=[ - "The diff's ours side carries no name -- no resolution candidate could be prefilled." - ], +class TestAutoMerge: + def test_arm_forwards_to_update_and_prompts_in_human_mode(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row(autoMergeStrategy="immediately") + args = [ + "merge-request", + "auto-merge", + "--project", + ALIAS, + "--id", + "7", + "--strategy", + "immediately", + ] + store = _store(tmp_path) + aborted = _run(args, store, service, input="n\n") + assert aborted.exit_code == 0 and "Aborted" in aborted.output + service.update_merge_request.assert_not_called() + confirmed = _run(args, store, service, input="y\n") + assert confirmed.exit_code == 0, confirmed.output + assert "Arm auto-merge" in confirmed.output + assert "Auto-merge is armed (immediately)" in confirmed.output + service.update_merge_request.assert_called_once_with( + ALIAS, 7, auto_merge_strategy="immediately", auto_merge_at=None ) - result = _run(_DIFF_ARGS, _store(tmp_path), service) - assert result.exit_code == 0, result.output - assert "cleared" not in result.output - assert "carries no name" in result.output - -class TestLayer2Followups: - def test_merge_render_keys_on_cleanup_skipped(self, tmp_path, service) -> None: - # F3: the renderer reads the structured flag, never the warning text. - service.merge.return_value = { - **_merged(), - "branch_from_id": None, - "was_active": False, - "cleanup_skipped": True, - "branch_from_id_raw": "0123x", - "message": "Merge request 7 merged into production. Source branch id could not be read; see warnings.", - "warnings": [ - "branchFromId '0123x' is not a numeric branch id -- local cleanup was skipped." - ], - } + def test_disarm_does_not_prompt(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row(autoMergeStrategy="none") result = _run( - ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + ["merge-request", "auto-merge", "--project", ALIAS, "--id", "7", "--strategy", "none"], _store(tmp_path), service, ) assert result.exit_code == 0, result.output - assert "Local cleanup skipped" in result.output and "0123x" in result.output - assert "branch reset" in result.output and "sync branch-unlink" in result.output + assert "Continue?" not in result.output and "Disarmed auto-merge" in result.output + assert "Auto-merge is armed" not in result.output - def test_detail_hint_respects_feature_enabled(self, tmp_path, service) -> None: - # F5: never recommend a write that cannot succeed. - service.get_merge_request.return_value = _detail(feature_enabled=False) + def test_yes_skips_the_prompt(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row(autoMergeStrategy="immediately") result = _run( - ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + [ + "merge-request", + "auto-merge", + "--project", + ALIAS, + "--id", + "7", + "--strategy", + "immediately", + "--yes", + ], + _store(tmp_path), + service, ) assert result.exit_code == 0, result.output - assert "not enabled on this project" in result.output - assert "merge-request merge" not in result.output + assert "Continue?" not in result.output - def test_detail_hint_unchanged_when_feature_enabled(self, tmp_path, service) -> None: - service.get_merge_request.return_value = _detail(feature_enabled=True) + @pytest.mark.parametrize( + "flags", + [ + ["--strategy", "sometimes"], + ["--strategy", "scheduled"], # missing --at + ["--strategy", "immediately", "--at", "2026-09-30T10:00:00Z"], # at without scheduled + ], + ) + def test_strategy_and_at_pairing_is_validated(self, tmp_path, service, flags) -> None: result = _run( - ["merge-request", "detail", "--project", ALIAS, "--id", "7"], _store(tmp_path), service + ["--json", "merge-request", "auto-merge", "--project", ALIAS, "--id", "7", *flags], + _store(tmp_path), + service, ) - assert "merge-request merge" in result.output - + assert result.exit_code == 2, result.output + service.update_merge_request.assert_not_called() -class TestCopilotBalancedFollowUps: - def test_warning_text_with_markup_does_not_crash(self, tmp_path, service) -> None: - service.merge.return_value = { - **_merged(), - "warnings": ["Post-merge cleanup failed: [/x] bad tag"], - } + def test_scheduled_passes_at_through(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row(autoMergeStrategy="scheduled") result = _run( - ["merge-request", "merge", "--project", ALIAS, "--id", "7", "--yes"], + [ + "--json", + "merge-request", + "auto-merge", + "--project", + ALIAS, + "--id", + "7", + "--strategy", + "scheduled", + "--at", + "2026-09-30T10:00:00Z", + ], _store(tmp_path), service, ) assert result.exit_code == 0, result.output - assert "[/x] bad tag" in result.output + assert ( + service.update_merge_request.call_args.kwargs["auto_merge_at"] == "2026-09-30T10:00:00Z" + ) - def test_output_is_utf8_and_a_bracketed_path_does_not_crash(self, tmp_path, service) -> None: - candidate = { - "name": "Příliš žluťoučký kůň", - "description": None, - "isDisabled": False, - "configuration": {}, - "rows": [], - } - service.get_config_diff.return_value = _diff_result(resolution_candidate=candidate) - target = tmp_path / "[x] resolved.json" - result = _run([*_DIFF_ARGS, "--output", str(target)], _store(tmp_path), service) + def test_create_and_update_no_longer_take_auto_merge(self, tmp_path, service) -> None: + for args in ( + [ + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + "--auto-merge-strategy", + "immediately", + ], + [ + "merge-request", + "update", + "--project", + ALIAS, + "--id", + "7", + "--auto-merge-strategy", + "immediately", + ], + ): + result = _run(["--json", *args], _store(tmp_path / args[1]), service) + assert result.exit_code == 2, result.output # Typer: no such option + + def test_armed_transition_warns_from_the_result_without_a_get(self, tmp_path, service) -> None: + # The warning reads autoMergeStrategy off the write's own result. + service.request_review.return_value = _row( + state="approved", derived_state="approved", autoMergeStrategy="immediately" + ) + result = _run( + ["merge-request", "request-review", "--project", ALIAS, "--id", "7"], + _store(tmp_path), + service, + ) assert result.exit_code == 0, result.output - assert json.loads(target.read_bytes().decode("utf-8")) == candidate - assert "[x] resolved.json" in result.output + assert ( + "Auto-merge is armed (immediately)" in result.output + and "on its next tick" in result.output + ) + service.get_merge_request_row.assert_not_called() diff --git a/tests/test_merge_request_service.py b/tests/test_merge_request_service.py index 4a322cba..fbe87b34 100644 --- a/tests/test_merge_request_service.py +++ b/tests/test_merge_request_service.py @@ -1493,10 +1493,9 @@ def test_no_default_branch_is_none(self) -> None: assert find_default_branch_id([{"isDefault": False, "id": 1}]) is None -class TestLayer1RfcWalkFollowUps: - """The three Layer 2 additions 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", 2026-09-03).""" +class TestRowTierAndResolutionCandidate: + """get_merge_request_row (the row tier by id), merge()'s `warnings` key, and the + resolution candidate `get_config_diff` composes for `diff --output`.""" # -- get_merge_request_row ------------------------------------------------- @@ -1645,9 +1644,9 @@ def test_branch_from_id_faults_blame_the_right_party(self, store, client_factory assert garbage.value.error_code == ErrorCode.VALIDATION_ERROR -class TestLayer2FollowupsInheritedByLayer1: - """docs/merge-requests-layer2-followups.md -- the non-blocking leftovers of PR #703 - that go through the Layer 1 PR (F2, F3, F4, F5, F7).""" +class TestDegradationRecordingAndFeatureAwareDetail: + """Envelope holes reported (not silently unclassified), merge()'s structured + degradation flag, the default-branch skip log, and the detail's feature flag.""" def _arm_merge(self, mock: MagicMock, branch_from: Any) -> None: row = _wire_mr(7, "approved") @@ -1655,7 +1654,7 @@ def _arm_merge(self, mock: MagicMock, branch_from: Any) -> None: mock.merge_requests.get.return_value = row mock.merge_requests.merge.return_value = {"id": 1, "status": "success", "results": {}} - # F2 -- the empty-envelope half of the classifier had no test + # the empty-envelope half of the classifier had no test def test_empty_envelope_side_yields_no_rows_and_a_warning(self, store, client_factory) -> None: factory, mock = client_factory mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) @@ -1672,7 +1671,7 @@ def test_empty_envelope_side_yields_no_rows_and_a_warning(self, store, client_fa # the ours side is intact, so the candidate is still composed assert result["resolution_candidate"]["configuration"] == {"limit": 500} - # F3 -- the branch-id degradation is recorded structurally, not in prose only + # the branch-id degradation is recorded structurally, not in prose only def test_non_numeric_branch_from_id_is_recorded_structurally( self, store, client_factory ) -> None: @@ -1693,7 +1692,7 @@ def test_legitimate_null_branch_carries_no_degradation_flag( result = _svc(store, factory).merge(ALIAS, 7) assert "cleanup_skipped" not in result and "branch_from_id_raw" not in result - # F7 -- the positive assertion for the outcome-gated sentence + # the positive assertion for the outcome-gated sentence def test_successful_reset_says_so(self, store, client_factory) -> None: factory, mock = client_factory store.set_project_branch(ALIAS, 123) @@ -1703,7 +1702,7 @@ def test_successful_reset_says_so(self, store, client_factory) -> None: assert "Active branch reset to main." in result["message"] assert store.get_project(ALIAS).active_branch_id is None - # F5 -- the detail tier is feature-aware for free + # the detail tier is feature-aware for free def test_detail_carries_feature_enabled(self, store, client_factory) -> None: factory, mock = client_factory mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) @@ -1714,7 +1713,7 @@ def test_detail_carries_feature_enabled(self, store, client_factory) -> None: # state-derived actions stay as they are -- the CONSUMER gates on the flag assert "merge" in detail["allowed_actions"] - # F4 -- the shared helper logs the skip instead of folding it into None silently + # the shared helper logs the skip instead of folding it into None silently def test_find_default_branch_id_logs_the_skipped_entry(self, caplog) -> None: import logging @@ -1724,7 +1723,7 @@ def test_find_default_branch_id_logs_the_skipped_entry(self, caplog) -> None: assert find_default_branch_id([{"isDefault": True, "id": "main"}]) is None assert any("non-numeric id 'main'" in r.getMessage() for r in caplog.records) - # Copilot (Balanced) on #736: a HOLED envelope must not classify either + # a HOLED envelope must not classify either def test_holed_theirs_envelope_yields_no_rows_and_a_warning( self, store, client_factory ) -> None: @@ -1742,3 +1741,64 @@ def test_holed_theirs_envelope_yields_no_rows_and_a_warning( assert result["changes"] == [] assert any("theirs side carries no configuration" in w for w in result["warnings"]) assert result["resolution_candidate"]["configuration"] == {"limit": 500} # ours intact + + +class TestFieldCapsAndRowTierFeatureGate: + """Server-side caps validated once in the service; the row tier's lazy feature pre-flight.""" + + def test_reason_over_cap_is_refused_before_any_call(self, store, client_factory) -> None: + factory, mock = client_factory + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).request_changes(ALIAS, 7, reason="x" * 1001) + assert exc_info.value.error_code == ErrorCode.INVALID_ARGUMENT + mock.merge_requests.request_changes.assert_not_called() + + def test_external_id_over_cap_is_refused_on_create_and_update( + self, store, client_factory + ) -> None: + factory, mock = client_factory + svc = _svc(store, factory) + with pytest.raises(KeboolaApiError) as create_exc: + svc.create_merge_request(ALIAS, branch_from_id=123, title="t", external_id="x" * 256) + assert create_exc.value.error_code == ErrorCode.INVALID_ARGUMENT + with pytest.raises(KeboolaApiError) as update_exc: + svc.update_merge_request(ALIAS, 7, external_id="x" * 256) + assert update_exc.value.error_code == ErrorCode.INVALID_ARGUMENT + mock.merge_requests.create.assert_not_called() + mock.merge_requests.update.assert_not_called() + + def test_row_403_on_a_featureless_project_becomes_feature_not_enabled( + self, store, client_factory + ) -> None: + # The explicit --merge-request-id path is the one --json callers are + # steered onto; it must not hand them a bare role-denial 403. + factory, mock = client_factory + mock.merge_requests.get.side_effect = KeboolaApiError( + message="Access denied", + status_code=403, + error_code=ErrorCode.ACCESS_DENIED, + retryable=False, + ) + mock.has_feature.return_value = False + with pytest.raises(FeatureNotEnabledError): + _svc(store, factory).get_merge_request_row(ALIAS, 7) + + def test_row_403_with_the_feature_present_is_a_real_denial(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.side_effect = KeboolaApiError( + message="Access denied", + status_code=403, + error_code=ErrorCode.ACCESS_DENIED, + retryable=False, + ) + mock.has_feature.return_value = True + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).get_merge_request_row(ALIAS, 7) + assert exc_info.value.error_code == ErrorCode.ACCESS_DENIED + + def test_row_happy_path_spends_no_feature_call(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development") + _svc(store, factory).get_merge_request_row(ALIAS, 7) + mock.has_feature.assert_not_called() + mock.verify_token.assert_not_called() diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index 49e27c10..ad37b327 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -2684,10 +2684,6 @@ def _mr_client(tmp_path: Path, svc: MagicMock, **app_kwargs: Any) -> TestClient: return TestClient(app) -def _armed_row(strategy: str = "immediately") -> dict[str, Any]: - return {"id": MR_ID, "state": "development", "autoMergeStrategy": strategy} - - def test_merge_request_list_forwards_state_kwarg(tmp_path: Path) -> None: svc = MagicMock() svc.list_merge_requests.return_value = {"count": 0, "merge_requests": []} @@ -2760,8 +2756,6 @@ def test_merge_request_create_forwards_every_kwarg(tmp_path: Path) -> None: title="T", description=None, reviewer_ids=[5], - auto_merge_strategy=None, - auto_merge_at=None, external_id="TCK-1", ) @@ -2836,36 +2830,6 @@ def test_merge_request_merge_is_destructive_over_http(tmp_path: Path) -> None: svc.merge.assert_not_called() -def test_merge_request_arming_auto_merge_is_destructive_over_http(tmp_path: Path) -> None: - svc = MagicMock() - svc.create_merge_request.return_value = {"id": MR_ID} - with _mr_client(tmp_path, svc, deny_destructive=True) as client: - armed = client.post( - f"/merge-requests/{PROJECT}", - json={"branch_from_id": 123, "title": "T", "auto_merge_strategy": "immediately"}, - headers=AUTH, - ) - disarmed = client.post( - f"/merge-requests/{PROJECT}", - json={"branch_from_id": 123, "title": "T", "auto_merge_strategy": "none"}, - headers=AUTH, - ) - assert armed.status_code == 403, armed.text - assert disarmed.status_code == 200, disarmed.text # `none` is the disarm, never escalates - svc.create_merge_request.assert_called_once() - - -def test_merge_request_transition_on_armed_mr_is_destructive_over_http(tmp_path: Path) -> None: - svc = MagicMock() - svc.get_merge_request_row.return_value = _armed_row() - with _mr_client(tmp_path, svc, deny_destructive=True) as client: - res = client.post(f"/merge-requests/{PROJECT}/{MR_ID}/request-review", headers=AUTH) - assert res.status_code == 403, res.text - svc.get_merge_request_row.assert_called_once_with(PROJECT, MR_ID) # the row tier, one GET - svc.get_merge_request.assert_not_called() - svc.request_review.assert_not_called() - - def test_merge_request_reads_pass_under_deny_destructive(tmp_path: Path) -> None: svc = MagicMock() svc.list_merge_requests.return_value = {"count": 0, "merge_requests": []} @@ -2873,8 +2837,15 @@ def test_merge_request_reads_pass_under_deny_destructive(tmp_path: Path) -> None assert client.get(f"/merge-requests/{PROJECT}", headers=AUTH).status_code == 200 -def test_merge_request_request_changes_caps_reason_like_the_cli(tmp_path: Path) -> None: +def test_merge_request_request_changes_cap_comes_from_the_service(tmp_path: Path) -> None: + # One constant, validated once in the service; the router only maps INVALID_ARGUMENT -> 400. svc = MagicMock() + svc.request_changes.side_effect = KeboolaApiError( + message="reason is capped at 1000 characters (got 1001).", + status_code=400, + error_code=ErrorCode.INVALID_ARGUMENT, + retryable=False, + ) with _mr_client(tmp_path, svc) as client: res = client.post( f"/merge-requests/{PROJECT}/{MR_ID}/request-changes", @@ -2882,7 +2853,7 @@ def test_merge_request_request_changes_caps_reason_like_the_cli(tmp_path: Path) headers=AUTH, ) assert res.status_code == 400, res.text - svc.request_changes.assert_not_called() + svc.request_changes.assert_called_once_with(PROJECT, MR_ID, reason="x" * 1001) def test_merge_request_merge_conflict_is_409_with_details_over_http(tmp_path: Path) -> None: @@ -2920,3 +2891,115 @@ def test_merge_request_caller_mistakes_from_the_service_are_400_over_http(tmp_pa headers=AUTH, ) assert res.status_code == 400, res.text + + +# -- route-level escalation coverage for the shared helpers (approve / resolve / update) -- + + +# -- Static destructive class over HTTP: the route dependency IS the whole check -- + + +@pytest.mark.parametrize( + ("method", "path", "body", "service_method"), + [ + ("POST", f"/merge-requests/{PROJECT}/{MR_ID}/request-review", None, "request_review"), + ("POST", f"/merge-requests/{PROJECT}/{MR_ID}/approve", None, "approve"), + ( + "POST", + f"/merge-requests/{PROJECT}/{MR_ID}/resolve/{COMPONENT}/{CONFIG_ID}", + {"take": "ours"}, + "resolve_conflict", + ), + ("POST", f"/merge-requests/{PROJECT}/{MR_ID}/merge", None, "merge"), + ( + "PUT", + f"/merge-requests/{PROJECT}/{MR_ID}/auto-merge", + {"strategy": "immediately"}, + "update_merge_request", + ), + ( + "PUT", + f"/merge-requests/{PROJECT}/{MR_ID}/auto-merge", + {"strategy": "none"}, + "update_merge_request", + ), + ], + ids=["request-review", "approve", "resolve", "merge", "auto-merge arm", "auto-merge disarm"], +) +def test_merge_request_destructive_routes_are_403_under_deny_destructive( + tmp_path: Path, method: str, path: str, body: dict[str, Any] | None, service_method: str +) -> None: + # Statically destructive: no row GET, no body inspection -- denied before + # the handler body runs. The disarm rides the same class on purpose (a + # caller who could not arm never needs to disarm). + svc = MagicMock() + with _mr_client(tmp_path, svc, deny_destructive=True) as client: + res = client.request(method, path, json=body, headers=AUTH) + assert res.status_code == 403, res.text + getattr(svc, service_method).assert_not_called() + svc.get_merge_request_row.assert_not_called() + + +def test_merge_request_write_routes_pass_under_deny_destructive(tmp_path: Path) -> None: + svc = MagicMock() + svc.create_merge_request.return_value = {"id": MR_ID} + svc.update_merge_request.return_value = {"id": MR_ID} + svc.request_changes.return_value = {"id": MR_ID} + with _mr_client(tmp_path, svc, deny_destructive=True) as client: + assert ( + client.post( + f"/merge-requests/{PROJECT}", + json={"branch_from_id": 123, "title": "T"}, + headers=AUTH, + ).status_code + == 200 + ) + assert ( + client.put( + f"/merge-requests/{PROJECT}/{MR_ID}", json={"title": "T2"}, headers=AUTH + ).status_code + == 200 + ) + assert ( + client.post( + f"/merge-requests/{PROJECT}/{MR_ID}/request-changes", json={}, headers=AUTH + ).status_code + == 200 + ) + + +def test_merge_request_auto_merge_route_forwards_and_validates(tmp_path: Path) -> None: + svc = MagicMock() + svc.update_merge_request.return_value = {"id": MR_ID, "autoMergeStrategy": "scheduled"} + url = f"/merge-requests/{PROJECT}/{MR_ID}/auto-merge" + with _mr_client(tmp_path, svc) as client: + assert client.put(url, json={"strategy": "sometimes"}, headers=AUTH).status_code == 400 + assert client.put(url, json={"strategy": "scheduled"}, headers=AUTH).status_code == 400 + res = client.put( + url, json={"strategy": "scheduled", "at": "2026-09-30T10:00:00Z"}, headers=AUTH + ) + assert res.status_code == 200, res.text + svc.update_merge_request.assert_called_once_with( + PROJECT, MR_ID, auto_merge_strategy="scheduled", auto_merge_at="2026-09-30T10:00:00Z" + ) + + +def test_merge_request_create_and_update_bodies_have_no_auto_merge_field(tmp_path: Path) -> None: + # Pydantic ignores unknown fields by default -- so an old-style body must NOT + # silently arm anything: the service call carries no auto-merge kwargs. + svc = MagicMock() + svc.create_merge_request.return_value = {"id": MR_ID} + svc.update_merge_request.return_value = {"id": MR_ID} + with _mr_client(tmp_path, svc) as client: + client.post( + f"/merge-requests/{PROJECT}", + json={"branch_from_id": 123, "title": "T", "auto_merge_strategy": "immediately"}, + headers=AUTH, + ) + client.put( + f"/merge-requests/{PROJECT}/{MR_ID}", + json={"title": "T", "auto_merge_strategy": "immediately"}, + headers=AUTH, + ) + assert "auto_merge_strategy" not in svc.create_merge_request.call_args.kwargs + assert "auto_merge_strategy" not in svc.update_merge_request.call_args.kwargs From 291026d2715be8d87a29afd56c2c27975404336a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=A0ifra?= Date: Fri, 11 Sep 2026 01:13:58 +0200 Subject: [PATCH 16/16] 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 --- .../commands/_merge_request_writes.py | 16 ++++++ .../services/merge_request_service.py | 18 +++++-- tests/test_merge_request_cli.py | 49 +++++++++++++++++++ tests/test_merge_request_service.py | 20 ++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/keboola_agent_cli/commands/_merge_request_writes.py b/src/keboola_agent_cli/commands/_merge_request_writes.py index 5833fcb3..73b36780 100644 --- a/src/keboola_agent_cli/commands/_merge_request_writes.py +++ b/src/keboola_agent_cli/commands/_merge_request_writes.py @@ -82,6 +82,20 @@ ) +def _check_external_id(formatter: Any, external_id: str | None) -> None: + """Exit 2 on an over-cap --external-id. The service validates the cap (one + constant, one rule); this pre-check exists only so the flag error carries + exit 2 like every other bad flag -- a service-raised INVALID_ARGUMENT maps + to exit 1 on the CLI (PR #736 review), and the sibling --reason cap + already exits 2.""" + if external_id is not None and len(external_id) > MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH: + _usage_error( + formatter, + f"--external-id is capped at {MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH} characters " + f"(got {len(external_id)}).", + ) + + def _confirm_or_abort(formatter: Any, yes: bool, question: str) -> None: """The house prompt shape: skipped by --yes and in --json (where consent is implied and the explicit-target rule stands in for it).""" @@ -139,6 +153,7 @@ def merge_request_create( `merge-request auto-merge`. """ formatter = get_formatter(ctx) + _check_external_id(formatter, external_id) service = get_service(ctx, "merge_request_service") try: alias = resolve_project_alias(ctx, formatter, project) @@ -196,6 +211,7 @@ def merge_request_update( # PUT {} is a server-side no-op that answers 200 -- refuse instead of # reporting success having changed nothing. _usage_error(formatter, "Nothing to update: pass at least one field flag.") + _check_external_id(formatter, external_id) service = get_service(ctx, "merge_request_service") try: target = _resolve_target( diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py index 318c8129..63164507 100644 --- a/src/keboola_agent_cli/services/merge_request_service.py +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -98,10 +98,22 @@ def _envelope_holes(side: dict[str, Any]) -> list[str]: - """Required content keys ABSENT from a (non-null, non-deleted) side's envelope. - An empty envelope reports all of them.""" + """Required content keys a (non-null, non-deleted) side's envelope does not + USABLY carry -- absent, or present but ``None``, or a blank ``name``. + + This is the SAME criterion ``resolve_conflict``'s replace guard applies to a + caller-authored body (``key not in body or body[key] is None``, plus the + non-empty ``name``). It must be: the candidate ``get_config_diff`` composes + is what the guard later receives, so a key this function accepts and the + guard refuses is a file kbagent writes and then blames the caller for + (PR #736 review: an envelope with ``"name": null`` passed here and + failed there). An empty envelope reports all four. + """ envelope = side.get("diff") or {} - return [key for key in _REQUIRED_CONTENT_KEYS if key not in envelope] + holes = [key for key in _REQUIRED_CONTENT_KEYS if envelope.get(key) is None] + if "name" not in holes and not str(envelope.get("name") or "").strip(): + holes.insert(0, "name") + return holes # The auto-merge vocabulary (AutoMergeStrategy enum, wire-exact). Public and diff --git a/tests/test_merge_request_cli.py b/tests/test_merge_request_cli.py index d6427ded..28d66c83 100644 --- a/tests/test_merge_request_cli.py +++ b/tests/test_merge_request_cli.py @@ -1612,3 +1612,52 @@ def test_armed_transition_warns_from_the_result_without_a_get(self, tmp_path, se and "on its next tick" in result.output ) service.get_merge_request_row.assert_not_called() + + +class TestFieldCaps: + """Both caps exit 2 on the CLI -- a service-raised INVALID_ARGUMENT maps to exit 1, + so the flag pre-checks are what keep the two sibling caps consistent.""" + + @pytest.mark.parametrize( + "args", + [ + [ + "merge-request", + "create", + "--project", + ALIAS, + "--title", + "T", + "--branch", + "123", + "--external-id", + "x" * 256, + ], + [ + "merge-request", + "update", + "--project", + ALIAS, + "--id", + "7", + "--external-id", + "x" * 256, + ], + [ + "merge-request", + "request-changes", + "--project", + ALIAS, + "--id", + "7", + "--reason", + "x" * 1001, + ], + ], + ids=["create external-id", "update external-id", "request-changes reason"], + ) + def test_over_cap_field_is_exit_2_before_any_call(self, tmp_path, service, args) -> None: + result = _run(["--json", *args], _store(tmp_path), service) + assert result.exit_code == 2, result.output + assert _json(result)["error"]["code"] == ErrorCode.INVALID_ARGUMENT + assert not [c for c in service.method_calls if not str(c).startswith("call.__")] diff --git a/tests/test_merge_request_service.py b/tests/test_merge_request_service.py index fbe87b34..bfcf4e5c 100644 --- a/tests/test_merge_request_service.py +++ b/tests/test_merge_request_service.py @@ -1802,3 +1802,23 @@ def test_row_happy_path_spends_no_feature_call(self, store, client_factory) -> N _svc(store, factory).get_merge_request_row(ALIAS, 7) mock.has_feature.assert_not_called() mock.verify_token.assert_not_called() + + def test_null_or_blank_name_in_the_envelope_is_a_hole(self, store, client_factory) -> None: + # The candidate criterion must equal the resolve guard's: an envelope with + # "name": null used to produce a candidate the guard then refused -- a file + # kbagent wrote and blamed the caller for. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + svc = _svc(store, factory) + for bad_name in (None, " "): + ours = _side({"limit": 500}, version=4) + ours["diff"]["name"] = bad_name + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=ours, + theirs=_side({"limit": 250}, version=7), + ) + result = svc.get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert result["resolution_candidate"] is None, bad_name + assert result["changes"] == [], bad_name + assert any("carries no name" in w for w in result["warnings"]), bad_name