| 1 | import { CliError } from "./io.js"; |
| 2 | import { VERSION } from "./version.js"; |
| 3 | |
| 4 | export class GitHubError extends CliError { |
| 5 | constructor( |
| 6 | readonly status: number, |
| 7 | message: string, |
| 8 | ) { |
| 9 | super(message); |
| 10 | } |
| 11 | } |
| 12 | |
| 13 | export interface GitHubOptions { |
| 14 | baseUrl?: string; |
| 15 | token?: string | null; |
| 16 | sleep: (ms: number) => Promise<void>; |
| 17 | log?: (msg: string) => void; |
| 18 | /** Longest single wait for a rate-limit reset before giving up (default 1h). */ |
| 19 | maxWaitMs?: number; |
| 20 | now?: () => number; |
| 21 | } |
| 22 | |
| 23 | /** Next-page URL from a GitHub `Link` header. */ |
| 24 | export function nextLink(link: string | null): string | null { |
| 25 | if (!link) return null; |
| 26 | for (const part of link.split(",")) { |
| 27 | const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part); |
| 28 | if (m) return m[1]!; |
| 29 | } |
| 30 | return null; |
| 31 | } |
| 32 | |
| 33 | /** Minimal GitHub REST client with pagination and rate-limit handling. */ |
| 34 | export class GitHub { |
| 35 | readonly baseUrl: string; |
| 36 | constructor(private o: GitHubOptions) { |
| 37 | this.baseUrl = (o.baseUrl ?? "https://api.github.com").replace(/\/+$/, ""); |
| 38 | } |
| 39 | |
| 40 | private async fetchRetrying(url: string): Promise<Response> { |
| 41 | const now = this.o.now ?? Date.now; |
| 42 | const maxWait = this.o.maxWaitMs ?? 3600_000; |
| 43 | for (let attempt = 0; ; attempt++) { |
| 44 | const headers: Record<string, string> = { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": `diffinery-cli/${VERSION}` }; |
| 45 | if (this.o.token) headers.authorization = `Bearer ${this.o.token}`; |
| 46 | let res: Response; |
| 47 | try { |
| 48 | res = await fetch(url, { headers }); |
| 49 | } catch (e) { |
| 50 | if (attempt < 3) { |
| 51 | await this.o.sleep(1000 * 2 ** attempt); |
| 52 | continue; |
| 53 | } |
| 54 | throw new CliError(`could not reach GitHub (${new URL(url).host}): ${(e as Error).message}`); |
| 55 | } |
| 56 | if ((res.status === 403 || res.status === 429) && attempt < 8) { |
| 57 | const retryAfter = res.headers.get("retry-after"); |
| 58 | const remaining = res.headers.get("x-ratelimit-remaining"); |
| 59 | const reset = res.headers.get("x-ratelimit-reset"); |
| 60 | let wait: number | null = null; |
| 61 | if (retryAfter && Number.isFinite(Number(retryAfter))) wait = Number(retryAfter) * 1000; |
| 62 | else if (remaining === "0" && reset && Number.isFinite(Number(reset))) wait = Math.max(0, Number(reset) * 1000 - now()) + 1000; |
| 63 | else if (res.status === 429) wait = 60_000; |
| 64 | else { |
| 65 | const text = await res.clone().text(); |
| 66 | if (/secondary rate limit|abuse/i.test(text)) wait = 60_000; |
| 67 | } |
| 68 | if (wait !== null) { |
| 69 | if (wait > maxWait) throw new GitHubError(res.status, `GitHub rate limit: reset is ${Math.ceil(wait / 60000)} minutes away; try again later${this.o.token ? "" : " or set GH_TOKEN"}`); |
| 70 | this.o.log?.(`GitHub rate limit reached; waiting ${Math.ceil(wait / 1000)}s…`); |
| 71 | await res.body?.cancel(); |
| 72 | await this.o.sleep(wait); |
| 73 | continue; |
| 74 | } |
| 75 | } |
| 76 | if (res.status >= 500 && attempt < 3) { |
| 77 | await res.body?.cancel(); |
| 78 | await this.o.sleep(1000 * 2 ** attempt); |
| 79 | continue; |
| 80 | } |
| 81 | return res; |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | private url(path: string, query?: Record<string, string | number>): string { |
| 86 | const u = new URL(/^https?:\/\//.test(path) ? path : `${this.baseUrl}${path}`); |
| 87 | for (const [k, v] of Object.entries(query ?? {})) u.searchParams.set(k, String(v)); |
| 88 | return u.toString(); |
| 89 | } |
| 90 | |
| 91 | private async check(res: Response, url: string): Promise<void> { |
| 92 | if (res.ok) return; |
| 93 | let msg = res.statusText; |
| 94 | try { |
| 95 | const b = (await res.json()) as { message?: string }; |
| 96 | if (b.message) msg = b.message; |
| 97 | } catch { |
| 98 | /* not JSON */ |
| 99 | } |
| 100 | throw new GitHubError(res.status, `GitHub ${new URL(url).pathname}: ${res.status} ${msg}`); |
| 101 | } |
| 102 | |
| 103 | async get<T>(path: string, query?: Record<string, string | number>): Promise<T> { |
| 104 | const url = this.url(path, query); |
| 105 | const res = await this.fetchRetrying(url); |
| 106 | await this.check(res, url); |
| 107 | return (await res.json()) as T; |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * All pages of a list endpoint. `key` picks the array out of wrapped responses |
| 112 | * such as `{ total_count, variables: [...] }`. |
| 113 | */ |
| 114 | async paginate<T>(path: string, query: Record<string, string | number> = {}, key?: string): Promise<T[]> { |
| 115 | const out: T[] = []; |
| 116 | let url: string | null = this.url(path, { per_page: 100, ...query }); |
| 117 | while (url) { |
| 118 | const res = await this.fetchRetrying(url); |
| 119 | await this.check(res, url); |
| 120 | const body = (await res.json()) as unknown; |
| 121 | const items = key ? ((body as Record<string, unknown>)[key] as T[] | undefined) : (body as T[]); |
| 122 | if (Array.isArray(items)) out.push(...items); |
| 123 | url = nextLink(res.headers.get("link")); |
| 124 | } |
| 125 | return out; |
| 126 | } |
| 127 | } |