#!/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 = { 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(); 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 ...` 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(); 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"), );