From e4de74c9359ce4654e2bde3908ea417c57b5b89f Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 25 Aug 2026 15:43:21 -0400 Subject: [PATCH] mise ~/.config/mise/config.toml tools: crush@0.91.0 feat(api): add /admin/solved endpoint and commit source handling --- apps/api/README.md | 13 +++++-- apps/api/migrations/0001_init.sql | 6 ++- apps/api/src/index.ts | 63 +++++++++++++++++++++++++++++-- apps/api/src/srs.ts | 29 ++++++++++++-- 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/apps/api/README.md b/apps/api/README.md index fef131e..efd8210 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -40,6 +40,7 @@ day never feeds drills just because its calendar week lapsed. | `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring | | `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) | | `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` | +| `POST /admin/solved` | `Authorization: Bearer ` | `{"lc":[…]}` — solutions committed under `work/`; logs each *first* solve (`source='commit'`), skips anything past stage `new`, takes `?dry=1&date=` | | `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer ` | manual triggers; `digest` takes `?dry=1&force=1&date=` | ## Crons (DST-proof) @@ -59,9 +60,15 @@ routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL` (`send_email`, restricted to the verified destination). Sender domain `prdlk.com` is onboarded to Email Sending. -The `sync-d1.yml` Actions workflow additionally pushes curriculum issue -edits into D1 immediately (`POST /admin/reconcile` with the -`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop. +Two Actions workflows push into D1 with the `SRS_ADMIN_KEY` repo secret: +`sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile` +(the morning cron is the backstop), and `close-solved.yml` sends the whole +implemented `work/` set to `POST /admin/solved` on every push to `main`. +That second call is what keeps a committed solution from being invisible +here: closing its issue is not a state change the Worker can see — the +catalog reconcile ignores issue state, and only `logAttempt()` moves the +ladder. `close-solved` still owns the issue close itself (it knows the files +and the commit), so this path only writes D1 and mirrors Project fields. ## Local dev diff --git a/apps/api/migrations/0001_init.sql b/apps/api/migrations/0001_init.sql index a16615e..32e3875 100644 --- a/apps/api/migrations/0001_init.sql +++ b/apps/api/migrations/0001_init.sql @@ -18,12 +18,14 @@ CREATE TABLE attempts ( date TEXT NOT NULL, kind TEXT NOT NULL, -- first|review|drill|gate result TEXT NOT NULL, -- pass|fail - source TEXT NOT NULL -- email|webhook|import + source TEXT NOT NULL -- email|webhook|commit|import ); -- One-tap email links must be idempotent (same link twice = no-op), but -- /done webhook corrections (pass then fail, same day) must stay legal — --- so uniqueness applies to email-sourced attempts only. +-- so uniqueness applies to email-sourced attempts only. Commit-sourced +-- attempts need no index: logAttempt() only accepts them for a stage-'new' +-- problem, so a re-push cannot insert a second one. CREATE UNIQUE INDEX attempts_email_once ON attempts (lc_number, date, kind) WHERE source = 'email'; CREATE INDEX attempts_by_date ON attempts (date); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 26e83c3..d8a4f55 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -22,6 +22,7 @@ import { daysBetween, etDate, etHour, + getProblem, logAttempt, weekdayOf, } from "./srs.ts"; @@ -29,6 +30,13 @@ import { buildStats } from "./stats.ts"; // ── 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 { + 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 * first-ever pass also close the problem's sub-issue (comment first — repo @@ -37,10 +45,7 @@ import { buildStats } from "./stats.ts"; async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise { if (outcome.error || outcome.duplicate) return; const gh = github(env.GH_PAT, env.REPO); - const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!); - 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); + await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome); if (outcome.first && outcome.result === "pass") { try { await gh.closeIssue( @@ -200,6 +205,55 @@ async function adminAuthorized(request: Request, env: Env): Promise { return timingSafeEqual(header.slice(7), env.LINK_KEY); } +/** + * Solutions landing in `work/` are the third way a problem gets solved, after + * the digest tap and a /done comment. `close-solved` posts its whole + * implemented set here on every push to main: logAttempt ignores anything + * already on the ladder, so this is a total recompute like the reconcilers + * that call it — a re-run changes nothing. + * + * Closing the sub-issue stays with close-solved: it is the side that knows + * which files at which commit, so this path mirrors Project fields only. + * Charts read D1 live, so a push moves them within the 5-minute cache. + */ +async function handleSolved(request: Request, env: Env, date: string): Promise { + const body: unknown = await request.json().catch(() => null); + const raw: unknown = body && typeof body === "object" && "lc" in body ? body.lc : null; + if (!Array.isArray(raw)) return new Response('expected {"lc": [, ...]}', { status: 400 }); + const lcs: number[] = []; + for (const item of raw) { + const lc: unknown = item; + if (typeof lc !== "number" || !Number.isFinite(lc)) { + return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 }); + } + lcs.push(lc); + } + const dry = new URL(request.url).searchParams.get("dry") === "1"; + + const logged: string[] = []; + const skipped: string[] = []; + let mirror: Mirror | undefined; + for (const lc of lcs) { + if (dry) { + const p = await getProblem(env.DB, lc); + if (!p) skipped.push(`LC ${lc} is not in the curriculum`); + else if (p.stage !== "new") skipped.push(`LC ${lc}: already at stage ${p.stage}`); + else logged.push(`LC ${lc}: would enter the ladder at +2`); + continue; + } + const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit" }); + if (outcome.error) skipped.push(outcome.error); + else if (outcome.duplicate) skipped.push(`LC ${lc}: already at stage ${outcome.stage}`); + else { + logged.push(outcomeLine(outcome)); + // One mirror for the batch: field IDs resolve once, not per problem. + mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!); + await mirrorFields(mirror, outcome); + } + } + return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] }); +} + async function handleAdmin(request: Request, env: Env, path: string): Promise { if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 }); const url = new URL(request.url); @@ -209,6 +263,7 @@ async function handleAdmin(request: Request, env: Env, path: string): Promise { const p = await getProblem(db, opts.lc); const nothing: LogOutcome = { @@ -266,6 +275,20 @@ export async function logAttempt( duplicate: false, }; if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` }; + // A commit only ever reports a first solve: pushing the file again — or the + // reconciler re-sending the whole solved set — must never move the ladder, + // and must never wake a retired problem. Reviews come from the digest tap, + // a /done comment, or a gate. + if (opts.source === "commit" && p.stage !== "new") { + return { + ...nothing, + title: p.title, + stage: p.stage, + next_review: p.next_review, + issue: p.issue, + duplicate: true, + }; + } if (p.stage === "retired") { return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` }; }