From d6b483f84ea974e4aa36188296e2d65dbab0f2a6 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 30 Sep 2026 16:31:15 -0400 Subject: [PATCH] feat(cli): add completion helper and handle review issue releases/closures --- apps/cli/close-topics.ts | 16 ++-- apps/cli/release-topics.ts | 157 ++++++++++++++++++++++--------------- apps/cli/topics.ts | 22 ++++-- 3 files changed, 116 insertions(+), 79 deletions(-) diff --git a/apps/cli/close-topics.ts b/apps/cli/close-topics.ts index ef82a59..2b09801 100755 --- a/apps/cli/close-topics.ts +++ b/apps/cli/close-topics.ts @@ -28,7 +28,7 @@ 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"; +import { completion, coreSet, readProblems } from "./topics.ts"; const DRY = process.argv.includes("--dry-run") || @@ -112,16 +112,16 @@ 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}`; + const set = completion(review.problems); + const progress = `${set.all.length - set.pending.length}/${set.all.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) { + if (set.all.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(" ")}`]); + if (set.pending.length > 0) { + reviewReport.push([`#${review.number}`, review.title, progress, `open: ${set.pending.map((n) => `#${n}`).join(" ")}`]); continue; } if (DRY) { @@ -130,8 +130,8 @@ for (const review of reviews.filter((r) => r.open)) { } await gh.closeIssue( review.number, - `Review complete — all ${review.problems.length} problems closed ` + - `(${review.problems.map((p) => `#${p.number}`).join(", ")}).\n\n` + + `Review complete — all ${set.all.length} problems closed ` + + `(${set.all.map((n) => `#${n}`).join(", ")}).\n\n` + "Closed automatically by `close-topics`.", ); reviewReport.push([`#${review.number}`, review.title, progress, "closed"]); diff --git a/apps/cli/release-topics.ts b/apps/cli/release-topics.ts index 5c7bc09..7de4e3b 100755 --- a/apps/cli/release-topics.ts +++ b/apps/cli/release-topics.ts @@ -1,21 +1,24 @@ #!/usr/bin/env bun /** - * Publish one GitHub release per finished topic, versioned by timever + * Publish one GitHub release per finished topic and per finished + * `Review — ` issue, versioned by timever * (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668). * - * "Finished" is topics.ts's rule — every core sub-issue closed — the same one - * close-topics.ts closes the topic issue on, and the version is the instant - * the LAST core problem closed. Keying off the sub-issues rather than the - * topic issue lets this run as the tail of Close Solved: the push that closes - * a topic's final core problem releases it in the same run, without waiting - * for close-topics to chain after it. The topic issue itself closes seconds - * later, on the same day. + * "Finished" is topics.ts's rule — a topic when every core problem is closed, + * a review when every one of its sub-issues is — the same one close-topics.ts + * closes the issue on, and the version is the instant the LAST required + * problem closed. Keying off the sub-issues rather than the topic/review issue + * lets this run as the tail of Close Solved: the push that closes the final + * required problem releases it in the same run, without waiting for + * close-topics to chain after it. The issue itself closes seconds later, on + * the same day. * - * 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. + * Each release is a changelog snapshot at that instant: the issue's problems + * (a topic's sub-issues plus those since re-parented into Review issues, or a + * review's sub-issues in solve order — 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. * * The tag is annotated and backdated (tagger date = finish), so * `git tag --sort=taggerdate` reads as the campaign's history even for tags @@ -24,10 +27,10 @@ * this timever tag instead (`releasesDatedByTag` in apps/docs/blume.config.ts) * — the tag format is that contract. * - * Reconciles instead of reacting: every finished topic without a release gets - * one, so a first run backfills the whole campaign; an existing release keeps - * its tag and commit and only has its body rewritten when render() would say - * something else. Re-runs with nothing new are no-ops. + * Reconciles instead of reacting: every finished topic or review without a + * release gets one, so a first run backfills the whole campaign; an existing + * release keeps its tag and commit and only has its body rewritten when + * render() would say something else. Re-runs with nothing new are no-ops. * A release is found by the marker comment in its body, never by tag name, so * reopening and re-closing a core problem can never mint a second release. * A tag that exists without its release (a run that died between the two) is @@ -44,7 +47,7 @@ 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 } from "./topics.ts"; +import { completion, coreSet, labelValue, readProblems } from "./topics.ts"; import { ROOT, parseWorkPath } from "./work.ts"; const DRY = @@ -64,9 +67,10 @@ function git(...args: string[]): string { return run.stdout.toString().trim(); } -// ── topics ─────────────────────────────────────────────────────── +// ── finished topics and reviews ────────────────────────────────── -interface Topic { +/** A topic or Review issue, as far as its release reads it. */ +interface Issue { number: number; title: string; milestone: string | null; @@ -74,7 +78,7 @@ interface Topic { } /** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */ -function readTopic(value: unknown): Topic | undefined { +function readTopic(value: unknown): Issue | undefined { if (!value || typeof value !== "object") return; if ("pull_request" in value) return; if (!("number" in value) || typeof value.number !== "number") return; @@ -92,29 +96,46 @@ function readTopic(value: unknown): Topic | undefined { } interface Finished { - topic: Topic; + kind: "topic" | "review"; + issue: Issue; + /** Topic: every problem it owns, by number. Review: its sub-issues, in solve order. */ problems: Problem[]; - core: number[]; + /** The set whose last close finished it: a topic's core, a review's every problem. */ + required: number[]; finishedAt: string; } -// Closed and open topics alike: the rule reads the sub-issues, so a topic -// finished moments ago (close-topics not yet run) releases now. +// Closed and open issues alike: the rules read the sub-issues, so a topic or +// review finished moments ago (close-topics not yet run) releases now. const finished: Finished[] = []; -const moved = movedFromTopics(await readReviews(gh)); +const reviews = await readReviews(gh); +const moved = movedFromTopics(reviews); 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, moved); + const issue = readTopic(raw); + if (!issue) continue; + const problems = await readProblems(gh, issue.number, moved); const core = coreSet(problems); - if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt }); + if (core.finishedAt) finished.push({ kind: "topic", issue, problems, required: core.all, finishedAt: core.finishedAt }); +} +for (const review of reviews) { + const set = completion(review.problems); + if (!set.finishedAt) continue; + finished.push({ + kind: "review", + // The review's phase is its body's `**Phase:**` line; its milestone is the + // shared "Review — Transfer & Mocks" and says nothing per issue. + issue: { number: review.number, title: review.title, milestone: null, body: review.body }, + problems: review.problems, + required: set.all, + finishedAt: set.finishedAt, + }); } // Oldest first, so a backfill creates releases in campaign order. -finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.topic.number - b.topic.number); +finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.issue.number - b.issue.number); // ── existing releases ──────────────────────────────────────────── -/** Topic -> its release. Name and body are kept in sync with the render on every run. */ +/** Topic or review issue -> its release. Name and body are kept in sync with the render on every run. */ const released = new Map(); for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) { if (!raw || typeof raw !== "object") continue; @@ -130,10 +151,11 @@ for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) { /** * Timever of an instant. The API reports whole seconds, so SUBTIME is the - * tiebreak: two topics finishing in the same second (one push closing both - * last core problems) get consecutive nanoseconds, in (instant, topic) order. + * tiebreak: two topics or reviews finishing in the same second (one push + * closing both last problems) get consecutive nanoseconds, in (instant, + * issue) order. */ -const tags = new Map(); // topic -> tag +const tags = new Map(); // issue -> tag { let prev = ""; let ns = 0; @@ -142,7 +164,7 @@ const tags = new Map(); // topic -> tag const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`; ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000; prev = base; - tags.set(f.topic.number, `${base}.${String(ns).padStart(9, "0")}`); + tags.set(f.issue.number, `${base}.${String(ns).padStart(9, "0")}`); } } @@ -177,7 +199,7 @@ function bodySection(body: string, heading: string): string | undefined { } function render(f: Finished, tag: string, sha: string, files: Map): string { - const { topic, problems } = f; + const { issue, problems } = f; const finishDay = etDate(new Date(f.finishedAt)); // Snapshot at the finish instant: a problem closed later was open then. const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt; @@ -209,27 +231,32 @@ function render(f: Finished, tag: string, sha: string, files: Map\n${summary}`, fields.join(" · ")]; - sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the topic finished\n\n${table(onDay)}`); + // One marker for both kinds: issue numbers never collide. + const sections = [`\n${summary}`, fields.join(" · ")]; + sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`); if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`); if (open.length) sections.push(`### Open at release\n\n${table(open)}`); const notes = NOTES.flatMap((h) => { - const text = bodySection(topic.body, h); + const text = bodySection(issue.body, h); return text ? [`**${h}.** ${text}`] : []; }); if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`); sections.push( - `---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the core set completed; ` + + `---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the ` + + `${f.kind === "topic" ? "core set completed" : "last problem closed"}; ` + `it tags ${sha.slice(0, 7)}, the last main commit before it.`, ); return `${sections.join("\n\n")}\n`; @@ -237,20 +264,20 @@ function render(f: Finished, tag: string, sha: string, files: Map its first-solve files in the tagged tree. Keyed by file name, @@ -272,20 +299,20 @@ for (const f of finished) { files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]); } const body = render(f, tag, sha, files); - const name = topic.title; + const name = issue.title; if (existing) { if (existing.body === body && existing.name === name) { - report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released"]); + report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released"]); } else if (DRY) { previews.push(`## ${name} (${tag})\n\n${body}`); - report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would update"]); + report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would update"]); } else { await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, { method: "PATCH", body: JSON.stringify({ name, body }), }); - report.push([`#${topic.number}`, topic.title, finishedEt, tag, "updated"]); + report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]); updated++; } continue; @@ -293,7 +320,7 @@ for (const f of finished) { if (DRY) { previews.push(`## ${name} (${tag})\n\n${body}`); - report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would release"]); + report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would release"]); continue; } @@ -302,7 +329,11 @@ for (const f of finished) { const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }); if (local.exitCode !== 0) { const tagged = Bun.spawnSync( - ["git", "tag", "-a", tag, sha, "-m", `${topic.title} (#${topic.number})\n\nCore set complete: ${f.core.map((n) => `#${n}`).join(", ")}`], + [ + "git", "tag", "-a", tag, sha, "-m", + `${issue.title} (#${issue.number})\n\n` + + `${f.kind === "topic" ? "Core set complete" : "All problems closed"}: ${f.required.map((n) => `#${n}`).join(", ")}`, + ], { cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } }, ); if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`); @@ -315,21 +346,21 @@ for (const f of finished) { tag_name: tag, name, body, - make_latest: topic.number === latest ? "true" : "false", + make_latest: issue.number === latest ? "true" : "false", }), }); - report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released now"]); + report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released now"]); created++; } // ── report ─────────────────────────────────────────────────────── if (previews.length) console.log(`${previews.join("\n")}\n`); -const rows = [["topic", "title", "finished (ET)", "tag", "status"], ...report]; +const rows = [["issue", "title", "finished (ET)", "tag", "status"], ...report]; printTable(rows); const would = (status: string) => report.filter((r) => r[4] === status).length; console.log( - `\n${finished.length} finished topics · ` + + `\n${finished.length} finished topics and reviews · ` + (DRY ? `would release ${would("would release")}, update ${would("would update")}` : `released ${created}, updated ${updated}`), @@ -337,7 +368,7 @@ console.log( await writeStepSummary( `### release-topics${DRY ? " (dry run)" : ""}\n\n` + - `${finished.length} finished topics, ${released.size} already released.\n\n` + - (report.length ? `${markdownTable(rows)}\n` : "No finished topics.\n") + + `${finished.length} finished topics and reviews, ${released.size} already released.\n\n` + + (report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") + (previews.length ? `\n${previews.join("\n")}` : ""), ); diff --git a/apps/cli/topics.ts b/apps/cli/topics.ts index 641ed3d..832d269 100644 --- a/apps/cli/topics.ts +++ b/apps/cli/topics.ts @@ -25,12 +25,13 @@ import type { Problem } from "../api/src/reviews.ts"; import type { GitHub } from "./github.ts"; +/** Completion of a required set: a topic's core problems, or every problem of a review. */ export interface Core { - /** Core sub-issues, closed and open alike, lowest number first. */ + /** The required problems, closed and open alike, in the order given. */ all: number[]; - /** The core sub-issues still open — non-empty means the topic stays open. */ + /** The required problems still open — non-empty means the issue stays open. */ pending: number[]; - /** When the last core sub-issue closed; null until the topic is finished. */ + /** When the last required problem closed; null until the set is finished (never, if empty). */ finishedAt: string | null; } @@ -71,13 +72,18 @@ export function labelValue(problem: Problem, prefix: string): string | undefined return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length); } +/** A topic's rule: its `set:core` problems are the required set. */ export function coreSet(problems: Problem[]): Core { - const core = problems.filter((p) => labelValue(p, "set:") === "core"); - const pending = core.filter((p) => !p.closedAt).map((p) => p.number); + return completion(problems.filter((p) => labelValue(p, "set:") === "core")); +} + +/** The shared rule: finished when non-empty and every required problem is closed. */ +export function completion(required: Problem[]): Core { + const pending = required.filter((p) => !p.closedAt).map((p) => p.number); // ISO instants from the API share one format, so string order is time order. const finishedAt = - core.length && !pending.length - ? core.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b)) + required.length && !pending.length + ? required.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b)) : null; - return { all: core.map((p) => p.number), pending, finishedAt }; + return { all: required.map((p) => p.number), pending, finishedAt }; }