Files
leetcode/scripts/srs-gate.ts
T

262 lines
8.8 KiB
TypeScript
Raw Normal View History

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