#!/usr/bin/env bun /** * Process `/done ` 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 { 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 \``); } 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);