feat(cli): add completion helper and handle review issue releases/closures

This commit is contained in:
Prad Nukala committed 2026-09-30 16:31:15 -04:00
1 parent a64a6c2379
commit d6b483f84e
3 files changed
+116 -79

No files matched your search

+8 -8
View File
@@ -28,7 +28,7 @@
import { movedFromTopics, readReviews } from "../api/src/reviews.ts"; 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 { completion, coreSet, readProblems } from "./topics.ts";
const DRY = const DRY =
process.argv.includes("--dry-run") || process.argv.includes("--dry-run") ||
@@ -112,16 +112,16 @@ const reviewReport: string[][] = [];
let reviewsClosed = 0; let reviewsClosed = 0;
for (const review of reviews.filter((r) => r.open)) { for (const review of reviews.filter((r) => r.open)) {
const pending = review.problems.filter((p) => !p.closedAt).map((p) => p.number); const set = completion(review.problems);
const progress = `${review.problems.length - pending.length}/${review.problems.length}`; 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 // An empty review would read as done; leave it for a human, like a topic
// with no core set. // with no core set.
if (review.problems.length === 0) { if (set.all.length === 0) {
reviewReport.push([`#${review.number}`, review.title, progress, "no sub-issues"]); reviewReport.push([`#${review.number}`, review.title, progress, "no sub-issues"]);
continue; continue;
} }
if (pending.length > 0) { if (set.pending.length > 0) {
reviewReport.push([`#${review.number}`, review.title, progress, `open: ${pending.map((n) => `#${n}`).join(" ")}`]); reviewReport.push([`#${review.number}`, review.title, progress, `open: ${set.pending.map((n) => `#${n}`).join(" ")}`]);
continue; continue;
} }
if (DRY) { if (DRY) {
@@ -130,8 +130,8 @@ for (const review of reviews.filter((r) => r.open)) {
} }
await gh.closeIssue( await gh.closeIssue(
review.number, review.number,
`Review complete — all ${review.problems.length} problems closed ` + `Review complete — all ${set.all.length} problems closed ` +
`(${review.problems.map((p) => `#${p.number}`).join(", ")}).\n\n` + `(${set.all.map((n) => `#${n}`).join(", ")}).\n\n` +
"Closed automatically by `close-topics`.", "Closed automatically by `close-topics`.",
); );
reviewReport.push([`#${review.number}`, review.title, progress, "closed"]); reviewReport.push([`#${review.number}`, review.title, progress, "closed"]);
+94 -63
View File
@@ -1,21 +1,24 @@
#!/usr/bin/env bun #!/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 <N> — <Phase>` issue, versioned by timever
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668). * (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
* *
* "Finished" is topics.ts's rule — every core sub-issue closed — the same one * "Finished" is topics.ts's rule — a topic when every core problem is closed,
* close-topics.ts closes the topic issue on, and the version is the instant * a review when every one of its sub-issues is — the same one close-topics.ts
* the LAST core problem closed. Keying off the sub-issues rather than the * closes the issue on, and the version is the instant the LAST required
* topic issue lets this run as the tail of Close Solved: the push that closes * problem closed. Keying off the sub-issues rather than the topic/review issue
* a topic's final core problem releases it in the same run, without waiting * lets this run as the tail of Close Solved: the push that closes the final
* for close-topics to chain after it. The topic issue itself closes seconds * required problem releases it in the same run, without waiting for
* later, on the same day. * 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 * Each release is a changelog snapshot at that instant: the issue's problems
* (its sub-issues, plus those since re-parented into Review issues — see * (a topic's sub-issues plus those since re-parented into Review issues, or a
* topics.ts) grouped as closed on the finish day (ET), closed earlier, and * review's sub-issues in solve order — see topics.ts) grouped as closed on the
* open at release, each with its set, difficulty, and the solution files as * finish day (ET), closed earlier, and open at release, each with its set,
* they stood in the tagged commit — the last main commit before the finish. * 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 * The tag is annotated and backdated (tagger date = finish), so
* `git tag --sort=taggerdate` reads as the campaign's history even for tags * `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) * this timever tag instead (`releasesDatedByTag` in apps/docs/blume.config.ts)
* — the tag format is that contract. * — the tag format is that contract.
* *
* Reconciles instead of reacting: every finished topic without a release gets * Reconciles instead of reacting: every finished topic or review without a
* one, so a first run backfills the whole campaign; an existing release keeps * release gets one, so a first run backfills the whole campaign; an existing
* its tag and commit and only has its body rewritten when render() would say * release keeps its tag and commit and only has its body rewritten when
* something else. Re-runs with nothing new are no-ops. * 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 * 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. * 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 * 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 { 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 } from "./topics.ts"; import { completion, coreSet, labelValue, readProblems } from "./topics.ts";
import { ROOT, parseWorkPath } from "./work.ts"; import { ROOT, parseWorkPath } from "./work.ts";
const DRY = const DRY =
@@ -64,9 +67,10 @@ function git(...args: string[]): string {
return run.stdout.toString().trim(); 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; number: number;
title: string; title: string;
milestone: string | null; milestone: string | null;
@@ -74,7 +78,7 @@ interface Topic {
} }
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */ /** 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 (!value || typeof value !== "object") return;
if ("pull_request" in value) return; if ("pull_request" in value) return;
if (!("number" in value) || typeof value.number !== "number") return; if (!("number" in value) || typeof value.number !== "number") return;
@@ -92,29 +96,46 @@ function readTopic(value: unknown): Topic | undefined {
} }
interface Finished { 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[]; problems: Problem[];
core: number[]; /** The set whose last close finished it: a topic's core, a review's every problem. */
required: number[];
finishedAt: string; finishedAt: string;
} }
// Closed and open topics alike: the rule reads the sub-issues, so a topic // Closed and open issues alike: the rules read the sub-issues, so a topic or
// finished moments ago (close-topics not yet run) releases now. // review finished moments ago (close-topics not yet run) releases now.
const finished: Finished[] = []; 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`)) { for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
const topic = readTopic(raw); const issue = readTopic(raw);
if (!topic) continue; if (!issue) continue;
const problems = await readProblems(gh, topic.number, moved); const problems = await readProblems(gh, issue.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({ 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. // 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 ──────────────────────────────────────────── // ── 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<number, { id: number; tag: string; name: string; body: string }>(); const released = new Map<number, { id: number; tag: string; name: string; body: string }>();
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) { for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
if (!raw || typeof raw !== "object") continue; 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 * 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 * tiebreak: two topics or reviews finishing in the same second (one push
* last core problems) get consecutive nanoseconds, in (instant, topic) order. * closing both last problems) get consecutive nanoseconds, in (instant,
* issue) order.
*/ */
const tags = new Map<number, string>(); // topic -> tag const tags = new Map<number, string>(); // issue -> tag
{ {
let prev = ""; let prev = "";
let ns = 0; let ns = 0;
@@ -142,7 +164,7 @@ const tags = new Map<number, string>(); // topic -> tag
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`; 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; ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
prev = base; 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<number, string[]>): string { function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
const { topic, problems } = f; const { issue, problems } = f;
const finishDay = etDate(new Date(f.finishedAt)); const finishDay = etDate(new Date(f.finishedAt));
// Snapshot at the finish instant: a problem closed later was open then. // Snapshot at the finish instant: a problem closed later was open then.
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt; 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<number, string
// with one self-contained sentence and keep the fields on one `·` line. // with one self-contained sentence and keep the fields on one `·` line.
const when = ET_WHEN.format(new Date(f.finishedAt)); const when = ET_WHEN.format(new Date(f.finishedAt));
const summary = const summary =
`Finished ${when} ET: all ${f.core.length} core problems closed, ` + `Finished ${when} ET: all ${f.required.length} ${f.kind === "topic" ? "core " : ""}problems closed, ` +
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`; `${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
const fields: string[] = []; const fields: string[] = [];
const scheduled = bodyField(topic.body, "Scheduled"); const scheduled = bodyField(issue.body, "Scheduled");
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`); if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
if (topic.milestone) fields.push(`**Phase:** ${topic.milestone}`); const phase = f.kind === "topic" ? issue.milestone : bodyField(issue.body, "Phase");
fields.push(`**Topic:** #${topic.number}`); if (phase) fields.push(`**Phase:** ${phase}`);
const buildsOn = bodyField(topic.body, "Builds on"); fields.push(`**${f.kind === "topic" ? "Topic" : "Review"}:** #${issue.number}`);
const buildsOn = bodyField(issue.body, "Builds on");
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`); if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
const load = bodyField(issue.body, "Load");
if (load) fields.push(`**Load:** ${load}`);
// The marker is how a later run finds this release; see the file header. // The marker is how a later run finds this release; see the file header.
const sections = [`<!-- release-topics: #${topic.number} -->\n${summary}`, fields.join(" · ")]; // One marker for both kinds: issue numbers never collide.
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the topic finished\n\n${table(onDay)}`); const sections = [`<!-- release-topics: #${issue.number} -->\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 (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
if (open.length) sections.push(`### Open at release\n\n${table(open)}`); if (open.length) sections.push(`### Open at release\n\n${table(open)}`);
const notes = NOTES.flatMap((h) => { const notes = NOTES.flatMap((h) => {
const text = bodySection(topic.body, h); const text = bodySection(issue.body, h);
return text ? [`**${h}.** ${text}`] : []; return text ? [`**${h}.** ${text}`] : [];
}); });
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`); if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
sections.push( 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.`, `it tags ${sha.slice(0, 7)}, the last main commit before it.`,
); );
return `${sections.join("\n\n")}\n`; return `${sections.join("\n\n")}\n`;
@@ -237,20 +264,20 @@ function render(f: Finished, tag: string, sha: string, files: Map<number, string
// ── reconcile ──────────────────────────────────────────────────── // ── reconcile ────────────────────────────────────────────────────
const latest = finished.at(-1)?.topic.number; const latest = finished.at(-1)?.issue.number;
const report: string[][] = []; const report: string[][] = [];
const previews: string[] = []; const previews: string[] = [];
let created = 0; let created = 0;
let updated = 0; let updated = 0;
for (const f of finished) { for (const f of finished) {
const { topic } = f; const { issue } = f;
const finishedEt = etDate(new Date(f.finishedAt)); const finishedEt = etDate(new Date(f.finishedAt));
const existing = released.get(topic.number); const existing = released.get(issue.number);
// An existing release keeps its tag and commit; only its name and body are // An existing release keeps its tag and commit; only its name and body are
// reconciled. Its tag may postdate this clone (released by another run), so // reconciled. Its tag may postdate this clone (released by another run), so
// fetch it when missing — read-only, so a dry run does it too. // fetch it when missing — read-only, so a dry run does it too.
const tag = existing?.tag ?? tags.get(topic.number)!; const tag = existing?.tag ?? tags.get(issue.number)!;
if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) { if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) {
git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`); git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`);
} }
@@ -258,7 +285,7 @@ for (const f of finished) {
? git("rev-parse", `${tag}^{commit}`) ? git("rev-parse", `${tag}^{commit}`)
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD"); : git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
if (!sha) { if (!sha) {
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "ERROR: no commit before finish"]); report.push([`#${issue.number}`, issue.title, finishedEt, tag, "ERROR: no commit before finish"]);
continue; continue;
} }
// LC number -> its first-solve files in the tagged tree. Keyed by file name, // LC number -> 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]); files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
} }
const body = render(f, tag, sha, files); const body = render(f, tag, sha, files);
const name = topic.title; const name = issue.title;
if (existing) { if (existing) {
if (existing.body === body && existing.name === name) { 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) { } else if (DRY) {
previews.push(`## ${name} (${tag})\n\n${body}`); 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 { } else {
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, { await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ name, body }), body: JSON.stringify({ name, body }),
}); });
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "updated"]); report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]);
updated++; updated++;
} }
continue; continue;
@@ -293,7 +320,7 @@ for (const f of finished) {
if (DRY) { if (DRY) {
previews.push(`## ${name} (${tag})\n\n${body}`); 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; continue;
} }
@@ -302,7 +329,11 @@ for (const f of finished) {
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }); const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
if (local.exitCode !== 0) { if (local.exitCode !== 0) {
const tagged = Bun.spawnSync( 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 } }, { cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
); );
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`); 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, tag_name: tag,
name, name,
body, 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++; created++;
} }
// ── report ─────────────────────────────────────────────────────── // ── report ───────────────────────────────────────────────────────
if (previews.length) console.log(`${previews.join("\n")}\n`); 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); printTable(rows);
const would = (status: string) => report.filter((r) => r[4] === status).length; const would = (status: string) => report.filter((r) => r[4] === status).length;
console.log( console.log(
`\n${finished.length} finished topics · ` + `\n${finished.length} finished topics and reviews · ` +
(DRY (DRY
? `would release ${would("would release")}, update ${would("would update")}` ? `would release ${would("would release")}, update ${would("would update")}`
: `released ${created}, updated ${updated}`), : `released ${created}, updated ${updated}`),
@@ -337,7 +368,7 @@ console.log(
await writeStepSummary( await writeStepSummary(
`### release-topics${DRY ? " (dry run)" : ""}\n\n` + `### release-topics${DRY ? " (dry run)" : ""}\n\n` +
`${finished.length} finished topics, ${released.size} already released.\n\n` + `${finished.length} finished topics and reviews, ${released.size} already released.\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "No finished topics.\n") + (report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") +
(previews.length ? `\n${previews.join("\n")}` : ""), (previews.length ? `\n${previews.join("\n")}` : ""),
); );
+14 -8
View File
@@ -25,12 +25,13 @@
import type { Problem } from "../api/src/reviews.ts"; import type { Problem } from "../api/src/reviews.ts";
import type { GitHub } from "./github.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 { 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[]; 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[]; 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; 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); 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 { export function coreSet(problems: Problem[]): Core {
const core = problems.filter((p) => labelValue(p, "set:") === "core"); return completion(problems.filter((p) => labelValue(p, "set:") === "core"));
const pending = core.filter((p) => !p.closedAt).map((p) => p.number); }
/** 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. // ISO instants from the API share one format, so string order is time order.
const finishedAt = const finishedAt =
core.length && !pending.length required.length && !pending.length
? core.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b)) ? required.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
: null; : null;
return { all: core.map((p) => p.number), pending, finishedAt }; return { all: required.map((p) => p.number), pending, finishedAt };
} }