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

Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #76.
This commit is contained in:
2026-10-08 06:18:07 +00:00
committed by mathias
parent d3faffc4c4
commit d1e501cc64
7 changed files with 529 additions and 85 deletions
+34 -20
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,14 +189,25 @@ 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"}
}
}
// evaluateMergeGate is what both merge tools call. It adds one precondition to evaluateShipGate: the PR's head commit
// must be known. CI is read for that commit and the merge is bound to it (head_commit_id); with an empty sha the runs
// query would lose its filter (the repo's most recent runs of ANY commit would be classified) and the binding would be
// dropped, so an unknown head commit never merges.
func evaluateMergeGate(headSHA string, runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk riskTier) shipGate {
if headSHA == "" {
return shipGate{false, "none", "the PR's head commit is unknown — fail-closed (CI cannot be scoped and the merge cannot be bound to it); PR left open, not merged"}
}
return evaluateShipGate(runs, bp, risk)
}
var shipSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
// deriveBranch builds a deterministic short-lived branch name from the change,
@@ -302,9 +313,9 @@ 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)
gate := evaluateShipGate(runs, bp, risk)
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 := evaluateMergeGate(pr.Head.Sha, runs, bp, risk)
result := map[string]any{
"merged": false,
@@ -320,9 +331,9 @@ 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 err := t.c.MergePullRequest(ctx, args.Owner, args.Repo, pr.Number, gitea.MergePRArgs{Do: "squash", HeadCommitID: pr.Head.Sha}); 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 +350,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 +364,10 @@ 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 headSHA == "" {
return nil // never query without the commit filter: Gitea would return the repo's latest runs of any commit
}
if timeoutSec < 0 {
timeoutSec = 0
}
@@ -362,7 +376,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 +407,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
}