mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +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
|
#!/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
|
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
|
||||||
* problem carries exactly one `set:` label — `set:core` is required, while
|
* problem carries exactly one `set:` label — `set:core` is required, while
|
||||||
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
* `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
|
* 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").
|
* 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
|
* 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
|
* 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
|
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
|
||||||
*
|
*
|
||||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
* 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 { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||||
import { coreSet, readProblems } from "./topics.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);
|
topics.sort((a, b) => a.number - b.number);
|
||||||
|
|
||||||
|
const reviews = await readReviews(gh);
|
||||||
|
const moved = movedFromTopics(reviews);
|
||||||
|
|
||||||
// ── reconcile ────────────────────────────────────────────────────
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const report: string[][] = [];
|
const report: string[][] = [];
|
||||||
let closed = 0;
|
let closed = 0;
|
||||||
|
|
||||||
for (const topic of topics) {
|
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}`;
|
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,
|
// A topic with no core sub-issues has nothing to complete: never close it,
|
||||||
@@ -95,19 +106,58 @@ for (const topic of topics) {
|
|||||||
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 ───────────────────────────────────────────────────────
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rows = [["topic", "title", "core", "status"], ...report];
|
const rows = [["topic", "title", "core", "status"], ...report];
|
||||||
printTable(rows);
|
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(
|
console.log(
|
||||||
`\n${topics.length} open topics · ` +
|
`\n${topics.length} open topics · ${DRY ? `would close ${topicsDone}` : `closed ${closed}`}` +
|
||||||
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
|
`\n${reviewReport.length} open reviews · ${DRY ? `would close ${reviewsDone}` : `closed ${reviewsClosed}`}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await writeStepSummary(
|
await writeStepSummary(
|
||||||
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
|
`${topics.length} open topic issues, ${topicsDone} with a complete core set.\n\n` +
|
||||||
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\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
|
* for close-topics to chain after it. The topic issue itself closes seconds
|
||||||
* later, on the same day.
|
* later, on the same day.
|
||||||
*
|
*
|
||||||
* Each release is a changelog snapshot at that instant: the topic's problem
|
* Each release is a changelog snapshot at that instant: the topic's problems
|
||||||
* sub-issues grouped as closed on the finish day (ET), closed earlier, and
|
* (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
|
* 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.
|
* they stood in the tagged commit — the last main commit before the finish.
|
||||||
*
|
*
|
||||||
@@ -40,9 +41,10 @@
|
|||||||
* committer identity (the tagger).
|
* committer identity (the tagger).
|
||||||
*/
|
*/
|
||||||
import { etDate } from "../api/src/srs.ts";
|
import { etDate } from "../api/src/srs.ts";
|
||||||
|
import { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts";
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
import { markdownTable, printTable, writeStepSummary } from "./report.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";
|
import { ROOT, parseWorkPath } from "./work.ts";
|
||||||
|
|
||||||
const DRY =
|
const DRY =
|
||||||
@@ -99,10 +101,11 @@ interface Finished {
|
|||||||
// Closed and open topics alike: the rule reads the sub-issues, so a topic
|
// Closed and open topics alike: the rule reads the sub-issues, so a topic
|
||||||
// finished moments ago (close-topics not yet run) releases now.
|
// finished moments ago (close-topics not yet run) releases now.
|
||||||
const finished: Finished[] = [];
|
const finished: Finished[] = [];
|
||||||
|
const moved = movedFromTopics(await readReviews(gh));
|
||||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||||
const topic = readTopic(raw);
|
const topic = readTopic(raw);
|
||||||
if (!topic) continue;
|
if (!topic) continue;
|
||||||
const problems = await readProblems(gh, topic.number);
|
const problems = await readProblems(gh, topic.number, moved);
|
||||||
const core = coreSet(problems);
|
const core = coreSet(problems);
|
||||||
if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt });
|
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
|
* 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
|
* finished", shared by close-topics.ts (closes the topic issue) and
|
||||||
* release-topics.ts (publishes the topic's timever release) so the two can
|
* 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
|
* 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
|
* 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
|
* 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.
|
* 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().
|
* 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";
|
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 {
|
export interface Core {
|
||||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
/** Core sub-issues, closed and open alike, lowest number first. */
|
||||||
all: number[];
|
all: number[];
|
||||||
@@ -33,11 +35,17 @@ export interface Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every sub-issue of a topic. The sub_issues payload carries the full issue
|
* Every problem a topic owns: its sub-issues plus the ones moved from it into
|
||||||
* objects (state + labels), so no per-problem follow-up request is needed.
|
* 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[]> {
|
export async function readProblems(
|
||||||
const problems: Problem[] = [];
|
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`)) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||||
if (!raw || typeof raw !== "object") continue;
|
if (!raw || typeof raw !== "object") continue;
|
||||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||||
|
|||||||
Reference in new issue
Block a user