mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(ci): auto-close problem issues when a solution lands in work/
Reconciles work/ against open `problem` issues instead of diffing the push, so re-runs are no-ops and backfills need no special casing. Skips scaffolded stubs: `bun run pick` writes a statement header plus an empty body, so file existence alone must not close an issue.
This commit is contained in:
@@ -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 }}
|
||||
@@ -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"
|
||||
|
||||
Executable
+275
@@ -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<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 ──────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ── 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 (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"),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user