feat(scripts): add GitHub helper module and close-topics script with reporting

This commit is contained in:
Prad Nukala
2026-08-24 16:47:03 -04:00
parent 48452fb72c
commit c3265f5672
4 changed files with 302 additions and 81 deletions
+22 -81
View File
@@ -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<string> {
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<string> {
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<unknown> {
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<number, ProblemIssue>();
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"),
);