diff --git a/review-enrichment/src/analyzers/eol-check.ts b/review-enrichment/src/analyzers/eol-check.ts new file mode 100644 index 0000000000..0968bb0058 --- /dev/null +++ b/review-enrichment/src/analyzers/eol-check.ts @@ -0,0 +1,137 @@ +// End-of-life runtime regression analyzer (#1504). Parses runtime/base-image/engine version pins a PR changes +// (Dockerfile FROM, .nvmrc, go.mod) and checks endoflife.date (free, no key) — flagging a pin onto a release that +// is already past end-of-support or goes EOL within 90 days. The no-checkout reviewer has no EOL calendar; this does. +import type { EnrichRequest, EolFinding } from "../types.js"; + +// Docker image / source → endoflife.date product slug. +const DOCKER_PRODUCT: Record = { + node: "nodejs", + python: "python", + golang: "go", + ruby: "ruby", + php: "php", + debian: "debian", + ubuntu: "ubuntu", + alpine: "alpine", +}; + +interface VersionPin { + file: string; + product: string; + version: string; +} + +// Leading numeric version from a tag/value: "3.8-slim" → "3.8", "18" → "18", "latest" → null. +function leadingVersion(value: string): string | null { + return /^v?(\d+(?:\.\d+)*)/.exec(value.trim())?.[1] ?? null; +} + +function isDockerfile(path: string): boolean { + const base = path.split("/").pop() ?? path; + return base === "Dockerfile" || /\.dockerfile$/i.test(base); +} + +/** Pull (product, version) pins out of the added lines of changed Dockerfile / .nvmrc / go.mod. Pure. */ +export function extractVersionPins( + files: NonNullable, +): VersionPin[] { + const pins: VersionPin[] = []; + for (const file of files) { + if (!file.patch) continue; + const base = file.path.split("/").pop() ?? file.path; + for (const raw of file.patch.split("\n")) { + if (raw[0] !== "+" || raw.startsWith("+++")) continue; + const line = raw.slice(1).trim(); + if (isDockerfile(file.path)) { + const match = + /^FROM\s+(?:--platform=\S+\s+)?([a-z0-9._/-]+):([a-zA-Z0-9._-]+)/i.exec( + line, + ); + if (match) { + const product = + DOCKER_PRODUCT[(match[1]!.split("/").pop() ?? "").toLowerCase()]; + const version = leadingVersion(match[2]!); + if (product && version) + pins.push({ file: file.path, product, version }); + } + } else if (base === ".nvmrc") { + const version = leadingVersion(line); + if (version) pins.push({ file: file.path, product: "nodejs", version }); + } else if (base === "go.mod") { + const match = /^go\s+(\d+\.\d+)/.exec(line); + if (match) + pins.push({ file: file.path, product: "go", version: match[1]! }); + } + } + } + return pins; +} + +interface Cycle { + cycle: string; + eol: string | boolean; +} + +// Match a version to its release cycle — most specific (longest) cycle prefix wins (so "18.17" → "18", "3.8" → "3.8"). +function matchCycle(cycles: Cycle[], version: string): Cycle | undefined { + const sorted = [...cycles].sort((a, b) => b.cycle.length - a.cycle.length); + return ( + sorted.find( + (c) => version === c.cycle || version.startsWith(c.cycle + "."), + ) ?? sorted.find((c) => version.split(".")[0] === c.cycle) + ); +} + +function eolStatus( + eol: string | boolean, + now: number, +): EolFinding["status"] | null { + if (eol === false) return null; + if (eol === true) return "eol"; + const eolMs = new Date(eol).getTime(); + if (!Number.isFinite(eolMs)) return null; + if (eolMs < now) return "eol"; + if (eolMs < now + 90 * 86_400_000) return "soon"; + return null; +} + +async function fetchCycles( + product: string, + fetchImpl: typeof fetch, +): Promise { + const response = await fetchImpl( + `https://endoflife.date/api/${product}.json`, + ); + if (!response.ok) return null; + const data = (await response.json()) as Cycle[]; + return Array.isArray(data) ? data : null; +} + +/** Analyzer entrypoint: changed runtime pins → endoflife.date → only the EOL / EOL-soon ones. `now` injectable. */ +export async function scanEol( + req: EnrichRequest, + fetchImpl: typeof fetch = fetch, + now: number = Date.now(), +): Promise { + const findings: EolFinding[] = []; + const seen = new Set(); + for (const pin of extractVersionPins(req.files ?? [])) { + const key = `${pin.product}:${pin.version}`; + if (seen.has(key)) continue; + seen.add(key); + const cycles = await fetchCycles(pin.product, fetchImpl); + if (!cycles) continue; + const cycle = matchCycle(cycles, pin.version); + if (!cycle) continue; + const status = eolStatus(cycle.eol, now); + if (status) + findings.push({ + file: pin.file, + product: pin.product, + version: pin.version, + eol: String(cycle.eol), + status, + }); + } + return findings; +} diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index 54eda8355e..75d91b181f 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -12,6 +12,7 @@ import { scanSecrets } from "./analyzers/secret-scan.js"; import { scanLicenses } from "./analyzers/license-check.js"; import { scanInstallScripts } from "./analyzers/install-scripts.js"; import { scanActionPins } from "./analyzers/actions-pin.js"; +import { scanEol } from "./analyzers/eol-check.js"; import { renderBrief } from "./render.js"; type AnalyzerFn = (req: EnrichRequest) => Promise; @@ -23,6 +24,7 @@ const ANALYZERS: Record = { license: (req) => scanLicenses(req), installScript: (req) => scanInstallScripts(req), actionPin: (req) => scanActionPins(req), + eol: (req) => scanEol(req), }; function withTimeout(promise: Promise, ms: number): Promise { diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index fd46922ed5..993f36f085 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -82,6 +82,17 @@ export function renderBrief( } } + const eol = findings.eol ?? []; + if (eol.length) { + lines.push("### End-of-life runtimes (upgrade before merging)"); + for (const item of eol) { + const label = item.status === "eol" ? "END-OF-LIFE" : "EOL soon"; + lines.push( + `- \`${item.file}\` pins ${item.product} ${item.version} — **${label}** (EOL ${item.eol})`, + ); + } + } + if (!lines.length) return { promptSection: "", systemSuffix: "" }; const header = diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index ff505df4a5..785650f7b0 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -75,6 +75,15 @@ export interface ActionPinFinding { ref: string; } +/** A runtime/base-image/engine pinned to a release that is past end-of-support (or EOL within 90 days). */ +export interface EolFinding { + file: string; + product: string; + version: string; + eol: string; + status: "eol" | "soon"; +} + /** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */ export interface BriefFindings { dependency?: DependencyFinding[]; @@ -82,6 +91,7 @@ export interface BriefFindings { license?: LicenseFinding[]; actionPin?: ActionPinFinding[]; installScript?: InstallScriptFinding[]; + eol?: EolFinding[]; } export type AnalyzerStatus = "ok" | "degraded" | "skipped"; diff --git a/review-enrichment/test/enrichment.test.ts b/review-enrichment/test/enrichment.test.ts index e2606badbd..763bd0aefb 100644 --- a/review-enrichment/test/enrichment.test.ts +++ b/review-enrichment/test/enrichment.test.ts @@ -14,6 +14,17 @@ import { scanWorkflowPins, scanActionPins, } from "../dist/analyzers/actions-pin.js"; +import { scanEol, extractVersionPins } from "../dist/analyzers/eol-check.js"; + +const NOW = new Date("2026-06-26").getTime(); +const eolFetch = + (cycles, ok = true) => + async () => ({ ok, json: async () => cycles }); +const dockerfilePatch = (tag) => ({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "Dockerfile", patch: `@@ -1,0 +1,1 @@\n+FROM node:${tag}` }], +}); const npmFetch = (scripts, time = {}) => @@ -469,3 +480,90 @@ test("buildBrief: action-pin analyzer runs (pure, no network)", async () => { globalThis.fetch = realFetch; } }); + +test("extractVersionPins: Dockerfile FROM + .nvmrc + go.mod; latest skipped", () => { + const pins = extractVersionPins([ + { + path: "Dockerfile", + patch: "@@ -1,0 +1,2 @@\n+FROM python:3.8-slim\n+FROM node:latest", + }, + { path: ".nvmrc", patch: "@@ -1,0 +1,1 @@\n+v18.17.0" }, + { path: "go.mod", patch: "@@ -1,0 +1,1 @@\n+go 1.20" }, + ]); + const byProduct = Object.fromEntries(pins.map((p) => [p.product, p])); + assert.equal(byProduct.python.version, "3.8"); + assert.equal(byProduct.nodejs.version, "18.17.0"); + assert.equal(byProduct.go.version, "1.20"); + assert.ok( + !pins.some((p) => p.product === "nodejs" && p.file === "Dockerfile"), + ); // node:latest skipped +}); + +test("scanEol: flags EOL + EOL-soon, skips current + fetch-fail (injected now)", async () => { + const cycles = [ + { cycle: "18", eol: "2023-06-01" }, + { cycle: "20", eol: "2026-07-01" }, + { cycle: "22", eol: "2027-04-30" }, + { cycle: "24", eol: false }, + ]; + const fetchImpl = eolFetch(cycles); + assert.equal( + (await scanEol(dockerfilePatch("18"), fetchImpl, NOW))[0].status, + "eol", + ); + assert.equal( + (await scanEol(dockerfilePatch("20"), fetchImpl, NOW))[0].status, + "soon", + ); + assert.equal( + (await scanEol(dockerfilePatch("22"), fetchImpl, NOW)).length, + 0, + ); + assert.equal( + (await scanEol(dockerfilePatch("24"), fetchImpl, NOW)).length, + 0, + ); // eol:false + assert.equal( + (await scanEol(dockerfilePatch("18"), eolFetch([], false), NOW)).length, + 0, + ); +}); + +test("renderBrief: renders the EOL block", () => { + const r = renderBrief({ + eol: [ + { + file: "Dockerfile", + product: "nodejs", + version: "18", + eol: "2023-06-01", + status: "eol", + }, + ], + }); + assert.match(r.promptSection, /End-of-life runtimes/); + assert.match( + r.promptSection, + /pins nodejs 18 — \*\*END-OF-LIFE\*\* \(EOL 2023-06-01\)/, + ); +}); + +test("buildBrief: eol analyzer runs (real now, 2023 cycle is past)", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = async (url) => + String(url).includes("endoflife.date") + ? { ok: true, json: async () => [{ cycle: "18", eol: "2023-06-01" }] } + : { ok: true, json: async () => ({}) }; + try { + const brief = await buildBrief({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "Dockerfile", patch: "@@ -1,0 +1,1 @@\n+FROM node:18" }], + }); + assert.equal(brief.analyzerStatus.eol, "ok"); + assert.equal(brief.findings.eol.length, 1); + assert.match(brief.promptSection, /End-of-life runtimes/); + } finally { + globalThis.fetch = realFetch; + } +});