feat(api): sync catalog with problems moved to review issues

This commit is contained in:
Prad Nukala committed 2026-09-30 16:23:50 -04:00
1 parent f8ff12b55e
commit b2dffa4615
2 files changed
+153 -5

No files matched your search

+17 -5
View File
@@ -9,9 +9,16 @@
* issue title — the title carries only the LC number and the problem's name, * 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. * so relabelling a problem is enough to move it and titles stay readable.
* The `problem` label is what marks a sub-issue as curriculum. * 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 <N> — <Phase>` 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 { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
import type { GitHub } from "./github.ts"; import type { GitHub } from "./github.ts";
import { movedFromTopics, readReviews } from "./reviews.ts";
const TITLE_RE = /^LC (\d+) · (.+)$/; const TITLE_RE = /^LC (\d+) · (.+)$/;
@@ -72,24 +79,29 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
); );
} }
const moved = movedFromTopics(await readReviews(gh));
let problems = 0; let problems = 0;
let deferredSeen = 0; let deferredSeen = 0;
for (const t of topics) { for (const t of topics) {
const members: { number: number; title: string; labels: string[] }[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) { for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
if (!raw || typeof raw !== "object") continue; if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue; if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue; if (!("title" in raw) || typeof raw.title !== "string") continue;
const labels = labelNames(raw); members.push({ number: raw.number, title: raw.title, labels: labelNames(raw) });
}
members.push(...(moved.get(t.number) ?? []));
for (const { number, title, labels } of members) {
if (!labels.includes("problem")) continue; // non-curriculum sub-issue if (!labels.includes("problem")) continue; // non-curriculum sub-issue
const m = raw.title.match(TITLE_RE); const m = title.match(TITLE_RE);
if (!m) { if (!m) {
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`); console.warn(`catalog: #${number} is a problem but not titled "LC <n> · <name>"`);
continue; continue;
} }
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`)); const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
const set = SETS.find((s) => labels.includes(`set:${s}`)); const set = SETS.find((s) => labels.includes(`set:${s}`));
if (!difficulty || !set) { 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; continue;
} }
const lc = Number(m[1]); const lc = Number(m[1]);
@@ -106,7 +118,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
ON CONFLICT(lc_number) DO UPDATE SET ON CONFLICT(lc_number) DO UPDATE SET
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`, issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
) )
.bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer), .bind(lc, number, t.number, m[2]!, difficulty, set, defer),
); );
problems++; problems++;
} }
+136
View File
@@ -0,0 +1,136 @@
/**
* `Review <N> — <Phase>` 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 <num> · <Name>`. */
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 <N> — <Phase>` 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<GitHub, "repo" | "graphql">): Promise<Review[]> {
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<number, Problem[]> {
const moved = new Map<number, Problem[]>();
for (const review of reviews) {
for (const { fromTopic, ...problem } of review.problems) {
if (fromTopic !== null) moved.set(fromTopic, [...(moved.get(fromTopic) ?? []), problem]);
}
}
return moved;
}