mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(cli): add issue reconciliation scripts and GitHub helper
This commit is contained in:
Executable
+150
@@ -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 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";
|
||||
|
||||
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<Core> {
|
||||
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"),
|
||||
);
|
||||
Reference in New Issue
Block a user