fix(merge gate): an unknown head commit never merges; pr_merge refuses closed and draft PRs (review of PR #76)
Review found a bypass: a PR reporting an empty head sha made the runs query lose its head_sha filter (the repo's 50 most recent runs of any commit were classified) and dropped head_commit_id (omitempty), so the merge was unbound. Both tools now go through evaluateMergeGate, which refuses without a head sha, and pollRuns makes no query without one. pr_merge also reports merged:false for a PR that is not open or is a draft (Gitea refuses closed ones itself; the gate no longer depends on that). Tests: unit (gate, counting-server pollRuns with positive control), end to end for both tools, CI scoped to head_sha, closed/merged/draft. The two caller-wiring mutants are equivalent to the pollRuns guard and survive by design. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
This commit is contained in:
@@ -197,6 +197,17 @@ func evaluateShipGate(runs []gitea.WorkflowRun, bp *gitea.BranchProtection, risk
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -304,7 +315,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
|
||||
// CI gate on the PR head commit.
|
||||
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)
|
||||
gate := evaluateMergeGate(pr.Head.Sha, runs, bp, risk)
|
||||
|
||||
result := map[string]any{
|
||||
"merged": false,
|
||||
@@ -354,6 +365,9 @@ func riskTierOf(ctx context.Context, c *gitea.Client, owner, repo string, issue
|
||||
// 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 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user