mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
380 lines
16 KiB
TypeScript
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;
|
|
},
|
|
};
|