diff --git a/apps/api/src/catalog.ts b/apps/api/src/catalog.ts index de46a81..d27dbb2 100644 --- a/apps/api/src/catalog.ts +++ b/apps/api/src/catalog.ts @@ -9,9 +9,16 @@ * issue title — the title carries only the LC number and the problem's name, * so relabelling a problem is enough to move it and titles stay readable. * The `problem` label is what marks a sub-issue as curriculum. + * + * A topic's problems are its sub-issues PLUS those re-parented into a + * `Review — ` issue (reviews.ts recovers which topic each came + * from). Walking topic sub-issues alone would silently freeze a moved + * problem's row: upserts only, so nothing breaks — its labels just stop + * syncing. */ import { DIFFICULTIES, SETS, workingDay } from "./srs.ts"; import type { GitHub } from "./github.ts"; +import { movedFromTopics, readReviews } from "./reviews.ts"; const TITLE_RE = /^LC (\d+) · (.+)$/; @@ -72,24 +79,29 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise · "`); + console.warn(`catalog: #${number} is a problem but not titled "LC · "`); continue; } const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`)); const set = SETS.find((s) => labels.includes(`set:${s}`)); if (!difficulty || !set) { - console.warn(`catalog: #${raw.number} is missing a diff:/set: label — skipped`); + console.warn(`catalog: #${number} is missing a diff:/set: label — skipped`); continue; } const lc = Number(m[1]); @@ -106,7 +118,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise — ` 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 three 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; + /** 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 + 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"; + 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", + 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; +}