refactor(api): limit project mirror to Target Date only

This commit is contained in:
Prad Nukala
2026-08-27 15:46:12 -04:00
parent 9b5879b6eb
commit 8689075ac9
3 changed files with 21 additions and 52 deletions
+7 -3
View File
@@ -16,9 +16,13 @@ Live at `https://srs-api.prdlk.workers.dev`.
never overwrites SRS-owned columns (`stage`, `next_review`). never overwrites SRS-owned columns (`stage`, `next_review`).
- **The repo owns the schedule** — `data/schedule.json`, human-edited, - **The repo owns the schedule** — `data/schedule.json`, human-edited,
bundled at deploy. Git history is its audit log. bundled at deploy. Git history is its audit log.
- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`) - The GitHub Project mirror writes `Target Date` and nothing else: it is
is best-effort: failures log warnings, never block a D1 write. Topic rows best-effort, so failures log warnings and never block a D1 write, and topic
are never written. rows are never written. SRS stage and first-attempt result stay in D1 only —
the charts, digest and `/api/stats` read them from there, so mirroring them
into Project single-selects bought only drift. Do not re-add them.
`Set`/`Difficulty` are a projection of the issue labels, reconciled by
`bun run sync-project-fields` at the repo root, not by this Worker.
## The ladder ## The ladder
+4 -10
View File
@@ -30,22 +30,16 @@ import { buildStats } from "./stats.ts";
// ── shared side effects after a state write ────────────────────── // ── shared side effects after a state write ──────────────────────
/** Project-field half of a mirror; shared by every write path. */
async function mirrorFields(mirror: Mirror, outcome: LogOutcome): Promise<void> {
await mirror.setStage(outcome.issue, outcome.stage);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
}
/** /**
* Mirror one logged attempt into GitHub: Project fields always; on a * Mirror one logged attempt into GitHub: the Project's Target Date always; on a
* first-ever pass also close the problem's sub-issue (comment first — repo * first-ever pass also close the problem's sub-issue (comment first — repo
* convention). Runs inside ctx.waitUntil; failures are warnings. * convention). Runs inside ctx.waitUntil; failures are warnings.
*/ */
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> { async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
if (outcome.error || outcome.duplicate) return; if (outcome.error || outcome.duplicate) return;
const gh = github(env.GH_PAT, env.REPO); const gh = github(env.GH_PAT, env.REPO);
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome); const mirror = projectMirror(gh, env.REPO.split("/")[0]!);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
if (outcome.first && outcome.result === "pass") { if (outcome.first && outcome.result === "pass") {
try { try {
await gh.closeIssue( await gh.closeIssue(
@@ -248,7 +242,7 @@ async function handleSolved(request: Request, env: Env, date: string): Promise<R
logged.push(outcomeLine(outcome)); logged.push(outcomeLine(outcome));
// One mirror for the batch: field IDs resolve once, not per problem. // One mirror for the batch: field IDs resolve once, not per problem.
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!); mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
await mirrorFields(mirror, outcome); await mirror.setTargetDate(outcome.issue, outcome.next_review);
} }
} }
return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] }); return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] });
+10 -39
View File
@@ -4,30 +4,26 @@
* console warning and never blocks a state write. Only problem issues are * console warning and never blocks a state write. Only problem issues are
* ever passed in, so topic rows' Target Date is never written. * ever passed in, so topic rows' Target Date is never written.
* *
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is * Target Date is the ONLY mirrored column. SRS stage and first-attempt result
* short-lived; the two extra queries per mirror burst are irrelevant at this * live exclusively in D1 — they are read from there (charts, digest, /api/stats)
* volume and survive field re-creation). * and duplicating them into Project single-selects bought nothing but drift and
* two extra writes per log. Do not re-add them here.
*
* Field IDs are resolved by NAME per invocation (a Worker isolate is
* short-lived; the extra query per mirror burst is irrelevant at this
* volume and survives field re-creation).
*/ */
import type { GitHub } from "./github.ts"; import type { GitHub } from "./github.ts";
const PROJECT_TITLE = "Interview Prep"; const PROJECT_TITLE = "Interview Prep";
interface SelectField {
id: string;
options: Record<string, string>;
}
interface ProjectInfo { interface ProjectInfo {
id: string; id: string;
targetDate: string; targetDate: string;
srsStage: SelectField;
firstAttempt: SelectField;
} }
export interface Mirror { export interface Mirror {
setTargetDate(issue: number, date: string | null): Promise<void>; setTargetDate(issue: number, date: string | null): Promise<void>;
setStage(issue: number, stage: string): Promise<void>;
setFirstAttempt(issue: number, result: string): Promise<void>;
warnings: string[]; warnings: string[];
} }
@@ -47,7 +43,6 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
fields(first: 30) { fields(first: 30) {
nodes { nodes {
... on ProjectV2FieldCommon { id name dataType } ... on ProjectV2FieldCommon { id name dataType }
... on ProjectV2SingleSelectField { id name options { id name } }
} }
} }
} }
@@ -62,7 +57,7 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
id: string; id: string;
title: string; title: string;
fields: { fields: {
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[]; nodes: { id: string; name: string }[];
}; };
}[]; }[];
}; };
@@ -70,19 +65,9 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
}; };
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE); const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`); if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
const select = (name: string): SelectField => {
const f = node.fields.nodes.find((n) => n.name === name);
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
};
const date = node.fields.nodes.find((n) => n.name === "Target Date"); const date = node.fields.nodes.find((n) => n.name === "Target Date");
if (!date) throw new Error(`date field "Target Date" missing`); if (!date) throw new Error(`date field "Target Date" missing`);
info = { info = { id: node.id, targetDate: date.id };
id: node.id,
targetDate: date.id,
srsStage: select("SRS Stage"),
firstAttempt: select("First Attempt"),
};
return info; return info;
} }
@@ -154,19 +139,5 @@ export function projectMirror(gh: GitHub, owner: string): Mirror {
await setField(issue, project.targetDate, { date }); await setField(issue, project.targetDate, { date });
} }
}), }),
setStage: (issue, stage) =>
attempt(`SRS Stage #${issue}`, async () => {
const project = await resolve();
const option = project.srsStage.options[stage];
if (!option) throw new Error(`no option "${stage}"`);
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
}),
setFirstAttempt: (issue, result) =>
attempt(`First Attempt #${issue}`, async () => {
const project = await resolve();
const option = project.firstAttempt.options[result];
if (!option) throw new Error(`no option "${result}"`);
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
}),
}; };
} }