diff --git a/.github/workflows/close-solved.yml b/.github/workflows/close-solved.yml new file mode 100644 index 0000000..8d7d0d7 --- /dev/null +++ b/.github/workflows/close-solved.yml @@ -0,0 +1,34 @@ +name: Close Solved + +on: + push: + branches: [main] + paths: ["work/**"] + workflow_dispatch: + inputs: + dry_run: + description: Report matches without closing anything + type: boolean + default: false + +permissions: + contents: read + issues: write + +concurrency: + group: close-solved + cancel-in-progress: false + +jobs: + close: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + # No install step: the script only uses Bun builtins + fetch. + - run: bun scripts/close-solved.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run }} diff --git a/package.json b/package.json index 8549583..cf866ac 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "blume dev", "build": "blume build", "doctor": "blume doctor", + "close-solved": "bun scripts/close-solved.ts", "pick": "bun scripts/pick.ts", "sync": "bun scripts/sync.ts", "test": "bun scripts/test.ts" diff --git a/scripts/close-solved.ts b/scripts/close-solved.ts new file mode 100755 index 0000000..1d4a4d5 --- /dev/null +++ b/scripts/close-solved.ts @@ -0,0 +1,275 @@ +#!/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 { $ } from "bun"; +import { basename, join } from "node:path"; + +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 ────────────────────────────────────────────────── + +async function resolveRepo(): Promise { + 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 { + 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; +} + +const repo = await resolveRepo(); +const token = await resolveToken(); + +async function api(path: string, init: RequestInit = {}): Promise { + 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(); +} + +// ── 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 (let page = 1; ; page++) { + const batch = await api( + `/repos/${repo}/issues?labels=problem&state=open&per_page=100&page=${page}`, + ); + if (!Array.isArray(batch)) throw new Error("unexpected /issues payload: not an array"); + for (const raw of batch) { + const parsed = readProblemIssue(raw); + if (parsed) open.set(parsed.lc, parsed.issue); + } + if (batch.length < 100) break; +} + +// ── 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/${repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``, + ) + .join(", "); + await api(`/repos/${repo}/issues/${issue.number}/comments`, { + method: "POST", + body: JSON.stringify({ + body: + `Solved — solution committed at ${links}.\n\n` + + "Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.", + }), + }); + await api(`/repos/${repo}/issues/${issue.number}`, { + method: "PATCH", + body: JSON.stringify({ state: "closed", state_reason: "completed" }), + }); + report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]); + closed++; +} + +// ── report ─────────────────────────────────────────────────────── + +const rows = [["lc", "issue", "set", "status"], ...report]; +const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length))); +const rendered = rows.map((r) => + r + .map((cell, i) => cell.padEnd(widths[i]!)) + .join(" ") + .trimEnd(), +); + +console.log(rendered[0]); +console.log(widths.map((n) => "─".repeat(n)).join(" ")); +for (const row of rendered.slice(1)) console.log(row); + +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}`}`, +); + +if (process.env.GITHUB_STEP_SUMMARY) { + const body = actionable.map((r) => `| ${r[0]} | ${r[1]} | ${r[2]} | ${r[3]} |`).join("\n"); + await Bun.write( + process.env.GITHUB_STEP_SUMMARY, + `### close-solved${DRY ? " (dry run)" : ""}\n\n` + + `${work.size} files in \`work/\`, ${implemented} implemented, ` + + `${actionable.length} matched an open issue.\n\n` + + (body + ? `| LC | Issue | Set | Status |\n| --- | --- | --- | --- |\n${body}\n` + : "Nothing to close.\n"), + ); +}