mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
231 lines
9.3 KiB
TypeScript
Executable File
231 lines
9.3 KiB
TypeScript
Executable File
#!/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 { join } from "node:path";
|
|
|
|
import { github } from "./github.ts";
|
|
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
|
import { isImplemented } from "./source.ts";
|
|
import { WORK, parseWorkPath, type Bucket } from "./work.ts";
|
|
|
|
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[];
|
|
/** Any bucket implemented — this is what closes the problem issue. */
|
|
implemented: boolean;
|
|
/** Buckets holding a real solution: 1 = first solve, 3/7 = the re-solves. */
|
|
solved: Set<Bucket>;
|
|
}
|
|
|
|
/** LC number -> its solution files across every bucket (js and py both count). */
|
|
const work = new Map<number, WorkEntry>();
|
|
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
|
|
const file = parseWorkPath(rel);
|
|
if (!file) {
|
|
console.warn(`skip (off-layout): work/${rel}`);
|
|
continue;
|
|
}
|
|
const src = await Bun.file(join(WORK, rel)).text();
|
|
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
|
|
const entry = work.get(file.lc) ?? { files: [], implemented: false, solved: new Set<Bucket>() };
|
|
entry.files.push(`work/${rel}`);
|
|
entry.implemented ||= implemented;
|
|
if (implemented) entry.solved.add(file.bucket);
|
|
work.set(file.lc, entry);
|
|
}
|
|
|
|
// ── 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.
|
|
//
|
|
// Each entry carries the BUCKET the file sits in, which is the rung it
|
|
// settles: work/1 is the first solve, work/3 the 3-day review, work/7 the
|
|
// 7-day one. That is what makes a pushed re-solve advance the ladder instead
|
|
// of vanishing — and the Worker writes only when a problem is standing on the
|
|
// rung named, so the whole implemented set can go over on every push (a total
|
|
// recompute that backfills whatever earlier runs missed) and re-runs write
|
|
// nothing.
|
|
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
|
|
const solved = [...work.entries()]
|
|
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
|
|
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
|
|
|
|
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({ 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} problems in work/ · ${solved.length} solved files across buckets · ` +
|
|
`${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} problems in \`work/\`, ${solved.length} solved files across buckets, ` +
|
|
`${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;
|