Files
leetcode/apps/api/src/index.ts
T
Prad Nukala e4de74c935 mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(api): add /admin/solved endpoint and commit source handling
2026-08-25 15:43:21 -04:00

380 lines
16 KiB
TypeScript

/**
* SRS Worker entry: fetch routes + DST-proof cron dispatch.
*
* Direction of truth: D1 owns SRS state; GitHub issues own the catalog;
* the bundled schedule.json owns the calendar. Every handler writes D1
* first and runs GitHub/Project side effects afterwards via ctx.waitUntil —
* a mirror failure logs a warning and never loses a state write.
*
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
* admin routes); everything else is read-only public.
*/
import { reconcileCatalog } from "./catalog.ts";
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
import { sendDigest } from "./digest.ts";
import { createReviewIssue, scoreReview } from "./gate.ts";
import { type GitHub, github } from "./github.ts";
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
import { type Mirror, projectMirror } from "./mirror.ts";
import {
CAMPAIGN_START,
type LogOutcome,
daysBetween,
etDate,
etHour,
getProblem,
logAttempt,
weekdayOf,
} from "./srs.ts";
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
* convention). Runs inside ctx.waitUntil; failures are warnings.
*/
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);
await mirrorFields(projectMirror(gh, env.REPO.split("/")[0]!), outcome);
if (outcome.first && outcome.result === "pass") {
try {
await gh.closeIssue(
outcome.issue,
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
);
} catch (err) {
console.warn(`close #${outcome.issue}: ${err}`);
}
}
}
function outcomeLine(o: LogOutcome): string {
if (o.error) return o.error;
if (o.duplicate) return `LC ${o.lc}: already logged today — nothing changed`;
return (
`LC ${o.lc}: ${o.kind} ${o.result} → stage ${o.stage}` +
(o.next_review ? `, review ${o.next_review}` : " — retired 🎉")
);
}
// ── one-tap email links ──────────────────────────────────────────
function page(title: string, body: string, status = 200): Response {
return new Response(
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
<body style="font-family:-apple-system,Segoe UI,sans-serif;max-width:420px;margin:15vh auto;padding:0 16px;text-align:center">
<h2>${title}</h2><p style="color:#57606a">${body}</p></body>`,
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
);
}
async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const lc = Number(url.searchParams.get("p"));
const result = url.searchParams.get("r");
const date = url.searchParams.get("d") ?? "";
const sig = url.searchParams.get("sig") ?? "";
if (!Number.isFinite(lc) || (result !== "pass" && result !== "fail") || !date) {
return page("Bad link", "Missing or malformed parameters.", 400);
}
if (!(await verifyLink(env.LINK_KEY, lc, result, date, sig))) {
return page("Bad signature", "This link was not signed by the SRS.", 403);
}
const today = etDate(new Date());
if (daysBetween(date, today) > 3 || daysBetween(today, date) > 1) {
return page("Link expired", "Older than 3 days — log it with a /done comment instead.", 410);
}
const outcome = await logAttempt(env.DB, { lc, date, result, source: "email" });
if (outcome.error) return page("Not logged", outcome.error, 422);
if (outcome.duplicate) {
return page("Already logged ✓", `LC ${lc} was already recorded today. Nothing changed.`);
}
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
return page(
result === "pass" ? "Logged ✅" : "Logged — back to +2",
outcomeLine(outcome),
);
}
// ── GitHub webhook ───────────────────────────────────────────────
interface CommentEvent {
action: string;
issue: { number: number; title: string; labels: { name: string }[] };
comment: { id: number; body: string; user: { login: string } };
}
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const raw = await request.arrayBuffer();
const ok = await verifyWebhook(env.WEBHOOK_SECRET, raw, request.headers.get("x-hub-signature-256"));
if (!ok) return new Response("bad signature", { status: 401 });
const event = request.headers.get("x-github-event");
const payload: unknown = JSON.parse(new TextDecoder().decode(raw));
const owner = env.REPO.split("/")[0]!;
const today = etDate(new Date());
if (event === "issues") {
if (!payload || typeof payload !== "object" || !("action" in payload) || !("issue" in payload)) {
return new Response("ignored");
}
const p = payload as { action: string; issue: { number: number; labels: { name: string }[] } };
if (p.action === "closed" && p.issue.labels.some((l) => l.name === "review")) {
const gh = github(env.GH_PAT, env.REPO);
const report = await scoreReview(env, gh, p.issue.number, today);
return Response.json(report);
}
return new Response("ignored");
}
if (event !== "issue_comment") return new Response("ignored");
const p = payload as CommentEvent;
if (p.action !== "created") return new Response("ignored");
if (p.comment.user.login !== owner) return new Response("ignored (owner only)");
const commands: { lc: number; result: "pass" | "fail" }[] = [];
const errors: string[] = [];
for (const rawLine of p.comment.body.split("\n")) {
const line = rawLine.trim();
if (!line.startsWith("/done")) continue;
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
if (m) commands.push({ lc: Number(m[1]), result: m[2] as "pass" | "fail" });
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
}
if (commands.length === 0 && errors.length === 0) return new Response("no commands");
// Comments on a review issue log its gate problems as kind='gate'.
const gateRow = await env.DB.prepare("SELECT problems FROM gates WHERE issue = ?")
.bind(p.issue.number)
.first<{ problems: string }>();
const gateLcs = new Set<number>(gateRow ? JSON.parse(gateRow.problems) : []);
const outcomes: LogOutcome[] = [];
for (const cmd of commands) {
const outcome = await logAttempt(env.DB, {
lc: cmd.lc,
date: today,
result: cmd.result,
source: "webhook",
gate: gateLcs.has(cmd.lc),
});
if (outcome.error) errors.push(outcome.error);
else outcomes.push(outcome);
}
const gh = github(env.GH_PAT, env.REPO);
ctx.waitUntil(
(async () => {
for (const outcome of outcomes) await mirrorOutcome(env, outcome, "webhook");
try {
if (errors.length === 0) {
await gh.react(p.comment.id, "+1");
} else {
await gh.comment(
p.issue.number,
`Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}` +
(outcomes.length
? `\n\nApplied anyway:\n\n${outcomes.map((o) => `- ${outcomeLine(o)}`).join("\n")}`
: ""),
);
}
} catch (err) {
console.warn(`webhook feedback: ${err}`);
}
})(),
);
return Response.json({ applied: outcomes.map(outcomeLine), errors });
}
// ── admin (bearer LINK_KEY, timing-safe) ─────────────────────────
async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return false;
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);
const date = url.searchParams.get("date") ?? etDate(new Date());
const gh = () => github(env.GH_PAT, env.REPO);
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",
force: url.searchParams.get("force") === "1",
});
if (url.searchParams.get("dry") === "1") {
return new Response(report.digest.html, { headers: { "content-type": "text/html" } });
}
return Response.json({ sent: report.sent, reason: report.reason, subject: report.digest.subject });
}
if (path === "/admin/review") {
return Response.json(await createReviewIssue(env, gh(), date));
}
return new Response("not found", { status: 404 });
}
// ── router ───────────────────────────────────────────────────────
const SVG_HEADERS = {
"content-type": "image/svg+xml",
// GitHub Camo honors this; 5 minutes is the freshness floor for README charts.
"cache-control": "public, max-age=300",
};
// The digest embeds the raster heatmap; email clients refuse SVG entirely.
const PNG_HEADERS = {
"content-type": "image/png",
"cache-control": "public, max-age=300",
};
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
try {
if (path === "/log" && request.method === "GET") return await handleTap(request, env, ctx);
if (path === "/webhook/github" && request.method === "POST") {
return await handleWebhook(request, env, ctx);
}
if (path.startsWith("/admin/") && request.method === "POST") {
return await handleAdmin(request, env, path);
}
if (path === "/chart/progress.svg") {
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
}
if (path === "/chart/ladder.svg") {
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
}
if (path === "/chart/heatmap.svg") {
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
}
if (path === "/chart/heatmap.png") {
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
}
if (path === "/badge/gate.svg") {
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
}
if (path === "/api/stats") {
// The docs serve from both the Pages origin and the custom domain;
// reflect the requesting origin only when it is on the allowlist.
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
const origin = request.headers.get("origin");
const cors = {
"access-control-allow-origin":
origin && allowed.includes(origin) ? origin : allowed[0]!,
"access-control-allow-methods": "GET",
vary: "Origin",
};
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
const stats = await buildStats(env.DB, etDate(new Date()));
return Response.json(stats, { headers: cors });
}
return new Response("srs-api", { status: path === "/" ? 200 : 404 });
} catch (err) {
console.error(`unhandled ${request.method} ${path}: ${err}`);
return new Response("internal error", { status: 500 });
}
},
/**
* DST-proof cron dispatch: crons fire at both possible UTC hours; the
* computed ET hour decides. 8 AM daily → catalog reconcile + digest;
* midnight Saturday → review issue (so the 8 AM digest can link to it).
*/
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
const now = new Date(controller.scheduledTime);
const hour = etHour(now);
const date = etDate(now);
if (hour === 8) {
try {
const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO));
console.log(`catalog: ${report.topics} topics, ${report.problems} problems`);
} catch (err) {
console.warn(`catalog reconcile failed (digest still goes out): ${err}`);
}
const report = await sendDigest(env, date);
console.log(`digest ${date}: ${report.reason}`);
return;
}
if (hour === 0 && weekdayOf(date) === 6) {
const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date);
console.log(`review issue ${date}: ${report.reason}`);
return;
}
console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`);
void ctx;
},
};