feat(pr_merge): gate it like tbd_ship — green CI, unprotected base, exactly one risk/low issue (gitea-mcp#74)
CD / Lint / Test / Vet (pull_request) Successful in 14s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped

pr_merge was an independent merge path with no CI, protection or tier check. It now reads the PR, the base's
branch protection and the head commit's CI runs, takes the tier from the issue named in `issue`, and runs the same
evaluateShipGate as tbd_ship (helpers shared, not copied). A refusal is a result (merged:false, reason, ci_status,
risk); an unreadable PR or protection probe is an error; a merge conflict is still an error. Above risk/low a human
merges in the UI. Tests: tier table, no-issue, issue lookup failures, CI and protection not overridden by risk/low,
read-side failures, tier read from the named issue only; nine wiring mutants flip-tested red. Shared reason text now
says "PR left open" (the PR already exists for pr_merge).

Behaviour change: pr_merge without `issue`, or on a PR with no green CI, merges nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
This commit is contained in:
2026-10-08 08:11:40 +02:00
co-authored by Claude Sonnet 5.5
parent d3faffc4c4
commit c3d8401604
4 changed files with 340 additions and 79 deletions
+13 -6
View File
@@ -125,12 +125,19 @@ by area:
| Search / Templates | code_search, create_project_from_template |
| Composite | tbd_ship (trunk-based ship helper) |
**`tbd_ship` merge gate (gitea-mcp#63).** A PR is squash-merged only when ALL of: the head commit's CI is fully green,
the base branch is not review-protected, and the issue named in `issue` carries **exactly one** `risk/` label, `risk/low`.
No `issue`, no `risk/` label, several, an unknown value (`risk/lowish`), `risk/medium`, `risk/high`, or an issue that
cannot be read all fail closed: the PR is opened, `merged:false`, with a `reason` and the `risk` tier in the result.
Unclassified is not low. Limit: the gate enforces the tier of the issue the caller names; it cannot prove that issue is
the one the change belongs to, so the link is the caller's claim (the convention in local-dev rule 5 still applies).
**Merge gate: `tbd_ship` (gitea-mcp#63) and `pr_merge` (gitea-mcp#74).** The two merge tools share one gate
(`evaluateShipGate`). A PR is merged only when ALL of: the head commit's CI is fully green, the base branch is not
review-protected, and the issue named in `issue` carries **exactly one** `risk/` label, `risk/low`. No `issue`, no
`risk/` label, several, an unknown value (`risk/lowish`), `risk/medium`, `risk/high`, or an issue that cannot be read
all fail closed: nothing is merged, the result is `merged:false` with a `reason`, the `ci_status` and the `risk` tier.
Unclassified is not low. Above `risk/low` a human reviews and merges in the Gitea UI (which this gate does not touch).
`pr_merge` additionally fails with an error, merging nothing, when the PR or the base's branch-protection state cannot
be read. A merge conflict is still an error.
Limits: the gate enforces the tier of the issue the caller names; it cannot prove that issue is the one the change
belongs to, so the link is the caller's claim (local-dev rule 5's convention still applies). Not gated: merging in the
Gitea UI, and `file_write_branch` committing directly to a branch (`main` included), which is how `risk/low` work
reaches trunk. Platform-level enforcement for those is branch protection on `main` (local-dev#27 option 2).
Shared tool-layer hygiene (`internal/tools/tool.go`):
- **Alias normalization** — `repo`↔`name` and `number`↔`index` are reconciled so