Files

90 lines
4.2 KiB
TypeScript

/**
* 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 <N> — <Phase>`, 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<number, Problem[]>,
): Promise<Problem[]> {
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 };
}