From c3265f56728c38c7d9aed89109a77b0565fb989a Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 24 Aug 2026 16:47:03 -0400 Subject: [PATCH] feat(scripts): add GitHub helper module and close-topics script with reporting --- scripts/close-solved.ts | 103 ++++++--------------------- scripts/close-topics.ts | 150 ++++++++++++++++++++++++++++++++++++++++ scripts/github.ts | 93 +++++++++++++++++++++++++ scripts/report.ts | 37 ++++++++++ 4 files changed, 302 insertions(+), 81 deletions(-) create mode 100755 scripts/close-topics.ts create mode 100644 scripts/github.ts create mode 100644 scripts/report.ts diff --git a/scripts/close-solved.ts b/scripts/close-solved.ts index 1d4a4d5..4431982 100755 --- a/scripts/close-solved.ts +++ b/scripts/close-solved.ts @@ -16,9 +16,11 @@ * * Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`. */ -import { $ } from "bun"; import { basename, join } from "node:path"; +import { github } from "./github.ts"; +import { markdownTable, printTable, writeStepSummary } from "./report.ts"; + const ROOT = join(import.meta.dir, ".."); const WORK = join(ROOT, "work"); @@ -78,40 +80,7 @@ function isImplemented(src: string, lang: "js" | "py"): boolean { // ── repo + auth ────────────────────────────────────────────────── -async function resolveRepo(): Promise { - if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY; - const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim(); - const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/); - if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`); - return m[1]!; -} - -async function resolveToken(): Promise { - const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; - if (env) return env; - const token = (await $`gh auth token`.text()).trim(); - if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`"); - return token; -} - -const repo = await resolveRepo(); -const token = await resolveToken(); - -async function api(path: string, init: RequestInit = {}): Promise { - const res = await fetch(`https://api.github.com${path}`, { - ...init, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "x-github-api-version": "2022-11-28", - ...(init.body ? { "content-type": "application/json" } : {}), - }, - }); - if (!res.ok) { - throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`); - } - return res.json(); -} +const gh = await github(); // ── work/ inventory ────────────────────────────────────────────── @@ -181,16 +150,9 @@ function readProblemIssue( /** LC number -> open issue carrying the `problem` label. */ const open = new Map(); -for (let page = 1; ; page++) { - const batch = await api( - `/repos/${repo}/issues?labels=problem&state=open&per_page=100&page=${page}`, - ); - if (!Array.isArray(batch)) throw new Error("unexpected /issues payload: not an array"); - for (const raw of batch) { - const parsed = readProblemIssue(raw); - if (parsed) open.set(parsed.lc, parsed.issue); - } - if (batch.length < 100) break; +for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) { + const parsed = readProblemIssue(raw); + if (parsed) open.set(parsed.lc, parsed.issue); } // ── reconcile ──────────────────────────────────────────────────── @@ -219,21 +181,14 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) { const links = entry.files .map((f) => - sha ? `[\`${f}\`](https://github.com/${repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``, + sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``, ) .join(", "); - await api(`/repos/${repo}/issues/${issue.number}/comments`, { - method: "POST", - body: JSON.stringify({ - body: - `Solved — solution committed at ${links}.\n\n` + - "Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.", - }), - }); - await api(`/repos/${repo}/issues/${issue.number}`, { - method: "PATCH", - body: JSON.stringify({ state: "closed", state_reason: "completed" }), - }); + await gh.closeIssue( + issue.number, + `Solved — solution committed at ${links}.\n\n` + + "Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.", + ); report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]); closed++; } @@ -241,17 +196,7 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) { // ── report ─────────────────────────────────────────────────────── const rows = [["lc", "issue", "set", "status"], ...report]; -const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length))); -const rendered = rows.map((r) => - r - .map((cell, i) => cell.padEnd(widths[i]!)) - .join(" ") - .trimEnd(), -); - -console.log(rendered[0]); -console.log(widths.map((n) => "─".repeat(n)).join(" ")); -for (const row of rendered.slice(1)) console.log(row); +printTable(rows); const implemented = [...work.values()].filter((e) => e.implemented).length; const actionable = report.filter((r) => r[3] !== "no open issue"); @@ -261,15 +206,11 @@ console.log( `${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`, ); -if (process.env.GITHUB_STEP_SUMMARY) { - const body = actionable.map((r) => `| ${r[0]} | ${r[1]} | ${r[2]} | ${r[3]} |`).join("\n"); - await Bun.write( - process.env.GITHUB_STEP_SUMMARY, - `### close-solved${DRY ? " (dry run)" : ""}\n\n` + - `${work.size} files in \`work/\`, ${implemented} implemented, ` + - `${actionable.length} matched an open issue.\n\n` + - (body - ? `| LC | Issue | Set | Status |\n| --- | --- | --- | --- |\n${body}\n` - : "Nothing to close.\n"), - ); -} +await writeStepSummary( + `### close-solved${DRY ? " (dry run)" : ""}\n\n` + + `${work.size} files in \`work/\`, ${implemented} implemented, ` + + `${actionable.length} matched an open issue.\n\n` + + (actionable.length + ? `${markdownTable([rows[0]!, ...actionable])}\n` + : "Nothing to close.\n"), +); diff --git a/scripts/close-topics.ts b/scripts/close-topics.ts new file mode 100755 index 0000000..6df530b --- /dev/null +++ b/scripts/close-topics.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env bun +/** + * Close the GitHub issue for every topic whose required (core) problems are done. + * + * Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each + * problem carries exactly one `set:` label — `set:core` is required, while + * `set:optional` and `set:deferred` are extra credit. A topic is finished when + * every one of its core sub-issues is closed; optional/deferred state is + * ignored, matching the day rule in each topic body ("Core problems first"). + * + * Like close-solved.ts this reconciles state instead of reacting to an event + * payload: re-runs are no-ops, backfilling needs no special casing, and only + * open topics are touched, so reopening a problem never reopens its topic. + * + * bun scripts/close-topics.ts # close finished topics + * bun scripts/close-topics.ts --dry-run # report only, touch nothing + * + * Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`. + */ +import { github } from "./github.ts"; +import { markdownTable, printTable, writeStepSummary } from "./report.ts"; + +const DRY = + process.argv.includes("--dry-run") || + process.env.DRY_RUN === "1" || + process.env.DRY_RUN === "true"; + +const gh = await github(); + +// ── open topic issues ──────────────────────────────────────────── + +interface Topic { + number: number; + title: string; +} + +/** + * Narrow one element of the /issues payload to the fields this script needs. + * The `topic` label filter cannot exclude pull requests, so drop those here. + */ +function readTopic(value: unknown): Topic | undefined { + if (!value || typeof value !== "object") return; + if ("pull_request" in value) return; // the /issues route also lists PRs + if (!("number" in value) || typeof value.number !== "number") return; + if (!("title" in value) || typeof value.title !== "string") return; + return { number: value.number, title: value.title }; +} + +const topics: Topic[] = []; +for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) { + const topic = readTopic(raw); + if (topic) topics.push(topic); +} +topics.sort((a, b) => a.number - b.number); + +// ── core sub-issue state per topic ─────────────────────────────── + +interface Core { + /** Core sub-issues, closed and open alike, lowest number first. */ + all: number[]; + /** The core sub-issues still open — non-empty means the topic stays open. */ + pending: number[]; +} + +/** + * Read a topic's core sub-issue state. The sub_issues payload carries the full + * issue objects (state + labels), so no per-problem follow-up request is needed. + */ +async function readCore(topic: number): Promise { + const all: number[] = []; + const pending: number[] = []; + for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) { + if (!raw || typeof raw !== "object") continue; + if (!("number" in raw) || typeof raw.number !== "number") continue; + if (!("state" in raw) || typeof raw.state !== "string") continue; + if (!("labels" in raw) || !Array.isArray(raw.labels)) continue; + + const core = raw.labels.some( + (label) => + label && + typeof label === "object" && + "name" in label && + label.name === "set:core", + ); + if (!core) continue; + + all.push(raw.number); + if (raw.state === "open") pending.push(raw.number); + } + all.sort((a, b) => a - b); + pending.sort((a, b) => a - b); + return { all, pending }; +} + +// ── reconcile ──────────────────────────────────────────────────── + +const report: string[][] = []; +let closed = 0; + +for (const topic of topics) { + const core = await readCore(topic.number); + const progress = `${core.all.length - core.pending.length}/${core.all.length}`; + + // A topic with no core sub-issues has nothing to complete: never close it, + // since that would be indistinguishable from a mis-labelled problem set. + if (core.all.length === 0) { + report.push([`#${topic.number}`, topic.title, progress, "no core set"]); + continue; + } + if (core.pending.length > 0) { + report.push([ + `#${topic.number}`, + topic.title, + progress, + `open: ${core.pending.map((n) => `#${n}`).join(" ")}`, + ]); + continue; + } + if (DRY) { + report.push([`#${topic.number}`, topic.title, progress, "would close"]); + continue; + } + + await gh.closeIssue( + topic.number, + `Core set complete — all ${core.all.length} core problems closed ` + + `(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` + + "Closed automatically by `close-topics`. Optional and deferred problems " + + "stay open as extra credit.", + ); + report.push([`#${topic.number}`, topic.title, progress, "closed"]); + closed++; +} + +// ── report ─────────────────────────────────────────────────────── + +const rows = [["topic", "title", "core", "status"], ...report]; +printTable(rows); + +const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close"); +console.log( + `\n${topics.length} open topics · ` + + `${DRY ? `would close ${finished.length}` : `closed ${closed}`}`, +); + +await writeStepSummary( + `### close-topics${DRY ? " (dry run)" : ""}\n\n` + + `${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` + + (report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"), +); diff --git a/scripts/github.ts b/scripts/github.ts new file mode 100644 index 0000000..145af82 --- /dev/null +++ b/scripts/github.ts @@ -0,0 +1,93 @@ +/** + * GitHub REST plumbing shared by the issue reconcilers (close-solved, + * close-topics). + * + * Importing this module is side-effect free — nothing resolves credentials or + * touches the network until github() is awaited — so a script can import it + * without inheriting another script's pipeline. + * + * Repo: GITHUB_REPOSITORY, else the `origin` remote. + * Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token`. + */ +import { $ } from "bun"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dir, ".."); + +/** Page size used for every list endpoint; also the "more pages" threshold. */ +const PER_PAGE = 100; + +export interface GitHub { + /** `owner/name`. */ + repo: string; + /** One authenticated request against api.github.com; throws on non-2xx. */ + api(path: string, init?: RequestInit): Promise; + /** Every page of a list endpoint, flattened into one stream of elements. */ + list(path: string): AsyncGenerator; + /** Comment on an issue, then close it as completed. */ + closeIssue(issue: number, comment: string): Promise; +} + +async function resolveRepo(): Promise { + if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY; + const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim(); + const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/); + if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`); + return m[1]!; +} + +async function resolveToken(): Promise { + const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (env) return env; + const token = (await $`gh auth token`.text()).trim(); + if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`"); + return token; +} + +export async function github(): Promise { + const repo = await resolveRepo(); + const token = await resolveToken(); + + async function api(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "x-github-api-version": "2022-11-28", + ...(init.body ? { "content-type": "application/json" } : {}), + }, + }); + if (!res.ok) { + throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`); + } + return res.json(); + } + + async function* list(path: string): AsyncGenerator { + const sep = path.includes("?") ? "&" : "?"; + for (let page = 1; ; page++) { + const batch = await api(`${path}${sep}per_page=${PER_PAGE}&page=${page}`); + if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`); + yield* batch; + if (batch.length < PER_PAGE) return; + } + } + + /** + * Comment before closing: if the PATCH fails, the issue still carries a + * visible note of what the automation decided, instead of failing silently. + */ + async function closeIssue(issue: number, comment: string): Promise { + await api(`/repos/${repo}/issues/${issue}/comments`, { + method: "POST", + body: JSON.stringify({ body: comment }), + }); + await api(`/repos/${repo}/issues/${issue}`, { + method: "PATCH", + body: JSON.stringify({ state: "closed", state_reason: "completed" }), + }); + } + + return { repo, api, list, closeIssue }; +} diff --git a/scripts/report.ts b/scripts/report.ts new file mode 100644 index 0000000..ad77cc7 --- /dev/null +++ b/scripts/report.ts @@ -0,0 +1,37 @@ +/** + * Console + GitHub Actions reporting shared by the issue reconcilers. + * + * Both reconcilers end the same way: an aligned table on stdout, and the same + * table as Markdown in the step summary when running under Actions. + */ + +/** Print rows[0] as a header, a rule, then the body — every column padded. */ +export function printTable(rows: string[][]): void { + const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length))); + const render = (r: string[]) => + r + .map((cell, i) => cell.padEnd(widths[i]!)) + .join(" ") + .trimEnd(); + + console.log(render(rows[0]!)); + console.log(widths.map((n) => "─".repeat(n)).join(" ")); + for (const row of rows.slice(1)) console.log(render(row)); +} + +/** Same rows as a GitHub-flavoured Markdown table; rows[0] is the header. */ +export function markdownTable(rows: string[][]): string { + const [header, ...body] = rows; + return [ + `| ${header!.join(" | ")} |`, + `| ${header!.map(() => "---").join(" | ")} |`, + ...body.map((r) => `| ${r.join(" | ")} |`), + ].join("\n"); +} + +/** Append to the Actions step summary; a no-op outside Actions. */ +export async function writeStepSummary(markdown: string): Promise { + const path = process.env.GITHUB_STEP_SUMMARY; + if (!path) return; + await Bun.write(path, markdown); +}