/** * 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. 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"; /** Completion of a required set: a topic's core problems, or every problem of a review. */ export interface Core { /** The required problems, closed and open alike, in the order given. */ all: number[]; /** The required problems still open — non-empty means the issue stays open. */ pending: number[]; /** When the last required problem closed; null until the set is finished (never, if empty). */ finishedAt: string | null; } /** * 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, 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; 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); } /** A topic's rule: its `set:core` problems are the required set. */ export function coreSet(problems: Problem[]): Core { 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 = required.length && !pending.length ? required.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b)) : null; return { all: required.map((p) => p.number), pending, finishedAt }; }