import { describe, expect, it, vi } from "vitest"; import { GiteaClient } from "../src/integrations/gitea/client.js"; import { assertSuccessfulActionRuns, assertSuccessfulPullRequestFullActionRun, exactHeadActionRuns } from "../src/integrations/gitea/policy.js"; import type { GiteaActionJob, GiteaActionRun } from "../src/integrations/gitea/types.js"; const branch = "codex/native-ci"; const sha = "a".repeat(40); const context = "CI / build-test (pull_request)"; const fullJob = (runId: number, headSha = sha, overrides: Partial = {}): GiteaActionJob => ({ id: runId * 10, html_url: `https://gitea.example/runs/${runId}/jobs/${runId * 10}`, run_id: runId, name: "full", labels: [], run_attempt: 1, head_sha: headSha, head_branch: branch, status: "completed", conclusion: "success", runner_id: 1, steps: [], created_at: "2026-10-06T21:33:06Z", ...overrides, }); const run = (id: number, overrides: Partial = {}): GiteaActionRun => ({ id, run_number: id, display_title: "Managed validation / full", path: ".gitea/workflows/managed-validation.yml", run_attempt: 1, event: "pull_request", head_sha: sha, head_branch: "", status: "completed", conclusion: "success", html_url: `https://gitea.example/runs/${id}`, jobs: [fullJob(id)], ...overrides, }); describe("native PR validation discovery", () => { it("discovers an empty branch by exact SHA and checks available PR identity", () => { const matching = run(1, { pull_requests: [{ number: 30, head: { ref: branch, sha } }] }); expect(exactHeadActionRuns([ matching, run(2), run(3, { head_sha: "stale" }), run(4, { head_branch: "other" }), run(5, { pull_requests: [{ number: 31, head: { ref: "other", sha } }] }), run(6, { event: "workflow_dispatch" }), ], branch, sha)).toEqual([matching, run(2)]); expect(assertSuccessfulActionRuns([matching], branch, sha)).toEqual([matching]); }); it("accepts Gitea 28 native PR full validation fields and its exact successful full job", () => { const nativeRun = run(22352, { run_number: 17, display_title: "ci: validate QuizMonitor backend tests", path: "managed-validation.yml@refs/pull/6/head", head_branch: undefined, pull_requests: [{ number: 6, head: { ref: branch, sha } }], jobs: [fullJob(22352, sha, { id: 38976, head_branch: undefined })], }); expect(assertSuccessfulPullRequestFullActionRun([nativeRun], branch, sha)).toBe(nativeRun); expect(assertSuccessfulPullRequestFullActionRun([nativeRun], branch, sha, 6)).toBe(nativeRun); }); it("binds merge validation to the requested PR number despite a blank native head branch", () => { const prSix = run(29, { path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", }); expect(() => assertSuccessfulPullRequestFullActionRun([prSix], branch, sha, 99)) .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); expect(() => assertSuccessfulPullRequestFullActionRun([run(30, { path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 99, head: { ref: branch, sha } }], head_branch: "", })], branch, sha, 6)).toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); expect(() => assertSuccessfulPullRequestFullActionRun([run(34, { path: ".gitea/workflows/managed-validation.yml", head_branch: "", })], branch, sha, 6)).toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); expect(() => assertSuccessfulPullRequestFullActionRun([run(35, { path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }, { number: 99, head: { ref: branch, sha } }], head_branch: "", })], branch, sha, 6)).toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); }); it("rejects a newer failed attempt for the requested PR when Gitea omits head_branch", () => { const successful = run(31, { run_number: 17, run_attempt: 1, path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", jobs: [fullJob(31)], }); const failed = run(32, { run_number: 17, run_attempt: 2, path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", jobs: [fullJob(32, sha, { run_attempt: 2, conclusion: "failure" })], }); expect(() => assertSuccessfulPullRequestFullActionRun([successful, failed], branch, sha, 6)) .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); }); it("keeps failed noncanonical PR workflows in the exact-head merge gate", () => { const full = run(37, { path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", }); const failedQualityWorkflow = run(38, { path: ".gitea/workflows/security-check.yml@refs/pull/6/head", conclusion: "failure", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", jobs: undefined, }); expect(() => assertSuccessfulPullRequestFullActionRun([full, failedQualityWorkflow], branch, sha, 6)) .toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); }); it("rejects malformed path metadata without throwing a generic runtime error", () => { const valid = run(33, { path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", }); const malformed = run(34, { event: "push", path: null as unknown as string, head_branch: branch }); expect(() => assertSuccessfulPullRequestFullActionRun([valid, malformed], branch, sha, 6)) .toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); }); it.each([ ["job id", { id: undefined as unknown as number }], ["run id", { run_id: undefined as unknown as number }], ["attempt", { run_attempt: undefined as unknown as number }], ["head SHA", { head_sha: undefined as unknown as string }], ])("rejects a full job missing its %s identity", (_label, overrides) => { expect(() => assertSuccessfulPullRequestFullActionRun([run(36, { jobs: [fullJob(36, sha, overrides)] })], branch, sha)) .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); }); it.each([ ["missing", []], ["failed", [fullJob(20, sha, { conclusion: "failure" })]], ["wrong run", [fullJob(21, sha, { run_id: 999 })]], ["wrong attempt", [fullJob(22, sha, { run_attempt: 2 })]], ["wrong head", [fullJob(23, "b".repeat(40))]], ["duplicate", [fullJob(24), fullJob(24, sha, { id: 241 })]], ])("rejects a %s canonical full job", (_label, jobs) => { expect(() => assertSuccessfulPullRequestFullActionRun([run(20, { jobs })], branch, sha)) .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); }); it("does not let an older successful attempt authorize a newer failed full attempt", () => { const successful = run(25, { run_number: 17, run_attempt: 1, jobs: [fullJob(25)] }); const failed = run(26, { run_number: 17, run_attempt: 2, jobs: [fullJob(26, sha, { run_attempt: 2, conclusion: "failure" })], }); expect(() => assertSuccessfulPullRequestFullActionRun([successful, failed], branch, sha)) .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); }); it.each(["untrusted/managed-validation.yml", "other.yml"]) ("does not treat %s or its title as canonical", (path) => { expect(() => assertSuccessfulPullRequestFullActionRun([run(27, { path, display_title: "Managed validation / full", })], branch, sha)).toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); }); it.each(["pending", "queued", "waiting", "in_progress"])("does not authorize %s", (status) => { expect(() => assertSuccessfulActionRuns([run(1, { status, conclusion: undefined })], branch, sha)).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); }); it.each(["cancelled", "canceled", "failure"])("does not authorize a %s job that never started", (conclusion) => { expect(() => assertSuccessfulActionRuns([run(1, { conclusion })], branch, sha)).toThrow("FEATURE_DELIVERY_FULL_VALIDATION_REQUIRED"); }); it("uses the newest attempt of each workflow and preserves all evidence", () => { const failure = run(2, { conclusion: "failure" }); expect(() => assertSuccessfulActionRuns([failure, run(1, { status: "pending" })], branch, sha)).toThrow(); const success = run(3); expect(assertSuccessfulActionRuns([failure, success], branch, sha)).toEqual([failure, success]); expect(() => assertSuccessfulActionRuns([success, run(4, { status: "queued" })], branch, sha)).toThrow(); }); it("never lets dispatch success replace PR failure even for the same workflow path", () => { expect(() => assertSuccessfulActionRuns([ run(1, { conclusion: "failure" }), run(2, { event: "workflow_dispatch", head_branch: branch }), ], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); }); it("queries by exact SHA and fetches jobs for the exact run attempt", async () => { const workflowRun = run(1); const job = fullJob(1); const fetcher = vi.fn(async (input: string | URL | Request) => { const url = new URL(String(input)); if (url.pathname.endsWith("/actions/runs")) return new Response(JSON.stringify({ total_count: 1, workflow_runs: [workflowRun] })); if (url.pathname.endsWith("/actions/runs/1/attempts/1/jobs")) return new Response(JSON.stringify({ total_count: 1, jobs: [job] })); return new Response("unexpected request", { status: 404 }); }); const result = await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch).listActionRuns("Jens", "Repo", { branch, headSha: sha }); const url = new URL(String(fetcher.mock.calls[0]![0])); expect(url.searchParams.get("head_sha")).toBe(sha); expect(url.searchParams.has("branch")).toBe(false); expect(result.workflow_runs[0]?.jobs).toEqual([job]); expect(fetcher).toHaveBeenCalledTimes(2); expect(new URL(String(fetcher.mock.calls[1]![0])).searchParams.get("limit")).toBe("100"); }); it("only fetches validation jobs for the requested PR when another PR has the same head SHA", async () => { const unrelated = run(40, { path: "managed-validation.yml@refs/pull/99/head", pull_requests: [{ number: 99, head: { ref: branch, sha } }], head_branch: "", jobs: undefined, }); const expected = run(41, { path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", jobs: undefined, }); const fetcher = vi.fn(async (input: string | URL | Request) => { const url = new URL(String(input)); if (url.pathname.endsWith("/actions/runs")) return new Response(JSON.stringify({ total_count: 2, workflow_runs: [unrelated, expected] })); if (url.pathname.endsWith("/actions/runs/41/attempts/1/jobs")) return new Response(JSON.stringify({ total_count: 1, jobs: [fullJob(41)] })); return new Response("unexpected request", { status: 404 }); }); const result = await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) .listActionRuns("Jens", "Repo", { branch, headSha: sha, pullRequestNumber: 6 }); expect(result.workflow_runs.find((item) => item.id === 41)?.jobs).toHaveLength(1); expect(result.workflow_runs.find((item) => item.id === 40)?.jobs).toBeUndefined(); expect(fetcher).toHaveBeenCalledTimes(2); }); it("rejects duplicate job identities across paginated job results", async () => { const workflowRun = run(42); const firstPage = Array.from({ length: 100 }, (_, index) => fullJob(42, sha, { id: index + 1 })); const secondPage = [fullJob(42, sha, { id: 100 })]; const fetcher = vi.fn(async (input: string | URL | Request) => { const url = new URL(String(input)); if (url.pathname.endsWith("/actions/runs")) return new Response(JSON.stringify({ total_count: 1, workflow_runs: [workflowRun] })); if (url.pathname.endsWith("/attempts/1/jobs")) { return new Response(JSON.stringify({ total_count: 101, jobs: url.searchParams.get("page") === "1" ? firstPage : secondPage })); } return new Response("unexpected request", { status: 404 }); }); await expect(new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) .listActionRuns("Jens", "Repo", { branch, headSha: sha })).rejects.toMatchObject({ code: "GITEA_ACTION_JOBS_INVALID" }); }); it("paginates the exact-head run inventory so a later failed PR run blocks merge", async () => { const successful = run(43, { run_number: 17, path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", jobs: undefined, }); const failed = run(44, { run_number: 18, path: "managed-validation.yml@refs/pull/6/head", pull_requests: [{ number: 6, head: { ref: branch, sha } }], head_branch: "", conclusion: "failure", jobs: undefined, }); const fetcher = vi.fn(async (input: string | URL | Request) => { const url = new URL(String(input)); if (url.pathname.endsWith("/actions/runs")) { return new Response(JSON.stringify({ total_count: 2, workflow_runs: url.searchParams.get("page") === "1" ? [successful] : [failed] })); } if (url.pathname.endsWith("/actions/runs/44/attempts/1/jobs")) { return new Response(JSON.stringify({ total_count: 1, jobs: [fullJob(44, sha, { conclusion: "failure" })] })); } return new Response("unexpected request", { status: 404 }); }); const result = await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) .listActionRuns("Jens", "Repo", { branch, headSha: sha, pullRequestNumber: 6 }); expect(result.workflow_runs).toHaveLength(2); expect(fetcher).toHaveBeenCalledTimes(3); expect(() => assertSuccessfulPullRequestFullActionRun(result.workflow_runs, branch, sha, 6)) .toThrow("FEATURE_DELIVERY_PULL_REQUEST_FULL_VALIDATION_REQUIRED"); }); it("rejects repeated exact-head run identities across API pages", async () => { const repeated = run(45); const fetcher = vi.fn(async (input: string | URL | Request) => { const url = new URL(String(input)); return new Response(JSON.stringify({ total_count: 2, workflow_runs: [repeated] })); }); await expect(new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) .listActionRuns("Jens", "Repo", { branch, headSha: sha })).rejects.toMatchObject({ code: "GITEA_ACTION_RUNS_INVALID" }); }); it.each([ [[{ context, status: "failure" }, { context, status: "pending" }], "GITEA_REQUIRED_CHECKS_FAILED"], [[{ context: "CI / build-test (workflow_dispatch)", status: "success" }], "GITEA_REQUIRED_CHECKS_PENDING"], [[{ context: `${context} extra`, status: "success" }], "GITEA_REQUIRED_CHECKS_PENDING"], [[{ context, status: "pending" }], "GITEA_REQUIRED_CHECKS_PENDING"], ] as const)("requires the exact latest protected status %#", async (statuses, error) => { const fetcher = vi.fn(async (url: URL | RequestInfo) => new Response(JSON.stringify(String(url).includes("branch_protections") ? { enable_status_check: true, status_check_contexts: [context] } : { statuses }))); const client = new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch); await expect(client.waitForRequiredPullRequestChecks("Jens", "Repo", "main", sha, { timeoutMs: 0 })).rejects.toMatchObject({ code: error }); expect(String(fetcher.mock.calls[1]![0])).toContain(`/commits/${sha}/status`); }); it("accepts a newer native success after an old native failure", async () => { const fetcher = vi.fn(async (url: URL | RequestInfo) => new Response(JSON.stringify(String(url).includes("branch_protections") ? { enable_status_check: true, status_check_contexts: [context] } : { statuses: [{ context, status: "success" }, { context, status: "failure" }] }))); await expect(new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) .waitForRequiredPullRequestChecks("Jens", "Repo", "main", sha, { timeoutMs: 0 })).resolves.toMatchObject({ state: "success" }); }); });