diff --git a/.github/srs/badge.json b/.github/srs/badge.json new file mode 100644 index 0000000..9248027 --- /dev/null +++ b/.github/srs/badge.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "label": "gate pass rate", + "message": "no gates yet", + "color": "lightgrey" +} diff --git a/.github/srs/schedule.json b/.github/srs/schedule.json new file mode 100644 index 0000000..49fcd85 --- /dev/null +++ b/.github/srs/schedule.json @@ -0,0 +1,26 @@ +{ + "2026-08-17": 3, + "2026-08-18": 4, + "2026-08-24": 5, + "2026-08-25": 6, + "2026-08-26": 7, + "2026-08-27": 8, + "2026-08-28": 9, + "2026-08-31": 10, + "2026-09-01": 11, + "2026-09-02": 12, + "2026-09-03": 13, + "2026-09-04": 14, + "2026-09-07": 15, + "2026-09-08": 16, + "2026-09-09": 17, + "2026-09-10": 18, + "2026-09-11": 19, + "2026-09-14": 20, + "2026-09-15": 21, + "2026-09-16": 22, + "2026-09-17": 23, + "2026-09-18": 24, + "2026-09-21": 25, + "2026-09-22": 26 +} diff --git a/.github/srs/srs.json b/.github/srs/srs.json new file mode 100644 index 0000000..26e79d7 --- /dev/null +++ b/.github/srs/srs.json @@ -0,0 +1,548 @@ +{ + "problems": { + "1": { + "issue": 29, + "topic": 3, + "difficulty": "easy", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "4": { + "issue": 66, + "topic": 7, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-09-29", + "defer_until": "2026-09-29", + "history": [] + }, + "11": { + "issue": 44, + "topic": 5, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "15": { + "issue": 43, + "topic": 5, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "33": { + "issue": 60, + "topic": 7, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "42": { + "issue": 49, + "topic": 5, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-09-28", + "defer_until": "2026-09-28", + "history": [] + }, + "49": { + "issue": 30, + "topic": 3, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "52": { + "issue": 156, + "topic": 22, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-10-03", + "defer_until": "2026-10-03", + "history": [] + }, + "72": { + "issue": 171, + "topic": 24, + "difficulty": "medium", + "set": "deferred", + "stage": "new", + "next_review": "2026-10-03", + "defer_until": "2026-10-03", + "history": [] + }, + "76": { + "issue": 57, + "topic": 6, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-09-28", + "defer_until": "2026-09-28", + "history": [] + }, + "84": { + "issue": 76, + "topic": 9, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-09-30", + "defer_until": "2026-09-30", + "history": [] + }, + "97": { + "issue": 172, + "topic": 24, + "difficulty": "medium", + "set": "deferred", + "stage": "new", + "next_review": "2026-10-04", + "defer_until": "2026-10-04", + "history": [] + }, + "121": { + "issue": 50, + "topic": 6, + "difficulty": "easy", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "124": { + "issue": 105, + "topic": 13, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-09-30", + "defer_until": "2026-09-30", + "history": [] + }, + "125": { + "issue": 41, + "topic": 5, + "difficulty": "easy", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "167": { + "issue": 42, + "topic": 5, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "212": { + "issue": 129, + "topic": 17, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-10-02", + "defer_until": "2026-10-02", + "history": [] + }, + "217": { + "issue": 27, + "topic": 3, + "difficulty": "easy", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "221": { + "issue": 173, + "topic": 24, + "difficulty": "medium", + "set": "deferred", + "stage": "new", + "next_review": "2026-10-04", + "defer_until": "2026-10-04", + "history": [] + }, + "224": { + "issue": 71, + "topic": 8, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-09-29", + "defer_until": "2026-09-29", + "history": [] + }, + "238": { + "issue": 37, + "topic": 4, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "242": { + "issue": 28, + "topic": 3, + "difficulty": "easy", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "269": { + "issue": 145, + "topic": 20, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-10-02", + "defer_until": "2026-10-02", + "history": [] + }, + "295": { + "issue": 125, + "topic": 16, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-10-01", + "defer_until": "2026-10-01", + "history": [] + }, + "303": { + "issue": 36, + "topic": 4, + "difficulty": "easy", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "347": { + "issue": 31, + "topic": 3, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "383": { + "issue": 32, + "topic": 3, + "difficulty": "easy", + "set": "optional", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "392": { + "issue": 45, + "topic": 5, + "difficulty": "easy", + "set": "optional", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "502": { + "issue": 126, + "topic": 16, + "difficulty": "hard", + "set": "deferred", + "stage": "new", + "next_review": "2026-10-01", + "defer_until": "2026-10-01", + "history": [] + }, + "560": { + "issue": 38, + "topic": 4, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "704": { + "issue": 58, + "topic": 7, + "difficulty": "easy", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "875": { + "issue": 59, + "topic": 7, + "difficulty": "medium", + "set": "core", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + }, + "977": { + "issue": 46, + "topic": 5, + "difficulty": "easy", + "set": "optional", + "solved_on": "2026-08-24", + "stage": "+2", + "next_review": "2026-08-26", + "history": [ + { + "date": "2026-08-24", + "kind": "first", + "result": "pass" + } + ] + } + }, + "topics": { + "3": { + "misses": 0, + "boost": false + }, + "4": { + "misses": 0, + "boost": false + }, + "5": { + "misses": 0, + "boost": false + }, + "6": { + "misses": 0, + "boost": false + }, + "7": { + "misses": 0, + "boost": false + }, + "8": { + "misses": 0, + "boost": false + }, + "9": { + "misses": 0, + "boost": false + }, + "10": { + "misses": 0, + "boost": false + }, + "11": { + "misses": 0, + "boost": false + }, + "12": { + "misses": 0, + "boost": false + }, + "13": { + "misses": 0, + "boost": false + }, + "14": { + "misses": 0, + "boost": false + }, + "15": { + "misses": 0, + "boost": false + }, + "16": { + "misses": 0, + "boost": false + }, + "17": { + "misses": 0, + "boost": false + }, + "18": { + "misses": 0, + "boost": false + }, + "19": { + "misses": 0, + "boost": false + }, + "20": { + "misses": 0, + "boost": false + }, + "21": { + "misses": 0, + "boost": false + }, + "22": { + "misses": 0, + "boost": false + }, + "23": { + "misses": 0, + "boost": false + }, + "24": { + "misses": 0, + "boost": false + }, + "25": { + "misses": 0, + "boost": false + }, + "26": { + "misses": 0, + "boost": false + } + }, + "drill_pool_used": [], + "gates": [] +} diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..d821751 --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,67 @@ +name: Gate + +on: + schedule: + - cron: "0 12 * * 6" # Saturday 8 AM ET β€” create this week's gate + workflow_dispatch: + issues: + types: [closed] # grade a gate when it closes + +permissions: + contents: write + issues: write + +# Shared with srs-scheduler / srs-logger / srs-setup: never race on srs.json. +concurrency: + group: srs-state + cancel-in-progress: false + +jobs: + create: + # Trigger A: the only issue-creating workflow in the SRS. The script + # no-ops when this week's gate already exists. + if: github.event_name != 'issues' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - run: bun scripts/srs-gate.ts create + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit state + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .github/srs + git diff --cached --quiet && { echo "no state change"; exit 0; } + git commit -m "srs: create weekly gate [skip ci]" + for i in 1 2 3; do git push && exit 0; git pull --rebase; done + git push + + close: + # Trigger B: grade, log, boost, badge. + if: >- + github.event_name == 'issues' && + contains(github.event.issue.labels.*.name, 'gate') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - run: bun scripts/srs-gate.ts close + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit state + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .github/srs docs/gate-log.md + git diff --cached --quiet && { echo "no state change"; exit 0; } + git commit -m "srs: grade gate #${{ github.event.issue.number }} [skip ci]" + for i in 1 2 3; do git push && exit 0; git pull --rebase; done + git push diff --git a/.github/workflows/srs-logger.yml b/.github/workflows/srs-logger.yml new file mode 100644 index 0000000..7f19dcc --- /dev/null +++ b/.github/workflows/srs-logger.yml @@ -0,0 +1,43 @@ +name: SRS Logger + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + +# Shared with srs-scheduler / gate / srs-setup: never race on srs.json. +concurrency: + group: srs-state + cancel-in-progress: false + +jobs: + log: + # Owner-only, and only on the daily brief or a gate issue. Put every + # /done line in ONE comment β€” queued duplicate runs may be superseded. + if: >- + github.event.comment.user.login == github.repository_owner && + (github.event.issue.title == 'πŸ“‹ Today' || + contains(github.event.issue.labels.*.name, 'gate')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - run: bun scripts/srs-logger.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROJECT_PAT: ${{ secrets.PROJECT_PAT }} + + - name: Commit state + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .github/srs + git diff --cached --quiet && { echo "no state change"; exit 0; } + git commit -m "srs: log results from #${{ github.event.issue.number }} [skip ci]" + for i in 1 2 3; do git push && exit 0; git pull --rebase; done + git push diff --git a/.github/workflows/srs-scheduler.yml b/.github/workflows/srs-scheduler.yml new file mode 100644 index 0000000..a6b9c2e --- /dev/null +++ b/.github/workflows/srs-scheduler.yml @@ -0,0 +1,30 @@ +name: SRS Scheduler + +on: + schedule: + - cron: "0 10 * * *" # 6 AM ET β€” before the day starts + workflow_dispatch: + +permissions: + contents: read + issues: write + +# Shared with srs-logger / gate / srs-setup: never race on srs.json. +concurrency: + group: srs-state + cancel-in-progress: false + +jobs: + brief: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + # Stateless: reads srs.json + schedule.json, rewrites the pinned + # πŸ“‹ Today issue, mirrors Target Date. Commits nothing. + - run: bun scripts/srs-scheduler.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROJECT_PAT: ${{ secrets.PROJECT_PAT }} diff --git a/.github/workflows/srs-setup.yml b/.github/workflows/srs-setup.yml new file mode 100644 index 0000000..bc97f97 --- /dev/null +++ b/.github/workflows/srs-setup.yml @@ -0,0 +1,29 @@ +name: SRS Setup + +on: + workflow_dispatch: + +permissions: + contents: read + issues: read + +# Shared with the other SRS workflows: never race on srs.json. +concurrency: + group: srs-state + cancel-in-progress: false + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + # Verifies PROJECT_PAT against the user project, creates the SRS + # single-select fields when missing, and re-mirrors every laddered + # problem's Target Date / SRS Stage / First Attempt. Idempotent. + - run: bun scripts/srs-setup.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROJECT_PAT: ${{ secrets.PROJECT_PAT }} diff --git a/README.md b/README.md index a120638..99b3bd5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@

follow badge + gate pass rate

diff --git a/docs/gate-log.md b/docs/gate-log.md new file mode 100644 index 0000000..c484e51 --- /dev/null +++ b/docs/gate-log.md @@ -0,0 +1,7 @@ +# Gate log + +Weekly topic-blind gates. Target: 70% first-attempt pass rate. Rows appended +automatically by `gate.yml` when a gate issue closes. + +| Week | Date | Problems | First-attempt rate | Result | +| --- | --- | --- | --- | --- | diff --git a/scripts/srs-gate.ts b/scripts/srs-gate.ts new file mode 100755 index 0000000..5d6817d --- /dev/null +++ b/scripts/srs-gate.ts @@ -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 { + 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}")`); +} diff --git a/scripts/srs-logger.ts b/scripts/srs-logger.ts new file mode 100755 index 0000000..5428ddb --- /dev/null +++ b/scripts/srs-logger.ts @@ -0,0 +1,226 @@ +#!/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); diff --git a/scripts/srs-project.ts b/scripts/srs-project.ts new file mode 100644 index 0000000..da0c0f0 --- /dev/null +++ b/scripts/srs-project.ts @@ -0,0 +1,222 @@ +/** + * Best-effort mirror of SRS state into the user-level GitHub Project + * ("Interview Prep"). + * + * The JSON state file is the truth; these fields only make the project's + * table and roadmap views double as a live review calendar. Every operation + * is wrapped: a failure records a warning and the run continues β€” a workflow + * must never lose an srs.json update because a GraphQL mutation failed. + * + * Auth: PROJECT_PAT (the default GITHUB_TOKEN cannot touch user projects), + * falling back to GH_TOKEN / `gh auth token` for local runs. + * + * Guardrail: only problem issues are ever passed in here, so topic rows' + * Target Date is never written. + */ +import { $ } from "bun"; + +const PROJECT_TITLE = "Interview Prep"; + +interface SelectField { + id: string; + options: Record; // option name -> option id +} + +interface ProjectInfo { + id: string; + targetDate: string; // field id + srsStage: SelectField; + firstAttempt: SelectField; +} + +export interface ProjectMirror { + /** Set (or clear, with null) a problem row's Target Date. */ + setTargetDate(issue: number, date: string | null): Promise; + setStage(issue: number, stage: string): Promise; + setFirstAttempt(issue: number, result: string): Promise; + /** Accumulated failures; print + step-summarize these, never throw. */ + warnings: string[]; +} + +async function resolveToken(): Promise { + const env = process.env.PROJECT_PAT || process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (env) return env; + return (await $`gh auth token`.text()).trim(); +} + +export async function projectMirror(owner: string, repo: string): Promise { + const warnings: string[] = []; + let token = ""; + let project: ProjectInfo | undefined; + const itemIds = new Map(); + + async function graphql(query: string, variables: Record): Promise { + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`); + const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] }; + if (payload.errors?.length) { + throw new Error(payload.errors.map((e) => e.message).join("; ")); + } + return payload.data; + } + + /** Resolve project + field/option ids once; by name, never hardcoded. */ + async function resolve(): Promise { + if (project) return project; + token ||= await resolveToken(); + if (!token) throw new Error("no PROJECT_PAT / token available"); + + const data = (await graphql( + `query($owner: String!, $title: String!) { + user(login: $owner) { + projectsV2(first: 10, query: $title) { + nodes { + id title + fields(first: 30) { + nodes { + ... on ProjectV2FieldCommon { id name dataType } + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + } + }`, + { owner, title: PROJECT_TITLE }, + )) as { + user: { + projectsV2: { + nodes: { + id: string; + title: string; + fields: { + nodes: { id: string; name: string; options?: { id: string; name: string }[] }[]; + }; + }[]; + }; + }; + }; + + const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE); + if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`); + + const select = (name: string): SelectField => { + const f = node.fields.nodes.find((n) => n.name === name); + if (!f?.options) throw new Error(`single-select field "${name}" missing β€” run srs-setup`); + return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) }; + }; + const date = node.fields.nodes.find((n) => n.name === "Target Date"); + if (!date) throw new Error(`date field "Target Date" missing from project`); + + project = { + id: node.id, + targetDate: date.id, + srsStage: select("SRS Stage"), + firstAttempt: select("First Attempt"), + }; + return project; + } + + /** The issue's item id in THIS project (an issue can be in several). */ + async function itemId(issue: number): Promise { + if (itemIds.has(issue)) { + const cached = itemIds.get(issue); + if (!cached) throw new Error(`issue #${issue} is not in the project`); + return cached; + } + const info = await resolve(); + const [repoOwner, repoName] = repo.split("/"); + const data = (await graphql( + `query($owner: String!, $name: String!, $issue: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $issue) { + projectItems(first: 10, includeArchived: true) { + nodes { id project { id } } + } + } + } + }`, + { owner: repoOwner, name: repoName, issue }, + )) as { + repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } }; + }; + const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === info.id); + itemIds.set(issue, item?.id); + if (!item) throw new Error(`issue #${issue} is not in the project`); + return item.id; + } + + async function setField(issue: number, fieldId: string, value: object): Promise { + const info = await resolve(); + await graphql( + `mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue( + input: { projectId: $project, itemId: $item, fieldId: $field, value: $value } + ) { projectV2Item { id } } + }`, + { project: info.id, item: await itemId(issue), field: fieldId, value }, + ); + } + + async function clearField(issue: number, fieldId: string): Promise { + const info = await resolve(); + await graphql( + `mutation($project: ID!, $item: ID!, $field: ID!) { + clearProjectV2ItemFieldValue( + input: { projectId: $project, itemId: $item, fieldId: $field } + ) { projectV2Item { id } } + }`, + { project: info.id, item: await itemId(issue), field: fieldId }, + ); + } + + /** Run op; on failure record a warning instead of propagating. */ + async function attempt(what: string, op: () => Promise): Promise { + try { + await op(); + } catch (err) { + warnings.push(`${what}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return { + warnings, + setTargetDate: (issue, date) => + attempt(`Target Date #${issue}`, async () => { + const info = await resolve(); + if (date === null) await clearField(issue, info.targetDate); + else await setField(issue, info.targetDate, { date }); + }), + setStage: (issue, stage) => + attempt(`SRS Stage #${issue}`, async () => { + const info = await resolve(); + const option = info.srsStage.options[stage]; + if (!option) throw new Error(`no option "${stage}"`); + await setField(issue, info.srsStage.id, { singleSelectOptionId: option }); + }), + setFirstAttempt: (issue, result) => + attempt(`First Attempt #${issue}`, async () => { + const info = await resolve(); + const option = info.firstAttempt.options[result]; + if (!option) throw new Error(`no option "${result}"`); + await setField(issue, info.firstAttempt.id, { singleSelectOptionId: option }); + }), + }; +} + +/** Print mirror warnings and surface them in the Actions run summary. */ +export async function reportMirror(mirror: ProjectMirror): Promise { + if (mirror.warnings.length === 0) return; + console.warn(`\nproject mirror: ${mirror.warnings.length} warning(s) β€” srs.json is the truth`); + for (const w of mirror.warnings) console.warn(` ⚠ ${w}`); + if (process.env.GITHUB_STEP_SUMMARY) { + await Bun.write( + process.env.GITHUB_STEP_SUMMARY, + `### ⚠ project mirror warnings\n\nsrs.json was updated; these project mutations failed:\n\n${mirror.warnings.map((w) => `- ${w}`).join("\n")}\n`, + ); + } +} diff --git a/scripts/srs-scheduler.ts b/scripts/srs-scheduler.ts new file mode 100755 index 0000000..3a55cd8 --- /dev/null +++ b/scripts/srs-scheduler.ts @@ -0,0 +1,201 @@ +#!/usr/bin/env bun +/** + * Rewrite the pinned `πŸ“‹ Today` daily-brief issue from SRS state. + * + * Runs every day at 10:00 UTC (6 AM ET) and on demand. Stateless by design: + * it reads srs.json + schedule.json and rewrites one issue body, so running + * it twice in a day is a byte-identical no-op (drill picks are seeded by the + * date). Overflow needs no bookkeeping either β€” anything past the daily cap + * simply stays due and is the oldest entry tomorrow. + * + * Retrieval rules enforced here: + * - Review and drill lines carry number + difficulty only. Never the topic, + * never a link β€” recognizing the pattern unaided is the exercise. + * - Blind drills come only from optional pools of topics covered in EARLIER + * weeks; the current week's optional pool is reserved for Saturday's gate. + * - Boosted topics (failed gate problem / repeated drill misses) inject up to + * 2 extra drills, inside the same daily cap. + * + * bun scripts/srs-scheduler.ts # rewrite the live issue + * SRS_DRY=1 SRS_TODAY=2026-08-30 bun ... # print the body, touch nothing + */ +import { github } from "./github.ts"; +import { projectMirror, reportMirror } from "./srs-project.ts"; +import { + CAMPAIGN_DAYS, + type Catalog, + type State, + TODAY_TITLE, + campaignDay, + campaignWeek, + dueReviews, + fetchCatalog, + loadSchedule, + loadState, + prettyDate, + rng, + sample, + todayET, + weekdayOf, +} from "./srs.ts"; + +const DRY = process.env.SRS_DRY === "1"; +const DAILY_CAP = 6; + +const gh = await github(); +const state = await loadState(); +const schedule = await loadSchedule(); +const today = todayET(); +const week = campaignWeek(today); + +// ── compose the body ───────────────────────────────────────────── + +/** Week in which a topic was (or will be) taught, from the schedule file. */ +function topicWeek(topic: number): number | undefined { + for (const [date, t] of Object.entries(schedule)) { + if (t === topic) return campaignWeek(date); + } + return undefined; +} + +interface Drill { + lc: number; + difficulty: string; +} + +function pickDrills(catalog: Catalog, budget: number): Drill[] { + // Optional problems from earlier weeks, unseen by both ladder and drills. + const pool = [...catalog.problems.values()] + .filter((p) => { + if (p.set !== "optional" || !p.open) return false; + if (state.problems[p.lc] || state.drill_pool_used.includes(p.lc)) return false; + const w = topicWeek(p.topic); + return w !== undefined && w < week; + }) + .sort((a, b) => a.lc - b.lc); // stable base order for the seeded shuffle + + const boosted = pool.filter((p) => state.topics[p.topic]?.boost); + const regular = pool.filter((p) => !state.topics[p.topic]?.boost); + + const picks = [ + ...sample(boosted, Math.min(2, budget), rng(`boost-${today}`)), + ...sample(regular, Math.min(2, Math.max(0, budget - Math.min(2, boosted.length))), rng(`drill-${today}`)), + ].slice(0, budget); + return picks.map((p) => ({ lc: p.lc, difficulty: p.difficulty })); +} + +function composeBody(catalog: Catalog): string { + if (weekdayOf(today) === 0) { + return "Rest day. Nothing is due. Overdue reviews moved to Monday."; + } + + const due = dueReviews(state, today); + const reviews = due.slice(0, DAILY_CAP); + const carried = due.length - reviews.length; + const drills = pickDrills(catalog, DAILY_CAP - reviews.length); + + const lines: string[] = [ + `## ${prettyDate(today)} β€” Day ${campaignDay(today)}/${CAMPAIGN_DAYS} Β· Week ${week}`, + "", + ]; + + const topicIssue = schedule[today]; + const topic = topicIssue === undefined ? undefined : catalog.topics.get(topicIssue); + if (topic) { + const core = [...catalog.problems.values()] + .filter((p) => p.topic === topic.issue && p.set === "core") + .sort((a, b) => a.lc - b.lc) + .map((p) => `[LC ${p.lc}](${p.url})${p.open ? "" : " βœ“"}`); + lines.push(`### New topic: ${topic.name} (#${topic.issue})`, `Core: ${core.join(" Β· ")}`, ""); + } else { + lines.push("_No new topic today β€” reviews and drills only._", ""); + } + + lines.push(`### Reviews due (${reviews.length})`); + if (reviews.length) { + lines.push("Solve each from scratch. Do not open your old solution first."); + for (const [lc, p] of reviews) { + const difficulty = catalog.problems.get(Number(lc))?.difficulty ?? p.difficulty; + lines.push(`- LC ${lc} β€” ${difficulty} β€” stage ${p.stage}`); + } + if (carried > 0) lines.push(`\n${carried} more carried to tomorrow (cap ${DAILY_CAP}).`); + } else { + lines.push("None."); + } + lines.push(""); + + lines.push(`### Blind drills (${drills.length})`); + if (drills.length) { + lines.push("No topic given. Name the pattern out loud before you code."); + for (const d of drills) lines.push(`- LC ${d.lc} β€” ${d.difficulty}`); + } else { + lines.push("None today."); + } + lines.push(""); + + lines.push( + "### Log your results", + "Comment on this issue, one line per problem:", + "`/done 704 pass` Β· `/done 15 fail`", + "", + "### Rules", + "90-minute cap Β· core first, then reviews, then drills Β· close-out", + "ritual on every submit.", + ); + return lines.join("\n"); +} + +// ── find / create / pin the Today issue ────────────────────────── + +async function todayIssue(): Promise<{ number: number; created: boolean }> { + for await (const raw of gh.list(`/repos/${gh.repo}/issues?state=open`)) { + const issue = raw as { number: number; title: string; pull_request?: unknown }; + if (!issue.pull_request && issue.title === TODAY_TITLE) { + return { number: issue.number, created: false }; + } + } + const created = (await gh.api(`/repos/${gh.repo}/issues`, { + method: "POST", + body: JSON.stringify({ title: TODAY_TITLE, body: "(initializing)" }), + })) as { number: number; node_id: string }; + try { + await gh.api("/graphql", { + method: "POST", + body: JSON.stringify({ + query: `mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { number } } }`, + variables: { id: created.node_id }, + }), + }); + } catch (err) { + console.warn(`could not pin #${created.number}: ${err} β€” pin it by hand`); + } + return { number: created.number, created: true }; +} + +// ── run ────────────────────────────────────────────────────────── + +const catalog = await fetchCatalog(gh); +const body = composeBody(catalog); + +if (DRY) { + console.log(body); + process.exit(0); +} + +const issue = await todayIssue(); +await gh.api(`/repos/${gh.repo}/issues/${issue.number}`, { + method: "PATCH", + body: JSON.stringify({ body }), +}); +console.log(`${issue.created ? "created + pinned" : "rewrote"} #${issue.number} ${TODAY_TITLE}`); + +// Mirror every due problem's next_review so the project's Target Date views +// stay a live review calendar (problem rows only β€” never topic rows). +const mirror = await projectMirror(gh.repo.split("/")[0]!, gh.repo); +if (weekdayOf(today) !== 0) { + for (const [, p] of dueReviews(state as State, today)) { + await mirror.setTargetDate(p.issue, p.next_review!); + } +} +await reportMirror(mirror); +console.log(body); diff --git a/scripts/srs-setup.ts b/scripts/srs-setup.ts new file mode 100755 index 0000000..901a5b0 --- /dev/null +++ b/scripts/srs-setup.ts @@ -0,0 +1,146 @@ +#!/usr/bin/env bun +/** + * One-shot (idempotent) SRS setup + PROJECT_PAT verification. + * + * 1. Proves the token can talk GraphQL (viewer login) β€” the acceptance test + * for the PROJECT_PAT repo secret when dispatched as a workflow. + * 2. Resolves the "Interview Prep" user project and its built-in Target Date + * field (which the SRS reuses β€” no custom date field is ever created). + * 3. Creates the `SRS Stage` and `First Attempt` single-selects when missing. + * 4. Mirrors every laddered problem's state into the project fields. + * + * Prints every resolved ID so they are on record in the run log. + */ +import { $ } from "bun"; + +import { github } from "./github.ts"; +import { projectMirror, reportMirror } from "./srs-project.ts"; +import { loadState } from "./srs.ts"; + +const PROJECT_TITLE = "Interview Prep"; + +const token = + process.env.PROJECT_PAT || + process.env.GH_TOKEN || + process.env.GITHUB_TOKEN || + (await $`gh auth token`.text()).trim(); + +async function graphql(query: string, variables: Record = {}): Promise { + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`); + const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] }; + if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; ")); + return payload.data; +} + +// 1. token check β€” hard fail here means PROJECT_PAT is missing or scopeless. +const viewerData = (await graphql(`{ viewer { login } }`)) as { viewer: { login: string } }; +console.log(`token OK β€” authenticated as @${viewerData.viewer.login}`); + +const gh = await github(); +const owner = gh.repo.split("/")[0]!; + +// 2. resolve the project and its fields. +interface FieldNode { + id: string; + name: string; + dataType?: string; + options?: { id: string; name: string }[]; +} +const projectData = (await graphql( + `query($owner: String!, $title: String!) { + user(login: $owner) { + projectsV2(first: 10, query: $title) { + nodes { + id number title + fields(first: 30) { + nodes { + ... on ProjectV2FieldCommon { id name dataType } + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + } + }`, + { owner, title: PROJECT_TITLE }, +)) as { + user: { + projectsV2: { nodes: { id: string; number: number; title: string; fields: { nodes: FieldNode[] } }[] }; + }; +}; +const project = projectData.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE); +if (!project) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`); +console.log(`project: "${project.title}" #${project.number} β€” ${project.id}`); + +let fields = project.fields.nodes; +const targetDate = fields.find((f) => f.name === "Target Date" && f.dataType === "DATE"); +if (!targetDate) { + throw new Error('the built-in "Target Date" field is missing β€” add it in the project UI'); +} +console.log(`Target Date field: ${targetDate.id} (reused, not created)`); + +// 3. create the two SRS single-selects when absent. +const WANTED: Record = { + "SRS Stage": [ + { name: "new", color: "GRAY", description: "not yet on the ladder" }, + { name: "+2", color: "YELLOW", description: "review due 2 days after last solve" }, + { name: "+5", color: "ORANGE", description: "review due 5 days after last solve" }, + { name: "+10", color: "BLUE", description: "review due 10 days after last solve" }, + { name: "retired", color: "GREEN", description: "passed all three stages" }, + ], + "First Attempt": [ + { name: "pass", color: "GREEN", description: "first timed solve passed" }, + { name: "fail", color: "RED", description: "first timed solve failed" }, + ], +}; + +for (const [name, options] of Object.entries(WANTED)) { + const existing = fields.find((f) => f.name === name); + if (existing) { + console.log(`${name} field: ${existing.id} (already exists)`); + continue; + } + const optionsArg = options + .map((o) => `{name:"${o.name}",color:${o.color},description:"${o.description}"}`) + .join(","); + const created = (await graphql( + `mutation($project: ID!, $name: String!) { + createProjectV2Field(input: { + projectId: $project, dataType: SINGLE_SELECT, name: $name, + singleSelectOptions: [${optionsArg}] + }) { + projectV2Field { ... on ProjectV2SingleSelectField { id name } } + } + }`, + { project: project.id, name }, + )) as { createProjectV2Field: { projectV2Field: { id: string } } }; + console.log(`${name} field: ${created.createProjectV2Field.projectV2Field.id} (created)`); + fields = [...fields, { id: created.createProjectV2Field.projectV2Field.id, name }]; +} + +// 4. mirror every laddered problem into the project. +const state = await loadState(); +const mirror = await projectMirror(owner, gh.repo); +let mirrored = 0; +for (const [lc, p] of Object.entries(state.problems)) { + await mirror.setStage(p.issue, p.stage); + await mirror.setTargetDate(p.issue, p.stage === "retired" ? null : (p.next_review ?? null)); + const first = p.history[0]; + if (first) await mirror.setFirstAttempt(p.issue, first.result); + mirrored++; + if (mirrored % 10 === 0) console.log(` mirrored ${mirrored} problems… (last LC ${lc})`); +} +console.log(`mirrored ${mirrored} laddered problems into the project`); +await reportMirror(mirror); + +if (process.env.GITHUB_STEP_SUMMARY && mirror.warnings.length === 0) { + await Bun.write( + process.env.GITHUB_STEP_SUMMARY, + `### srs-setup βœ…\n\n- token: @${viewerData.viewer.login}\n- project: ${project.id} (#${project.number})\n- Target Date: ${targetDate.id}\n- mirrored: ${mirrored} problems\n`, + ); +} diff --git a/scripts/srs.ts b/scripts/srs.ts new file mode 100644 index 0000000..156296f --- /dev/null +++ b/scripts/srs.ts @@ -0,0 +1,300 @@ +/** + * Domain library for the spaced-repetition system (SRS). + * + * State model β€” `.github/srs/srs.json` is the single source of truth; the + * GitHub Project fields are a best-effort mirror (see srs-project.ts). + * + * The stage names the review a problem must pass NEXT: a problem at `+2` has + * a review due 2 days after its last clean solve. Passing advances + * new β†’ +2 β†’ +5 β†’ +10 β†’ retired; any failure resets to +2. `new` is reserved + * for deferred Hards that have never been solved β€” `defer_until` keeps them + * out of the queue until Sep 28. + * + * All dates are America/New_York calendar dates (YYYY-MM-DD): the cron fires + * at 10:00 UTC = 6 AM ET, and the campaign is lived in ET. + */ +import { join } from "node:path"; + +import type { GitHub } from "./github.ts"; + +const ROOT = join(import.meta.dir, ".."); + +// ── state ──────────────────────────────────────────────────────── + +export type Stage = "new" | "+2" | "+5" | "+10" | "retired"; +export type Result = "pass" | "fail"; + +export interface Attempt { + date: string; + /** first = learning-day solve, drill = blind drill, gate = gate problem. */ + kind: "first" | "review" | "drill" | "gate"; + result: Result; +} + +export interface ProblemState { + issue: number; + topic: number; + difficulty: string; + set: string; + solved_on?: string; + stage: Stage; + next_review?: string; + /** Deferred Hards stay out of the due queue until this date. */ + defer_until?: string; + history: Attempt[]; +} + +export interface TopicState { + /** Blind-drill recognition misses since the last boost reset. */ + misses: number; + /** Set at gate close; scheduler injects extra drills; next gate clears it. */ + boost: boolean; +} + +export interface GateState { + week: number; + issue: number; + date: string; + problems: number[]; + /** Problems that fell back to the core set (optional pool exhausted). */ + fallbacks: number[]; + results: Record; + rate?: number; +} + +export interface State { + problems: Record; + topics: Record; + drill_pool_used: number[]; + gates: GateState[]; +} + +export const STATE_PATH = process.env.SRS_STATE ?? join(ROOT, ".github", "srs", "srs.json"); +export const SCHEDULE_PATH = + process.env.SRS_SCHEDULE ?? join(ROOT, ".github", "srs", "schedule.json"); + +export async function loadState(): Promise { + return JSON.parse(await Bun.file(STATE_PATH).text()); +} + +export async function saveState(state: State): Promise { + await Bun.write(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`); +} + +/** Day (YYYY-MM-DD) β†’ topic issue number. Human-edited when life happens. */ +export async function loadSchedule(): Promise> { + return JSON.parse(await Bun.file(SCHEDULE_PATH).text()); +} + +// ── dates (America/New_York) ───────────────────────────────────── + +/** Campaign day 1 β€” Monday of Phase I week 1. Day 56 = Oct 11. */ +export const CAMPAIGN_START = "2026-08-17"; +export const CAMPAIGN_DAYS = 56; + +/** Today's ET calendar date; SRS_TODAY overrides for tests and reruns. */ +export function todayET(): string { + if (process.env.SRS_TODAY) return process.env.SRS_TODAY; + return new Intl.DateTimeFormat("en-CA", { + timeZone: "America/New_York", + dateStyle: "short", + }).format(new Date()); +} + +/** Noon UTC anchor: date-only arithmetic immune to DST edges. */ +function atNoon(date: string): Date { + return new Date(`${date}T12:00:00Z`); +} + +export function addDays(date: string, days: number): string { + return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10); +} + +export function daysBetween(from: string, to: string): number { + return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000); +} + +/** 0 = Sunday … 6 = Saturday. */ +export function weekdayOf(date: string): number { + return atNoon(date).getUTCDay(); +} + +/** 1-based campaign day; may exceed CAMPAIGN_DAYS after the camp ends. */ +export function campaignDay(date: string): number { + return daysBetween(CAMPAIGN_START, date) + 1; +} + +/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */ +export function campaignWeek(date: string): number { + return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1; +} + +export function isoWeek(date: string): number { + const d = atNoon(date); + // ISO 8601: week containing the year's first Thursday is week 1. + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7); +} + +export function prettyDate(date: string): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + weekday: "long", + month: "long", + day: "numeric", + }).format(atNoon(date)); +} + +// ── the interval ladder ────────────────────────────────────────── + +export const INTERVAL: Record = { "+2": 2, "+5": 5, "+10": 10 }; +const NEXT_STAGE: Record = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" }; + +/** Advance on a pass; returns false when the problem was already retired. */ +export function pass(p: ProblemState, today: string): boolean { + const next = NEXT_STAGE[p.stage]; + if (!next) return false; + p.stage = next; + p.solved_on = today; + if (next === "retired") { + delete p.next_review; + } else { + p.next_review = addDays(today, INTERVAL[next]!); + } + return true; +} + +/** Reset on a failure: back to the +2 stage, due again in 2 days. */ +export function fail(p: ProblemState, today: string): void { + p.stage = "+2"; + p.next_review = addDays(today, 2); +} + +/** Reviews due today or earlier, oldest first β€” the overflow carry order. */ +export function dueReviews(state: State, today: string): [string, ProblemState][] { + return Object.entries(state.problems) + .filter( + ([, p]) => + p.stage !== "retired" && + p.next_review !== undefined && + p.next_review <= today && + (p.defer_until === undefined || p.defer_until <= today), + ) + .sort(([a, pa], [b, pb]) => + pa.next_review === pb.next_review + ? Number(a) - Number(b) + : pa.next_review! < pb.next_review! + ? -1 + : 1, + ); +} + +// ── deterministic sampling ─────────────────────────────────────── + +/** FNV-1a β†’ mulberry32: a seeded PRNG so re-runs pick identical problems. */ +export function rng(seed: string): () => number { + let h = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return () => { + h = Math.imul(h ^ (h >>> 15), h | 1); + h ^= h + Math.imul(h ^ (h >>> 7), h | 61); + return ((h ^ (h >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Take up to n elements, Fisher–Yates order driven by the seeded PRNG. */ +export function sample(pool: T[], n: number, random: () => number): T[] { + const copy = [...pool]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [copy[i], copy[j]] = [copy[j]!, copy[i]!]; + } + return copy.slice(0, n); +} + +// ── the live catalog (topics + problem sub-issues) ─────────────── + +export interface CatalogProblem { + lc: number; + issue: number; + name: string; + difficulty: "Easy" | "Medium" | "Hard"; + set: "core" | "optional" | "deferred"; + topic: number; + open: boolean; + url: string; +} + +export interface CatalogTopic { + issue: number; + /** e.g. "Topic 03 β€” Two Pointers". */ + title: string; + /** Bare name, e.g. "Two Pointers". */ + name: string; + milestone: number | undefined; +} + +export interface Catalog { + topics: Map; + problems: Map; + byIssue: Map; +} + +const TITLE_RE = /^LC (\d+) Β· (.+) Β· (Easy|Medium|Hard) Β· (core|optional|deferred)$/; + +/** + * Walk every `topic` issue and its sub-issues into one lookup structure. + * The sub-issue linkage is authoritative: every problem hangs off exactly + * one topic (verified against the full issue inventory). + */ +export async function fetchCatalog(gh: GitHub): Promise { + const topics = new Map(); + for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) { + const t = raw as { + number: number; + title: string; + pull_request?: unknown; + milestone?: { number: number } | null; + }; + if (t.pull_request) continue; + const name = t.title.replace(/^Topic \d+ β€” /, ""); + topics.set(t.number, { + issue: t.number, + title: t.title, + name, + milestone: t.milestone?.number, + }); + } + + const problems = new Map(); + const byIssue = new Map(); + for (const topic of topics.keys()) { + for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) { + const s = raw as { number: number; title: string; state: string; body?: string }; + const m = s.title.match(TITLE_RE); + if (!m) continue; // non-curriculum sub-issue + const url = (s.body ?? "").match(/https:\/\/leetcode\.com\/problems\/[a-z0-9-]+\/?/)?.[0]; + const p: CatalogProblem = { + lc: Number(m[1]), + issue: s.number, + name: m[2]!, + difficulty: m[3] as CatalogProblem["difficulty"], + set: m[4] as CatalogProblem["set"], + topic, + open: s.state === "open", + url: url ?? "", + }; + problems.set(p.lc, p); + byIssue.set(p.issue, p); + } + } + return { topics, problems, byIssue }; +} + +// ── shared constants ───────────────────────────────────────────── + +export const TODAY_TITLE = "πŸ“‹ Today";