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:
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user