Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions scripts/check-roadmap-issue-drift.ts
Original file line number Diff line number Diff line change
@@ -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<string>(["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<string, string> }) => Promise<any>;

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<StaleRoadmapItem[]> {
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<string[]> {
const items = parseRoadmapItems(roadmapSourceText);
const stale = await findStaleRoadmapItems({ items, githubApi, owner, repo });
return formatStaleRoadmapFailures(stale);
}

async function main(): Promise<void> {
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();
}
178 changes: 178 additions & 0 deletions test/unit/check-roadmap-issue-drift-script.test.ts
Original file line number Diff line number Diff line change
@@ -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<number, { state: string; state_reason: string | null }>): 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/);
});
});
Loading