Files
leetcode/scripts/srs-logger.ts
T

227 lines
7.6 KiB
TypeScript
Raw Normal View History

#!/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);