diff --git a/packages/gittensory-miner/lib/ci-poller.d.ts b/packages/gittensory-miner/lib/ci-poller.d.ts new file mode 100644 index 0000000000..583e7b97f0 --- /dev/null +++ b/packages/gittensory-miner/lib/ci-poller.d.ts @@ -0,0 +1,33 @@ +export type CheckRunConclusion = "pending" | "success" | "failure" | "neutral"; + +export type NormalizedCheckRun = { + name: string; + status: string; + conclusion: CheckRunConclusion; + detailsUrl: string | null; + startedAt: string | null; + completedAt: string | null; +}; + +export type PollCheckRunsResult = { + conclusion: CheckRunConclusion; + checks: NormalizedCheckRun[]; + headSha: string; + attempts: number; +}; + +export type PollCheckRunsOptions = { + apiBaseUrl?: string; + fetchFn?: typeof fetch; + githubToken?: string; + maxAttempts?: number; + minIntervalMs?: number; + maxIntervalMs?: number; + sleepFn?: (delayMs: number) => Promise; +}; + +export function pollCheckRuns( + repoFullName: string, + prNumber: number, + options?: PollCheckRunsOptions, +): Promise; diff --git a/packages/gittensory-miner/lib/ci-poller.js b/packages/gittensory-miner/lib/ci-poller.js new file mode 100644 index 0000000000..df0f54c84e --- /dev/null +++ b/packages/gittensory-miner/lib/ci-poller.js @@ -0,0 +1,228 @@ +const defaultApiBaseUrl = "https://api.github.com"; +const defaultMinIntervalMs = 60_000; +const defaultMaxIntervalMs = 5 * 60_000; +const defaultMaxAttempts = 1; +const githubApiVersion = "2022-11-28"; + +function normalizeApiBaseUrl(value) { + if (value === undefined) return defaultApiBaseUrl; + if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl; + let parsed; + try { + parsed = new URL(value.trim()); + } catch { + throw new Error("invalid_api_base_url"); + } + if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") { + throw new Error("invalid_api_base_url"); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/+$/, ""); +} + +function normalizePositiveInt(value, fallback, min, max) { + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); +} + +function normalizeOptions(options = {}) { + return { + apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl), + fetchFn: options.fetchFn ?? fetch, + githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "", + maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20), + minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000), + maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000), + sleepFn: + options.sleepFn ?? + ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), + }; +} + +function parseRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) { + throw new Error("invalid_repo_full_name"); + } + return { owner: owner.trim(), repo: repo.trim() }; +} + +function normalizePullNumber(value) { + if (!Number.isInteger(value) || value <= 0) throw new Error("invalid_pr_number"); + return value; +} + +function githubHeaders(githubToken) { + const headers = { + accept: "application/vnd.github+json", + "user-agent": "gittensory-miner", + "x-github-api-version": githubApiVersion, + }; + if (githubToken) headers.authorization = `Bearer ${githubToken}`; + return headers; +} + +function repoPath(target, suffix) { + return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; +} + +function apiUrl(apiBaseUrl, path, query = "") { + return `${apiBaseUrl}${path}${query}`; +} + +function githubError(response, payload) { + const code = `github_${response.status}`; + const githubMessage = + typeof payload?.message === "string" && payload.message.trim() ? payload.message : null; + const message = githubMessage ? `${code}: ${githubMessage}` : code; + return Object.assign(new Error(message), { code, githubMessage }); +} + +async function githubGetJsonResponse(url, options) { + const response = await options.fetchFn(url, { + method: "GET", + headers: githubHeaders(options.githubToken), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw githubError(response, payload); + } + return { payload, response }; +} + +async function githubGetJson(url, options) { + const { payload } = await githubGetJsonResponse(url, options); + return payload; +} + +function hasNextLink(response) { + return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? ""); +} + +function payloadTotalCount(payload) { + const totalCount = Number(payload?.total_count); + return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null; +} + +function normalizeConclusion(checkRun) { + if (!checkRun || typeof checkRun !== "object") return "pending"; + if (checkRun.status !== "completed") return "pending"; + switch (checkRun.conclusion) { + case "success": + case "skipped": + return "success"; + case "neutral": + return "neutral"; + case "failure": + case "cancelled": + case "timed_out": + case "action_required": + case "stale": + case "startup_failure": + return "failure"; + default: + return "pending"; + } +} + +function normalizeCheckRun(checkRun) { + return { + name: typeof checkRun?.name === "string" ? checkRun.name : "", + status: typeof checkRun?.status === "string" ? checkRun.status : "unknown", + conclusion: normalizeConclusion(checkRun), + detailsUrl: typeof checkRun?.details_url === "string" ? checkRun.details_url : null, + startedAt: typeof checkRun?.started_at === "string" ? checkRun.started_at : null, + completedAt: typeof checkRun?.completed_at === "string" ? checkRun.completed_at : null, + }; +} + +function aggregateConclusion(checks) { + if (checks.length === 0) return "pending"; + if (checks.some((check) => check.conclusion === "failure")) return "failure"; + if (checks.some((check) => check.conclusion === "pending")) return "pending"; + if (checks.every((check) => check.conclusion === "success")) return "success"; + return "neutral"; +} + +function backoffDelayMs(attemptIndex, options) { + const exponent = Math.min(10, Math.max(0, attemptIndex)); + return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent); +} + +async function fetchHeadSha(target, prNumber, options) { + const payload = await githubGetJson( + apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), + options, + ); + const headSha = payload?.head?.sha; + if (typeof headSha !== "string" || !headSha) throw new Error("github_pr_head_sha_missing"); + return headSha; +} + +async function fetchCheckRuns(target, headSha, options) { + const checks = []; + let page = 1; + let expectedTotalCount = null; + while (true) { + const { payload, response } = await githubGetJsonResponse( + apiUrl( + options.apiBaseUrl, + repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`), + `?per_page=100&page=${page}`, + ), + options, + ); + if (!Array.isArray(payload?.check_runs)) { + throw new Error("github_check_runs_malformed"); + } + const pageChecks = payload.check_runs.map(normalizeCheckRun); + checks.push(...pageChecks); + expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount; + if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) { + return checks; + } + if (pageChecks.length === 0) { + throw new Error("github_check_runs_pagination_incomplete"); + } + page += 1; + } +} + +export async function pollCheckRuns(repoFullName, prNumber, options = {}) { + const target = parseRepoFullName(repoFullName); + const normalizedPrNumber = normalizePullNumber(prNumber); + const normalizedOptions = normalizeOptions(options); + + let latest = { conclusion: "pending", checks: [], headSha: "", attempts: 0 }; + for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { + const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + const checks = await fetchCheckRuns(target, headSha, normalizedOptions); + latest = { + conclusion: aggregateConclusion(checks), + checks, + headSha, + attempts: attempt + 1, + }; + if (latest.conclusion !== "pending") { + const currentHeadSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + if (currentHeadSha === headSha) { + return latest; + } + latest = { + conclusion: "pending", + checks: [], + headSha: currentHeadSha, + attempts: attempt + 1, + }; + } + if (attempt === normalizedOptions.maxAttempts - 1) { + return latest; + } + await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); + } + + return latest; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 54edb7b0da..0bfab3d0a2 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-ci-poller.test.ts b/test/unit/miner-ci-poller.test.ts new file mode 100644 index 0000000000..0831a4c46e --- /dev/null +++ b/test/unit/miner-ci-poller.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from "vitest"; +import { pollCheckRuns } from "../../packages/gittensory-miner/lib/ci-poller.js"; + +const API = "https://api.github.com"; + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return Response.json(body, init); +} + +function prResponse(sha = "abc123") { + return jsonResponse({ head: { sha } }); +} + +function checkRun(name: string, status: string, conclusion: string | null = null) { + return { + name, + status, + conclusion, + details_url: `https://github.test/checks/${name}`, + started_at: "2026-07-01T00:00:00Z", + completed_at: status === "completed" ? "2026-07-01T00:01:00Z" : null, + }; +} + +function checksResponse(checks: unknown[], init: ResponseInit & { totalCount?: number } = {}) { + const { totalCount, ...responseInit } = init; + return jsonResponse({ total_count: totalCount ?? checks.length, check_runs: checks }, responseInit); +} + +describe("miner CI check-run poller (#2323)", () => { + it("fetches PR head SHA and check runs with read-only authenticated GET requests", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/repos/acme/widgets/pulls/42")) return prResponse("head-sha"); + if (url.endsWith("/repos/acme/widgets/commits/head-sha/check-runs?per_page=100&page=1")) { + return checksResponse([checkRun("validate", "completed", "success")]); + } + return jsonResponse({}, { status: 404 }); + }); + + const result = await pollCheckRuns("acme/widgets", 42, { + apiBaseUrl: API, + githubToken: "github-token", + fetchFn, + }); + + expect(result).toEqual({ + conclusion: "success", + headSha: "head-sha", + attempts: 1, + checks: [ + { + name: "validate", + status: "completed", + conclusion: "success", + detailsUrl: "https://github.test/checks/validate", + startedAt: "2026-07-01T00:00:00Z", + completedAt: "2026-07-01T00:01:00Z", + }, + ], + }); + expect(fetchFn).toHaveBeenCalledTimes(3); + expect(fetchFn.mock.calls.every(([, init]) => init?.method === "GET")).toBe(true); + expect( + fetchFn.mock.calls.every( + ([, init]) => (init?.headers as Record).authorization === "Bearer github-token", + ), + ).toBe(true); + }); + + it("rejects untrusted apiBaseUrl values before any token-bearing request", async () => { + const fetchFn = vi.fn(); + for (const apiBaseUrl of [ + "http://api.github.com", + "https://evil.example", + "https://api.github.com.evil.example", + "not a url", + ]) { + await expect( + pollCheckRuns("acme/widgets", 42, { + apiBaseUrl, + githubToken: "github-token", + fetchFn, + }), + ).rejects.toThrow("invalid_api_base_url"); + } + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("uses the default GitHub API base URL when apiBaseUrl is omitted", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "https://api.github.com/repos/acme/widgets/pulls/42") return prResponse("head-sha"); + if (url === "https://api.github.com/repos/acme/widgets/commits/head-sha/check-runs?per_page=100&page=1") { + return checksResponse([checkRun("validate", "completed", "success")]); + } + return jsonResponse({}, { status: 404 }); + }); + + await expect(pollCheckRuns("acme/widgets", 42, { fetchFn })).resolves.toMatchObject({ + conclusion: "success", + }); + }); + + it("follows paginated check-run responses before aggregating failures (regression for #2621)", async () => { + const pageOneChecks = Array.from({ length: 100 }, (_, index) => + checkRun(`success-${index}`, "completed", "success"), + ); + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/repos/acme/widgets/pulls/43")) return prResponse("many-checks-sha"); + if (url.endsWith("/repos/acme/widgets/commits/many-checks-sha/check-runs?per_page=100&page=1")) { + return checksResponse(pageOneChecks, { + totalCount: 101, + headers: { + link: `<${API}/repos/acme/widgets/commits/many-checks-sha/check-runs?per_page=100&page=2>; rel="next"`, + }, + }); + } + if (url.endsWith("/repos/acme/widgets/commits/many-checks-sha/check-runs?per_page=100&page=2")) { + return checksResponse([checkRun("late-failure", "completed", "failure")], { totalCount: 101 }); + } + return jsonResponse({}, { status: 404 }); + }); + + const result = await pollCheckRuns("acme/widgets", 43, { apiBaseUrl: API, fetchFn }); + + expect(result.conclusion).toBe("failure"); + expect(result.checks).toHaveLength(101); + expect(result.checks.at(-1)).toMatchObject({ name: "late-failure", conclusion: "failure" }); + expect(fetchFn).toHaveBeenCalledTimes(4); + }); + + it("normalizes failed terminal conclusions, including stale, to failure", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(prResponse()) + .mockResolvedValueOnce( + checksResponse([ + checkRun("validate", "completed", "success"), + checkRun("workers", "completed", "timed_out"), + checkRun("expired", "completed", "stale"), + ]), + ) + .mockResolvedValueOnce(prResponse()); + + await expect( + pollCheckRuns("acme/widgets", 7, { apiBaseUrl: API, fetchFn }), + ).resolves.toMatchObject({ + conclusion: "failure", + checks: [ + { name: "validate", conclusion: "success" }, + { name: "workers", conclusion: "failure" }, + { name: "expired", conclusion: "failure" }, + ], + }); + }); + + it("treats a completed stale check run as terminal failure (regression for #2621)", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(prResponse()) + .mockResolvedValueOnce(checksResponse([checkRun("github-timeout", "completed", "stale")])) + .mockResolvedValueOnce(prResponse()); + + await expect( + pollCheckRuns("acme/widgets", 7, { + apiBaseUrl: API, + fetchFn, + maxAttempts: 3, + sleepFn: vi.fn(), + }), + ).resolves.toMatchObject({ + conclusion: "failure", + attempts: 1, + checks: [{ name: "github-timeout", conclusion: "failure" }], + }); + }); + + it("keeps pending when checks are queued or absent", async () => { + const queuedFetch = vi + .fn() + .mockResolvedValueOnce(prResponse()) + .mockResolvedValueOnce(checksResponse([checkRun("validate", "queued")])); + await expect( + pollCheckRuns("acme/widgets", 8, { apiBaseUrl: API, fetchFn: queuedFetch }), + ).resolves.toMatchObject({ conclusion: "pending" }); + + const emptyFetch = vi + .fn() + .mockResolvedValueOnce(prResponse()) + .mockResolvedValueOnce(checksResponse([])); + await expect( + pollCheckRuns("acme/widgets", 8, { apiBaseUrl: API, fetchFn: emptyFetch }), + ).resolves.toMatchObject({ conclusion: "pending", checks: [] }); + }); + + it("returns neutral when terminal checks are neither failing nor all-success", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(prResponse()) + .mockResolvedValueOnce( + checksResponse([ + checkRun("validate", "completed", "success"), + checkRun("docs", "completed", "neutral"), + ]), + ) + .mockResolvedValueOnce(prResponse()); + + await expect( + pollCheckRuns("acme/widgets", 9, { apiBaseUrl: API, fetchFn }), + ).resolves.toMatchObject({ conclusion: "neutral" }); + }); + + it("backs off between pending polls until a terminal conclusion is observed", async () => { + const sleeps: number[] = []; + const fetchFn = vi + .fn() + .mockResolvedValueOnce(prResponse("head-sha")) + .mockResolvedValueOnce(checksResponse([checkRun("validate", "in_progress")])) + .mockResolvedValueOnce(prResponse("head-sha")) + .mockResolvedValueOnce(checksResponse([checkRun("validate", "queued")])) + .mockResolvedValueOnce(prResponse("head-sha")) + .mockResolvedValueOnce(checksResponse([checkRun("validate", "completed", "success")])) + .mockResolvedValueOnce(prResponse("head-sha")); + + const result = await pollCheckRuns("acme/widgets", 10, { + apiBaseUrl: API, + fetchFn, + maxAttempts: 3, + minIntervalMs: 100, + maxIntervalMs: 150, + sleepFn: async (delayMs) => { + sleeps.push(delayMs); + }, + }); + + expect(result).toMatchObject({ conclusion: "success", attempts: 3 }); + expect(sleeps).toEqual([100, 150]); + expect(fetchFn).toHaveBeenCalledTimes(7); + }); + + it("re-resolves the PR head on every retry so a force-push during backoff polls the new commit", async () => { + const sleeps: number[] = []; + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/repos/acme/widgets/pulls/12")) { + const pollCount = fetchFn.mock.calls.filter(([request]) => String(request).endsWith("/repos/acme/widgets/pulls/12")) + .length; + return prResponse(pollCount === 1 ? "old-head" : "new-head"); + } + if (url.endsWith("/repos/acme/widgets/commits/old-head/check-runs?per_page=100&page=1")) { + return checksResponse([checkRun("validate", "queued")]); + } + if (url.endsWith("/repos/acme/widgets/commits/new-head/check-runs?per_page=100&page=1")) { + return checksResponse([checkRun("validate", "completed", "success")]); + } + return jsonResponse({}, { status: 404 }); + }); + + const result = await pollCheckRuns("acme/widgets", 12, { + apiBaseUrl: API, + fetchFn, + maxAttempts: 2, + minIntervalMs: 100, + maxIntervalMs: 100, + sleepFn: async (delayMs) => { + sleeps.push(delayMs); + }, + }); + + expect(result).toMatchObject({ + conclusion: "success", + headSha: "new-head", + attempts: 2, + checks: [{ name: "validate", conclusion: "success" }], + }); + expect(sleeps).toEqual([100]); + expect(fetchFn).toHaveBeenCalledTimes(5); + }); + + it("re-checks the PR head before returning a terminal result and retries when it drifted", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/repos/acme/widgets/pulls/13")) { + const pollCount = fetchFn.mock.calls.filter(([request]) => String(request).endsWith("/repos/acme/widgets/pulls/13")) + .length; + if (pollCount <= 2) return prResponse(pollCount === 1 ? "old-head" : "new-head"); + return prResponse("new-head"); + } + if (url.endsWith("/repos/acme/widgets/commits/old-head/check-runs?per_page=100&page=1")) { + return checksResponse([checkRun("validate", "completed", "success")]); + } + if (url.endsWith("/repos/acme/widgets/commits/new-head/check-runs?per_page=100&page=1")) { + return checksResponse([checkRun("validate", "completed", "failure")]); + } + return jsonResponse({}, { status: 404 }); + }); + + const result = await pollCheckRuns("acme/widgets", 13, { + apiBaseUrl: API, + fetchFn, + maxAttempts: 2, + minIntervalMs: 100, + maxIntervalMs: 100, + sleepFn: vi.fn(), + }); + + expect(result).toMatchObject({ + conclusion: "failure", + headSha: "new-head", + attempts: 2, + checks: [{ name: "validate", conclusion: "failure" }], + }); + expect(fetchFn).toHaveBeenCalledTimes(6); + }); + + it("validates repo and PR input before fetching", async () => { + const fetchFn = vi.fn(); + + await expect( + pollCheckRuns("missing-slash", 1, { apiBaseUrl: API, fetchFn }), + ).rejects.toThrow("invalid_repo_full_name"); + await expect( + pollCheckRuns("acme/widgets", 0, { apiBaseUrl: API, fetchFn }), + ).rejects.toThrow("invalid_pr_number"); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("surfaces GitHub and malformed PR responses as deterministic errors", async () => { + const missingPr = vi.fn().mockResolvedValueOnce(jsonResponse({ message: "not found" }, { status: 404 })); + await expect( + pollCheckRuns("acme/widgets", 11, { apiBaseUrl: API, fetchFn: missingPr }), + ).rejects.toThrow("github_404: not found"); + + const missingSha = vi.fn().mockResolvedValueOnce(jsonResponse({ head: {} })); + await expect( + pollCheckRuns("acme/widgets", 11, { apiBaseUrl: API, fetchFn: missingSha }), + ).rejects.toThrow("github_pr_head_sha_missing"); + }); + + it("surfaces malformed check-run responses as deterministic errors", async () => { + const malformedChecks = vi + .fn() + .mockResolvedValueOnce(prResponse()) + .mockResolvedValueOnce(jsonResponse({ total_count: 1, check_runs: null })); + + await expect( + pollCheckRuns("acme/widgets", 12, { apiBaseUrl: API, fetchFn: malformedChecks }), + ).rejects.toThrow("github_check_runs_malformed"); + }); +});