mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
164 lines
6.7 KiB
TypeScript
Executable File
164 lines
6.7 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* Close the GitHub issue for every topic whose required (core) problems are
|
|
* done, and for every `Review <N> — <Phase>` issue whose problems all are.
|
|
*
|
|
* 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").
|
|
* Problems re-parented into Review issues still count for their topic. The
|
|
* rules live in topics.ts, shared with release-topics.ts.
|
|
*
|
|
* A Review issue is a fixed day's assignment, so it closes when every one of
|
|
* its sub-issues is closed, whatever their set. Closing it fires the Worker's
|
|
* `review`-label webhook, which scores only issues with a gate record — these
|
|
* have none, so that is a no-op.
|
|
*
|
|
* 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/reviews are touched, so reopening a problem never reopens either.
|
|
*
|
|
* bun apps/cli/close-topics.ts # close finished topics and reviews
|
|
* 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 { movedFromTopics, readReviews } from "../api/src/reviews.ts";
|
|
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);
|
|
|
|
const reviews = await readReviews(gh);
|
|
const moved = movedFromTopics(reviews);
|
|
|
|
// ── reconcile ────────────────────────────────────────────────────
|
|
|
|
const report: string[][] = [];
|
|
let closed = 0;
|
|
|
|
for (const topic of topics) {
|
|
const core = coreSet(await readProblems(gh, topic.number, moved));
|
|
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++;
|
|
}
|
|
|
|
// ── reviews ──────────────────────────────────────────────────────
|
|
|
|
const reviewReport: string[][] = [];
|
|
let reviewsClosed = 0;
|
|
|
|
for (const review of reviews.filter((r) => r.open)) {
|
|
const pending = review.problems.filter((p) => !p.closedAt).map((p) => p.number);
|
|
const progress = `${review.problems.length - pending.length}/${review.problems.length}`;
|
|
// An empty review would read as done; leave it for a human, like a topic
|
|
// with no core set.
|
|
if (review.problems.length === 0) {
|
|
reviewReport.push([`#${review.number}`, review.title, progress, "no sub-issues"]);
|
|
continue;
|
|
}
|
|
if (pending.length > 0) {
|
|
reviewReport.push([`#${review.number}`, review.title, progress, `open: ${pending.map((n) => `#${n}`).join(" ")}`]);
|
|
continue;
|
|
}
|
|
if (DRY) {
|
|
reviewReport.push([`#${review.number}`, review.title, progress, "would close"]);
|
|
continue;
|
|
}
|
|
await gh.closeIssue(
|
|
review.number,
|
|
`Review complete — all ${review.problems.length} problems closed ` +
|
|
`(${review.problems.map((p) => `#${p.number}`).join(", ")}).\n\n` +
|
|
"Closed automatically by `close-topics`.",
|
|
);
|
|
reviewReport.push([`#${review.number}`, review.title, progress, "closed"]);
|
|
reviewsClosed++;
|
|
}
|
|
|
|
// ── report ───────────────────────────────────────────────────────
|
|
|
|
const rows = [["topic", "title", "core", "status"], ...report];
|
|
printTable(rows);
|
|
const reviewRows = [["review", "title", "closed", "status"], ...reviewReport];
|
|
console.log();
|
|
printTable(reviewRows);
|
|
|
|
const finished = new Set(["closed", "would close"]);
|
|
const topicsDone = report.filter((r) => finished.has(r[3]!)).length;
|
|
const reviewsDone = reviewReport.filter((r) => finished.has(r[3]!)).length;
|
|
console.log(
|
|
`\n${topics.length} open topics · ${DRY ? `would close ${topicsDone}` : `closed ${closed}`}` +
|
|
`\n${reviewReport.length} open reviews · ${DRY ? `would close ${reviewsDone}` : `closed ${reviewsClosed}`}`,
|
|
);
|
|
|
|
await writeStepSummary(
|
|
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
|
`${topics.length} open topic issues, ${topicsDone} with a complete core set.\n\n` +
|
|
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n") +
|
|
`\n${reviewReport.length} open review issues, ${reviewsDone} with every problem closed.\n\n` +
|
|
(reviewReport.length ? `${markdownTable(reviewRows)}\n` : "No open reviews.\n"),
|
|
);
|