|
| 1 | +// CI check-run signals, read from structured GitHub check-run API fields only — no diff/text/log parsing. |
| 2 | +// Surfaces two things a PR's own checks tab doesn't summarize at a glance: a named check that only passed after |
| 3 | +// one or more earlier non-success attempts at the SAME head commit (retried, not stable-green), and a completed |
| 4 | +// check run whose wall-clock duration crossed a fixed threshold (a slow job worth investigating). Reads only |
| 5 | +// documented fields from the GitHub check-runs API (name, status, conclusion, started_at, completed_at) and |
| 6 | +// compares them — no ambiguous-syntax parsing, so it cannot suffer a patch scanner's edge cases. Pure GitHub- |
| 7 | +// metadata read, no repo content, no logs. Fail-safe: no token, no head SHA, a bad repo slug, or a fetch error all |
| 8 | +// yield no finding. Bounded to one page of check-runs (MAX_CHECK_RUNS) — realistic CI setups fit comfortably. |
| 9 | +import type { |
| 10 | + AnalyzerDiagnostics, |
| 11 | + CiCheckSignalFinding, |
| 12 | + EnrichRequest, |
| 13 | +} from "../types.js"; |
| 14 | +import type { AnalysisContext } from "../analysis-context.js"; |
| 15 | +import { boundedFetchJson } from "../external-fetch.js"; |
| 16 | + |
| 17 | +const GITHUB_API = "https://api.github.com"; |
| 18 | +const SLUG_RE = /^[A-Za-z0-9._-]+$/; |
| 19 | +const MAX_CHECK_RUNS = 100; |
| 20 | +const MAX_FINDINGS = 25; |
| 21 | +// A completed run at or beyond this wall-clock duration is flagged as long-running. |
| 22 | +const LONG_RUN_THRESHOLD_MS = 15 * 60 * 1000; |
| 23 | +// Conclusions that count as a non-success ATTEMPT for the retry signal. `neutral`/`skipped` are intentionally |
| 24 | +// excluded — neither means the job failed and had to be retried. |
| 25 | +const FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]); |
| 26 | + |
| 27 | +interface ScanOptions { |
| 28 | + signal?: AbortSignal; |
| 29 | + analysis?: Pick<AnalysisContext, "fetchJson">; |
| 30 | + diagnostics?: AnalyzerDiagnostics; |
| 31 | +} |
| 32 | + |
| 33 | +/** The slice of a GitHub check-run list item this analyzer reads. */ |
| 34 | +interface CheckRunListItem { |
| 35 | + name?: string; |
| 36 | + status?: string; |
| 37 | + conclusion?: string | null; |
| 38 | + started_at?: string; |
| 39 | + completed_at?: string | null; |
| 40 | +} |
| 41 | + |
| 42 | +interface CheckRunsResponse { |
| 43 | + check_runs?: CheckRunListItem[]; |
| 44 | +} |
| 45 | + |
| 46 | +function githubHeaders(token: string): Record<string, string> { |
| 47 | + return { |
| 48 | + Authorization: `Bearer ${token}`, |
| 49 | + Accept: "application/vnd.github+json", |
| 50 | + "X-GitHub-Api-Version": "2022-11-28", |
| 51 | + }; |
| 52 | +} |
| 53 | + |
| 54 | +async function fetchCheckRuns( |
| 55 | + owner: string, |
| 56 | + repo: string, |
| 57 | + headSha: string, |
| 58 | + headers: Record<string, string>, |
| 59 | + fetchFn: typeof fetch, |
| 60 | + signal: AbortSignal | undefined, |
| 61 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 62 | +): Promise<CheckRunListItem[] | null> { |
| 63 | + const url = |
| 64 | + `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/` + |
| 65 | + `${encodeURIComponent(headSha)}/check-runs?per_page=${MAX_CHECK_RUNS}`; |
| 66 | + const fetchOptions = { |
| 67 | + endpointCategory: "github-check-runs", |
| 68 | + headers, |
| 69 | + signal, |
| 70 | + fetchImpl: fetchFn, |
| 71 | + diagnostics: options.diagnostics, |
| 72 | + phase: "ci-check-signals", |
| 73 | + subcall: "github-check-runs", |
| 74 | + maxBytes: 512 * 1024, |
| 75 | + }; |
| 76 | + const response = options.analysis |
| 77 | + ? await options.analysis.fetchJson<CheckRunsResponse>(url, fetchOptions) |
| 78 | + : await boundedFetchJson<CheckRunsResponse>(url, fetchOptions); |
| 79 | + return response.ok && Array.isArray(response.data.check_runs) ? response.data.check_runs : null; |
| 80 | +} |
| 81 | + |
| 82 | +/** Groups completed runs by name (in the order the API returned them) and, within each group, orders them by |
| 83 | + * `started_at` so "the last attempt" and "attempts before it" are well-defined. Runs still in progress/queued |
| 84 | + * (no conclusion yet) are excluded — they are not a finished attempt. Pure. */ |
| 85 | +function groupCompletedRunsByName(runs: CheckRunListItem[]): Map<string, CheckRunListItem[]> { |
| 86 | + const groups = new Map<string, CheckRunListItem[]>(); |
| 87 | + for (const run of runs) { |
| 88 | + if (run.status !== "completed" || !run.name || !run.conclusion || !run.started_at) continue; |
| 89 | + const list = groups.get(run.name); |
| 90 | + if (list) list.push(run); |
| 91 | + else groups.set(run.name, [run]); |
| 92 | + } |
| 93 | + for (const list of groups.values()) { |
| 94 | + list.sort((a, b) => (a.started_at! < b.started_at! ? -1 : a.started_at! > b.started_at! ? 1 : 0)); |
| 95 | + } |
| 96 | + return groups; |
| 97 | +} |
| 98 | + |
| 99 | +/** Pure reduction: completed check-runs at one head commit → retry/long-run findings, in a stable, bounded order |
| 100 | + * (retries first, then long-running runs, each in the order their check name was first seen). */ |
| 101 | +export function analyzeCheckRuns(runs: CheckRunListItem[]): CiCheckSignalFinding[] { |
| 102 | + const findings: CiCheckSignalFinding[] = []; |
| 103 | + const groups = groupCompletedRunsByName(runs); |
| 104 | + |
| 105 | + for (const [name, attempts] of groups) { |
| 106 | + if (findings.length >= MAX_FINDINGS) break; |
| 107 | + const last = attempts[attempts.length - 1]!; |
| 108 | + if (last.conclusion === "success") { |
| 109 | + const failedAttempts = attempts.slice(0, -1).filter((run) => FAILURE_CONCLUSIONS.has(run.conclusion!)).length; |
| 110 | + if (failedAttempts > 0) { |
| 111 | + findings.push({ checkName: name, kind: "retried-after-failure", failedAttempts }); |
| 112 | + } |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + for (const [name, attempts] of groups) { |
| 117 | + for (const run of attempts) { |
| 118 | + if (findings.length >= MAX_FINDINGS) return findings; |
| 119 | + if (!run.completed_at) continue; |
| 120 | + const durationMs = Date.parse(run.completed_at) - Date.parse(run.started_at!); |
| 121 | + if (Number.isFinite(durationMs) && durationMs >= LONG_RUN_THRESHOLD_MS) { |
| 122 | + findings.push({ |
| 123 | + checkName: name, |
| 124 | + kind: "long-running-check", |
| 125 | + durationMinutes: Math.round(durationMs / 60_000), |
| 126 | + }); |
| 127 | + } |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + return findings; |
| 132 | +} |
| 133 | + |
| 134 | +/** Analyzer entrypoint: a PR's head-commit check-runs → retry/long-run CI signal findings. Fail-safe — no token, |
| 135 | + * no head SHA, a bad repo slug, or a fetch error all yield no finding rather than an error. */ |
| 136 | +export async function scanCiCheckSignals( |
| 137 | + req: EnrichRequest, |
| 138 | + fetchFn: typeof fetch = fetch, |
| 139 | + options: ScanOptions = {}, |
| 140 | +): Promise<CiCheckSignalFinding[]> { |
| 141 | + const { repoFullName, githubToken, headSha } = req; |
| 142 | + if (!githubToken || !headSha) return []; |
| 143 | + const parts = repoFullName.split("/"); |
| 144 | + const owner = parts[0]; |
| 145 | + const repo = parts[1]; |
| 146 | + if (parts.length !== 2 || !owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return []; |
| 147 | + |
| 148 | + const headers = githubHeaders(githubToken); |
| 149 | + const runs = await fetchCheckRuns(owner, repo, headSha, headers, fetchFn, options.signal, options); |
| 150 | + if (!runs) return []; |
| 151 | + |
| 152 | + return analyzeCheckRuns(runs); |
| 153 | +} |
0 commit comments