feat(api): extract difficulty and set from issue labels in catalog reconciliation

This commit is contained in:
Prad Nukala
2026-08-27 16:04:14 -04:00
parent 9e90cfb729
commit cac2f744e4
+34 -4
View File
@@ -4,11 +4,19 @@
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns * 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 * (stage, next_review) are NEVER overwritten; defer_until is set once, on
* first sight of a deferred problem. The Worker never invents catalog rows. * 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 { addDays } from "./srs.ts"; import { addDays } from "./srs.ts";
import type { GitHub } from "./github.ts"; import type { GitHub } from "./github.ts";
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/; 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 day. */ /** Deferred Hards enter the queue from this date, two per day. */
const DEFER_FROM = "2026-09-28"; const DEFER_FROM = "2026-09-28";
@@ -18,6 +26,18 @@ export interface ReconcileReport {
problems: 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> { export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
interface TopicIssue { interface TopicIssue {
number: number; number: number;
@@ -62,10 +82,20 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
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);
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
const m = raw.title.match(TITLE_RE); const m = raw.title.match(TITLE_RE);
if (!m) continue; // non-curriculum sub-issue 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]); const lc = Number(m[1]);
const set = m[4]!;
// Two deferred Hards per day from DEFER_FROM, in catalog walk order — // Two deferred Hards per day from DEFER_FROM, in catalog walk order —
// applied only when the row is first created (SRS owns it afterwards). // applied only when the row is first created (SRS owns it afterwards).
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null; const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
@@ -78,7 +108,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]!, m[3]!.toLowerCase(), set, defer), .bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer),
); );
problems++; problems++;
} }