mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
Add SRS automation: scheduler, logger, weekly gates
This commit is contained in:
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Weekly gate: create Saturday's topic-blind quiz, and grade it on close.
|
||||
*
|
||||
* bun scripts/srs-gate.ts create # Trigger A (cron Sat / dispatch)
|
||||
* bun scripts/srs-gate.ts close --issue N # Trigger B (issues: closed)
|
||||
*
|
||||
* create — samples ONE unsolved `set:optional` problem per topic scheduled
|
||||
* THIS week (daily drills only ever draw from earlier weeks, so every gate
|
||||
* problem is a first encounter). Seeded by ISO week number: re-runs pick the
|
||||
* same problems, and an existing `Gate — Week N` issue makes the run a no-op.
|
||||
* Exhausted optional pools fall back to an unsolved core problem, and the
|
||||
* body says so. Boost flags + miss counters from LAST week's gate are cleared
|
||||
* here — the boosted topics had their remedial week.
|
||||
*
|
||||
* close — reads this gate's `/done` results (recorded by srs-logger) from
|
||||
* srs.json, computes the first-attempt pass rate (unlogged problems count as
|
||||
* failures — skipping a gate problem is not a pass), appends docs/gate-log.md,
|
||||
* comments a summary, sets `boost: true` on every topic that failed a gate
|
||||
* problem or accumulated ≥ 2 drill misses, and refreshes the README badge
|
||||
* endpoint at .github/srs/badge.json.
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { github } from "./github.ts";
|
||||
import {
|
||||
campaignWeek,
|
||||
fetchCatalog,
|
||||
isoWeek,
|
||||
loadSchedule,
|
||||
loadState,
|
||||
rng,
|
||||
sample,
|
||||
saveState,
|
||||
todayET,
|
||||
} from "./srs.ts";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const GATE_LOG = process.env.SRS_GATE_LOG ?? join(ROOT, "docs", "gate-log.md");
|
||||
const BADGE = process.env.SRS_BADGE ?? join(ROOT, ".github", "srs", "badge.json");
|
||||
const PASS_TARGET = 70; // percent, first-attempt
|
||||
|
||||
const { positionals, values } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: { issue: { type: "string" } },
|
||||
});
|
||||
const mode = positionals[0];
|
||||
|
||||
const gh = await github();
|
||||
const state = await loadState();
|
||||
const today = todayET();
|
||||
|
||||
// ── Trigger A: create this week's gate ───────────────────────────
|
||||
|
||||
async function create(): Promise<void> {
|
||||
const week = campaignWeek(today);
|
||||
const title = `Gate — Week ${week}`;
|
||||
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=gate&state=all`)) {
|
||||
const issue = raw as { number: number; title: string };
|
||||
if (issue.title === title) {
|
||||
console.log(`${title} already exists (#${issue.number}); nothing to do`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await loadSchedule();
|
||||
const topics = Object.entries(schedule)
|
||||
.filter(([date]) => campaignWeek(date) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, topic]) => topic);
|
||||
if (topics.length === 0) {
|
||||
console.log(`no topics scheduled in week ${week}; no gate to create`);
|
||||
return;
|
||||
}
|
||||
|
||||
const catalog = await fetchCatalog(gh);
|
||||
const random = rng(`gate-${isoWeek(today)}`);
|
||||
const picks: { lc: number; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const fresh = (set: string) =>
|
||||
[...catalog.problems.values()]
|
||||
.filter(
|
||||
(p) =>
|
||||
p.topic === topic &&
|
||||
p.set === set &&
|
||||
p.open &&
|
||||
!state.problems[p.lc] &&
|
||||
!state.drill_pool_used.includes(p.lc),
|
||||
)
|
||||
.sort((a, b) => a.lc - b.lc);
|
||||
const optional = fresh("optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ lc: sample(optional, 1, random)[0]!.lc, fallback: false });
|
||||
continue;
|
||||
}
|
||||
const core = fresh("core");
|
||||
if (core.length > 0) picks.push({ lc: sample(core, 1, random)[0]!.lc, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) {
|
||||
console.log(`every pool for week ${week} is exhausted; no gate to create`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = picks.map(({ lc, fallback }) => {
|
||||
const p = catalog.problems.get(lc)!;
|
||||
return `- [LC ${p.lc}](${p.url}) — ${p.difficulty}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`;
|
||||
});
|
||||
const body = [
|
||||
`**Target: ${PASS_TARGET}% first-attempt pass rate. Timed. Narrate out loud.**`,
|
||||
"",
|
||||
...lines,
|
||||
"",
|
||||
"Log with `/done <number> <pass|fail>` comments, then close this issue.",
|
||||
].join("\n");
|
||||
|
||||
const milestone = catalog.topics.get(topics[0]!)?.milestone;
|
||||
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, body, labels: ["gate"], milestone }),
|
||||
})) as { number: number };
|
||||
|
||||
state.gates.push({
|
||||
week,
|
||||
issue: created.number,
|
||||
date: today,
|
||||
problems: picks.map((p) => p.lc),
|
||||
fallbacks: picks.filter((p) => p.fallback).map((p) => p.lc),
|
||||
results: {},
|
||||
});
|
||||
|
||||
// Last week's boosted topics had their remedial week: clear flags/counters.
|
||||
for (const t of Object.values(state.topics)) {
|
||||
if (t.boost) {
|
||||
t.boost = false;
|
||||
t.misses = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await saveState(state);
|
||||
console.log(`created #${created.number} ${title}: ${picks.map((p) => `LC ${p.lc}`).join(", ")}`);
|
||||
}
|
||||
|
||||
// ── Trigger B: grade a closed gate ───────────────────────────────
|
||||
|
||||
async function close(issueNumber: number): Promise<void> {
|
||||
const gate = state.gates.find((g) => g.issue === issueNumber);
|
||||
if (!gate) {
|
||||
console.log(`issue #${issueNumber} has no gate record in srs.json; ignoring`);
|
||||
return;
|
||||
}
|
||||
if (gate.rate !== undefined) {
|
||||
console.log(`gate #${issueNumber} already graded (${gate.rate}%); nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
const catalog = await fetchCatalog(gh);
|
||||
const passes = gate.problems.filter((lc) => gate.results[lc] === "pass");
|
||||
const unlogged = gate.problems.filter((lc) => gate.results[lc] === undefined);
|
||||
const rate = Math.round((passes.length / gate.problems.length) * 100);
|
||||
gate.rate = rate;
|
||||
const passed = rate >= PASS_TARGET;
|
||||
|
||||
// Boost: explicit gate failures, plus topics with ≥ 2 recognition misses.
|
||||
const boosted: number[] = [];
|
||||
for (const lc of gate.problems) {
|
||||
if (gate.results[lc] === "fail") {
|
||||
const topic = catalog.problems.get(lc)?.topic;
|
||||
if (topic !== undefined) {
|
||||
(state.topics[topic] ??= { misses: 0, boost: false }).boost = true;
|
||||
boosted.push(topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [topic, t] of Object.entries(state.topics)) {
|
||||
if (t.misses >= 2 && !t.boost) {
|
||||
t.boost = true;
|
||||
boosted.push(Number(topic));
|
||||
}
|
||||
}
|
||||
|
||||
// Gate log row.
|
||||
const log = await Bun.file(GATE_LOG).text();
|
||||
const problems = gate.problems.map((lc) => `LC ${lc}`).join(" · ");
|
||||
await Bun.write(
|
||||
GATE_LOG,
|
||||
`${log.trimEnd()}\n| ${gate.week} | ${today} | ${problems} | ${rate}% | ${passed ? "pass" : "fail"} |\n`,
|
||||
);
|
||||
|
||||
// Badge endpoint (shields.io schema).
|
||||
await Bun.write(
|
||||
BADGE,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
label: "gate pass rate",
|
||||
message: `${rate}%`,
|
||||
color: passed ? "brightgreen" : "red",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
await saveState(state);
|
||||
|
||||
const summary = [
|
||||
`## Gate — Week ${gate.week}: **${rate}%** first-attempt (target ${PASS_TARGET}%) — ${passed ? "✅ pass" : "❌ fail"}`,
|
||||
"",
|
||||
...gate.problems.map((lc) => {
|
||||
const r = gate.results[lc];
|
||||
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
|
||||
}),
|
||||
"",
|
||||
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
|
||||
boosted.length
|
||||
? `Boosted topics for next week's drills: ${[...new Set(boosted)].map((t) => `#${t}`).join(", ")}.`
|
||||
: "No topics boosted.",
|
||||
"",
|
||||
"Logged to `docs/gate-log.md`.",
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n");
|
||||
await gh.api(`/repos/${gh.repo}/issues/${issueNumber}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: summary }),
|
||||
});
|
||||
console.log(`graded gate #${issueNumber}: ${rate}% (${passed ? "pass" : "fail"})`);
|
||||
}
|
||||
|
||||
// ── dispatch ─────────────────────────────────────────────────────
|
||||
|
||||
async function eventIssueNumber(): Promise<number> {
|
||||
if (values.issue) return Number(values.issue);
|
||||
if (!process.env.GITHUB_EVENT_PATH) return NaN;
|
||||
const event: unknown = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text());
|
||||
if (
|
||||
event &&
|
||||
typeof event === "object" &&
|
||||
"issue" in event &&
|
||||
event.issue &&
|
||||
typeof event.issue === "object" &&
|
||||
"number" in event.issue &&
|
||||
typeof event.issue.number === "number"
|
||||
) {
|
||||
return event.issue.number;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
if (mode === "create") {
|
||||
await create();
|
||||
} else if (mode === "close") {
|
||||
const issueNumber = await eventIssueNumber();
|
||||
if (!Number.isFinite(issueNumber)) throw new Error("close mode needs --issue N or an event");
|
||||
await close(issueNumber);
|
||||
} else {
|
||||
throw new Error(`usage: srs-gate.ts create | close --issue N (got "${mode}")`);
|
||||
}
|
||||
Reference in New Issue
Block a user