Files
leetcode/apps/cli/github.ts
T

94 lines
3.3 KiB
TypeScript

/**
* 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 };
}