mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
227 lines
7.6 KiB
TypeScript
227 lines
7.6 KiB
TypeScript
#!/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);
|