mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(api): add /admin/solved endpoint and commit source handling
This commit is contained in:
+10
-3
@@ -40,6 +40,7 @@ day never feeds drills just because its calendar week lapsed.
|
||||
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> 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 <LINK_KEY>` | `{"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 <LINK_KEY>` | 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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+59
-4
@@ -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<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
|
||||
* 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<void> {
|
||||
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<boolean> {
|
||||
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<Response> {
|
||||
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": [<number>, ...]}', { 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<Response> {
|
||||
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<Re
|
||||
if (path === "/admin/reconcile") {
|
||||
return Response.json(await reconcileCatalog(env.DB, gh()));
|
||||
}
|
||||
if (path === "/admin/solved") return await handleSolved(request, env, date);
|
||||
if (path === "/admin/digest") {
|
||||
const report = await sendDigest(env, date, {
|
||||
dry: url.searchParams.get("dry") === "1",
|
||||
|
||||
+26
-3
@@ -242,16 +242,25 @@ export interface LogOutcome {
|
||||
|
||||
/**
|
||||
* Record an attempt and move the ladder. Ladder semantics live here and only
|
||||
* here — email one-taps, webhook /done lines, and gate scoring all converge.
|
||||
* here — email one-taps, webhook /done lines, gate scoring, and solutions
|
||||
* landing in work/ all converge.
|
||||
*
|
||||
* Email idempotency comes from the partial unique index on
|
||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||
* on the same day) remain legal — every webhook attempt appends.
|
||||
* on the same day) remain legal — every webhook attempt appends. Commit
|
||||
* idempotency needs no index: a commit records a FIRST solve only, so the
|
||||
* stage check below turns every re-push into a no-op.
|
||||
*/
|
||||
export async function logAttempt(
|
||||
db: D1Database,
|
||||
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; gate?: boolean },
|
||||
opts: {
|
||||
lc: number;
|
||||
date: string;
|
||||
result: Result;
|
||||
source: "email" | "webhook" | "commit";
|
||||
gate?: boolean;
|
||||
},
|
||||
): Promise<LogOutcome> {
|
||||
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` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user