diff --git a/CLAUDE.md b/CLAUDE.md index 3f2a7a614..c1e71aa11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -737,6 +737,67 @@ 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 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 ...] [--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 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 +# `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). 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/docs/merge-requests-layer1.md b/docs/merge-requests-layer1.md new file mode 100644 index 000000000..0dd267038 --- /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 000000000..2d3c19c70 --- /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 000000000..d99f3fd60 --- /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 000000000..d50f7f8d7 --- /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 000000000..40ac7e5ab --- /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"). diff --git a/docs/web-server-endpoints.md b/docs/web-server-endpoints.md index a7b50443b..ea0526427 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**. +**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,6 +353,26 @@ 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` (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 *`. + +| 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 | +| `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 | +| `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/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 0a6a6e2a8..c59d7a79d 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 "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 5b70f8ad0..4720e2dd9 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -3,15 +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, 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, 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 +239,35 @@ 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 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 (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 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 (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` | @@ -411,6 +436,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 3d5968a11..484b62874 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 e1dfa2a5d..960359035 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -237,12 +237,31 @@ 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`, `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 ...] [--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]` -- 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) - `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 4694a3830..e75d42d00 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -5043,3 +5043,76 @@ 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, 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: +`merge-request-workflow.md`. The parts that bite: + +- **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 + 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`. 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`. +- **`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 new file mode 100644 index 000000000..589f84045 --- /dev/null +++ b/plugins/kbagent/skills/kbagent/references/merge-request-workflow.md @@ -0,0 +1,160 @@ +# 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, +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`, 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. + +## What is destructive -- 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. 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. + +| 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 | +| `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 **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 + +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` 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 + `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`/`--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` | + +## 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/cli.py b/src/keboola_agent_cli/cli.py index a5411c95d..72c9b05ed 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 8ecdce97f..e72a72410 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,36 @@ 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``). 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/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 == "-": + 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 + 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: """Read a password from stdin. 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 000000000..7fee3dbf6 --- /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 rich.markup import escape + +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ._helpers import ( + 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" + ), +) + + +# -- 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, + 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) + + +# -- 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. 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 + merge_request_id: int + row: dict[str, Any] | None + branch_id: int | None + resolved_from_branch: bool + + +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) + # 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 --------------------------------------------------- + + +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: + """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 + 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. + + 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 + 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}.", + ) + + +# -- 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 []: + # 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: + """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: + # 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 new file mode 100644 index 000000000..c453dc09e --- /dev/null +++ b/src/keboola_agent_cli/commands/_merge_request_render.py @@ -0,0 +1,454 @@ +"""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)), + ] + 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)) + 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).""" + # 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 ------------------------------------------------------------------------------- + + +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") + # `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" + "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." + 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: + 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( + "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_writes.py b/src/keboola_agent_cli/commands/_merge_request_writes.py new file mode 100644 index 000000000..73b367808 --- /dev/null +++ b/src/keboola_agent_cli/commands/_merge_request_writes.py @@ -0,0 +1,657 @@ +"""Write commands of the ``kbagent merge-request`` group. + +``create`` / ``update`` / ``request-review`` / ``approve`` / ``request-changes`` / +``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``. +""" + +from __future__ import annotations + +from typing import Any + +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 ( + get_formatter, + get_service, + parse_json_arg, + resolve_branch, + resolve_project_alias, +) +from ._merge_request_common import ( + _BRANCH_OPT, + _MERGE_REQUEST_ID_OPT, + _PROJECT_OPT, + _emit_warnings, + _handle_error, + _hint_from_actions, + _hint_next, + _print_row_success, + _require_explicit_target_under_json, + _resolve_target, + _stamp_target, + _usage_error, +) + +_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" + ), +) +_EXTERNAL_ID_OPT = typer.Option( + None, + "--external-id", + help=f"Free-form correlation id, e.g. a ticket (max {MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH} chars)", +) + + +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).""" + if yes or formatter.json_mode: + return + if not typer.confirm(question): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + +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 ------------------------------------------- + + +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, + external_id: str | None = _EXTERNAL_ID_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. Auto-merge is a separate, destructive step: + `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) + 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) + 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 + 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) + _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) + + +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, + external_id: str | None = _EXTERNAL_ID_OPT, +) -> None: + """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. Auto-merge + is not a field here -- it is the destructive `merge-request auto-merge`. + """ + formatter = get_formatter(ctx) + 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.") + _check_external_id(formatter, external_id) + 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.update_merge_request( + target.alias, + target.merge_request_id, + title=title, + description=description, + reviewer_ids=reviewer_id or None, + external_id=external_id, + ), + target, + ) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + _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, + *, + destructive_reason: str | None, + project: str | None, + merge_request_id: int | None, + branch: int | None, + call: Any, + headline: str, +) -> None: + """Shared body of request-review / approve / request-changes. + + ``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( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=False, + ) + result = _stamp_target(call(service, target), target) + except (ConfigError, KeboolaApiError) as exc: + _handle_error(formatter, exc) + + _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) + + +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 (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` -- 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, + 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, + call=lambda s, t: s.request_review(t.alias, t.merge_request_id), + headline="Review requested for merge request #{id}", + ) + + +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 (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. Under --json the + target must be explicit. + """ + _transition( + ctx, + 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, + call=lambda s, t: s.approve(t.alias, t.merge_request_id), + headline="Approved merge request #{id}", + ) + + +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 {MERGE_REQUEST_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) + # 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 {MERGE_REQUEST_REASON_MAX_LENGTH} characters (got {len(reason)}).", + ) + _transition( + ctx, + destructive_reason=None, + project=project, + merge_request_id=merge_request_id, + branch=branch, + call=lambda s, t: s.request_changes(t.alias, t.merge_request_id, reason=reason), + headline="Changes requested on merge request #{id}", + ) + + +# -- 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, + 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) + _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") + # 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, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=will_prompt, + ) + 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, _render_merge_result) + _emit_warnings(formatter, result) + 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, 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." + ) + + +# -- 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 (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, 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): + _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 + _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( + ctx, + formatter, + project=project, + merge_request_id=merge_request_id, + branch=branch, + need_row=False, + ) + 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) + + formatter.output( + result, + lambda c, d: c.print( + 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) + _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 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/branch.py b/src/keboola_agent_cli/commands/branch.py index 2ac143ce6..7a000944c 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 012f64668..f98b374c8 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,75 @@ 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, 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 ...] [--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 ...] [--external-id X] + Omitted fields stay; "" clears description/external-id; --reviewer-id REPLACES the set. + 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] + 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] + 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. 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. + 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] + 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[]. + ### Workspaces (SQL Debugging) kbagent workspace create --project ALIAS [--name NAME] [--backend TYPE] [--ui] [--read-only/--no-read-only] 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 000000000..718509096 --- /dev/null +++ b/src/keboola_agent_cli/commands/merge_request.py @@ -0,0 +1,333 @@ +"""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`. +- **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 -> + 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``; 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 pathlib import Path + +import typer +from rich.markup import escape + +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 ( + _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, + format_conflicts_table, + format_merge_request_detail, + format_merge_requests_table, + next_step_hints, +) +from ._merge_request_writes import register as _register_write_commands + +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") + + +# -- 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) + 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)) + + +@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=True, + ) + 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 {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") + + +@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. 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", 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, + ) + 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 {escape(component_id)} --config-id {escape(config_id)}" + if output is not None: + 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: + _hint_next( + formatter, + f"`{resolve_cmd} --take ours|theirs`, or `merge-request diff ... --output FILE` " + "to edit a resolution by hand", + ) + + +# 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) diff --git a/src/keboola_agent_cli/commands/transformation.py b/src/keboola_agent_cli/commands/transformation.py index 1ebbc4086..d5d1bcfbe 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/constants.py b/src/keboola_agent_cli/constants.py index eaf4d56a9..8861b0f6b 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 adfbc329d..573048317 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -129,6 +129,38 @@ "branch.metadata-get": "read", "branch.metadata-set": "write", "branch.metadata-delete": "destructive", + # 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-changes": "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 + # 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", @@ -405,7 +437,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/src/keboola_agent_cli/server/_serve_command_map.py b/src/keboola_agent_cli/server/_serve_command_map.py index fffe1b31a..42eb22bdc 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,8 @@ ("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", "/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/app.py b/src/keboola_agent_cli/server/app.py index 07211adad..af2840107 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": ( @@ -479,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. @@ -489,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 @@ -523,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." @@ -908,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 @@ -947,6 +970,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 07211ec0a..dc67aa76d 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 000000000..0ff80705a --- /dev/null +++ b/src/keboola_agent_cli/server/routers/merge_requests.py @@ -0,0 +1,319 @@ +"""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``, ``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 + +from typing import Any + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from ...errors import ErrorCode, KeboolaApiError +from ...services.merge_request_service import ( + STATE_FILTER_VOCABULARY, + TAKE_MODES, + validate_auto_merge_flags, +) +from ..dependencies import ServiceRegistry, get_registry, require_permission + +router = APIRouter(prefix="/merge-requests", tags=["merge-requests"]) + + +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 + ) + + +# -- Bodies ------------------------------------------------------------------------------------ + + +class MergeRequestCreate(BaseModel): + branch_from_id: int + title: str + description: str | None = None + reviewer_ids: list[int] | None = None + external_id: str | None = None + + +class MergeRequestUpdate(BaseModel): + title: str | None = None + description: str | None = None + reviewer_ids: list[int] | None = None + external_id: str | None = None + + +class AutoMerge(BaseModel): + strategy: str + at: 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), +) -> dict[str, Any]: + """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, + 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), +) -> dict[str, Any]: + """Omitted fields stay; an empty string clears description/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.") + return registry.merge_request.update_merge_request( + project, + merge_request_id, + title=body.title, + description=body.description, + reviewer_ids=body.reviewer_ids, + 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", + dependencies=[_perm("request-review")], +) +def request_review( + project: str, + merge_request_id: int, + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """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) + + +@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), +) -> dict[str, Any]: + """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) + + +@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`.""" + # The cap is validated in the service (one constant, one rule); INVALID_ARGUMENT -> 400. + 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), +) -> dict[str, Any]: + """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)}.") + 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/src/keboola_agent_cli/services/base.py b/src/keboola_agent_cli/services/base.py index def780e4a..766eddf8b 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 e820849e0..205e65fde 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 32e371396..631645073 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 @@ -86,6 +91,69 @@ # 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 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 {} + 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 +# 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 _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 + def _same_id(a: Any, b: Any) -> bool: """Compare two ids that may arrive as int or str (approverId is a string @@ -127,7 +195,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 @@ -416,6 +485,39 @@ 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: + 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)} + def get_merge_request( self, alias: str, @@ -453,6 +555,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() @@ -465,6 +573,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 @@ -501,6 +610,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: @@ -551,6 +661,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: @@ -606,6 +717,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: @@ -690,14 +802,18 @@ 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] = [] - if raw_branch_from is not None and branch_from_id is None: + warnings: list[str] = [] + 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) - 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 +829,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 +839,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 {} @@ -734,6 +850,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: @@ -754,8 +872,17 @@ 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 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 + # 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: @@ -869,7 +996,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, @@ -881,25 +1008,101 @@ 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, } + 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: + """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 + # 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). + 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. + + 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 + holes = _envelope_holes(side) + if not holes: + continue + 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]]: """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: - 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 [] @@ -1185,7 +1388,14 @@ 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, + # 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 @@ -1206,6 +1416,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_e2e.py b/tests/test_e2e.py index a9d7be8a8..592bc77ee 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -14409,3 +14409,256 @@ 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) -- 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). ``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 THAT refusal precisely. + """ + + @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 -- 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) + 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`" + ) + + @pytest.fixture(autouse=True) + def cleanup(self) -> Any: + yield + 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 _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"] + + 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": {"side": "base", "e2e": RUN_ID}}, + description="E2E throwaway -- DMD-1900 merge-request lifecycle", + ) + config_id = str(cfg["id"]) + self._production_config_ids.append(config_id) + + _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", + "--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(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(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 detail["feature_enabled"] is True + assert not (detail.get("changeLog") or {}).get("configurations") + + _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 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(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 == 1, approve.output + err = json.loads(approve.output)["error"] + assert err["code"] == ErrorCode.API_ERROR and "422" in err["message"], err + + _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(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(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["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 new file mode 100644 index 000000000..28d66c833 --- /dev/null +++ b/tests/test_merge_request_cli.py @@ -0,0 +1,1663 @@ +"""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 + + 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 +# --------------------------------------------------------------------------- + + +_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 + + @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 +# --------------------------------------------------------------------------- + + +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 + + 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 +# --------------------------------------------------------------------------- + + +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 + + 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 +# --------------------------------------------------------------------------- + + +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() + + +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 _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 + + 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 +# --------------------------------------------------------------------------- + + +_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_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, + "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 + + 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", *_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: + # 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 + + +class TestMergeConflictRender: + 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 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", + "--project", + ALIAS, + "--id", + "7", + "--component-id", + "c", + "--config-id", + "1", + "--take", + "ours", + ], + ["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", "request-review", "--project", ALIAS], + ["merge-request", "approve", "--project", ALIAS], + [ + "merge-request", + "resolve", + "--project", + ALIAS, + "--component-id", + "c", + "--config-id", + "1", + "--take", + "ours", + ], + ["merge-request", "auto-merge", "--project", ALIAS, "--strategy", "immediately"], + ], + ids=["request-review", "approve", "resolve", "auto-merge"], + ) + def test_destructive_under_json_needs_an_explicit_target_before_any_call( + self, tmp_path, service, args + ) -> None: + # 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 == 2, result.output + assert "explicit target" in _json(result)["error"]["message"] + service.find_merge_request_for_branch.assert_not_called() + + 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", + "request-changes", + "--project", + ALIAS, + "--id", + "7", + "--reason", + "x" * 1001, + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 2 + service.request_changes.assert_not_called() + + +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 + ) + + def test_disarm_does_not_prompt(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row(autoMergeStrategy="none") + result = _run( + ["merge-request", "auto-merge", "--project", ALIAS, "--id", "7", "--strategy", "none"], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, 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_yes_skips_the_prompt(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row(autoMergeStrategy="immediately") + result = _run( + [ + "merge-request", + "auto-merge", + "--project", + ALIAS, + "--id", + "7", + "--strategy", + "immediately", + "--yes", + ], + _store(tmp_path), + service, + ) + assert result.exit_code == 0, result.output + assert "Continue?" not in result.output + + @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( + ["--json", "merge-request", "auto-merge", "--project", ALIAS, "--id", "7", *flags], + _store(tmp_path), + service, + ) + assert result.exit_code == 2, result.output + service.update_merge_request.assert_not_called() + + def test_scheduled_passes_at_through(self, tmp_path, service) -> None: + service.update_merge_request.return_value = _row(autoMergeStrategy="scheduled") + result = _run( + [ + "--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 ( + service.update_merge_request.call_args.kwargs["auto_merge_at"] == "2026-09-30T10:00:00Z" + ) + + 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 ( + "Auto-merge is armed (immediately)" in result.output + 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 506cdb31f..bfcf4e5c7 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 @@ -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() @@ -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,334 @@ 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 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 ------------------------------------------------- + + 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 + + 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) + + 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 + + +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") + row["branches"]["branchFromId"] = branch_from + mock.merge_requests.get.return_value = row + mock.merge_requests.merge.return_value = {"id": 1, "status": "success", "results": {}} + + # 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} + + # 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 + + # 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 + + # 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"] + + # 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) + + # 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 + + +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() + + 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 diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index 51aa27ff4..ad37b3277 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -2668,3 +2668,338 @@ 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 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], + 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_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 + + +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", + json={"reason": "x" * 1001}, + headers=AUTH, + ) + assert res.status_code == 400, res.text + 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: + # 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 + + +# -- 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