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
+18 -18
View File
@@ -117,19 +117,19 @@ func classifyRisk(labels []gitea.Label) riskTier {
}
}
const unclassifiedReason = "no risk/ label on the named issue (or no issue named) — unclassified is not low; PR opened, not merged"
const unclassifiedReason = "no risk/ label on the named issue (or no issue named) — unclassified is not low; PR left open, not merged"
// riskBlockReason explains why a tier other than riskLow blocks the merge.
func riskBlockReason(r riskTier) string {
switch r {
case riskMedium, riskHigh:
return fmt.Sprintf("the issue is risk/%s — above risk/low a human reviews the change; PR opened, not merged", r)
return fmt.Sprintf("the issue is risk/%s — above risk/low a human reviews the change; PR left open, not merged", r)
case riskAmbiguous:
return "the issue carries more than one risk/ label — ambiguous classification; PR opened, not merged"
return "the issue carries more than one risk/ label — ambiguous classification; PR left open, not merged"
case riskUnknown:
return "the issue carries an unknown risk/ value — PR opened, not merged"
return "the issue carries an unknown risk/ value — PR left open, not merged"
case riskUnreadable:
return "could not read the issue's labels — fail-closed; PR opened, not merged"
return "could not read the issue's labels — fail-closed; PR left open, not merged"
case riskLow:
return "" // never asked: only a non-low tier needs explaining
case riskNone:
@@ -177,7 +177,7 @@ func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk
// Review-protected base can't be auto-merged regardless of CI.
if bp != nil && bp.Protected && bp.RequiredApprovals > 0 {
return shipGate{false, ci, fmt.Sprintf(
"base branch requires %d approving review(s) — auto-merge disabled; PR opened for manual merge", bp.RequiredApprovals)}
"base branch requires %d approving review(s) — auto-merge disabled; PR left open for manual merge", bp.RequiredApprovals)}
}
// Only risk/low merges on green. Checked before CI so an unclassified change never reads as "waiting for CI".
@@ -189,11 +189,11 @@ func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk
case "success":
return shipGate{true, ci, ""}
case "pending":
return shipGate{false, ci, "CI is still running for the head commit — PR opened; re-invoke once checks complete, or merge manually"}
return shipGate{false, ci, "CI is still running for the head commit — PR left open; re-invoke once checks complete, or merge manually"}
case "failed":
return shipGate{false, ci, "CI failed for the head commit — PR opened, not merged"}
return shipGate{false, ci, "CI failed for the head commit — PR left open, not merged"}
default: // none
return shipGate{false, ci, "no CI runs found for the head commit — fail-closed: a change with no CI gate is never auto-merged to trunk; PR opened for manual merge"}
return shipGate{false, ci, "no CI runs found for the head commit — fail-closed: a change with no CI gate is never auto-merged to trunk; PR left open for manual merge"}
}
}
@@ -302,8 +302,8 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
}
// CI gate on the PR head commit.
runs := t.pollRuns(ctx, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
risk := t.riskTierOf(ctx, args.Owner, args.Repo, args.Issue)
runs := pollRuns(ctx, t.c, args.Owner, args.Repo, pr.Head.Sha, args.CITimeoutSec)
risk := riskTierOf(ctx, t.c, args.Owner, args.Repo, args.Issue)
gate := evaluateShipGate(runs, bp, risk)
result := map[string]any{
@@ -322,7 +322,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
// Green + unprotected → squash-merge, then delete the short-lived branch.
if err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, pr.Number, gitea.MergePRArgs{Do: "squash"}); err != nil {
if errors.Is(err, gitea.ErrConflict) {
result["reason"] = "base moved and the merge is not clean — PR opened, not merged; resolve the conflict and merge manually"
result["reason"] = "base moved and the merge is not clean — PR left open, not merged; resolve the conflict and merge manually"
return textOK(result)
}
return nil, fmt.Errorf("merge PR #%d: %w", pr.Number, err)
@@ -339,11 +339,11 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
// riskTierOf classifies the change from the named issue. No issue, or an issue that cannot be read, never yields
// riskLow. The link between the PR and the issue is the caller's claim: the gate enforces the tier of the issue it
// is given, it cannot prove that issue is the one the change belongs to (stated limit, gitea-mcp#63).
func (t *TBDShip) riskTierOf(ctx context.Context, owner, repo string, issue int) riskTier {
func riskTierOf(ctx context.Context, c *gitea.Client, owner, repo string, issue int) riskTier {
if issue <= 0 {
return riskNone
}
iss, err := t.c.GetIssue(ctx, owner, repo, issue)
iss, err := c.GetIssue(ctx, owner, repo, issue)
if err != nil || iss == nil {
return riskUnreadable
}
@@ -353,7 +353,7 @@ func (t *TBDShip) riskTierOf(ctx context.Context, owner, repo string, issue int)
// pollRuns lists the head commit's workflow runs, polling up to timeoutSec for
// them to reach a terminal state. timeoutSec == 0 is a single snapshot (no
// sleep). A listing error yields no runs → the gate fails closed.
func (t *TBDShip) pollRuns(ctx context.Context, owner, repo, headSHA string, timeoutSec int) []gitea.WorkflowRun {
func pollRuns(ctx context.Context, c *gitea.Client, owner, repo, headSHA string, timeoutSec int) []gitea.WorkflowRun {
if timeoutSec < 0 {
timeoutSec = 0
}
@@ -362,7 +362,7 @@ func (t *TBDShip) pollRuns(ctx context.Context, owner, repo, headSHA string, tim
}
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for {
runs := t.listRuns(ctx, owner, repo, headSHA)
runs := listRuns(ctx, c, owner, repo, headSHA)
switch classifyCI(runs) {
case "success", "failed":
return runs // terminal — no point waiting
@@ -393,8 +393,8 @@ func (t *TBDShip) findOpenPR(ctx context.Context, owner, repo, branch string) (*
return nil, fmt.Errorf("no open PR found for head %s: %w", branch, gitea.ErrNotFound)
}
func (t *TBDShip) listRuns(ctx context.Context, owner, repo, headSHA string) []gitea.WorkflowRun {
resp, err := t.c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50})
func listRuns(ctx context.Context, c *gitea.Client, owner, repo, headSHA string) []gitea.WorkflowRun {
resp, err := c.ListWorkflowRuns(ctx, owner, repo, gitea.ListWorkflowRunsArgs{HeadSHA: headSHA, Limit: 50})
if err != nil || resp == nil {
return nil
}