mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
Add SRS automation: scheduler, logger, weekly gates
This commit is contained in:
Executable
+226
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Process `/done <lc> <pass|fail>` comments on the `📋 Today` brief and on
|
||||
* gate issues, and move problems along the interval ladder.
|
||||
*
|
||||
* Transitions (see srs.ts):
|
||||
* - pass: new → +2 → +5 → +10 → retired; next_review = today + new interval.
|
||||
* - fail: back to +2, due in 2 days.
|
||||
* - First-ever log of a problem enters the ladder at +2 either way; a failed
|
||||
* blind drill additionally increments its topic's recognition-miss counter
|
||||
* (a signal the gate's boost logic reads). Drills join drill_pool_used so
|
||||
* they are never served twice.
|
||||
* - A clean first PASS also closes the problem's sub-issue.
|
||||
*
|
||||
* Feedback is never silent: a clean parse gets a 👍 reaction, anything else
|
||||
* gets a reply naming the exact parse error. Only the repo owner is heard.
|
||||
*
|
||||
* Input: GITHUB_EVENT_PATH (issue_comment payload) in Actions, or
|
||||
* --issue N --author LOGIN --comment-id N --body "..." locally.
|
||||
*/
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { github } from "./github.ts";
|
||||
import { projectMirror, reportMirror } from "./srs-project.ts";
|
||||
import {
|
||||
type Attempt,
|
||||
addDays,
|
||||
fail,
|
||||
fetchCatalog,
|
||||
loadState,
|
||||
pass,
|
||||
saveState,
|
||||
TODAY_TITLE,
|
||||
todayET,
|
||||
} from "./srs.ts";
|
||||
|
||||
const DRY = process.env.SRS_DRY === "1";
|
||||
|
||||
// ── event input ──────────────────────────────────────────────────
|
||||
|
||||
interface Input {
|
||||
issue: number;
|
||||
issueTitle: string;
|
||||
issueLabels: string[];
|
||||
author: string;
|
||||
commentId: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
async function readInput(): Promise<Input> {
|
||||
if (process.env.GITHUB_EVENT_PATH) {
|
||||
const event = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text());
|
||||
return {
|
||||
issue: event.issue.number,
|
||||
issueTitle: event.issue.title,
|
||||
issueLabels: (event.issue.labels ?? []).map((l: { name: string }) => l.name),
|
||||
author: event.comment.user.login,
|
||||
commentId: event.comment.id,
|
||||
body: event.comment.body ?? "",
|
||||
};
|
||||
}
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
issue: { type: "string" },
|
||||
author: { type: "string" },
|
||||
"comment-id": { type: "string" },
|
||||
body: { type: "string" },
|
||||
title: { type: "string" },
|
||||
labels: { type: "string" },
|
||||
},
|
||||
});
|
||||
return {
|
||||
issue: Number(values.issue),
|
||||
issueTitle: values.title ?? TODAY_TITLE,
|
||||
issueLabels: values.labels?.split(",") ?? [],
|
||||
author: values.author ?? "",
|
||||
commentId: Number(values["comment-id"] ?? 0),
|
||||
body: values.body ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const gh = await github();
|
||||
const input = await readInput();
|
||||
const owner = gh.repo.split("/")[0]!;
|
||||
|
||||
if (input.author !== owner) {
|
||||
console.log(`ignoring comment by @${input.author} (owner only)`);
|
||||
process.exit(0);
|
||||
}
|
||||
const isGate = input.issueLabels.includes("gate");
|
||||
const isToday = input.issueTitle === TODAY_TITLE;
|
||||
if (!isGate && !isToday) {
|
||||
console.log("not the Today brief or a gate issue; ignoring");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── parse ────────────────────────────────────────────────────────
|
||||
|
||||
interface Command {
|
||||
lc: number;
|
||||
result: "pass" | "fail";
|
||||
}
|
||||
|
||||
const commands: Command[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const raw of input.body.split("\n")) {
|
||||
const line = raw.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 Command["result"] });
|
||||
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
|
||||
}
|
||||
|
||||
if (commands.length === 0 && errors.length === 0) {
|
||||
console.log("no /done lines; nothing to do");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── apply ────────────────────────────────────────────────────────
|
||||
|
||||
const state = await loadState();
|
||||
const catalog = await fetchCatalog(gh);
|
||||
const today = todayET();
|
||||
const mirror = await projectMirror(owner, gh.repo);
|
||||
const applied: string[] = [];
|
||||
|
||||
const gate = isGate ? state.gates.find((g) => g.issue === input.issue) : undefined;
|
||||
if (isGate && !gate) errors.push(`no gate record for issue #${input.issue} in srs.json`);
|
||||
|
||||
for (const cmd of commands) {
|
||||
const known = catalog.problems.get(cmd.lc);
|
||||
const entry = state.problems[cmd.lc];
|
||||
|
||||
if (!entry && !known) {
|
||||
errors.push(`LC ${cmd.lc} is not in the curriculum`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gate && known && gate.problems.includes(cmd.lc)) {
|
||||
gate.results[cmd.lc] = cmd.result;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
// First-ever attempt: enters the ladder at +2 regardless of result — a
|
||||
// pass earns a +2 review, a fail must be re-solved just as soon.
|
||||
const kind: Attempt["kind"] = gate ? "gate" : known!.set === "optional" ? "drill" : "first";
|
||||
const p: (typeof state.problems)[string] = {
|
||||
issue: known!.issue,
|
||||
topic: known!.topic,
|
||||
difficulty: known!.difficulty.toLowerCase(),
|
||||
set: known!.set,
|
||||
solved_on: today,
|
||||
stage: "+2",
|
||||
next_review: addDays(today, 2),
|
||||
history: [{ date: today, kind, result: cmd.result }],
|
||||
};
|
||||
state.problems[cmd.lc] = p;
|
||||
if (kind === "drill") {
|
||||
if (!state.drill_pool_used.includes(cmd.lc)) state.drill_pool_used.push(cmd.lc);
|
||||
if (cmd.result === "fail") {
|
||||
const t = (state.topics[known!.topic] ??= { misses: 0, boost: false });
|
||||
t.misses++;
|
||||
applied.push(`LC ${cmd.lc}: recognition miss — topic #${known!.topic} misses=${t.misses}`);
|
||||
}
|
||||
}
|
||||
if (!DRY) {
|
||||
await mirror.setFirstAttempt(known!.issue, cmd.result);
|
||||
await mirror.setStage(known!.issue, "+2");
|
||||
await mirror.setTargetDate(known!.issue, p.next_review!);
|
||||
if (cmd.result === "pass" && known!.open) {
|
||||
await gh.closeIssue(
|
||||
known!.issue,
|
||||
`First attempt passed (${kind}) — entering the review ladder at +2. Logged by \`srs-logger\`.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
applied.push(`LC ${cmd.lc}: first ${kind} ${cmd.result} → stage +2, review ${p.next_review}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already on the ladder: a review (or a gate/drill re-encounter).
|
||||
if (entry.stage === "retired") {
|
||||
errors.push(`LC ${cmd.lc} is already retired`);
|
||||
continue;
|
||||
}
|
||||
entry.history.push({ date: today, kind: "review", result: cmd.result });
|
||||
if (cmd.result === "pass") {
|
||||
pass(entry, today);
|
||||
} else {
|
||||
fail(entry, today);
|
||||
}
|
||||
delete entry.defer_until; // once touched, it lives by next_review alone
|
||||
if (!DRY) {
|
||||
await mirror.setStage(entry.issue, entry.stage);
|
||||
await mirror.setTargetDate(entry.issue, entry.next_review ?? null);
|
||||
}
|
||||
applied.push(
|
||||
`LC ${cmd.lc}: review ${cmd.result} → stage ${entry.stage}` +
|
||||
(entry.next_review ? `, review ${entry.next_review}` : " — retired 🎉"),
|
||||
);
|
||||
}
|
||||
|
||||
// ── persist + respond ────────────────────────────────────────────
|
||||
|
||||
if (!DRY) await saveState(state);
|
||||
|
||||
for (const line of applied) console.log(line);
|
||||
for (const line of errors) console.log(`error: ${line}`);
|
||||
|
||||
if (!DRY && input.commentId) {
|
||||
if (errors.length === 0) {
|
||||
await gh.api(`/repos/${gh.repo}/issues/comments/${input.commentId}/reactions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "+1" }),
|
||||
});
|
||||
} else {
|
||||
await gh.api(`/repos/${gh.repo}/issues/${input.issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: `Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}${applied.length ? `\n\nApplied anyway:\n\n${applied.map((a) => `- ${a}`).join("\n")}` : ""}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await reportMirror(mirror);
|
||||
Reference in New Issue
Block a user