diff --git a/package.json b/package.json index 21b975c3bb..34ffc553dc 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "ui:version-audit": "node --experimental-strip-types scripts/check-ui-mcp-version-copy.ts", "ui:version-audit:sync": "node --experimental-strip-types scripts/check-ui-mcp-version-copy.ts --write", "docs:drift-check": "node --experimental-strip-types scripts/check-docs-drift.ts", + "roadmap:drift-check": "node --experimental-strip-types scripts/check-roadmap-issue-drift.ts", "branding-drift:check": "node --experimental-strip-types scripts/check-branding-drift.ts", "branding-drift:update": "node --experimental-strip-types scripts/check-branding-drift.ts --update", "manifest:drift-check": "tsx scripts/check-manifest-drift.ts", diff --git a/scripts/check-roadmap-issue-drift.ts b/scripts/check-roadmap-issue-drift.ts new file mode 100644 index 0000000000..cdd1db09ff --- /dev/null +++ b/scripts/check-roadmap-issue-drift.ts @@ -0,0 +1,164 @@ +#!/usr/bin/env node +// Detects when /roadmap still presents phase epics as active/upcoming ("shipping-soon" / "planned") +// while the linked GitHub issues are already closed as completed. That drift silently persisted on the +// public page after phases #233-#238 shipped (#8390). Local/manual (or future scheduled) check only — +// needs live GitHub API access, so it is deliberately NOT wired into `test:ci`. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_ROADMAP_SOURCE = "apps/loopover-ui/src/routes/roadmap.tsx"; +export const DEFAULT_OWNER = "JSONbored"; +export const DEFAULT_REPO = "loopover"; + +/** Statuses that visually present the item as still-active / upcoming work on /roadmap. */ +export const ACTIVE_ROADMAP_STATUSES = new Set(["shipping-soon", "planned"]); + +export type RoadmapItemStatus = "shipping-soon" | "planned" | "exploring"; + +export type RoadmapItemRef = { + status: RoadmapItemStatus; + issue: number; +}; + +export type GithubIssueState = { + state: string; + stateReason: string | null; +}; + +// Deliberately `any`-shaped like check-stuck-required-checks: callers read issue fields off the +// resolved JSON without a runtime schema, and typing this `unknown` would only force casts. +export type GithubApi = (path: string, options?: { method?: string; headers?: Record }) => Promise; + +export function makeGithubApi(token: string): GithubApi { + return async function githubApi(path, options = {}) { + const response = await fetch(`https://api.github.com${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...options.headers, + }, + }); + if (!response.ok) { + throw new Error(`GitHub API error ${response.status} on ${path}: ${await response.text()}`); + } + return response.status === 204 ? null : response.json(); + }; +} + +/** Parse `ROADMAP_ITEMS` object literals from `roadmap.tsx` source text (status + issue only). */ +export function parseRoadmapItems(sourceText: string): RoadmapItemRef[] { + const catalogMatch = /const\s+ROADMAP_ITEMS[^=]*=\s*\[([\s\S]*?)\];/.exec(sourceText); + if (!catalogMatch) throw new Error("ROADMAP_ITEMS array not found in roadmap source"); + const body = catalogMatch[1]!; + const items: RoadmapItemRef[] = []; + for (const objectMatch of body.matchAll(/\{([^{}]+)\}/g)) { + const objectBody = objectMatch[1]!; + const status = /status:\s*"(shipping-soon|planned|exploring)"/.exec(objectBody)?.[1] as RoadmapItemStatus | undefined; + const issueRaw = /issue:\s*(\d+)/.exec(objectBody)?.[1]; + if (!status || !issueRaw) continue; + items.push({ status, issue: Number(issueRaw) }); + } + if (items.length === 0) throw new Error("ROADMAP_ITEMS contained no parseable { status, issue } entries"); + return items; +} + +/** + * Pure drift rule (#8390): fail only when the page presents the item as active/upcoming + * (`shipping-soon` / `planned`) but GitHub says the issue is closed as completed. + * `exploring` items are never flagged here — abandoned "later" ideas (e.g. NOT_PLANNED) and + * finished exploring work are both content judgment calls outside this mechanical check. + */ +export function isStaleActiveRoadmapPresentation(input: { + status: string; + issueState: string; + issueStateReason: string | null | undefined; +}): boolean { + if (!ACTIVE_ROADMAP_STATUSES.has(input.status)) return false; + if (input.issueState.toLowerCase() !== "closed") return false; + return (input.issueStateReason ?? "").toUpperCase() === "COMPLETED"; +} + +export type StaleRoadmapItem = RoadmapItemRef & GithubIssueState; + +export async function findStaleRoadmapItems({ + items, + githubApi, + owner, + repo, +}: { + items: RoadmapItemRef[]; + githubApi: GithubApi; + owner: string; + repo: string; +}): Promise { + const stale: StaleRoadmapItem[] = []; + for (const item of items) { + const issue = await githubApi(`/repos/${owner}/${repo}/issues/${item.issue}`); + const state = String(issue.state ?? ""); + const stateReason = issue.state_reason == null ? null : String(issue.state_reason); + if (isStaleActiveRoadmapPresentation({ status: item.status, issueState: state, issueStateReason: stateReason })) { + stale.push({ ...item, state, stateReason }); + } + } + return stale; +} + +export function formatStaleRoadmapFailures(stale: StaleRoadmapItem[]): string[] { + return stale.map( + (item) => + `#${item.issue} (status=${item.status}): GitHub state=${item.state} stateReason=${item.stateReason ?? "null"} — still presented as active/upcoming on /roadmap while closed as completed`, + ); +} + +export async function checkRoadmapIssueDrift({ + roadmapSourceText, + githubApi, + owner = DEFAULT_OWNER, + repo = DEFAULT_REPO, +}: { + roadmapSourceText: string; + githubApi: GithubApi; + owner?: string; + repo?: string; +}): Promise { + const items = parseRoadmapItems(roadmapSourceText); + const stale = await findStaleRoadmapItems({ items, githubApi, owner, repo }); + return formatStaleRoadmapFailures(stale); +} + +async function main(): Promise { + const root = process.cwd(); + const sourcePath = join(root, DEFAULT_ROADMAP_SOURCE); + const roadmapSourceText = readFileSync(sourcePath, "utf8"); + + const repoEnv = process.env.GITHUB_REPOSITORY; + const [owner, repo] = repoEnv?.includes("/") ? repoEnv.split("/") : [DEFAULT_OWNER, DEFAULT_REPO]; + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (!token) throw new Error("GITHUB_TOKEN (or GH_TOKEN) is required for roadmap:drift-check"); + + const failures = await checkRoadmapIssueDrift({ + roadmapSourceText, + githubApi: makeGithubApi(token), + owner: owner!, + repo: repo!, + }); + + if (failures.length > 0) { + console.error(`Roadmap issue-drift check found ${failures.length} stale active/upcoming item(s):`); + for (const failure of failures) console.error(` ${failure}`); + console.error( + "Update ROADMAP_ITEMS in apps/loopover-ui/src/routes/roadmap.tsx so active columns only reference open (or not-yet-done) phase issues.", + ); + process.exit(1); + } + + console.log("Roadmap issue-drift check ok: no active/upcoming ROADMAP_ITEMS reference a completed closed issue."); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/test/unit/check-roadmap-issue-drift-script.test.ts b/test/unit/check-roadmap-issue-drift-script.test.ts new file mode 100644 index 0000000000..18f07d81b0 --- /dev/null +++ b/test/unit/check-roadmap-issue-drift-script.test.ts @@ -0,0 +1,178 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + ACTIVE_ROADMAP_STATUSES, + checkRoadmapIssueDrift, + findStaleRoadmapItems, + formatStaleRoadmapFailures, + isStaleActiveRoadmapPresentation, + parseRoadmapItems, + type GithubApi, + type RoadmapItemRef, +} from "../../scripts/check-roadmap-issue-drift.js"; + +// (#8390) Pure drift rule + parser coverage for the roadmap issue-drift check. Live GitHub is injected; +// these tests never hit the network. + +describe("isStaleActiveRoadmapPresentation (#8390)", () => { + it.each([ + { + name: "shipping-soon + closed completed → stale", + input: { status: "shipping-soon", issueState: "closed", issueStateReason: "completed" }, + expected: true, + }, + { + name: "planned + closed COMPLETED (GraphQL casing) → stale", + input: { status: "planned", issueState: "CLOSED", issueStateReason: "COMPLETED" }, + expected: true, + }, + { + name: "shipping-soon + open → not stale", + input: { status: "shipping-soon", issueState: "open", issueStateReason: null }, + expected: false, + }, + { + name: "planned + closed not_planned → not stale (wrong reason)", + input: { status: "planned", issueState: "closed", issueStateReason: "not_planned" }, + expected: false, + }, + { + name: "planned + closed with missing reason → not stale", + input: { status: "planned", issueState: "closed", issueStateReason: null }, + expected: false, + }, + { + name: "exploring + closed completed → not stale (only active columns are gated)", + input: { status: "exploring", issueState: "closed", issueStateReason: "completed" }, + expected: false, + }, + { + name: "exploring + closed not_planned → not stale", + input: { status: "exploring", issueState: "closed", issueStateReason: "not_planned" }, + expected: false, + }, + ])("$name", ({ input, expected }) => { + expect(isStaleActiveRoadmapPresentation(input)).toBe(expected); + }); + + it("treats only shipping-soon and planned as active presentation statuses", () => { + expect([...ACTIVE_ROADMAP_STATUSES].sort()).toEqual(["planned", "shipping-soon"]); + }); +}); + +describe("parseRoadmapItems (#8390)", () => { + it("extracts status + issue from a ROADMAP_ITEMS array literal", () => { + const source = ` +const ROADMAP_ITEMS: Array<{ status: string; issue: number }> = [ + { + title: "Phase 0", + status: "shipping-soon", + issue: 233, + description: "x", + }, + { + title: "Phase 2", + status: "planned", + issue: 235, + description: "y", + }, + { + title: "Phase 4", + status: "exploring", + issue: 237, + description: "z", + }, +]; +`; + expect(parseRoadmapItems(source)).toEqual([ + { status: "shipping-soon", issue: 233 }, + { status: "planned", issue: 235 }, + { status: "exploring", issue: 237 }, + ]); + }); + + it("throws when ROADMAP_ITEMS is missing or empty of parseable entries", () => { + expect(() => parseRoadmapItems("const OTHER = [];")).toThrow(/ROADMAP_ITEMS array not found/); + expect(() => parseRoadmapItems("const ROADMAP_ITEMS = [];")).toThrow(/no parseable/); + }); + + it("parses the real apps/loopover-ui roadmap source", () => { + const source = readFileSync(join(process.cwd(), "apps/loopover-ui/src/routes/roadmap.tsx"), "utf8"); + const items = parseRoadmapItems(source); + expect(items.length).toBeGreaterThanOrEqual(6); + expect(items.every((item) => typeof item.issue === "number" && item.issue > 0)).toBe(true); + expect(items.some((item) => ACTIVE_ROADMAP_STATUSES.has(item.status))).toBe(true); + }); +}); + +describe("findStaleRoadmapItems / checkRoadmapIssueDrift (#8390)", () => { + const items: RoadmapItemRef[] = [ + { status: "shipping-soon", issue: 233 }, + { status: "planned", issue: 235 }, + { status: "exploring", issue: 237 }, + ]; + + function issueApi(byNumber: Record): GithubApi { + return async (path: string) => { + const match = /\/issues\/(\d+)$/.exec(path); + if (!match) throw new Error(`unexpected path: ${path}`); + const issue = byNumber[Number(match[1])]; + if (!issue) throw new Error(`unexpected issue: ${match[1]}`); + return issue; + }; + } + + it("returns stale active items and skips exploring even when completed", async () => { + const stale = await findStaleRoadmapItems({ + items, + owner: "JSONbored", + repo: "loopover", + githubApi: issueApi({ + 233: { state: "closed", state_reason: "completed" }, + 235: { state: "open", state_reason: null }, + 237: { state: "closed", state_reason: "completed" }, + }), + }); + expect(stale).toEqual([{ status: "shipping-soon", issue: 233, state: "closed", stateReason: "completed" }]); + expect(formatStaleRoadmapFailures(stale)[0]).toContain("#233"); + expect(formatStaleRoadmapFailures(stale)[0]).toContain("stateReason=completed"); + }); + + it("passes when every active item is still open", async () => { + const failures = await checkRoadmapIssueDrift({ + roadmapSourceText: ` +const ROADMAP_ITEMS = [ + { status: "shipping-soon", issue: 1 }, + { status: "planned", issue: 2 }, + { status: "exploring", issue: 3 }, +]; +`, + githubApi: issueApi({ + 1: { state: "open", state_reason: null }, + 2: { state: "open", state_reason: null }, + 3: { state: "closed", state_reason: "completed" }, + }), + }); + expect(failures).toEqual([]); + }); + + it("fails with clear messages when planned/shipping-soon issues are completed", async () => { + const failures = await checkRoadmapIssueDrift({ + roadmapSourceText: ` +const ROADMAP_ITEMS = [ + { status: "shipping-soon", issue: 10 }, + { status: "planned", issue: 11 }, +]; +`, + githubApi: issueApi({ + 10: { state: "closed", state_reason: "completed" }, + 11: { state: "closed", state_reason: "completed" }, + }), + }); + expect(failures).toHaveLength(2); + expect(failures[0]).toMatch(/#10.*shipping-soon/); + expect(failures[1]).toMatch(/#11.*planned/); + }); +});