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:
+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