Files
leetcode/apps/api/src/catalog.ts
T

124 lines
4.8 KiB
TypeScript

/**
* 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<ReconcileReport> {
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 <n> · <name>"`);
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 };
}