/** * `Review — ` issues: the transfer phase's day blocks. Problems were * re-parented into them from their topics (`replace_parent` — a GitHub issue * has exactly one parent), so a topic's sub-issue list no longer names every * problem it owns. This module is the ONE recovery of that ownership: each * moved problem's topic is its last `parent_issue_removed` event naming a * topic, which GitHub records and no human edits. * * Two consumers must agree on it: the Worker's catalog reconcile * (catalog.ts — else a moved problem's D1 row stops syncing its labels) and * the CLI's topic rules (apps/cli/topics.ts — else close-topics and the * release bodies change when a problem moves). Hence it lives here, the lower * layer, the way apps/cli already imports srs.ts. * * Library: side-effect-free on import; the caller passes its GitHub client — * the Worker's or the CLI's, which share the two members used here. */ import type { GitHub } from "./github.ts"; /** `Review 7 — Nodal & Grid`, never the Worker's weekly `Review — Week N` gate. */ const REVIEW_TITLE = /^Review \d+ — /; const TOPIC_TITLE = /^Topic \d+ — /; /** One problem issue as a topic or review lists it. */ 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 Review { number: number; title: string; open: boolean; /** Markdown body: `**Scheduled:**` / `**Phase:**` / `**Load:**` lines the release reads. */ body: string; /** Sub-issues in the order GitHub lists them (the solve order). */ problems: (Problem & { fromTopic: number | null })[]; } /** * Every `Review — ` issue, open and closed, with its sub-issues. * One GraphQL request per 50 `review`-labelled issues (the Worker's weekly * gate issues share the label and are dropped by title): the sub-issues and * the timeline filter ride along, so recovering each problem's source topic * costs no extra request. That matters in the Worker, whose nightly cron * spends its subrequest budget on the catalog walk and the digest. */ export async function readReviews(gh: Pick): Promise { const [owner, name] = gh.repo.split("/"); const reviews: Review[] = []; for (let after: string | null = null; ; ) { // Shape fixed by the query below; GraphQL errors throw in gh.graphql(). const data = (await gh.graphql( `query($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { issues(labels: ["review"], first: 50, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) { pageInfo { hasNextPage endCursor } nodes { number title state body subIssues(first: 50) { pageInfo { hasNextPage } nodes { number title state closedAt labels(first: 20) { nodes { name } } timelineItems(itemTypes: [PARENT_ISSUE_REMOVED_EVENT], last: 10) { nodes { ... on ParentIssueRemovedEvent { parent { number title } } } } } } } } } }`, { owner, name, after }, )) as { repository: { issues: { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: { number: number; title: string; state: "OPEN" | "CLOSED"; body: string; subIssues: { pageInfo: { hasNextPage: boolean }; nodes: { number: number; title: string; state: "OPEN" | "CLOSED"; closedAt: string | null; labels: { nodes: { name: string }[] }; timelineItems: { nodes: { parent: { number: number; title: string } | null }[] }; }[]; }; }[]; }; }; }; const page = data.repository.issues; for (const issue of page.nodes) { if (!REVIEW_TITLE.test(issue.title)) continue; // A review is one day's handful; more than a page means a mis-linked issue. if (issue.subIssues.pageInfo.hasNextPage) throw new Error(`review #${issue.number} has over 50 sub-issues`); reviews.push({ number: issue.number, title: issue.title, open: issue.state === "OPEN", body: issue.body, problems: issue.subIssues.nodes.map((p) => ({ number: p.number, title: p.title, closedAt: p.state === "CLOSED" ? p.closedAt : null, labels: p.labels.nodes.map((l) => l.name), // Oldest first, so the last topic it left is its owner; a later // review-to-review move names a review and is skipped. fromTopic: p.timelineItems.nodes.findLast((e) => e.parent && TOPIC_TITLE.test(e.parent.title))?.parent?.number ?? null, })), }); } if (!page.pageInfo.hasNextPage) break; after = page.pageInfo.endCursor; } return reviews.sort((a, b) => a.number - b.number); } /** Topic -> the problems moved out of it into Review issues, in review order. */ export function movedFromTopics(reviews: Review[]): Map { const moved = new Map(); for (const review of reviews) { for (const { fromTopic, ...problem } of review.problems) { if (fromTopic !== null) moved.set(fromTopic, [...(moved.get(fromTopic) ?? []), problem]); } } return moved; }