import { appendFile, mkdir, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createGhApiRequest } from "./github-api.mjs"; const OUTPUT_DIRECTORY = join(".cache", "ci-pr-health"); const PAGE_SIZE = 100; const MAX_PULL_REQUEST_PAGES = 3; const MAX_WORKFLOW_RUN_PAGES = 5; const MAX_PULL_REQUEST_DETAILS = 40; const MAX_API_REQUESTS = MAX_PULL_REQUEST_PAGES + MAX_WORKFLOW_RUN_PAGES + MAX_PULL_REQUEST_DETAILS; const LOOKBACK_DAYS = 14; const MAX_LOOKBACK_DAYS = 30; const MAX_TOTAL_TIME_MS = 60_000; const SHA_PATTERN = /^[0-9a-f]{40}$/iu; const COMPLETED_CATEGORIES = new Map([ ["failure", "completed_failure"], ["timed_out", "completed_failure"], ["cancelled", "completed_canceled"], ["success", "completed_success"], ]); /** Read workflow/PR state and conservatively distinguish absent CI from unknown. */ export async function collectPrCiHealth({ repository, repositoryId, defaultBranch, lookbackDays = LOOKBACK_DAYS, now = Date.now(), apiRequest = createGhApiRequest(), }) { const identity = validateInputs({ repository, repositoryId, defaultBranch, lookbackDays, now, }); const observedAt = new Date(now).toISOString(); const cutoff = now - identity.lookbackDays * 24 * 60 * 60 * 1_000; const snapshot = await collectApiSnapshot({ apiRequest, identity, cutoff }); const classified = classifyPullRequests(snapshot); const { pulls, runs, budget } = snapshot; const errors = [pulls.error, runs.error, ...snapshot.detailsErrors].filter( Boolean, ); return { schemaVersion: 1, observedAt, repository: identity.repository, repositoryId: identity.repositoryId, defaultBranch: identity.defaultBranch, lookbackDays: identity.lookbackDays, scan: { status: errors.length === 0 && pulls.complete && runs.complete ? "complete" : "partial", cutoff: new Date(cutoff).toISOString(), pullRequestPages: pulls.pages, workflowRunPages: runs.pages, pullRequestInventoryComplete: pulls.complete, workflowRunInventoryComplete: runs.complete, pullRequestDetailsFetched: snapshot.detailsFetched, pullRequestDetailsLimit: MAX_PULL_REQUEST_DETAILS, apiRequests: budget.requests, apiRequestLimit: MAX_API_REQUESTS, errors, }, counts: countCategories(classified), pullRequests: classified, }; } async function collectApiSnapshot({ apiRequest, identity, cutoff }) { const budget = { requests: 0, startedAt: Date.now(), limitMs: MAX_TOTAL_TIME_MS, }; const requestGet = async (endpoint) => { if (budget.requests >= MAX_API_REQUESTS) throw new Error("lookup_request_budget_exhausted"); if (Date.now() - budget.startedAt >= budget.limitMs) throw new Error("lookup_time_budget_exhausted"); budget.requests += 1; return await apiRequest("GET", endpoint, budget); }; const pulls = await listOpenPullRequests({ requestGet, identity, }); const candidates = pulls.items.filter( (pull) => !pull.draft && Number.isFinite(Date.parse(pull.createdAt ?? "")) && Date.parse(pull.createdAt ?? "") >= cutoff && pull.base.ref === identity.defaultBranch && (identity.repositoryId === null || pull.base.repositoryId === identity.repositoryId), ); const runs = await listRecentWorkflowRuns({ requestGet, identity, cutoff, }); const detailResult = await resolveMergeability({ candidates, requestGet, identity, }); return { budget, pulls, runs, candidates, detailsFetched: detailResult.fetched, detailsErrors: detailResult.errors, }; } function classifyPullRequests(snapshot) { const { candidates, pulls, runs } = snapshot; const identityCounts = countHeadIdentities(pulls.items); return candidates.map((pull) => { const relevant = []; const ambiguousRuns = []; for (const run of runs.items) { const match = matchRunToPullRequest({ run, pull, identityCounts, inventoryComplete: pulls.complete, }); if (match.kind === "match") relevant.push({ run, basis: match.basis }); if (match.kind === "ambiguous") ambiguousRuns.push({ run, basis: "ambiguous_head_identity" }); } return { ...pull, ci: classifyPrCi({ pull, runs: relevant, runsComplete: runs.complete, pullRequestInventoryComplete: pulls.complete, ambiguousRuns, }), }; }); } async function resolveMergeability({ candidates, requestGet, identity }) { let detailsFetched = 0; const errors = new Set(); for (const pull of candidates) { if (pull.mergeable !== undefined) continue; if (detailsFetched >= MAX_PULL_REQUEST_DETAILS) { errors.add("pull_request_mergeability_lookup_budget_exhausted"); continue; } const result = await getPullRequestDetails({ requestGet, identity, pull }); detailsFetched += 1; if (result.error !== null) { errors.add(result.error); continue; } const { detail } = result; const identityChanged = detail.headSha !== pull.head.sha || detail.headRef !== pull.head.ref || detail.headRepositoryId !== pull.head.repositoryId || detail.baseRef !== pull.base.ref || detail.baseRepositoryId !== pull.base.repositoryId; if (identityChanged) { pull.identityValid = false; pull.identityChangedDuringScan = true; errors.add("pull_request_identity_changed_during_scan"); } else { pull.mergeable = detail.mergeable; pull.detailFetched = true; if (detail.mergeable === undefined) errors.add("pull_request_mergeability_unknown"); } } return { fetched: detailsFetched, errors: [...errors] }; } function countCategories(pulls) { const categories = [ "missing", "startup_failure", "queued_pending_executing", "approval_required", "completed_success", "completed_failure", "completed_canceled", "completed_other", "merge_conflict", "unknown", "deferred", ]; return Object.fromEntries( categories.map((category) => [ category, pulls.filter(({ ci }) => ci.category === category).length, ]), ); } /** Pure status classifier; missing requires complete, identity-safe inventories. */ export function classifyPrCi({ pull, runs, runsComplete, pullRequestInventoryComplete, ambiguousRuns, }) { if (!pull.identityValid) return result("unknown", "pull_request_head_identity_invalid", []); const evidence = [...runs, ...ambiguousRuns]; if (pull.mergeable === false) return result("merge_conflict", "github_reports_merge_conflict", evidence); // The newest collected run is not proven current until pagination completes: // an older-created run outside this snapshot may have been retried recently. if (!runsComplete) return result( evidence.length > 0 ? "unknown" : "deferred", evidence.length > 0 ? "workflow_run_inventory_incomplete" : "lookup_inventory_incomplete", evidence, ); const selection = newestRun(runs); if (!selection.known) return result("unknown", "workflow_run_order_unknown", evidence); const latest = selection.latest; if ( ambiguousRuns.some((candidate) => { const order = latest ? compareRunEvidence(candidate, latest) : null; return order === null || order >= 0; }) ) return result("unknown", "workflow_run_identity_ambiguous", evidence); if (latest !== undefined) { const run = latest.run; if (run.conclusion === "startup_failure") return result("startup_failure", "workflow_startup_failure", evidence); if (run.status === "waiting" || run.conclusion === "action_required") return result( "approval_required", "workflow_waiting_for_approval_or_gate", evidence, ); if (["queued", "requested", "pending", "in_progress"].includes(run.status)) return result( "queued_pending_executing", `workflow_${run.status}`, evidence, ); if (run.status === "completed") return result( COMPLETED_CATEGORIES.get(run.conclusion) ?? "completed_other", run.conclusion === "success" ? "workflow_succeeded" : `workflow_${run.conclusion ?? "unknown_conclusion"}`, evidence, ); return result("unknown", "workflow_status_unrecognized", evidence); } if (!pullRequestInventoryComplete) return result("deferred", "lookup_inventory_incomplete", []); if (pull.mergeable !== true) return result("unknown", "pull_request_mergeability_unknown", []); return result("missing", "no_pull_request_event_run_for_current_head", []); } /** Match only a pull_request event proven to belong to this immutable PR head. */ export function matchRunToPullRequest({ run, pull, identityCounts, inventoryComplete = true, }) { if (run?.event !== "pull_request") return { kind: "none" }; if (!pull.identityValid || !isRecord(run)) return { kind: "none" }; const association = matchExplicitPullAssociation(run, pull); if (association === "match") return { kind: "match", basis: "event_pr_number_and_head_sha" }; if (association === "ambiguous") return { kind: "ambiguous" }; if (association === "other" || !sameHeadRepositoryAndRef(run, pull)) return { kind: "none" }; if (!uniqueHeadRepositoryAndRef(pull, identityCounts, inventoryComplete)) return { kind: "ambiguous" }; if (isSha(run.head_sha) && run.head_sha.toLowerCase() === pull.head.sha) return { kind: "match", basis: "event_unique_head_ref_and_repository_id" }; return { kind: "none" }; } function matchExplicitPullAssociation(run, pull) { const associations = Array.isArray(run.pull_requests) ? run.pull_requests : []; if (associations.length === 0) return "absent"; const matchingNumber = associations.filter( (item) => item?.number === pull.number, ); if (matchingNumber.length === 0) return "other"; const exactHead = matchingNumber.find( (item) => item?.head?.sha === pull.head.sha, ); if (exactHead === undefined) { return matchingNumber.some( (item) => typeof item?.head?.sha === "string" && item.head.sha !== pull.head.sha, ) ? "other" : "absent"; } const associatedHeadRepositoryId = positiveInteger(exactHead.head?.repo?.id); const runHeadRepositoryId = positiveInteger(run.head_repository?.id); if (associatedHeadRepositoryId === null || runHeadRepositoryId === null) return "ambiguous"; if ( associatedHeadRepositoryId !== pull.head.repositoryId || runHeadRepositoryId !== pull.head.repositoryId ) return "other"; const baseRefMatches = typeof exactHead.base?.ref !== "string" || exactHead.base.ref === pull.base.ref; const baseRepositoryId = positiveInteger(exactHead.base?.repo?.id); const baseRepositoryMatches = baseRepositoryId === null || baseRepositoryId === pull.base.repositoryId; return baseRefMatches && baseRepositoryMatches ? "match" : "other"; } function sameHeadRepositoryAndRef(run, pull) { return ( run.head_branch === pull.head.ref && run.head_repository?.id === pull.head.repositoryId ); } function uniqueHeadRepositoryAndRef(pull, identityCounts, inventoryComplete) { if (!inventoryComplete) return false; const key = headIdentityKey(pull.head.repositoryId, pull.head.ref); const unknownRepositoryKey = headIdentityKey(null, pull.head.ref); return ( identityCounts.get(key) === 1 && (identityCounts.get(unknownRepositoryKey) ?? 0) === 0 ); } function normalizePullRequest(raw, identity) { const number = positiveInteger(raw?.number); const base = normalizeBase(raw?.base); const head = normalizeHead(raw?.head); const draft = raw?.draft === true; return { number, draft, createdAt: typeof raw?.created_at === "string" ? raw.created_at : null, url: typeof raw?.html_url === "string" ? raw.html_url : null, base, head, mergeable: typeof raw?.mergeable === "boolean" ? raw.mergeable : undefined, identityValid: hasValidPullIdentity({ raw, identity, number, base, head }), }; } function normalizeBase(raw) { return { ref: typeof raw?.ref === "string" ? raw.ref : null, repositoryId: positiveInteger(raw?.repo?.id), }; } function normalizeHead(raw) { return { repositoryId: positiveInteger(raw?.repo?.id), repository: typeof raw?.repo?.full_name === "string" ? raw.repo.full_name : null, ref: typeof raw?.ref === "string" ? raw.ref : null, sha: typeof raw?.sha === "string" && isSha(raw.sha) ? raw.sha.toLowerCase() : null, }; } function hasValidPullIdentity({ raw, identity, number, base, head }) { return ( number !== null && typeof raw?.draft === "boolean" && base.ref === identity.defaultBranch && (identity.repositoryId === null || base.repositoryId === identity.repositoryId) && head.repositoryId !== null && head.sha !== null && head.ref !== null ); } function countHeadIdentities(pulls) { const counts = new Map(); for (const pull of pulls) { if (typeof pull.head.ref !== "string") continue; const key = headIdentityKey(pull.head.repositoryId, pull.head.ref); counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; } function headIdentityKey(repositoryId, ref) { return `${String(repositoryId)}\u0000${ref}`; } function newestRun(runs) { let latest = runs[0]; for (const candidate of runs) { if (latest && (compareRunEvidence(candidate, latest) ?? 0) > 0) latest = candidate; } const known = runs.every((candidate) => { if (candidate === latest) return true; const order = compareRunEvidence(candidate, latest); return order !== null && order <= 0; }); return { known, latest }; } // Attempt ordering is meaningful within one run; different run IDs are not clocks. function compareRunEvidence(left, right) { const leftRun = left.run; const rightRun = right.run; const id = positiveInteger(leftRun.id); if (id !== null && id === positiveInteger(rightRun.id)) { const leftAttempt = positiveInteger(leftRun.run_attempt); const rightAttempt = positiveInteger(rightRun.run_attempt); if (leftAttempt === null || rightAttempt === null) return null; if (leftAttempt !== rightAttempt) return leftAttempt - rightAttempt; return leftRun.status === rightRun.status && leftRun.conclusion === rightRun.conclusion ? 0 : null; } // A retry may start long after its original creation; without an attempt // clock, creation dates only order first attempts from different runs. if ( positiveInteger(leftRun.run_attempt) !== 1 || positiveInteger(rightRun.run_attempt) !== 1 ) return null; const time = Date.parse(leftRun.created_at ?? "") - Date.parse(rightRun.created_at ?? ""); return Number.isFinite(time) && time !== 0 ? time : null; } function result(category, reason, runs) { return { category, reason, runs: runs.map(({ run, basis }) => ({ id: positiveInteger(run.id), attempt: positiveInteger(run.run_attempt), status: typeof run.status === "string" ? run.status : null, conclusion: typeof run.conclusion === "string" ? run.conclusion : null, headSha: isSha(run.head_sha) ? run.head_sha.toLowerCase() : null, createdAt: typeof run.created_at === "string" ? run.created_at : null, url: typeof run.html_url === "string" ? run.html_url : null, identityBasis: basis, })), }; } async function listOpenPullRequests({ requestGet, identity }) { const items = []; let complete = false; let pages = 0; let error = null; for (let page = 1; page <= MAX_PULL_REQUEST_PAGES; page += 1) { try { const endpoint = `repos/${identity.repository}/pulls?state=open&sort=created&direction=desc&per_page=${PAGE_SIZE}&page=${page}`; const response = await requestGet(endpoint); if (!Array.isArray(response)) throw new Error("pull_request_page_malformed"); pages += 1; const normalized = response.map((raw) => normalizePullRequest(raw, identity), ); if ( normalized.some( (pull) => !Number.isFinite(Date.parse(pull.createdAt ?? "")), ) ) throw new Error("pull_request_page_malformed"); items.push(...normalized); if (response.length < PAGE_SIZE) { complete = true; break; } } catch (cause) { error = safeErrorCode(cause); break; } } return { items, pages, complete, error }; } async function listRecentWorkflowRuns({ requestGet, identity, cutoff }) { const items = []; let complete = false; let pages = 0; let error = null; for (let page = 1; page <= MAX_WORKFLOW_RUN_PAGES; page += 1) { try { const cutoffDate = new Date(cutoff).toISOString().slice(0, 10); const endpoint = `repos/${identity.repository}/actions/workflows/ci.yml/runs?event=pull_request&created=%3E%3D${cutoffDate}&per_page=${PAGE_SIZE}&page=${page}`; const response = await requestGet(endpoint); if (!isRecord(response) || !Array.isArray(response.workflow_runs)) throw new Error("workflow_run_page_malformed"); pages += 1; if (!response.workflow_runs.every(isRecord)) throw new Error("workflow_run_page_malformed"); items.push(...response.workflow_runs); if (response.workflow_runs.length < PAGE_SIZE) { complete = true; break; } } catch (cause) { error = safeErrorCode(cause); break; } } return { items, pages, complete, error }; } async function getPullRequestDetails({ requestGet, identity, pull }) { if (pull.number === null) return { detail: null, error: "pull_request_detail_identity_missing" }; try { const response = await requestGet( `repos/${identity.repository}/pulls/${pull.number}`, ); if (!isRecord(response)) return { detail: null, error: "pull_request_detail_malformed" }; return { detail: { mergeable: typeof response.mergeable === "boolean" ? response.mergeable : undefined, headSha: isSha(response.head?.sha) ? response.head.sha.toLowerCase() : null, headRef: typeof response.head?.ref === "string" ? response.head.ref : null, headRepositoryId: positiveInteger(response.head?.repo?.id), baseRef: typeof response.base?.ref === "string" ? response.base.ref : null, baseRepositoryId: positiveInteger(response.base?.repo?.id), }, error: null, }; } catch (cause) { return { detail: null, error: safeErrorCode(cause) }; } } function validateInputs({ repository, repositoryId, defaultBranch, lookbackDays, now, }) { if ( typeof repository !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository) ) throw new Error( "CI_REPOSITORY must be an owner/name repository identifier", ); if ( typeof defaultBranch !== "string" || defaultBranch.length === 0 || /[\r\n]/u.test(defaultBranch) ) throw new Error("CI_DEFAULT_BRANCH must be a nonempty branch name"); const parsedRepositoryId = repositoryId === undefined || repositoryId === null || repositoryId === "" ? null : positiveInteger(repositoryId); if ( repositoryId !== undefined && repositoryId !== null && repositoryId !== "" && parsedRepositoryId === null ) throw new Error("CI_REPOSITORY_ID must be a positive safe integer"); if ( !Number.isInteger(lookbackDays) || lookbackDays < 1 || lookbackDays > MAX_LOOKBACK_DAYS ) throw new Error( `lookbackDays must be an integer from 1 to ${String(MAX_LOOKBACK_DAYS)}`, ); if (!Number.isFinite(now)) throw new Error("now must be a finite timestamp"); return { repository, repositoryId: parsedRepositoryId, defaultBranch, lookbackDays, }; } function positiveInteger(value) { const number = typeof value === "number" ? value : Number(value); return Number.isSafeInteger(number) && number > 0 ? number : null; } function isSha(value) { return typeof value === "string" && SHA_PATTERN.test(value); } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function safeErrorCode(cause) { if (cause?.message === "lookup_time_budget_exhausted") return "lookup_time_budget_exhausted"; if (cause?.message === "lookup_request_budget_exhausted") return "lookup_request_budget_exhausted"; if (cause?.message === "github_api_malformed_json") return "github_api_malformed_json"; if (cause?.message?.endsWith("_malformed")) return cause.message; if (cause?.code === "ETIMEDOUT" || cause?.killed) return "github_api_timeout"; return "github_api_request_failed"; } export async function writePrCiHealthReport( report, outputDirectory = OUTPUT_DIRECTORY, ) { const jsonPath = join(outputDirectory, "report.json"); const markdownPath = join(outputDirectory, "summary.md"); await mkdir(outputDirectory, { recursive: true }); await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); await writeFile(markdownPath, renderMarkdownSummary(report), "utf8"); return { jsonPath, markdownPath }; } export function renderMarkdownSummary(report) { const rows = report.pullRequests.map( (pull) => `| #${String(pull.number ?? "?")} | ${escapeCell(pull.head.repository ?? "unknown")} | ${escapeCell(pull.head.ref ?? "unknown")} | \`${pull.head.sha ?? "unknown"}\` | ${pull.ci.category} | ${escapeCell(pull.ci.reason)} |`, ); return [ "# Pull request CI health snapshot", "", `Observed ${report.observedAt} for \`${report.repository}\`, target branch \`${escapeCell(report.defaultBranch)}\`; recent window ${String(report.lookbackDays)} days. Scan: **${report.scan.status}**.`, "", "This is read-only point-in-time telemetry. Missing is reported only when PR and workflow-run inventories are complete and the current immutable PR head identity is unambiguous. Incomplete lookups remain deferred or unknown.", "", `API pages: ${String(report.scan.pullRequestPages)} PR, ${String(report.scan.workflowRunPages)} workflow runs; ${String(report.scan.apiRequests)} bounded requests. Errors: ${report.scan.errors.length === 0 ? "none" : report.scan.errors.join(", ")}.`, "", "| PR | Head repository | Head ref | Head SHA | CI classification | Evidence |", "| ---: | --- | --- | --- | --- | --- |", ...rows, "", "Categories: missing, startup failure, queued/pending/executing, approval required, completed outcomes, merge conflict, unknown, and deferred.", "", ].join("\n"); } function escapeCell(value) { return String(value) .replaceAll("|", "\\|") .replaceAll("\n", " ") .replaceAll("\r", " "); } if ( process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { try { const report = await collectPrCiHealth({ repository: process.env.CI_REPOSITORY, repositoryId: process.env.CI_REPOSITORY_ID, defaultBranch: process.env.CI_DEFAULT_BRANCH, }); const paths = await writePrCiHealthReport( report, process.env.CI_PR_HEALTH_DIRECTORY ?? OUTPUT_DIRECTORY, ); if (process.env.GITHUB_STEP_SUMMARY) await appendFile( process.env.GITHUB_STEP_SUMMARY, renderMarkdownSummary(report), "utf8", ); process.stdout.write( `Wrote ${report.pullRequests.length} recent PR CI records to ${paths.jsonPath}\n`, ); } catch (cause) { process.stderr.write( `PR CI health telemetry failed: ${cause instanceof Error ? cause.message : "unknown_error"}\n`, ); process.exitCode = 1; } }