Files
gitea-mcp/internal/tools/tbd_ship_internal_test.go
T
mathiasandClaude Sonnet 5.5 3955758c24
CD / Lint / Test / Vet (pull_request) Successful in 13s
CD / Build & Import (pull_request) Skipped
CD / Deploy via GitOps (pull_request) Skipped
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
2026-10-08 08:15:49 +02:00

139 lines
6.3 KiB
Go

package tools
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
// The ship gate is the load-bearing safety of tbd_ship: it must NEVER return
// merge=true unless CI is fully green AND the base isn't review-protected.
// Every non-green / unknown / protected state fails closed to PR-only (#40).
func TestEvaluateShipGate(t *testing.T) {
run := func(status, concl string) gitea.WorkflowRun {
return gitea.WorkflowRun{Status: status, Conclusion: concl}
}
protected := &gitea.BranchProtection{Protected: true, RequiredApprovals: 1}
open := &gitea.BranchProtection{Protected: false}
tests := []struct {
name string
runs []gitea.WorkflowRun
bp *gitea.BranchProtection
risk riskTier
wantMerge bool
wantCI string
}{
{"all green → merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskLow, true, "success"},
{"in_progress → no merge", []gitea.WorkflowRun{run("in_progress", "")}, open, riskLow, false, "pending"},
{"queued → no merge", []gitea.WorkflowRun{run("queued", "")}, open, riskLow, false, "pending"},
{"failed → no merge", []gitea.WorkflowRun{run("completed", "failure")}, open, riskLow, false, "failed"},
{"cancelled → no merge (fail-closed)", []gitea.WorkflowRun{run("completed", "cancelled")}, open, riskLow, false, "failed"},
{"no runs → no merge (no CI gate)", nil, open, riskLow, false, "none"},
{"mixed success+queued → no merge", []gitea.WorkflowRun{run("completed", "success"), run("queued", "")}, open, riskLow, false, "pending"},
{"green but protected → no merge", []gitea.WorkflowRun{run("completed", "success")}, protected, riskLow, false, "success"},
{"no runs + protected → no merge", nil, protected, riskLow, false, "none"},
// gitea-mcp#63: the tier is an input. Green CI never overrides it; unclassified is not low.
{"green + risk/medium → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskMedium, false, "success"},
{"green + risk/high → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskHigh, false, "success"},
{"green + no risk label → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskNone, false, "success"},
{"green + ambiguous labels → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskAmbiguous, false, "success"},
{"green + unknown value → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskUnknown, false, "success"},
{"green + unreadable issue → no merge", []gitea.WorkflowRun{run("completed", "success")}, open, riskUnreadable, false, "success"},
{"green + the zero tier → no merge (a forgotten field must not mean low)", []gitea.WorkflowRun{run("completed", "success")}, open, riskTier(""), false, "success"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
g := evaluateShipGate(tc.runs, tc.bp, tc.risk)
if g.merge != tc.wantMerge {
t.Errorf("merge = %v, want %v (reason: %q)", g.merge, tc.wantMerge, g.reason)
}
if g.ciStatus != tc.wantCI {
t.Errorf("ciStatus = %q, want %q", g.ciStatus, tc.wantCI)
}
if !tc.wantMerge && g.reason == "" {
t.Errorf("no-merge decision must carry a reason")
}
if tc.wantMerge && g.reason != "" {
t.Errorf("merge decision must have empty reason, got %q", g.reason)
}
})
}
}
func TestClassifyRisk(t *testing.T) {
l := func(names ...string) []gitea.Label {
out := make([]gitea.Label, len(names))
for i, n := range names {
out[i] = gitea.Label{Name: n}
}
return out
}
tests := []struct {
name string
labels []gitea.Label
want riskTier
}{
{"low", l("risk/low"), riskLow},
{"medium", l("risk/medium"), riskMedium},
{"high", l("risk/high"), riskHigh},
{"low among others", l("type/fix", "risk/low", "size/s"), riskLow},
{"none", l("type/fix"), riskNone},
{"no labels", nil, riskNone},
{"two different", l("risk/low", "risk/high"), riskAmbiguous},
{"two identical", l("risk/low", "risk/low"), riskAmbiguous},
{"unknown value", l("risk/extreme"), riskUnknown},
{"empty value", l("risk/"), riskUnknown},
{"wrong case", l("Risk/Low"), riskNone},
{"value case matters: risk/Low is unknown, not low", l("risk/Low"), riskUnknown},
{"trailing space in the value is unknown, not low", l("risk/low "), riskUnknown},
{"leading space in the value is unknown, not low", l("risk/ low"), riskUnknown},
{"prefix only inside a name", l("not-risk/low"), riskNone},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := classifyRisk(tc.labels); got != tc.want {
t.Errorf("classifyRisk(%v) = %q, want %q", tc.labels, got, tc.want)
}
})
}
}
// 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())
}
}