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.

96 lines · 3.4 KBRaw History
1import { nextLink, type RepoJson, type RepoRef } from "./util";
2
3/** An HTTP error from the Diffinery API, with the server's own message when it sent one. */
4export class ApiError extends Error {
5 constructor(
6 readonly status: number,
7 message: string,
8 ) {
9 super(message);
10 }
11}
12
13export interface UserJson {
14 login: string;
15 id: number;
16 type: "User" | "Bot" | "Organization";
17 name?: string | null;
18}
19
20export interface OrgJson {
21 login: string;
22 role?: string;
23}
24
25const UA = "Diffinery-VSCode";
26
27/** The REST API (GitHub-compatible, at /api/v3) with one token. */
28export class Api {
29 constructor(
30 readonly server: string,
31 private readonly token: string,
32 private readonly version: string,
33 ) {}
34
35 private async request(method: string, url: string, body?: unknown): Promise<Response> {
36 const res = await fetch(url.startsWith("http") ? url : `${this.server}/api/v3${url}`, {
37 method,
38 headers: {
39 authorization: `Bearer ${this.token}`,
40 accept: "application/json",
41 "user-agent": `${UA}/${this.version}`,
42 ...(body === undefined ? {} : { "content-type": "application/json" }),
43 },
44 body: body === undefined ? undefined : JSON.stringify(body),
45 signal: AbortSignal.timeout(30_000),
46 });
47 if (!res.ok) {
48 let msg = `${res.status} ${res.statusText}`;
49 try {
50 const j = (await res.json()) as { message?: string; error?: string };
51 msg = j.message || j.error || msg;
52 } catch {
53 // not JSON
54 }
55 throw new ApiError(res.status, res.status === 401 ? `Diffinery didn't accept your sign-in (${msg}). Sign in again.` : msg);
56 }
57 return res;
58 }
59
60 async get<T>(path: string): Promise<T> {
61 return (await (await this.request("GET", path)).json()) as T;
62 }
63
64 async post<T>(path: string, body: unknown): Promise<T> {
65 return (await (await this.request("POST", path, body)).json()) as T;
66 }
67
68 /** Every page of a list, following Link headers, up to `max` items. */
69 async all<T>(path: string, max = 500): Promise<T[]> {
70 const out: T[] = [];
71 let url: string | null = path;
72 while (url && out.length < max) {
73 const res: Response = await this.request("GET", url);
74 out.push(...((await res.json()) as T[]));
75 url = nextLink(res.headers.get("link"));
76 }
77 return out.slice(0, max);
78 }
79
80 user = () => this.get<UserJson>("/user");
81 orgs = () => this.get<OrgJson[]>("/user/orgs");
82 /** The repositories the account can read: its own, its organizations', and ones shared with it, most recently pushed first. */
83 myRepos = () => this.all<RepoJson>("/user/repos?per_page=100");
84 repo = (r: RepoRef) => this.get<RepoJson>(`/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}`);
85 async search(query: string): Promise<RepoJson[]> {
86 const q = new URLSearchParams({ q: query, per_page: "30", sort: "updated" });
87 return (await this.get<{ items: RepoJson[] }>(`/search/repositories?${q}`)).items;
88 }
89 async branches(r: RepoRef): Promise<string[]> {
90 const list = await this.all<{ name: string }>(`/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}/branches?per_page=100`, 1000);
91 return list.map((b) => b.name);
92 }
93 createRepo(owner: string | null, body: { name: string; private: boolean; description?: string }) {
94 return this.post<RepoJson>(owner ? `/orgs/${encodeURIComponent(owner)}/repos` : "/user/repos", body);
95 }
96}