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.

95 lines · 3.7 KBRaw History
1import { CliError } from "./io.js";
2import { VERSION } from "./version.js";
3
4export class ApiError extends CliError {
5 constructor(
6 readonly status: number,
7 message: string,
8 readonly body: unknown,
9 ) {
10 super(message);
11 }
12}
13
14export interface RequestOptions {
15 body?: unknown;
16 query?: Record<string, string | number | boolean | undefined | null>;
17 /** Return the response text instead of parsing JSON. */
18 text?: boolean;
19 /** Treat these statuses as success (e.g. 404 when probing existence). */
20 allow?: number[];
21 headers?: Record<string, string>;
22}
23
24export interface ApiResponse<T> {
25 status: number;
26 data: T;
27 headers: Headers;
28}
29
30/** Thin client for the Diffinery REST API (`/api/v3`, GitHub-shaped). */
31export class Api {
32 constructor(
33 readonly baseUrl: string,
34 readonly token: string | null,
35 ) {}
36
37 url(path: string, query?: RequestOptions["query"]): string {
38 const p = /^https?:\/\//.test(path) ? path : `${this.baseUrl}${path.startsWith("/api/") || path === "/mcp" ? "" : "/api/v3"}${path.startsWith("/") ? path : `/${path}`}`;
39 const u = new URL(p);
40 for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v));
41 return u.toString();
42 }
43
44 async raw<T = unknown>(method: string, path: string, o: RequestOptions = {}): Promise<ApiResponse<T>> {
45 const headers: Record<string, string> = { accept: "application/json", "user-agent": `diffinery-cli/${VERSION}`, ...o.headers };
46 if (this.token) headers.authorization = `Bearer ${this.token}`;
47 let body: string | undefined;
48 if (o.body !== undefined) {
49 body = typeof o.body === "string" ? o.body : JSON.stringify(o.body);
50 headers["content-type"] ??= "application/json";
51 }
52 let res: Response;
53 const url = this.url(path, o.query);
54 try {
55 res = await fetch(url, { method, headers, body });
56 } catch (e) {
57 const cause = (e as { cause?: { code?: string; message?: string } }).cause;
58 throw new CliError(`could not reach ${new URL(url).origin}: ${cause?.code ?? cause?.message ?? (e as Error).message}`);
59 }
60 const text = await res.text();
61 let data: unknown = text;
62 if (!o.text && text && (res.headers.get("content-type") ?? "").includes("json")) {
63 try {
64 data = JSON.parse(text);
65 } catch {
66 /* leave as text */
67 }
68 } else if (!o.text && !text) data = null;
69 if (!res.ok && !o.allow?.includes(res.status)) {
70 const msg = data && typeof data === "object" && "message" in data ? String((data as { message: unknown }).message) : text.slice(0, 300) || res.statusText;
71 let hint = "";
72 if (res.status === 401) hint = this.token ? " (token invalid or expired; run `diffinery login`)" : " (not logged in; run `diffinery login`)";
73 throw new ApiError(res.status, `${method} ${new URL(url).pathname}: ${res.status} ${msg}${hint}`, data);
74 }
75 return { status: res.status, data: data as T, headers: res.headers };
76 }
77
78 async get<T = unknown>(path: string, query?: RequestOptions["query"]): Promise<T> {
79 return (await this.raw<T>("GET", path, { query })).data;
80 }
81 async post<T = unknown>(path: string, body?: unknown): Promise<T> {
82 return (await this.raw<T>("POST", path, { body: body ?? {} })).data;
83 }
84 async put<T = unknown>(path: string, body?: unknown): Promise<T> {
85 return (await this.raw<T>("PUT", path, { body: body ?? {} })).data;
86 }
87 async patch<T = unknown>(path: string, body: unknown): Promise<T> {
88 return (await this.raw<T>("PATCH", path, { body })).data;
89 }
90 async delete<T = unknown>(path: string): Promise<T> {
91 return (await this.raw<T>("DELETE", path)).data;
92 }
93}
94
95export const enc = encodeURIComponent;