mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
Migrate SRS to Cloudflare Worker: D1 state, email digest, review issues, live charts
This commit is contained in:
@@ -1,261 +0,0 @@
|
||||
#!/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}")`);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Process `/done <lc> <pass|fail>` 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<Input> {
|
||||
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 <number> <pass|fail>\``);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* 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<string, string>; // 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<void>;
|
||||
setStage(issue: number, stage: string): Promise<void>;
|
||||
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||||
/** Accumulated failures; print + step-summarize these, never throw. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
async function resolveToken(): Promise<string> {
|
||||
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<ProjectMirror> {
|
||||
const warnings: string[] = [];
|
||||
let token = "";
|
||||
let project: ProjectInfo | undefined;
|
||||
const itemIds = new Map<number, string | undefined>();
|
||||
|
||||
async function graphql(query: string, variables: Record<string, unknown>): Promise<unknown> {
|
||||
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<ProjectInfo> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>): Promise<void> {
|
||||
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<void> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
#!/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);
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/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<string, unknown> = {}): Promise<unknown> {
|
||||
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<string, { name: string; color: string; description: string }[]> = {
|
||||
"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`,
|
||||
);
|
||||
}
|
||||
-300
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* 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<string, Result>;
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
problems: Record<string, ProblemState>;
|
||||
topics: Record<string, TopicState>;
|
||||
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<State> {
|
||||
return JSON.parse(await Bun.file(STATE_PATH).text());
|
||||
}
|
||||
|
||||
export async function saveState(state: State): Promise<void> {
|
||||
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<Record<string, number>> {
|
||||
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<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { 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<T>(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<number, CatalogTopic>;
|
||||
problems: Map<number, CatalogProblem>;
|
||||
byIssue: Map<number, CatalogProblem>;
|
||||
}
|
||||
|
||||
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<Catalog> {
|
||||
const topics = new Map<number, CatalogTopic>();
|
||||
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<number, CatalogProblem>();
|
||||
const byIssue = new Map<number, CatalogProblem>();
|
||||
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";
|
||||
Reference in New Issue
Block a user