/** * GitHub REST + GraphQL plumbing shared by the issue reconcilers (close-solved, * close-topics) and the Project field reconciler (sync-project-fields). * * 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; /** One GraphQL call (Projects v2 lives here); throws on transport or query errors. */ graphql(query: string, variables?: Record): Promise; /** Every page of a list endpoint, flattened into one stream of elements. */ list(path: string): AsyncGenerator; /** Comment on an issue, then close it as completed. */ closeIssue(issue: number, comment: string): Promise; } 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; } export async function github(): Promise { 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(); } async function* list(path: string): AsyncGenerator { 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; } } async function graphql( query: string, variables: Record = {}, ): Promise { const res = await fetch("https://api.github.com/graphql", { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", }, body: JSON.stringify({ query, variables }), }); if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`); const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] }; if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; ")); return payload.data; } /** * 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 { 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, graphql, list, closeIssue }; }