fix(merge gate): an unknown head commit never merges; pr_merge refuses closed and draft PRs (review of PR #76)
CD / Lint / Test / Vet (pull_request) Successful in 13s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped

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:
2026-10-08 08:15:49 +02:00
co-authored by Claude Sonnet 5.5
parent 6efd309159
commit 3955758c24
5 changed files with 149 additions and 4 deletions
+68 -1
View File
@@ -34,6 +34,9 @@ type prMergeFakeOpts struct {
protectionStatus int // 0 → 404 (unprotected); 200 with required approvals when requiredApprovals>0
requiredApprovals int
mergeStatus int
emptyHeadSha bool // the PR reports head.sha == ""
prState string // default "open"
prDraft bool
}
type prMergeFake struct {
@@ -41,6 +44,7 @@ type prMergeFake struct {
mu sync.Mutex
mergeCalls int
mergeBody []byte
runsQuery string // raw query of the last workflow-runs request
}
func (f *prMergeFake) merged() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.mergeCalls > 0 }
@@ -62,6 +66,13 @@ func newPRMergeFake(t *testing.T, o prMergeFakeOpts) *prMergeFake {
if o.mergeStatus == 0 {
o.mergeStatus = http.StatusNoContent
}
if o.prState == "" {
o.prState = "open"
}
sha := "abc"
if o.emptyHeadSha {
sha = ""
}
f := &prMergeFake{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
@@ -69,7 +80,7 @@ func newPRMergeFake(t *testing.T, o prMergeFakeOpts) *prMergeFake {
switch {
case r.Method == http.MethodGet && p == "/api/v1/repos/owner/repo/pulls/7":
w.WriteHeader(o.prStatus)
_, _ = w.Write([]byte(`{"number":7,"title":"t","state":"open","html_url":"http://x/pulls/7","head":{"ref":"feat","sha":"abc"},"base":{"ref":"main"}}`))
_, _ = w.Write([]byte(`{"number":7,"title":"t","state":"` + o.prState + `","draft":` + boolJSON(o.prDraft) + `,"html_url":"http://x/pulls/7","head":{"ref":"feat","sha":"` + sha + `"},"base":{"ref":"main"}}`))
case r.Method == http.MethodGet && strings.Contains(p, "/branch_protections/"):
switch {
case o.protectionStatus != 0:
@@ -82,6 +93,9 @@ func newPRMergeFake(t *testing.T, o prMergeFakeOpts) *prMergeFake {
_, _ = w.Write([]byte(`{"message":"not found"}`))
}
case r.Method == http.MethodGet && strings.Contains(p, "/actions/runs"):
f.mu.Lock()
f.runsQuery = r.URL.RawQuery
f.mu.Unlock()
_, _ = w.Write([]byte(`{"total_count":0,"workflow_runs":` + o.runs + `}`))
case r.Method == http.MethodGet && p == "/api/v1/repos/owner/repo/issues/42":
w.WriteHeader(o.issueStatus)
@@ -103,6 +117,13 @@ func newPRMergeFake(t *testing.T, o prMergeFakeOpts) *prMergeFake {
return f
}
func boolJSON(b bool) string {
if b {
return "true"
}
return "false"
}
func callMerge(t *testing.T, f *prMergeFake, args string) (map[string]any, error) {
t.Helper()
tool := tools.NewPRMerge(gitea.NewClient(f.URL, "tok"), allowlist.New([]string{"owner"}))
@@ -327,3 +348,49 @@ func TestPRMerge_TheTierIsReadFromTheNamedIssueOnly(t *testing.T) {
assert.Equal(t, "high", res["risk"])
assert.False(t, f.merged())
}
// ---- review of PR #76 ----
// An empty head sha used to disable BOTH the CI scoping (the runs query lost its head_sha filter, so the repo's most
// recent runs of any commit were classified) and the merge binding (head_commit_id is omitempty). Fail closed.
func TestPRMerge_AnEmptyHeadShaFailsClosed(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{emptyHeadSha: true})
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.Contains(t, res["reason"], "head commit")
assert.False(t, f.merged(), "an unknown head commit must never merge")
assert.Empty(t, f.runsQuery, "no unscoped CI query may be made when the head sha is unknown")
}
// CI is read for the PR's head commit only: the query names it.
func TestPRMerge_CIIsScopedToTheHeadCommit(t *testing.T) {
f := newPRMergeFake(t, prMergeFakeOpts{})
_, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Contains(t, f.runsQuery, "head_sha=abc")
}
// Defence in depth: Gitea refuses closed/merged PRs itself, but the gate should not depend on that.
func TestPRMerge_ClosedOrDraftPRsAreNotMerged(t *testing.T) {
for name, o := range map[string]prMergeFakeOpts{
"closed": {prState: "closed"},
"merged": {prState: "merged"},
"draft": {prDraft: true},
} {
t.Run(name, func(t *testing.T) {
f := newPRMergeFake(t, o)
res, err := callMerge(t, f, mergeArgs)
require.NoError(t, err)
assert.Equal(t, false, res["merged"])
assert.NotEmpty(t, res["reason"])
assert.False(t, f.merged())
})
}
}