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:
@@ -1,6 +1,11 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
@@ -98,3 +103,36 @@ func TestClassifyRisk(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The merge gate needs a head commit to scope CI to and to bind the merge to: without one nothing merges.
|
||||
func TestEvaluateMergeGate_UnknownHeadCommitNeverMerges(t *testing.T) {
|
||||
green := []gitea.WorkflowRun{{Status: "completed", Conclusion: "success"}}
|
||||
g := evaluateMergeGate("", green, &gitea.BranchProtection{}, riskLow)
|
||||
if g.merge {
|
||||
t.Fatalf("merge = true for an empty head sha (reason %q)", g.reason)
|
||||
}
|
||||
if !strings.Contains(g.reason, "head commit") {
|
||||
t.Errorf("reason %q should name the head commit", g.reason)
|
||||
}
|
||||
if ok := evaluateMergeGate("abc", green, &gitea.BranchProtection{}, riskLow); !ok.merge {
|
||||
t.Errorf("a known head sha with green CI and risk/low must still merge, got %q", ok.reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollRuns_NoShaMakesNoQuery(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
_, _ = w.Write([]byte(`{"total_count":1,"workflow_runs":[{"id":1,"status":"completed","conclusion":"success"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := gitea.NewClient(srv.URL, "tok")
|
||||
|
||||
if runs := pollRuns(context.Background(), c, "o", "r", "", 0); len(runs) != 0 || requests.Load() != 0 {
|
||||
t.Errorf("an empty sha must make no request and return no runs, got %d runs after %d requests", len(runs), requests.Load())
|
||||
}
|
||||
// positive control: with a sha the same server is asked, so the zero above means something
|
||||
if runs := pollRuns(context.Background(), c, "o", "r", "abc", 0); len(runs) != 1 || requests.Load() != 1 {
|
||||
t.Errorf("control: with a sha expected 1 run after 1 request, got %d runs after %d requests", len(runs), requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user