Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

127 lines · 4.6 KBRaw History
1import { CliError } from "./io.js";
2import { VERSION } from "./version.js";
3
4export class GitHubError extends CliError {
5 constructor(
6 readonly status: number,
7 message: string,
8 ) {
9 super(message);
10 }
11}
12
13export 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. */
24export 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. */
34export 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}