Files
leetcode/apps/cli/close-topics.ts
T

114 lines
4.3 KiB
TypeScript
Executable File

#!/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").
* The rule lives in topics.ts, shared with release-topics.ts.
*
* 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 apps/cli/close-topics.ts # close finished topics
* bun apps/cli/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";
import { coreSet, readProblems } from "./topics.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);
// ── reconcile ────────────────────────────────────────────────────
const report: string[][] = [];
let closed = 0;
for (const topic of topics) {
const core = coreSet(await readProblems(gh, 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"),
);