mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +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 |
|
| `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 /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` |
|
| `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=` |
|
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
|
||||||
|
|
||||||
## Crons (DST-proof)
|
## 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
|
(`send_email`, restricted to the verified destination). Sender domain
|
||||||
`prdlk.com` is onboarded to Email Sending.
|
`prdlk.com` is onboarded to Email Sending.
|
||||||
|
|
||||||
The `sync-d1.yml` Actions workflow additionally pushes curriculum issue
|
Two Actions workflows push into D1 with the `SRS_ADMIN_KEY` repo secret:
|
||||||
edits into D1 immediately (`POST /admin/reconcile` with the
|
`sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile`
|
||||||
`SRS_ADMIN_KEY` repo secret); the morning cron is the backstop.
|
(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
|
## Local dev
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ CREATE TABLE attempts (
|
|||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
kind TEXT NOT NULL, -- first|review|drill|gate
|
kind TEXT NOT NULL, -- first|review|drill|gate
|
||||||
result TEXT NOT NULL, -- pass|fail
|
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
|
-- One-tap email links must be idempotent (same link twice = no-op), but
|
||||||
-- /done webhook corrections (pass then fail, same day) must stay legal —
|
-- /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
|
CREATE UNIQUE INDEX attempts_email_once
|
||||||
ON attempts (lc_number, date, kind) WHERE source = 'email';
|
ON attempts (lc_number, date, kind) WHERE source = 'email';
|
||||||
CREATE INDEX attempts_by_date ON attempts (date);
|
CREATE INDEX attempts_by_date ON attempts (date);
|
||||||
|
|||||||
+59
-4
@@ -22,6 +22,7 @@ import {
|
|||||||
daysBetween,
|
daysBetween,
|
||||||
etDate,
|
etDate,
|
||||||
etHour,
|
etHour,
|
||||||
|
getProblem,
|
||||||
logAttempt,
|
logAttempt,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
@@ -29,6 +30,13 @@ 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: Project fields 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
|
||||||
@@ -37,10 +45,7 @@ import { buildStats } from "./stats.ts";
|
|||||||
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);
|
||||||
const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!);
|
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome);
|
||||||
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);
|
|
||||||
if (outcome.first && outcome.result === "pass") {
|
if (outcome.first && outcome.result === "pass") {
|
||||||
try {
|
try {
|
||||||
await gh.closeIssue(
|
await gh.closeIssue(
|
||||||
@@ -200,6 +205,55 @@ async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
|||||||
return timingSafeEqual(header.slice(7), env.LINK_KEY);
|
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> {
|
async function handleAdmin(request: Request, env: Env, path: string): Promise<Response> {
|
||||||
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
|
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
|
||||||
const url = new URL(request.url);
|
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") {
|
if (path === "/admin/reconcile") {
|
||||||
return Response.json(await reconcileCatalog(env.DB, gh()));
|
return Response.json(await reconcileCatalog(env.DB, gh()));
|
||||||
}
|
}
|
||||||
|
if (path === "/admin/solved") return await handleSolved(request, env, date);
|
||||||
if (path === "/admin/digest") {
|
if (path === "/admin/digest") {
|
||||||
const report = await sendDigest(env, date, {
|
const report = await sendDigest(env, date, {
|
||||||
dry: url.searchParams.get("dry") === "1",
|
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
|
* 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
|
* Email idempotency comes from the partial unique index on
|
||||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
* 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(
|
export async function logAttempt(
|
||||||
db: D1Database,
|
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> {
|
): Promise<LogOutcome> {
|
||||||
const p = await getProblem(db, opts.lc);
|
const p = await getProblem(db, opts.lc);
|
||||||
const nothing: LogOutcome = {
|
const nothing: LogOutcome = {
|
||||||
@@ -266,6 +275,20 @@ export async function logAttempt(
|
|||||||
duplicate: false,
|
duplicate: false,
|
||||||
};
|
};
|
||||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
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") {
|
if (p.stage === "retired") {
|
||||||
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user