/** * 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. * * 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. * * Library: side-effect-free on import; every call takes the caller's github(). */ 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[]; /** The core sub-issues still open โ€” non-empty means the topic stays open. */ pending: number[]; /** When the last core sub-issue closed; null until the topic is finished. */ finishedAt: string | null; } /** * 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. */ export async function readProblems(gh: GitHub, topic: number): Promise { const problems: Problem[] = []; 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; if (!("title" in raw) || typeof raw.title !== "string") continue; if (!("state" in raw) || typeof raw.state !== "string") continue; if (!("labels" in raw) || !Array.isArray(raw.labels)) continue; const closedAt = raw.state === "closed" && "closed_at" in raw && typeof raw.closed_at === "string" ? raw.closed_at : null; const labels = raw.labels.flatMap((label: unknown) => label && typeof label === "object" && "name" in label && typeof label.name === "string" ? [label.name] : [], ); problems.push({ number: raw.number, title: raw.title, closedAt, labels }); } return problems.sort((a, b) => a.number - b.number); } /** The value of a `prefix:value` label (`set:` โ†’ `core`), if the problem has one. */ export function labelValue(problem: Problem, prefix: string): string | undefined { return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length); } 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); // 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)) : null; return { all: core.map((p) => p.number), pending, finishedAt }; }