From 9c7d48aad8ec3af66ce081e0678bd5e5256c42b1 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 30 Sep 2026 16:23:54 -0400 Subject: [PATCH] feat(cli): close review issues and support moved problems --- apps/cli/close-topics.ts | 70 ++++++++++++++++++++++++++++++++------ apps/cli/release-topics.ts | 11 +++--- apps/cli/topics.ts | 38 +++++++++++++-------- 3 files changed, 90 insertions(+), 29 deletions(-) diff --git a/apps/cli/close-topics.ts b/apps/cli/close-topics.ts index 4a66b6e..ef82a59 100755 --- a/apps/cli/close-topics.ts +++ b/apps/cli/close-topics.ts @@ -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 — ` 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"), ); diff --git a/apps/cli/release-topics.ts b/apps/cli/release-topics.ts index 7a20de7..5c7bc09 100755 --- a/apps/cli/release-topics.ts +++ b/apps/cli/release-topics.ts @@ -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 }); } diff --git a/apps/cli/topics.ts b/apps/cli/topics.ts index 7543a2a..641ed3d 100644 --- a/apps/cli/topics.ts +++ b/apps/cli/topics.ts @@ -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 — `, 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 · `. */ - 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 { - const problems: Problem[] = []; +export async function readProblems( + gh: GitHub, + topic: number, + moved: Map, +): Promise { + 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;