mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
217 lines
7.9 KiB
TypeScript
Executable File
217 lines
7.9 KiB
TypeScript
Executable File
#!/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 scripts/close-solved.ts # close matches
|
|
* bun scripts/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"),
|
|
);
|