From de5101b4d14839989522e2934fbf48ac3e432336 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 25 Aug 2026 15:44:46 -0400 Subject: [PATCH] feat(cli): report solved set to SRS worker and log sync status --- apps/cli/close-solved.ts | 75 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/apps/cli/close-solved.ts b/apps/cli/close-solved.ts index 17890db..a9081e8 100755 --- a/apps/cli/close-solved.ts +++ b/apps/cli/close-solved.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun /** - * Close the GitHub issue for every LeetCode problem actually solved under work/. + * Close the GitHub issue for every LeetCode problem actually solved under work/, + * then report the solved set to the SRS Worker so D1 agrees. * * Reconciles state instead of reacting to a push diff: any `problem`-labelled * issue whose LC number has an *implemented* solution file in work/ gets @@ -11,10 +12,12 @@ * file existence alone means nothing — a stub must not close its issue. See * isImplemented(). * - * bun apps/cli/close-solved.ts # close matches + * bun apps/cli/close-solved.ts # close matches, log them in D1 * bun apps/cli/close-solved.ts --dry-run # report only, touch nothing * * Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`. + * SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides + * the Worker URL for local `wrangler dev` runs. */ import { basename, join } from "node:path"; @@ -193,24 +196,82 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) { closed++; } +// ── push the solved set into D1 ─────────────────────────────────── + +// Closing an issue is invisible to the Worker: its catalog reconcile reads +// GitHub for titles, labels and milestones and never looks at issue state, +// and D1's SRS columns only ever move through logAttempt(). So a solution +// pushed here — rather than tapped in the digest email — has to be reported, +// or the charts, the digest's "already solved" ticks, and the drill/gate +// pools all keep treating it as untouched. +// +// The whole implemented set goes over, not just this run's closes: the +// Worker drops anything already on the ladder, so the call is a total +// recompute and backfills whatever earlier runs missed. +const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev"; +const solved = [...work.entries()] + .filter(([, entry]) => entry.implemented) + .map(([lc]) => lc) + .sort((a, b) => a - b); + +let syncNote: string; +let syncFailed = false; +if (!process.env.SRS_ADMIN_KEY) { + syncNote = "SRS_ADMIN_KEY unset — D1 not touched"; +} else if (solved.length === 0) { + syncNote = "nothing implemented — D1 not touched"; +} else { + try { + const res = await fetch(`${SRS_API}/admin/solved${DRY ? "?dry=1" : ""}`, { + method: "POST", + headers: { + authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ lc: solved }), + }); + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); + // Narrow the report payload instead of casting it; a shape change should + // read as "logged nothing", not throw inside the reconciler. + const payload: unknown = await res.json(); + const fields = payload && typeof payload === "object" ? payload : {}; + const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : []); + const logged = strings("logged" in fields ? fields.logged : null); + const skipped = strings("skipped" in fields ? fields.skipped : null); + syncNote = + `${DRY ? "would log" : "logged"} ${logged.length} of ${solved.length} ` + + `(${skipped.length} already on the ladder or off-curriculum)`; + for (const line of logged) console.log(` d1: ${line}`); + } catch (err) { + // The closes above already landed; surface the failure instead of + // letting D1 drift silently until the next push. + syncFailed = true; + syncNote = `D1 push FAILED: ${err instanceof Error ? err.message : String(err)}`; + } +} + // ── report ─────────────────────────────────────────────────────── const rows = [["lc", "issue", "set", "status"], ...report]; printTable(rows); -const implemented = [...work.values()].filter((e) => e.implemented).length; const actionable = report.filter((r) => r[3] !== "no open issue"); console.log( - `\n${work.size} in work/ · ${implemented} implemented · ` + + `\n${work.size} in work/ · ${solved.length} implemented · ` + `${actionable.length} matched an open issue · ` + - `${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`, + `${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` + + `\nd1: ${syncNote}`, ); await writeStepSummary( `### close-solved${DRY ? " (dry run)" : ""}\n\n` + - `${work.size} files in \`work/\`, ${implemented} implemented, ` + + `${work.size} files in \`work/\`, ${solved.length} implemented, ` + `${actionable.length} matched an open issue.\n\n` + (actionable.length ? `${markdownTable([rows[0]!, ...actionable])}\n` - : "Nothing to close.\n"), + : "Nothing to close.\n") + + `\nD1: ${syncNote}\n`, ); + +// Non-zero only for the D1 push: the closes are already reported above. +if (syncFailed) process.exitCode = 1;