feat(cli): close review issues and support moved problems

This commit is contained in:
Prad Nukala committed 2026-09-30 16:23:54 -04:00
1 parent 1d77c5a8b5
commit 9c7d48aad8
3 files changed
+90 -29

No files matched your search

+60 -10
View File
@@ -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"),
); );
+7 -4
View File
@@ -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
View File
@@ -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;