Files
leetcode/apps/cli/close-solved.ts
T

230 lines
8.8 KiB
TypeScript
Raw Normal View History

#!/usr/bin/env bun
/**
* Close the GitHub issue for every LeetCode problem actually solved under work/,
* then report the solved set to the SRS Worker so D1 agrees.
*
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
* issue whose LC number has an *implemented* solution file in work/ gets
* closed. Only open issues are touched, so re-runs are no-ops and backfilling
* needs no special casing. Removing a solution never reopens an issue.
*
* `bun run pick` scaffolds a statement header plus an empty function body, so
* file existence alone means nothing — a stub must not close its issue. See
* isImplemented() in source.ts.
*
* bun apps/cli/close-solved.ts # close matches, log them in D1
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
* the Worker URL for local `wrangler dev` runs.
*/
import { basename, join } from "node:path";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
import { isImplemented } from "./source.ts";
const ROOT = join(import.meta.dir, "..", "..");
const WORK = join(ROOT, "work");
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
// ── repo + auth ──────────────────────────────────────────────────
const gh = await github();
// ── work/ inventory ──────────────────────────────────────────────
interface WorkEntry {
files: string[];
implemented: boolean;
}
/** LC number -> solution files (a problem may have both js and py). */
const work = new Map<number, WorkEntry>();
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/);
if (!m) {
console.warn(`skip (unrecognized name): work/${rel}`);
continue;
}
const num = Number(m[1]);
const src = await Bun.file(join(WORK, rel)).text();
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
const entry = work.get(num);
if (entry) {
entry.files.push(`work/${rel}`);
entry.implemented ||= implemented;
} else {
work.set(num, { files: [`work/${rel}`], implemented });
}
}
// ── open problem issues ──────────────────────────────────────────
interface ProblemIssue {
number: number;
set: string;
}
/**
* Narrow one element of the /issues payload to the fields this script needs.
* Returns undefined for pull requests and for anything whose title is not a
* `LC <num> ...` problem, which is how non-curriculum rows get skipped.
*/
function readProblemIssue(
value: unknown,
): { lc: number; issue: ProblemIssue } | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return; // the /issues route also lists PRs
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
const lc = value.title.match(/^LC (\d+) /);
if (!lc) return;
let set = "—";
if ("labels" in value && Array.isArray(value.labels)) {
for (const label of value.labels) {
if (
label &&
typeof label === "object" &&
"name" in label &&
typeof label.name === "string" &&
label.name.startsWith("set:")
) {
set = label.name;
}
}
}
return { lc: Number(lc[1]), issue: { number: value.number, set } };
}
/** LC number -> open issue carrying the `problem` label. */
const open = new Map<number, ProblemIssue>();
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) {
const parsed = readProblemIssue(raw);
if (parsed) open.set(parsed.lc, parsed.issue);
}
// ── reconcile ────────────────────────────────────────────────────
const sha = process.env.GITHUB_SHA;
const report: string[][] = [];
let closed = 0;
for (const num of [...work.keys()].sort((a, b) => a - b)) {
const entry = work.get(num)!;
const issue = open.get(num);
// Warm-ups and out-of-curriculum practice have no issue. Not an error.
if (!issue) {
report.push([`${num}`, "—", "—", "no open issue"]);
continue;
}
if (!entry.implemented) {
report.push([`${num}`, `#${issue.number}`, issue.set, "stub — skipped"]);
continue;
}
if (DRY) {
report.push([`${num}`, `#${issue.number}`, issue.set, "would close"]);
continue;
}
const links = entry.files
.map((f) =>
sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
)
.join(", ");
await gh.closeIssue(
issue.number,
`Solved — solution committed at ${links}.\n\n` +
"Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.",
);
report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]);
closed++;
}
// ── push the solved set into D1 ───────────────────────────────────
// Closing an issue is invisible to the Worker: its catalog reconcile reads
// GitHub for titles, labels and milestones and never looks at issue state,
// and D1's SRS columns only ever move through logAttempt(). So a solution
// pushed here — rather than tapped in the digest email — has to be reported,
// or the charts, the digest's "already solved" ticks, and the drill/gate
// pools all keep treating it as untouched.
//
// The whole implemented set goes over, not just this run's closes: the
// Worker drops anything already on the ladder, so the call is a total
// recompute and backfills whatever earlier runs missed.
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
const solved = [...work.entries()]
.filter(([, entry]) => entry.implemented)
.map(([lc]) => lc)
.sort((a, b) => a - b);
let syncNote: string;
let syncFailed = false;
if (!process.env.SRS_ADMIN_KEY) {
syncNote = "SRS_ADMIN_KEY unset — D1 not touched";
} else if (solved.length === 0) {
syncNote = "nothing implemented — D1 not touched";
} else {
try {
const res = await fetch(`${SRS_API}/admin/solved${DRY ? "?dry=1" : ""}`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ lc: solved }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
// Narrow the report payload instead of casting it; a shape change should
// read as "logged nothing", not throw inside the reconciler.
const payload: unknown = await res.json();
const fields = payload && typeof payload === "object" ? payload : {};
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : []);
const logged = strings("logged" in fields ? fields.logged : null);
const skipped = strings("skipped" in fields ? fields.skipped : null);
syncNote =
`${DRY ? "would log" : "logged"} ${logged.length} of ${solved.length} ` +
`(${skipped.length} already on the ladder or off-curriculum)`;
for (const line of logged) console.log(` d1: ${line}`);
} catch (err) {
// The closes above already landed; surface the failure instead of
// letting D1 drift silently until the next push.
syncFailed = true;
syncNote = `D1 push FAILED: ${err instanceof Error ? err.message : String(err)}`;
}
}
// ── report ───────────────────────────────────────────────────────
const rows = [["lc", "issue", "set", "status"], ...report];
printTable(rows);
const actionable = report.filter((r) => r[3] !== "no open issue");
console.log(
`\n${work.size} in work/ · ${solved.length} implemented · ` +
`${actionable.length} matched an open issue · ` +
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
`\nd1: ${syncNote}`,
);
await writeStepSummary(
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
`${work.size} files in \`work/\`, ${solved.length} implemented, ` +
`${actionable.length} matched an open issue.\n\n` +
(actionable.length
? `${markdownTable([rows[0]!, ...actionable])}\n`
: "Nothing to close.\n") +
`\nD1: ${syncNote}\n`,
);
// Non-zero only for the D1 push: the closes are already reported above.
if (syncFailed) process.exitCode = 1;