mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
feat(cli): close review issues and support moved problems
This commit is contained in:
1 parent
1d77c5a8b5
commit
9c7d48aad8
3 files changed
+90
-29
No files matched your search
+60
-10
@@ -1,23 +1,31 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Close the GitHub issue for every topic whose required (core) problems are done.
|
||||
* 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").
|
||||
* The rule lives in topics.ts, shared with release-topics.ts.
|
||||
* 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 are touched, so reopening a problem never reopens its topic.
|
||||
* open topics/reviews are touched, so reopening a problem never reopens either.
|
||||
*
|
||||
* bun apps/cli/close-topics.ts # close finished topics
|
||||
* 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";
|
||||
@@ -55,13 +63,16 @@ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=ope
|
||||
}
|
||||
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));
|
||||
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,
|
||||
@@ -95,19 +106,58 @@ for (const topic of topics) {
|
||||
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 = report.filter((r) => r[3] === "closed" || r[3] === "would close");
|
||||
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 ${finished.length}` : `closed ${closed}`}`,
|
||||
`\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, ${finished.length} with a complete core set.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\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"),
|
||||
);
|
||||
@@ -11,8 +11,9 @@
|
||||
* for close-topics to chain after it. The topic issue itself closes seconds
|
||||
* later, on the same day.
|
||||
*
|
||||
* Each release is a changelog snapshot at that instant: the topic's problem
|
||||
* sub-issues grouped as closed on the finish day (ET), closed earlier, and
|
||||
* Each release is a changelog snapshot at that instant: the topic's problems
|
||||
* (its sub-issues, plus those since re-parented into Review issues — see
|
||||
* topics.ts) grouped as closed on the finish day (ET), closed earlier, and
|
||||
* open at release, each with its set, difficulty, and the solution files as
|
||||
* they stood in the tagged commit — the last main commit before the finish.
|
||||
*
|
||||
@@ -40,9 +41,10 @@
|
||||
* committer identity (the tagger).
|
||||
*/
|
||||
import { etDate } from "../api/src/srs.ts";
|
||||
import { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { coreSet, labelValue, readProblems, type Problem } from "./topics.ts";
|
||||
import { coreSet, labelValue, readProblems } from "./topics.ts";
|
||||
import { ROOT, parseWorkPath } from "./work.ts";
|
||||
|
||||
const DRY =
|
||||
@@ -99,10 +101,11 @@ interface Finished {
|
||||
// Closed and open topics alike: the rule reads the sub-issues, so a topic
|
||||
// finished moments ago (close-topics not yet run) releases now.
|
||||
const finished: Finished[] = [];
|
||||
const moved = movedFromTopics(await readReviews(gh));
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||
const topic = readTopic(raw);
|
||||
if (!topic) continue;
|
||||
const problems = await readProblems(gh, topic.number);
|
||||
const problems = await readProblems(gh, topic.number, moved);
|
||||
const core = coreSet(problems);
|
||||
if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt });
|
||||
}
|
||||
|
||||
+23
-15
@@ -2,27 +2,29 @@
|
||||
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
|
||||
* finished", shared by close-topics.ts (closes the topic issue) and
|
||||
* release-topics.ts (publishes the topic's timever release) so the two can
|
||||
* never disagree about when a topic was done.
|
||||
* never disagree about when a topic was done. Also the one reading of "is this
|
||||
* Review issue finished", which close-topics.ts closes on.
|
||||
*
|
||||
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
|
||||
* every one of them is closed. Optional and deferred problems are extra credit
|
||||
* and never hold a topic open. A topic with no core sub-issues is never
|
||||
* finished — that would be indistinguishable from a mis-labelled problem set.
|
||||
*
|
||||
* Review issues (`Review <N> — <Phase>`, label `review`) are day blocks for
|
||||
* the transfer phase: problems were re-parented into them from their topics
|
||||
* (a GitHub issue has one parent). A topic still OWNS such a problem —
|
||||
* readProblems() returns it alongside the topic's direct sub-issues, with the
|
||||
* ownership recovered by apps/api/src/reviews.ts (shared with the Worker's
|
||||
* catalog reconcile) — so the finished rule and the release snapshots do not
|
||||
* change when a problem moves. A Review issue is finished when every one of
|
||||
* its sub-issues is closed: it is a fixed assignment, so the set labels do
|
||||
* not matter there.
|
||||
*
|
||||
* Library: side-effect-free on import; every call takes the caller's github().
|
||||
*/
|
||||
import type { Problem } from "../api/src/reviews.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
/** One problem sub-issue, narrowed from the sub_issues payload. */
|
||||
export interface Problem {
|
||||
number: number;
|
||||
/** `LC <num> · <Name>`. */
|
||||
title: string;
|
||||
/** ISO instant the issue closed; null while it is open. */
|
||||
closedAt: string | null;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
||||
all: number[];
|
||||
@@ -33,11 +35,17 @@ export interface Core {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sub-issue of a topic. The sub_issues payload carries the full issue
|
||||
* objects (state + labels), so no per-problem follow-up request is needed.
|
||||
* Every problem a topic owns: its sub-issues plus the ones moved from it into
|
||||
* a Review issue (`moved`, from movedFromTopics()). The sub_issues payload
|
||||
* carries the full issue objects (state + labels), so no per-problem
|
||||
* follow-up request is needed.
|
||||
*/
|
||||
export async function readProblems(gh: GitHub, topic: number): Promise<Problem[]> {
|
||||
const problems: Problem[] = [];
|
||||
export async function readProblems(
|
||||
gh: GitHub,
|
||||
topic: number,
|
||||
moved: Map<number, Problem[]>,
|
||||
): Promise<Problem[]> {
|
||||
const problems = [...(moved.get(topic) ?? [])];
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user