feat(cli): add issue reconciliation scripts and GitHub helper

This commit is contained in:
Prad Nukala
2026-08-25 11:19:26 -04:00
parent 8f62875fa7
commit 10d133f005
8 changed files with 1072 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env bun
/**
* Close the GitHub issue for every LeetCode problem actually solved under work/.
*
* 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().
*
* bun apps/cli/close-solved.ts # close matches
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
*/
import { basename, join } from "node:path";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.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";
// ── is the file a real solution or just a scaffolded stub? ────────
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
/**
* Decide whether a work/ file contains an implementation.
*
* The header comment is dropped the same way sync.ts splitSource() does it
* (duplicated rather than imported, because sync.ts runs its whole pipeline on
* import). Comments must go before any brace analysis: the scaffold's JSDoc
* carries `@param {number[]}`, whose braces would otherwise read as a body.
*
* A brace-language file counts as implemented when at least one *innermost*
* brace pair holds real content. That distinguishes a bare stub
* (`function(nums) {}`) and a class-shaped design stub (every method body
* empty) from any genuine solution, whose innermost block always has code.
*/
function isImplemented(src: string, lang: "js" | "py"): boolean {
if (lang === "py") {
const open = src.indexOf('"""');
const close = src.indexOf('"""', open + 3);
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
for (const raw of code.split("\n")) {
const line = raw.replace(/#.*$/, "").trim();
if (!line || PLACEHOLDERS[line]) continue;
if (/^(?:@|def\s|class\s)/.test(line)) continue;
return true; // a statement inside some def body
}
return false;
}
const headerEnd = src.indexOf("*/");
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
let innermost = -1;
for (let i = 0; i < code.length; i++) {
if (code[i] === "{") {
innermost = i;
} else if (code[i] === "}" && innermost !== -1) {
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
if (inner && !PLACEHOLDERS[inner]) return true;
innermost = -1; // measured; the enclosing pair is not innermost
}
}
return false;
}
// ── 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++;
}
// ── report ───────────────────────────────────────────────────────
const rows = [["lc", "issue", "set", "status"], ...report];
printTable(rows);
const implemented = [...work.values()].filter((e) => e.implemented).length;
const actionable = report.filter((r) => r[3] !== "no open issue");
console.log(
`\n${work.size} in work/ · ${implemented} implemented · ` +
`${actionable.length} matched an open issue · ` +
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`,
);
await writeStepSummary(
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
`${work.size} files in \`work/\`, ${implemented} implemented, ` +
`${actionable.length} matched an open issue.\n\n` +
(actionable.length
? `${markdownTable([rows[0]!, ...actionable])}\n`
: "Nothing to close.\n"),
);
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bun
/**
* Close the GitHub issue for every topic whose required (core) problems are done.
*
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
* problem carries exactly one `set:` label — `set:core` is required, while
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
* every one of its core sub-issues is closed; optional/deferred state is
* ignored, matching the day rule in each topic body ("Core problems first").
*
* Like close-solved.ts this reconciles state instead of reacting to an event
* payload: re-runs are no-ops, backfilling needs no special casing, and only
* open topics are touched, so reopening a problem never reopens its topic.
*
* bun apps/cli/close-topics.ts # close finished topics
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
*/
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
const gh = await github();
// ── open topic issues ────────────────────────────────────────────
interface Topic {
number: number;
title: string;
}
/**
* Narrow one element of the /issues payload to the fields this script needs.
* The `topic` label filter cannot exclude pull requests, so drop those here.
*/
function readTopic(value: unknown): Topic | 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;
return { number: value.number, title: value.title };
}
const topics: Topic[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) {
const topic = readTopic(raw);
if (topic) topics.push(topic);
}
topics.sort((a, b) => a.number - b.number);
// ── core sub-issue state per topic ───────────────────────────────
interface Core {
/** Core sub-issues, closed and open alike, lowest number first. */
all: number[];
/** The core sub-issues still open — non-empty means the topic stays open. */
pending: number[];
}
/**
* Read a topic's core sub-issue state. The sub_issues payload carries the full
* issue objects (state + labels), so no per-problem follow-up request is needed.
*/
async function readCore(topic: number): Promise<Core> {
const all: number[] = [];
const pending: number[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("state" in raw) || typeof raw.state !== "string") continue;
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
const core = raw.labels.some(
(label) =>
label &&
typeof label === "object" &&
"name" in label &&
label.name === "set:core",
);
if (!core) continue;
all.push(raw.number);
if (raw.state === "open") pending.push(raw.number);
}
all.sort((a, b) => a - b);
pending.sort((a, b) => a - b);
return { all, pending };
}
// ── reconcile ────────────────────────────────────────────────────
const report: string[][] = [];
let closed = 0;
for (const topic of topics) {
const core = await readCore(topic.number);
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
// A topic with no core sub-issues has nothing to complete: never close it,
// since that would be indistinguishable from a mis-labelled problem set.
if (core.all.length === 0) {
report.push([`#${topic.number}`, topic.title, progress, "no core set"]);
continue;
}
if (core.pending.length > 0) {
report.push([
`#${topic.number}`,
topic.title,
progress,
`open: ${core.pending.map((n) => `#${n}`).join(" ")}`,
]);
continue;
}
if (DRY) {
report.push([`#${topic.number}`, topic.title, progress, "would close"]);
continue;
}
await gh.closeIssue(
topic.number,
`Core set complete — all ${core.all.length} core problems closed ` +
`(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` +
"Closed automatically by `close-topics`. Optional and deferred problems " +
"stay open as extra credit.",
);
report.push([`#${topic.number}`, topic.title, progress, "closed"]);
closed++;
}
// ── report ───────────────────────────────────────────────────────
const rows = [["topic", "title", "core", "status"], ...report];
printTable(rows);
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
console.log(
`\n${topics.length} open topics · ` +
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
);
await writeStepSummary(
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
);
+93
View File
@@ -0,0 +1,93 @@
/**
* GitHub REST plumbing shared by the issue reconcilers (close-solved,
* close-topics).
*
* Importing this module is side-effect free — nothing resolves credentials or
* touches the network until github() is awaited — so a script can import it
* without inheriting another script's pipeline.
*
* Repo: GITHUB_REPOSITORY, else the `origin` remote.
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token`.
*/
import { $ } from "bun";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
/** Page size used for every list endpoint; also the "more pages" threshold. */
const PER_PAGE = 100;
export interface GitHub {
/** `owner/name`. */
repo: string;
/** One authenticated request against api.github.com; throws on non-2xx. */
api(path: string, init?: RequestInit): Promise<unknown>;
/** Every page of a list endpoint, flattened into one stream of elements. */
list(path: string): AsyncGenerator<unknown, void, void>;
/** Comment on an issue, then close it as completed. */
closeIssue(issue: number, comment: string): Promise<void>;
}
async function resolveRepo(): Promise<string> {
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim();
const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`);
return m[1]!;
}
async function resolveToken(): Promise<string> {
const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (env) return env;
const token = (await $`gh auth token`.text()).trim();
if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`");
return token;
}
export async function github(): Promise<GitHub> {
const repo = await resolveRepo();
const token = await resolveToken();
async function api(path: string, init: RequestInit = {}): Promise<unknown> {
const res = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"x-github-api-version": "2022-11-28",
...(init.body ? { "content-type": "application/json" } : {}),
},
});
if (!res.ok) {
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
}
return res.json();
}
async function* list(path: string): AsyncGenerator<unknown, void, void> {
const sep = path.includes("?") ? "&" : "?";
for (let page = 1; ; page++) {
const batch = await api(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
yield* batch;
if (batch.length < PER_PAGE) return;
}
}
/**
* Comment before closing: if the PATCH fails, the issue still carries a
* visible note of what the automation decided, instead of failing silently.
*/
async function closeIssue(issue: number, comment: string): Promise<void> {
await api(`/repos/${repo}/issues/${issue}/comments`, {
method: "POST",
body: JSON.stringify({ body: comment }),
});
await api(`/repos/${repo}/issues/${issue}`, {
method: "PATCH",
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
});
}
return { repo, api, list, closeIssue };
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "cli",
"private": true,
"type": "module",
"scripts": {
"pick": "bun ./pick.ts",
"test": "bun ./test.ts",
"sync": "bun ./sync.ts",
"close-solved": "bun ./close-solved.ts",
"close-topics": "bun ./close-topics.ts"
},
"devDependencies": {
"@clack/prompts": "^1.7.0",
"@night-slayer18/leetcode-cli": "^3.5.0",
"@types/bun": "^1.3.14"
}
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bun
/**
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
* Problem index is fetched from leetcode.com and cached for 24h in .cache/.
*/
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
interface Problem {
id: number;
title: string;
slug: string;
difficulty: "Easy" | "Medium" | "Hard";
paidOnly: boolean;
}
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
async function loadProblems(): Promise<Problem[]> {
const file = Bun.file(CACHE_FILE);
if (await file.exists()) {
const stale = Date.now() - file.lastModified > CACHE_TTL_MS;
if (!stale) return file.json();
}
const s = spinner();
s.start("Fetching problem index from leetcode.com");
const res = await fetch("https://leetcode.com/api/problems/all/", {
headers: { "user-agent": "Mozilla/5.0" },
});
if (!res.ok) {
s.error(`Fetch failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const data = (await res.json()) as {
stat_status_pairs: Array<{
stat: {
frontend_question_id: number;
question__title: string;
question__title_slug: string;
};
difficulty: { level: 1 | 2 | 3 };
paid_only: boolean;
}>;
};
const problems: Problem[] = data.stat_status_pairs
.map((p) => ({
id: p.stat.frontend_question_id,
title: p.stat.question__title,
slug: p.stat.question__title_slug,
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
paidOnly: p.paid_only,
}))
.sort((a, b) => a.id - b.id);
s.stop(`Loaded ${problems.length} problems`);
await mkdir(join(ROOT, ".cache"), { recursive: true });
await Bun.write(CACHE_FILE, JSON.stringify(problems));
return problems;
}
const problems = await loadProblems();
const picked = await autocomplete<Problem>({
message: "Pick a problem",
placeholder: "Type to search by number or title...",
maxItems: 12,
options: problems.map((p) => ({
value: p,
label: `${p.id}. ${p.title}`,
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
})),
filter: (search, option) => {
const haystack = option.label!.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing picked.");
process.exit(0);
}
const proc = Bun.spawn(
// bun installs workspace-dep bins into this package's node_modules, not the root's
[join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
process.exit(await proc.exited);
+37
View File
@@ -0,0 +1,37 @@
/**
* Console + GitHub Actions reporting shared by the issue reconcilers.
*
* Both reconcilers end the same way: an aligned table on stdout, and the same
* table as Markdown in the step summary when running under Actions.
*/
/** Print rows[0] as a header, a rule, then the body — every column padded. */
export function printTable(rows: string[][]): void {
const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)));
const render = (r: string[]) =>
r
.map((cell, i) => cell.padEnd(widths[i]!))
.join(" ")
.trimEnd();
console.log(render(rows[0]!));
console.log(widths.map((n) => "─".repeat(n)).join(" "));
for (const row of rows.slice(1)) console.log(render(row));
}
/** Same rows as a GitHub-flavoured Markdown table; rows[0] is the header. */
export function markdownTable(rows: string[][]): string {
const [header, ...body] = rows;
return [
`| ${header!.join(" | ")} |`,
`| ${header!.map(() => "---").join(" | ")} |`,
...body.map((r) => `| ${r.join(" | ")} |`),
].join("\n");
}
/** Append to the Actions step summary; a no-op outside Actions. */
export async function writeStepSummary(markdown: string): Promise<void> {
const path = process.env.GITHUB_STEP_SUMMARY;
if (!path) return;
await Bun.write(path, markdown);
}
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env bun
/**
* Sync work/ leetcode solutions into apps/docs/content/ pages.
*
* - New problems get a full page (frontmatter, badge, warning, examples,
* constraints, solution) in the gold-standard format.
* - Already-ported pages only get their `## Solution` section regenerated,
* so hand-curated prose is never touched.
* - When a problem is solved in both JavaScript and Python the solution
* renders as a <CodeGroup> (Python first); single-language solutions
* render as a plain fence.
*/
import { mkdir, rename } from "node:fs/promises";
import { basename, join } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
const WORK = join(ROOT, "work");
const DOCS = join(ROOT, "apps", "docs", "content");
type Lang = "js" | "py";
interface Example {
input: string;
output: string;
explanation: string[]; // [] = none; 1 entry = inline; >1 = bullet list
}
interface Header {
num: number;
title: string;
difficulty: string;
statement: string[]; // unwrapped paragraphs
examples: Example[];
constraints: string[];
followUp: string;
}
interface Solution {
num: number;
slug: string;
category: string;
header: Header;
code: Partial<Record<Lang, string>>;
}
// ── work/ parsing ────────────────────────────────────────────────
/** Split a source file into (header comment text, code). */
function splitSource(src: string, lang: Lang): { header: string; code: string } {
let header: string, code: string;
if (lang === "js") {
const end = src.indexOf("*/");
if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */");
header = src
.slice(src.indexOf("/*") + 2, end)
.split("\n")
.map((l) => l.replace(/^\s*\* ?/, ""))
.join("\n");
code = src.slice(end + 2);
} else {
const open = src.indexOf('"""');
const close = src.indexOf('"""', open + 3);
if (open === -1 || close === -1) throw new Error('missing """ header """');
header = src.slice(open + 3, close);
code = src.slice(close + 3);
}
return { header, code: code.replace(/^\s*\n/, "").trimEnd() };
}
/** Unwrap hard-wrapped lines into logical paragraphs / bullets. */
function paragraphs(lines: string[]): string[] {
const out: string[] = [];
let cur = "";
const flush = () => {
if (cur) out.push(cur);
cur = "";
};
for (const raw of lines) {
const line = raw.replace(/\t/g, " ").trimEnd();
const text = line.trim();
if (!text || /^[─-]{3,}$/.test(text)) {
flush();
continue;
}
if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) {
flush();
cur = text;
} else {
cur = cur ? `${cur} ${text}` : text;
}
}
flush();
return out;
}
function parseHeader(header: string): Header {
const lines = header.split("\n");
const titleLine = lines.find((l) => /^\s*\d+\.\s/.test(l))?.trim();
if (!titleLine) throw new Error("missing '<num>. <title>' line");
const num = Number.parseInt(titleLine, 10);
const title = titleLine.replace(/^\d+\.\s*/, "");
const difficulty =
lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? "";
// Body: everything after the ───── separator.
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
const paras = paragraphs(lines.slice(sep + 1));
const statement: string[] = [];
const examples: Example[] = [];
const constraints: string[] = [];
let followUp = "";
let section: "statement" | "example" | "constraints" = "statement";
let ex: Example | null = null;
for (const p of paras) {
if (/^Example \d+:?$/.test(p)) {
if (ex) examples.push(ex);
ex = { input: "", output: "", explanation: [] };
section = "example";
continue;
}
if (/^Constraints:$/.test(p)) {
if (ex) examples.push(ex);
ex = null;
section = "constraints";
continue;
}
const fu = p.match(/^Follow[- ]?ups?:\s*(.*)$/i);
if (fu) {
followUp = fu[1]!;
continue;
}
if (section === "statement") statement.push(p.replace(/^• /, ""));
else if (section === "constraints") constraints.push(p.replace(/^• /, ""));
else if (ex) {
// Example paragraphs: Input/Output/Explanation, wrapped arbitrarily.
// A paragraph may fuse "Input: … Output: …" only across real lines,
// but source always keeps them on separate wrapped paragraphs.
if (p.startsWith("Input:")) ex.input = p.slice(6).trim();
else if (p.startsWith("Output:")) ex.output = p.slice(7).trim();
else if (p.startsWith("Explanation:")) {
const rest = p.slice(12).trim();
if (rest) ex.explanation.push(rest);
} else if (p.startsWith("- ")) ex.explanation.push(p.slice(2));
else if (ex.explanation.length)
ex.explanation[ex.explanation.length - 1] += ` ${p}`;
else ex.explanation.push(p);
}
}
if (ex) examples.push(ex);
return { num, title, difficulty, statement, examples, constraints, followUp };
}
// ── README topic map ─────────────────────────────────────────────
const TOPIC_ALIASES: Record<string, string> = {
"Hash-Based Lookup": "Hash Table",
};
async function readmeTopics(): Promise<Map<number, string>> {
const md = await Bun.file(join(ROOT, "README.md")).text();
const map = new Map<number, string>();
let topic = "";
for (const line of md.split("\n")) {
const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/);
if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim();
if (!topic) continue;
const n = line.match(/<td align="center">(\d+)<\/td>/);
if (n) map.set(Number(n[1]), topic);
}
return map;
}
// ── inline-code formatting heuristics ────────────────────────────
/** Identifiers worth backticking in prose, harvested from the code. */
function identifiers(sol: Solution): Set<string> {
const ids = new Set<string>(["n", "m", "k"]);
for (const code of Object.values(sol.code)) {
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!);
for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g))
for (const arg of m[1]!.split(","))
if (arg.trim()) ids.add(arg.split(":")[0]!.trim());
for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g))
for (const arg of m[2]!.split(","))
if (arg.trim()) ids.add(arg.trim());
}
return ids;
}
function formatConstraint(c: string, ids: Set<string>): string {
if (/<=|>=|==|!=|<|>/.test(c)) return `\`${c}\``;
// Prose constraint: backtick identifier-ish tokens only.
return c
.split(" ")
.map((word) => {
const m = word.match(/^([\w.]+(?:\[[^\]]*\])?)([.,;:]?)$/);
if (!m) return word;
const [, tok, punct] = m;
if (/\[[^\]]*\]/.test(tok!) || ids.has(tok!)) return `\`${tok}\`${punct}`;
return word;
})
.join(" ");
}
/** Wrap value literals / expressions in an explanation sentence. */
function backtickify(text: string, ids: Set<string>): string {
const words = text.split(" ");
type Tok = { pre: string; core: string; post: string; codey: boolean };
const toks: Tok[] = words.map((w) => {
const m = w.match(/^([("']*)(.*?)([)"'.,;:!?]*)$/)!;
const core = m[2]!;
const codey =
/^-?\d+(\.\d+)?([+\-*/]-?\d+(\.\d+)?)*$/.test(core) || // number / compact arithmetic
/^\[[^\]]*\]$/.test(core) || // array literal
/^[a-zA-Z_]\w*\[[^\]]*\]$/.test(core) || // indexed identifier
/^[+\-*/=%]$/.test(core) || // operator
ids.has(core);
return { pre: m[1]!, core, post: m[3]!, codey };
});
const out: string[] = [];
let i = 0;
while (i < toks.length) {
if (!toks[i]!.codey || /^[+\-*/=%]$/.test(toks[i]!.core)) {
out.push(words[i]!); // prose, or an operator with no codey run to its left
i++;
continue;
}
// Extend a run of codey tokens; only unbroken by punctuation.
let j = i;
while (
j + 1 < toks.length &&
toks[j + 1]!.codey &&
!toks[j]!.post && // punctuation after a token ends the run
!toks[j + 1]!.pre.includes('"')
)
j++;
// Trim trailing operators from the run (e.g. "5 and" keeps "and" out anyway).
while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--;
const span = toks
.slice(i, j + 1)
.map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`))
.join(" ");
out.push(`\`${span}\`${toks[j]!.post}`);
i = j + 1;
}
return out.join(" ");
}
// ── page rendering ───────────────────────────────────────────────
const FENCE: Record<Lang, { info: string; label: string }> = {
py: { info: "py", label: "Python" },
js: { info: "js", label: "JavaScript" },
};
function solutionSection(sol: Solution): string {
const langs = (["py", "js"] as const).filter((l) => sol.code[l]);
const fence = (l: Lang, titled: boolean) =>
`\`\`\`${FENCE[l].info}${titled ? ` ${FENCE[l].label}` : ""}\n${sol.code[l]}\n\`\`\``;
if (langs.length === 1) return `## Solution\n\n${fence(langs[0]!, false)}\n`;
return `## Solution\n\n<CodeGroup>\n\n${langs.map((l) => fence(l, true)).join("\n\n")}\n\n</CodeGroup>\n`;
}
function warningText(h: Header): string {
if (h.followUp) return h.followUp;
return (
h.statement
.slice(1)
.find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? ""
);
}
function renderPage(sol: Solution, topic: string): string {
const h = sol.header;
const ids = identifiers(sol);
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
const parts: string[] = [];
parts.push(
"---",
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
"sidebar:",
` label: '${h.title.replaceAll("'", "''")}'`,
` badge: '${h.difficulty}'`,
"---",
"",
`<Badge variant="accent">${topic}</Badge>`,
"",
);
const warning = warningText(h);
if (warning) parts.push("::::warning", warning, "::::", "");
h.examples.forEach((ex, i) => {
parts.push(`### Example ${i + 1}:`);
parts.push(`- Input: \`${ex.input}\``);
parts.push(`- Output: \`${ex.output}\``);
if (ex.explanation.length === 1)
parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`);
else if (ex.explanation.length > 1) {
parts.push("- Explanation:");
for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`);
}
parts.push("");
});
parts.push("### Constraints:", "");
for (const c of h.constraints) parts.push(`- ${formatConstraint(c, ids)}`);
parts.push("", solutionSection(sol));
return parts.join("\n");
}
// ── main ─────────────────────────────────────────────────────────
const solutions = new Map<number, Solution>();
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
const lang = rel.endsWith(".py") ? "py" : ("js" as Lang);
const file = basename(rel);
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
if (!m) {
console.warn(`skip (unrecognized name): work/${rel}`);
continue;
}
const num = Number(m[1]);
const slug = m[2]!;
const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang);
const parsed = parseHeader(header);
const existing = solutions.get(num);
if (existing) {
existing.code[lang] = code;
if (lang === "js") existing.header = parsed; // js header wins when both exist
} else {
solutions.set(num, {
num,
slug,
category: rel.split("/")[1] ?? "",
header: parsed,
code: { [lang]: code },
});
}
}
const topics = await readmeTopics();
/** Insert a number-free `sidebar.label` into existing frontmatter when missing. */
function ensureLabel(page: string, h: Header): string {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm || /^ {2}label: /m.test(fm[0])) return page;
const label = ` label: '${h.title.replaceAll("'", "''")}'`;
return page.replace(/^sidebar:$/m, `sidebar:\n${label}`);
}
// Existing docs pages, keyed by leetcode number from the frontmatter title.
const pages = new Map<number, string>(); // num -> path relative to DOCS
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
const text = await Bun.file(join(DOCS, rel)).text();
const t = text.match(/^title: '(\d+)\./m);
if (t) pages.set(Number(t[1]), rel);
}
const report: [string, string, string][] = [];
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+");
const workRef = `${sol.num}.${sol.slug} (${langs})`;
// (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`;
const name = `${group}/${sol.num}-${sol.slug}.mdx`;
const existing = pages.get(sol.num);
await mkdir(join(DOCS, group), { recursive: true });
if (existing) {
const page = await Bun.file(join(DOCS, existing)).text();
const at = page.indexOf("## Solution");
if (at === -1) {
report.push([workRef, existing, "ERROR: no '## Solution' heading"]);
continue;
}
const next = ensureLabel(page.slice(0, at) + solutionSection(sol), sol.header);
const actions: string[] = [];
if (existing !== name) {
await rename(join(DOCS, existing), join(DOCS, name));
actions.push("moved");
}
if (next !== page) {
await Bun.write(join(DOCS, name), next);
actions.push("updated");
}
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
} else {
const topic = topics.get(sol.num) ?? sol.category;
await Bun.write(join(DOCS, name), renderPage(sol, topic));
report.push([workRef, name, "created"]);
}
}
const w0 = Math.max(...report.map((r) => r[0].length), 9);
const w1 = Math.max(...report.map((r) => r[1].length), 9);
console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`);
for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bun
/**
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
*/
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
import { join, relative } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
const WORK_DIR = join(ROOT, "work");
const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })]
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
if (files.length === 0) {
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
process.exit(1);
}
const picked = await autocomplete<string>({
message: "Test which solution?",
placeholder: "Type to search...",
maxItems: 12,
options: files.map((f) => {
// work layout: Difficulty/Category/<id>.<slug>.<ext>
const [difficulty, category, name] = f.split("/");
return {
value: f,
label: name ?? f,
hint: category ? `${difficulty} · ${category}` : difficulty,
};
}),
filter: (search, option) => {
const haystack = option.value.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing selected.");
process.exit(0);
}
const proc = Bun.spawn(
[
// bun installs workspace-dep bins into this package's node_modules, not the root's
join(import.meta.dir, "node_modules", ".bin", "leetcode"),
"test",
join(WORK_DIR, picked),
],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
process.exit(await proc.exited);