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