/** * Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog * (topics, problems, set/diff labels, milestones); this recomputes desired * rows from scratch on every run — re-runs are no-ops. SRS-owned columns * (stage, next_review) are NEVER overwritten; defer_until is set once, on * first sight of a deferred problem. The Worker never invents catalog rows. * * Difficulty and set come from the `diff:*` / `set:*` LABELS, never from the * 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. */ import { workingDay } from "./srs.ts"; import type { GitHub } from "./github.ts"; const TITLE_RE = /^LC (\d+) · (.+)$/; const DIFFICULTIES = ["easy", "medium", "hard"] as const; const SETS = ["core", "optional", "deferred"] as const; /** Deferred Hards enter the queue from this date, two per working day. */ const DEFER_FROM = "2026-09-28"; export interface ReconcileReport { topics: number; problems: number; } /** Label names on one issue payload, narrowed with guards rather than cast. */ function labelNames(value: object): string[] { if (!("labels" in value) || !Array.isArray(value.labels)) return []; const names: string[] = []; for (const label of value.labels) { if (label && typeof label === "object" && "name" in label && typeof label.name === "string") { names.push(label.name); } } return names; } export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise { interface TopicIssue { number: number; title: string; milestone: number | null; } const topics: TopicIssue[] = []; for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) { if (!raw || typeof raw !== "object") continue; if ("pull_request" in raw) continue; if (!("number" in raw) || typeof raw.number !== "number") continue; if (!("title" in raw) || typeof raw.title !== "string") continue; let milestone: number | null = null; if ( "milestone" in raw && raw.milestone && typeof raw.milestone === "object" && "number" in raw.milestone && typeof raw.milestone.number === "number" ) { milestone = raw.milestone.number; } topics.push({ number: raw.number, title: raw.title, milestone }); } const statements: D1PreparedStatement[] = []; for (const t of topics) { statements.push( db .prepare( `INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3) ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`, ) .bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone), ); } let problems = 0; let deferredSeen = 0; for (const t of topics) { for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/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; const labels = labelNames(raw); if (!labels.includes("problem")) continue; // non-curriculum sub-issue const m = raw.title.match(TITLE_RE); if (!m) { console.warn(`catalog: #${raw.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`); continue; } const lc = Number(m[1]); // Two deferred Hards per WORKING day from DEFER_FROM, in catalog walk // order — applied only when the row is first created (SRS owns it // afterwards). Sunday is skipped like every other scheduled date. const defer = set === "deferred" ? workingDay(DEFER_FROM, Math.floor(deferredSeen / 2)) : null; if (set === "deferred") deferredSeen++; statements.push( db .prepare( `INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) ON CONFLICT(lc_number) DO UPDATE SET issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`, ) .bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer), ); problems++; } } // D1 batches are transactional; chunk to stay under statement limits. for (let i = 0; i < statements.length; i += 50) { await db.batch(statements.slice(i, i + 50)); } return { topics: topics.length, problems }; }