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()) } }