mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
feat(scripts): add GitHub helper module and close-topics script with reporting
This commit is contained in:
+13
-72
@@ -16,9 +16,11 @@
|
|||||||
*
|
*
|
||||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
|
||||||
*/
|
*/
|
||||||
import { $ } from "bun";
|
|
||||||
import { basename, join } from "node:path";
|
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 ROOT = join(import.meta.dir, "..");
|
||||||
const WORK = join(ROOT, "work");
|
const WORK = join(ROOT, "work");
|
||||||
|
|
||||||
@@ -78,40 +80,7 @@ function isImplemented(src: string, lang: "js" | "py"): boolean {
|
|||||||
|
|
||||||
// ── repo + auth ──────────────────────────────────────────────────
|
// ── repo + auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async function resolveRepo(): Promise<string> {
|
const gh = await github();
|
||||||
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 ──────────────────────────────────────────────
|
// ── work/ inventory ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -181,17 +150,10 @@ function readProblemIssue(
|
|||||||
|
|
||||||
/** LC number -> open issue carrying the `problem` label. */
|
/** LC number -> open issue carrying the `problem` label. */
|
||||||
const open = new Map<number, ProblemIssue>();
|
const open = new Map<number, ProblemIssue>();
|
||||||
for (let page = 1; ; page++) {
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) {
|
||||||
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);
|
const parsed = readProblemIssue(raw);
|
||||||
if (parsed) open.set(parsed.lc, parsed.issue);
|
if (parsed) open.set(parsed.lc, parsed.issue);
|
||||||
}
|
}
|
||||||
if (batch.length < 100) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── reconcile ────────────────────────────────────────────────────
|
// ── reconcile ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -219,21 +181,14 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
|||||||
|
|
||||||
const links = entry.files
|
const links = entry.files
|
||||||
.map((f) =>
|
.map((f) =>
|
||||||
sha ? `[\`${f}\`](https://github.com/${repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
|
sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
|
||||||
)
|
)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
await api(`/repos/${repo}/issues/${issue.number}/comments`, {
|
await gh.closeIssue(
|
||||||
method: "POST",
|
issue.number,
|
||||||
body: JSON.stringify({
|
|
||||||
body:
|
|
||||||
`Solved — solution committed at ${links}.\n\n` +
|
`Solved — solution committed at ${links}.\n\n` +
|
||||||
"Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.",
|
"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"]);
|
report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]);
|
||||||
closed++;
|
closed++;
|
||||||
}
|
}
|
||||||
@@ -241,17 +196,7 @@ for (const num of [...work.keys()].sort((a, b) => a - b)) {
|
|||||||
// ── report ───────────────────────────────────────────────────────
|
// ── report ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rows = [["lc", "issue", "set", "status"], ...report];
|
const rows = [["lc", "issue", "set", "status"], ...report];
|
||||||
const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)));
|
printTable(rows);
|
||||||
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 implemented = [...work.values()].filter((e) => e.implemented).length;
|
||||||
const actionable = report.filter((r) => r[3] !== "no open issue");
|
const actionable = report.filter((r) => r[3] !== "no open issue");
|
||||||
@@ -261,15 +206,11 @@ console.log(
|
|||||||
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`,
|
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
await writeStepSummary(
|
||||||
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` +
|
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
|
||||||
`${work.size} files in \`work/\`, ${implemented} implemented, ` +
|
`${work.size} files in \`work/\`, ${implemented} implemented, ` +
|
||||||
`${actionable.length} matched an open issue.\n\n` +
|
`${actionable.length} matched an open issue.\n\n` +
|
||||||
(body
|
(actionable.length
|
||||||
? `| LC | Issue | Set | Status |\n| --- | --- | --- | --- |\n${body}\n`
|
? `${markdownTable([rows[0]!, ...actionable])}\n`
|
||||||
: "Nothing to close.\n"),
|
: "Nothing to close.\n"),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|||||||
Executable
+150
@@ -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 scripts/close-topics.ts # close finished topics
|
||||||
|
* bun scripts/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"),
|
||||||
|
);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user