| 3e3af55 | | | 1 | /** |
| 3c994d3 | | | 2 | * Grove API client — used by the Next.js frontend. |
| 3c994d3 | | | 3 | * |
| f0bb192 | | | 4 | * Auth and instance requests go to hub-api. |
| d12933e | | | 5 | * Repo, diff, and Canopy requests go directly to grove-api. |
| f0bb192 | | | 6 | * In production, Caddy routes /api/* to the correct backend. |
| f0bb192 | | | 7 | * In local dev, Next.js rewrites handle the routing. |
| 3e3af55 | | | 8 | */ |
| 3e3af55 | | | 9 | |
| f0bb192 | | | 10 | const API_BASE = "/api"; |
| 3e3af55 | | | 11 | |
| 3c994d3 | | | 12 | function getToken(): string | null { |
| 3c994d3 | | | 13 | return typeof window !== "undefined" |
| 3c994d3 | | | 14 | ? localStorage.getItem("grove_hub_token") |
| 3c994d3 | | | 15 | : null; |
| 3c994d3 | | | 16 | } |
| 3c994d3 | | | 17 | |
| a9b2860 | | | 18 | // ── Token refresh infrastructure ────────────────────────────────── |
| a9b2860 | | | 19 | |
| a9b2860 | | | 20 | let refreshInFlight: Promise<string | null> | null = null; |
| a9b2860 | | | 21 | |
| a9b2860 | | | 22 | /** Write token + user to localStorage and cross-subdomain cookies. */ |
| a9b2860 | | | 23 | function persistToken(token: string, user: { id: number; username: string; display_name: string }) { |
| a9b2860 | | | 24 | localStorage.setItem("grove_hub_token", token); |
| a9b2860 | | | 25 | localStorage.setItem("grove_hub_user", JSON.stringify(user)); |
| a9b2860 | | | 26 | |
| a9b2860 | | | 27 | const hostname = window.location.hostname; |
| a9b2860 | | | 28 | const secure = window.location.protocol === "https:" ? "; Secure" : ""; |
| a9b2860 | | | 29 | const maxAge = 60 * 60 * 24 * 30; |
| a9b2860 | | | 30 | |
| a9b2860 | | | 31 | const domains: string[] = []; |
| a9b2860 | | | 32 | if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { |
| a9b2860 | | | 33 | // no domain cookies for IPs |
| a9b2860 | | | 34 | } else if (hostname === "localhost" || hostname.endsWith(".localhost")) { |
| a9b2860 | | | 35 | domains.push("localhost", ".localhost"); |
| a9b2860 | | | 36 | } else { |
| a9b2860 | | | 37 | const parts = hostname.split("."); |
| a9b2860 | | | 38 | if (parts.length <= 2) domains.push(`.${hostname}`); |
| a9b2860 | | | 39 | else domains.push(`.${parts.slice(-2).join(".")}`); |
| a9b2860 | | | 40 | } |
| a9b2860 | | | 41 | |
| a9b2860 | | | 42 | const base = (name: string, val: string) => |
| a9b2860 | | | 43 | `${name}=${encodeURIComponent(val)}; path=/; SameSite=Lax; max-age=${maxAge}${secure}`; |
| a9b2860 | | | 44 | |
| a9b2860 | | | 45 | document.cookie = base("grove_hub_token", token); |
| a9b2860 | | | 46 | document.cookie = base("grove_hub_user", JSON.stringify(user)); |
| a9b2860 | | | 47 | for (const domain of domains) { |
| a9b2860 | | | 48 | document.cookie = `${base("grove_hub_token", token)}; domain=${domain}`; |
| a9b2860 | | | 49 | document.cookie = `${base("grove_hub_user", JSON.stringify(user))}; domain=${domain}`; |
| a9b2860 | | | 50 | } |
| a9b2860 | | | 51 | |
| a9b2860 | | | 52 | window.dispatchEvent(new CustomEvent("grove:token-refreshed", { detail: { token, user } })); |
| a9b2860 | | | 53 | } |
| a9b2860 | | | 54 | |
| a9b2860 | | | 55 | /** |
| a9b2860 | | | 56 | * Refresh the current session token. |
| a9b2860 | | | 57 | * Deduplicates concurrent calls so only one /auth/refresh request is in-flight. |
| a9b2860 | | | 58 | */ |
| a9b2860 | | | 59 | export function refreshToken(): Promise<string | null> { |
| a9b2860 | | | 60 | if (refreshInFlight) return refreshInFlight; |
| a9b2860 | | | 61 | |
| a9b2860 | | | 62 | refreshInFlight = (async () => { |
| a9b2860 | | | 63 | const currentToken = getToken(); |
| a9b2860 | | | 64 | if (!currentToken) return null; |
| a9b2860 | | | 65 | |
| a9b2860 | | | 66 | try { |
| a9b2860 | | | 67 | const res = await fetch(`${API_BASE}/auth/refresh`, { |
| a9b2860 | | | 68 | method: "POST", |
| a9b2860 | | | 69 | headers: { Authorization: `Bearer ${currentToken}` }, |
| a9b2860 | | | 70 | }); |
| a9b2860 | | | 71 | |
| a9b2860 | | | 72 | if (!res.ok) return null; |
| a9b2860 | | | 73 | |
| a9b2860 | | | 74 | const data = await res.json(); |
| a9b2860 | | | 75 | if (!data.token) return null; |
| a9b2860 | | | 76 | |
| a9b2860 | | | 77 | persistToken(data.token, data.user); |
| a9b2860 | | | 78 | return data.token as string; |
| a9b2860 | | | 79 | } catch { |
| a9b2860 | | | 80 | return null; |
| a9b2860 | | | 81 | } finally { |
| a9b2860 | | | 82 | refreshInFlight = null; |
| a9b2860 | | | 83 | } |
| a9b2860 | | | 84 | })(); |
| a9b2860 | | | 85 | |
| a9b2860 | | | 86 | return refreshInFlight; |
| a9b2860 | | | 87 | } |
| a9b2860 | | | 88 | |
| a9b2860 | | | 89 | // ── Core fetch ──────────────────────────────────────────────────── |
| a9b2860 | | | 90 | |
| 3c994d3 | | | 91 | async function fetchApi<T>( |
| 3c994d3 | | | 92 | base: string, |
| 3e3af55 | | | 93 | path: string, |
| 3e3af55 | | | 94 | options: RequestInit = {} |
| 3e3af55 | | | 95 | ): Promise<T> { |
| 3c994d3 | | | 96 | const token = getToken(); |
| 3e3af55 | | | 97 | |
| ab61b9d | | | 98 | const headers: Record<string, string> = { |
| ab61b9d | | | 99 | ...(token ? { Authorization: `Bearer ${token}` } : {}), |
| ab61b9d | | | 100 | }; |
| ab61b9d | | | 101 | if (options.body) { |
| ab61b9d | | | 102 | headers["Content-Type"] = "application/json"; |
| ab61b9d | | | 103 | } |
| ab61b9d | | | 104 | |
| 3c994d3 | | | 105 | const res = await fetch(`${base}${path}`, { |
| 3e3af55 | | | 106 | ...options, |
| 3e3af55 | | | 107 | headers: { |
| ab61b9d | | | 108 | ...headers, |
| 3e3af55 | | | 109 | ...options.headers, |
| 3e3af55 | | | 110 | }, |
| 3e3af55 | | | 111 | }); |
| 3e3af55 | | | 112 | |
| 3e3af55 | | | 113 | if (!res.ok) { |
| a9b2860 | | | 114 | // On 401, attempt a single token refresh and retry |
| a9b2860 | | | 115 | if (res.status === 401 && token && !path.startsWith("/auth/")) { |
| a9b2860 | | | 116 | const newToken = await refreshToken(); |
| a9b2860 | | | 117 | if (newToken) { |
| a9b2860 | | | 118 | const retryHeaders: Record<string, string> = { |
| a9b2860 | | | 119 | Authorization: `Bearer ${newToken}`, |
| a9b2860 | | | 120 | }; |
| a9b2860 | | | 121 | if (options.body) { |
| a9b2860 | | | 122 | retryHeaders["Content-Type"] = "application/json"; |
| a9b2860 | | | 123 | } |
| a9b2860 | | | 124 | |
| a9b2860 | | | 125 | const retryRes = await fetch(`${base}${path}`, { |
| a9b2860 | | | 126 | ...options, |
| a9b2860 | | | 127 | headers: { |
| a9b2860 | | | 128 | ...retryHeaders, |
| a9b2860 | | | 129 | ...options.headers, |
| a9b2860 | | | 130 | }, |
| a9b2860 | | | 131 | }); |
| a9b2860 | | | 132 | |
| a9b2860 | | | 133 | if (!retryRes.ok) { |
| a9b2860 | | | 134 | const body = await retryRes.json().catch(() => ({})); |
| a9b2860 | | | 135 | throw new ApiError(retryRes.status, body.error ?? "Request failed"); |
| a9b2860 | | | 136 | } |
| a9b2860 | | | 137 | |
| a9b2860 | | | 138 | if (retryRes.status === 204) return null as T; |
| a9b2860 | | | 139 | return retryRes.json(); |
| a9b2860 | | | 140 | } |
| a9b2860 | | | 141 | } |
| a9b2860 | | | 142 | |
| 3e3af55 | | | 143 | const body = await res.json().catch(() => ({})); |
| 3e3af55 | | | 144 | throw new ApiError(res.status, body.error ?? "Request failed"); |
| 3e3af55 | | | 145 | } |
| 3e3af55 | | | 146 | |
| 4a006da | | | 147 | if (res.status === 204) return null as T; |
| 3e3af55 | | | 148 | return res.json(); |
| 3e3af55 | | | 149 | } |
| 3e3af55 | | | 150 | |
| f0bb192 | | | 151 | function apiRequest<T>(path: string, options?: RequestInit): Promise<T> { |
| f0bb192 | | | 152 | return fetchApi(API_BASE, path, options); |
| 3c994d3 | | | 153 | } |
| 3c994d3 | | | 154 | |
| 3e3af55 | | | 155 | export class ApiError extends Error { |
| 3e3af55 | | | 156 | constructor( |
| 3e3af55 | | | 157 | public status: number, |
| 3e3af55 | | | 158 | message: string |
| 3e3af55 | | | 159 | ) { |
| 3e3af55 | | | 160 | super(message); |
| 3e3af55 | | | 161 | } |
| 3e3af55 | | | 162 | } |
| 3e3af55 | | | 163 | |
| f0bb192 | | | 164 | // Auth (WebAuthn passkeys) |
| 3e3af55 | | | 165 | export const auth = { |
| 4a006da | | | 166 | registerBegin: (data: { username: string; display_name?: string }) => |
| f0bb192 | | | 167 | apiRequest<{ options: any }>( |
| 4a006da | | | 168 | "/auth/register/begin", |
| 4a006da | | | 169 | { method: "POST", body: JSON.stringify(data) } |
| 4a006da | | | 170 | ), |
| 4a006da | | | 171 | |
| 4a006da | | | 172 | registerComplete: (data: { response: unknown; challenge: string }) => |
| f0bb192 | | | 173 | apiRequest<{ token: string; user: User }>("/auth/register/complete", { |
| 4a006da | | | 174 | method: "POST", |
| 4a006da | | | 175 | body: JSON.stringify(data), |
| 4a006da | | | 176 | }), |
| 4a006da | | | 177 | |
| 4a006da | | | 178 | loginBegin: (data?: { username?: string }) => |
| f0bb192 | | | 179 | apiRequest<{ options: any }>( |
| 4a006da | | | 180 | "/auth/login/begin", |
| 4a006da | | | 181 | { method: "POST", body: JSON.stringify(data ?? {}) } |
| 4a006da | | | 182 | ), |
| 4a006da | | | 183 | |
| 4a006da | | | 184 | loginComplete: (data: { response: unknown; challenge: string }) => |
| f0bb192 | | | 185 | apiRequest<{ token: string; user: User }>("/auth/login/complete", { |
| 3e3af55 | | | 186 | method: "POST", |
| 3e3af55 | | | 187 | body: JSON.stringify(data), |
| 3e3af55 | | | 188 | }), |
| 3e3af55 | | | 189 | |
| f0bb192 | | | 190 | me: () => apiRequest<{ user: User }>("/auth/me"), |
| 3c994d3 | | | 191 | |
| 3c994d3 | | | 192 | // Personal Access Tokens |
| 3c994d3 | | | 193 | listTokens: () => |
| f0bb192 | | | 194 | apiRequest<{ tokens: ApiToken[] }>("/auth/tokens"), |
| 3c994d3 | | | 195 | |
| 3c994d3 | | | 196 | createToken: (data: { name: string; expires_in?: "30d" | "90d" | "1y" }) => |
| f0bb192 | | | 197 | apiRequest<{ token: string; api_token: ApiToken }>("/auth/tokens", { |
| 3c994d3 | | | 198 | method: "POST", |
| 3c994d3 | | | 199 | body: JSON.stringify(data), |
| 3c994d3 | | | 200 | }), |
| 3c994d3 | | | 201 | |
| 3c994d3 | | | 202 | deleteToken: (id: number) => |
| f0bb192 | | | 203 | apiRequest<void>(`/auth/tokens/${id}`, { method: "DELETE" }), |
| 3e3af55 | | | 204 | }; |
| 3e3af55 | | | 205 | |
| f0bb192 | | | 206 | // Instances |
| 4a006da | | | 207 | export const instances = { |
| f0bb192 | | | 208 | list: () => apiRequest<{ instances: Instance[] }>("/instances"), |
| 4a006da | | | 209 | |
| 966d71f | | | 210 | create: (data: { name?: string; domain?: string }) => |
| f0bb192 | | | 211 | apiRequest<{ instance: Instance }>("/instances", { |
| 4a006da | | | 212 | method: "POST", |
| 4a006da | | | 213 | body: JSON.stringify(data), |
| 4a006da | | | 214 | }), |
| 4a006da | | | 215 | |
| 4a006da | | | 216 | update: (id: number, data: { status?: string; ip?: string }) => |
| f0bb192 | | | 217 | apiRequest<{ instance: Instance }>(`/instances/${id}`, { |
| 4a006da | | | 218 | method: "PATCH", |
| 4a006da | | | 219 | body: JSON.stringify(data), |
| 4a006da | | | 220 | }), |
| 4a006da | | | 221 | |
| 4a006da | | | 222 | ready: (id: number, data?: { ip?: string; token?: string }) => |
| f0bb192 | | | 223 | apiRequest<{ instance: Instance }>(`/instances/${id}/ready`, { |
| 4a006da | | | 224 | method: "POST", |
| 4a006da | | | 225 | body: JSON.stringify(data ?? {}), |
| 4a006da | | | 226 | }), |
| 4a006da | | | 227 | |
| 4a006da | | | 228 | delete: (id: number) => |
| f0bb192 | | | 229 | apiRequest<void>(`/instances/${id}`, { method: "DELETE" }), |
| 4a006da | | | 230 | }; |
| 4a006da | | | 231 | |
| 79efd41 | | | 232 | // Organizations |
| 79efd41 | | | 233 | export const orgs = { |
| 79efd41 | | | 234 | list: () => apiRequest<{ orgs: Org[] }>("/orgs"), |
| 79efd41 | | | 235 | |
| 79efd41 | | | 236 | get: (name: string) => |
| 79efd41 | | | 237 | apiRequest<{ org: Org; members: OrgMember[] }>(`/orgs/${name}`), |
| 79efd41 | | | 238 | |
| 79efd41 | | | 239 | create: (data: { name: string; display_name?: string }) => |
| 79efd41 | | | 240 | apiRequest<{ org: Org }>("/orgs", { |
| 79efd41 | | | 241 | method: "POST", |
| 79efd41 | | | 242 | body: JSON.stringify(data), |
| 79efd41 | | | 243 | }), |
| 79efd41 | | | 244 | |
| 79efd41 | | | 245 | addMember: (orgName: string, data: { username: string }) => |
| 79efd41 | | | 246 | apiRequest<{ member: OrgMember }>(`/orgs/${orgName}/members`, { |
| 79efd41 | | | 247 | method: "POST", |
| 79efd41 | | | 248 | body: JSON.stringify(data), |
| 79efd41 | | | 249 | }), |
| 79efd41 | | | 250 | |
| 79efd41 | | | 251 | removeMember: (orgName: string, username: string) => |
| 79efd41 | | | 252 | apiRequest<void>(`/orgs/${orgName}/members/${username}`, { |
| 79efd41 | | | 253 | method: "DELETE", |
| 79efd41 | | | 254 | }), |
| 79efd41 | | | 255 | |
| 79efd41 | | | 256 | delete: (name: string) => |
| 79efd41 | | | 257 | apiRequest<void>(`/orgs/${name}`, { method: "DELETE" }), |
| 79efd41 | | | 258 | }; |
| 79efd41 | | | 259 | |
| f0bb192 | | | 260 | // Repos |
| 3e3af55 | | | 261 | export const repos = { |
| f0bb192 | | | 262 | list: () => apiRequest<{ repos: Repo[] }>("/repos"), |
| 3e3af55 | | | 263 | |
| 8d8e815 | | | 264 | create: (data: { name: string; description?: string; default_branch?: string; owner?: string; is_private?: boolean }) => |
| f0bb192 | | | 265 | apiRequest<{ repo: Repo }>("/repos", { |
| 4a006da | | | 266 | method: "POST", |
| 4a006da | | | 267 | body: JSON.stringify(data), |
| 4a006da | | | 268 | }), |
| 4a006da | | | 269 | |
| 3e3af55 | | | 270 | get: (owner: string, repo: string) => |
| f0bb192 | | | 271 | apiRequest<{ repo: Repo; readme: string | null; branches: Bookmark[]; clone_url: string }>( |
| 3e3af55 | | | 272 | `/repos/${owner}/${repo}` |
| 3e3af55 | | | 273 | ), |
| 3e3af55 | | | 274 | |
| e5b523e | | | 275 | update: (owner: string, repo: string, data: { description?: string; is_private?: boolean; require_diffs?: boolean; pages_enabled?: boolean; pages_domain?: string | null }) => |
| 8d8e815 | | | 276 | apiRequest<{ repo: Repo }>(`/repos/${owner}/${repo}`, { |
| 8d8e815 | | | 277 | method: "PATCH", |
| 8d8e815 | | | 278 | body: JSON.stringify(data), |
| 8d8e815 | | | 279 | }), |
| 8d8e815 | | | 280 | |
| ab61b9d | | | 281 | delete: (owner: string, repo: string) => |
| ab61b9d | | | 282 | apiRequest<void>(`/repos/${owner}/${repo}`, { method: "DELETE" }), |
| ab61b9d | | | 283 | |
| 3e3af55 | | | 284 | tree: (owner: string, repo: string, ref: string, path: string = "") => |
| f0bb192 | | | 285 | apiRequest<{ path: string; ref: string; entries: TreeEntry[] }>( |
| 80fafdf | | | 286 | `/repos/${owner}/${repo}/tree/${ref}${path ? `/${path}` : ""}` |
| 3e3af55 | | | 287 | ), |
| 3e3af55 | | | 288 | |
| 3e3af55 | | | 289 | blob: (owner: string, repo: string, ref: string, path: string) => |
| f0bb192 | | | 290 | apiRequest<{ path: string; ref: string; content: string; size: number }>( |
| 80fafdf | | | 291 | `/repos/${owner}/${repo}/blob/${ref}/${path}` |
| 3e3af55 | | | 292 | ), |
| 3e3af55 | | | 293 | |
| 3e3af55 | | | 294 | commits: ( |
| 3e3af55 | | | 295 | owner: string, |
| 3e3af55 | | | 296 | repo: string, |
| 3e3af55 | | | 297 | ref: string, |
| 4a006da | | | 298 | options?: { limit?: number } |
| 3e3af55 | | | 299 | ) => |
| f0bb192 | | | 300 | apiRequest<{ ref: string; commits: BridgeCommit[] }>( |
| 80fafdf | | | 301 | `/repos/${owner}/${repo}/commits/${ref}?limit=${options?.limit ?? 30}` |
| 3e3af55 | | | 302 | ), |
| 3e3af55 | | | 303 | |
| 3e3af55 | | | 304 | blame: (owner: string, repo: string, ref: string, path: string) => |
| f0bb192 | | | 305 | apiRequest<{ path: string; ref: string; blame: BlameLine[] }>( |
| 80fafdf | | | 306 | `/repos/${owner}/${repo}/blame/${ref}/${path}` |
| 3e3af55 | | | 307 | ), |
| 3e3af55 | | | 308 | |
| bf5fc33 | | | 309 | bookmarks: (owner: string, repo: string) => |
| f0bb192 | | | 310 | apiRequest<{ bookmarks: Bookmark[] }>(`/repos/${owner}/${repo}/branches`), |
| 3e3af55 | | | 311 | |
| 3e3af55 | | | 312 | diff: (owner: string, repo: string, base: string, head: string) => |
| f0bb192 | | | 313 | apiRequest<{ diffs: DiffFile[] }>( |
| 80fafdf | | | 314 | `/repos/${owner}/${repo}/diff?base=${base}&head=${head}` |
| 3e3af55 | | | 315 | ), |
| 3e3af55 | | | 316 | }; |
| 3e3af55 | | | 317 | |
| d12933e | | | 318 | // Diffs |
| d12933e | | | 319 | export const diffs = { |
| 3e3af55 | | | 320 | list: (owner: string, repo: string, status: string = "open") => |
| d12933e | | | 321 | apiRequest<{ diffs: Diff[] }>( |
| d12933e | | | 322 | `/repos/${owner}/${repo}/diffs?status=${status}` |
| 3e3af55 | | | 323 | ), |
| 3e3af55 | | | 324 | |
| 3e3af55 | | | 325 | get: (owner: string, repo: string, number: number) => |
| f0bb192 | | | 326 | apiRequest<{ |
| d12933e | | | 327 | diff: Diff; |
| 3e3af55 | | | 328 | comments: Comment[]; |
| 3e3af55 | | | 329 | reviews: Review[]; |
| d12933e | | | 330 | }>(`/repos/${owner}/${repo}/diffs/${number}`), |
| 3e3af55 | | | 331 | |
| 3e3af55 | | | 332 | create: ( |
| 3e3af55 | | | 333 | owner: string, |
| 3e3af55 | | | 334 | repo: string, |
| 2ec6868 | | | 335 | data: { title: string; head_commit: string; description?: string; base_commit?: string } |
| 3e3af55 | | | 336 | ) => |
| d12933e | | | 337 | apiRequest<{ diff: Diff }>( |
| d12933e | | | 338 | `/repos/${owner}/${repo}/diffs`, |
| 3e3af55 | | | 339 | { method: "POST", body: JSON.stringify(data) } |
| 3e3af55 | | | 340 | ), |
| 3e3af55 | | | 341 | |
| 3e3af55 | | | 342 | update: ( |
| 3e3af55 | | | 343 | owner: string, |
| 3e3af55 | | | 344 | repo: string, |
| 3e3af55 | | | 345 | number: number, |
| 2ec6868 | | | 346 | data: { title?: string; description?: string; status?: string; head_commit?: string } |
| 3e3af55 | | | 347 | ) => |
| d12933e | | | 348 | apiRequest<{ diff: Diff }>( |
| d12933e | | | 349 | `/repos/${owner}/${repo}/diffs/${number}`, |
| 3e3af55 | | | 350 | { method: "PATCH", body: JSON.stringify(data) } |
| 3e3af55 | | | 351 | ), |
| 3e3af55 | | | 352 | |
| 2ec6868 | | | 353 | land: (owner: string, repo: string, number: number) => |
| d12933e | | | 354 | apiRequest<{ diff: Diff }>( |
| 2ec6868 | | | 355 | `/repos/${owner}/${repo}/diffs/${number}/land`, |
| 3e3af55 | | | 356 | { method: "POST" } |
| 3e3af55 | | | 357 | ), |
| 3e3af55 | | | 358 | |
| 3e3af55 | | | 359 | comment: ( |
| 3e3af55 | | | 360 | owner: string, |
| 3e3af55 | | | 361 | repo: string, |
| 3e3af55 | | | 362 | number: number, |
| 4a006da | | | 363 | data: { body: string; file_path?: string; line_number?: number; side?: "left" | "right"; commit_sha?: string; parent_id?: number } |
| 3e3af55 | | | 364 | ) => |
| f0bb192 | | | 365 | apiRequest<{ comment: Comment }>( |
| d12933e | | | 366 | `/repos/${owner}/${repo}/diffs/${number}/comments`, |
| 3e3af55 | | | 367 | { method: "POST", body: JSON.stringify(data) } |
| 3e3af55 | | | 368 | ), |
| 3e3af55 | | | 369 | |
| 3e3af55 | | | 370 | review: ( |
| 3e3af55 | | | 371 | owner: string, |
| 3e3af55 | | | 372 | repo: string, |
| 3e3af55 | | | 373 | number: number, |
| 3e3af55 | | | 374 | data: { status: "approved" | "changes_requested"; body?: string } |
| 3e3af55 | | | 375 | ) => |
| f0bb192 | | | 376 | apiRequest<{ review: Review }>( |
| d12933e | | | 377 | `/repos/${owner}/${repo}/diffs/${number}/reviews`, |
| 3e3af55 | | | 378 | { method: "POST", body: JSON.stringify(data) } |
| 3e3af55 | | | 379 | ), |
| 3e3af55 | | | 380 | }; |
| 3e3af55 | | | 381 | |
| 3e3af55 | | | 382 | // Types |
| 4a006da | | | 383 | |
| 3e3af55 | | | 384 | export interface User { |
| 3e3af55 | | | 385 | id: number; |
| 3e3af55 | | | 386 | username: string; |
| 3e3af55 | | | 387 | display_name: string; |
| 4a006da | | | 388 | created_at?: string; |
| 4a006da | | | 389 | } |
| 4a006da | | | 390 | |
| 3c994d3 | | | 391 | export interface ApiToken { |
| 3c994d3 | | | 392 | id: number; |
| 3c994d3 | | | 393 | name: string; |
| 3c994d3 | | | 394 | expires_at: string; |
| 3c994d3 | | | 395 | last_used_at: string | null; |
| 3c994d3 | | | 396 | created_at: string; |
| 3c994d3 | | | 397 | } |
| 3c994d3 | | | 398 | |
| 79efd41 | | | 399 | export interface Org { |
| 79efd41 | | | 400 | id: number; |
| 79efd41 | | | 401 | name: string; |
| 79efd41 | | | 402 | display_name: string; |
| 79efd41 | | | 403 | created_by: number; |
| 79efd41 | | | 404 | created_at: string; |
| 79efd41 | | | 405 | updated_at: string; |
| 79efd41 | | | 406 | } |
| 79efd41 | | | 407 | |
| 79efd41 | | | 408 | export interface OrgMember { |
| 79efd41 | | | 409 | user_id: number; |
| 79efd41 | | | 410 | username: string; |
| 79efd41 | | | 411 | display_name: string; |
| 79efd41 | | | 412 | created_at: string; |
| 79efd41 | | | 413 | } |
| 79efd41 | | | 414 | |
| 4a006da | | | 415 | export interface Instance { |
| 4a006da | | | 416 | id: number; |
| 4a006da | | | 417 | user_id: number; |
| 4a006da | | | 418 | name: string; |
| 4a006da | | | 419 | ip: string | null; |
| 4a006da | | | 420 | domain: string | null; |
| 4a006da | | | 421 | region: string; |
| 4a006da | | | 422 | size: string; |
| 4a006da | | | 423 | status: "creating" | "active" | "error" | "destroyed"; |
| 4a006da | | | 424 | created_at: string; |
| 4a006da | | | 425 | updated_at: string; |
| 3e3af55 | | | 426 | } |
| 3e3af55 | | | 427 | |
| 3e3af55 | | | 428 | export interface Repo { |
| 3e3af55 | | | 429 | id: number; |
| 3e3af55 | | | 430 | owner_id: number; |
| 3e3af55 | | | 431 | owner_name: string; |
| 79efd41 | | | 432 | owner_type: "user" | "org"; |
| 3e3af55 | | | 433 | name: string; |
| 3e3af55 | | | 434 | description: string | null; |
| 3e3af55 | | | 435 | default_branch: string; |
| 3e3af55 | | | 436 | is_private: boolean; |
| 8d8e815 | | | 437 | require_diffs: boolean; |
| e5b523e | | | 438 | pages_enabled: boolean; |
| e5b523e | | | 439 | pages_domain: string | null; |
| 3e3af55 | | | 440 | created_at: string; |
| 3e3af55 | | | 441 | updated_at: string; |
| bc2f205 | | | 442 | last_commit_ts?: number | null; |
| 3e3af55 | | | 443 | } |
| 3e3af55 | | | 444 | |
| 3e3af55 | | | 445 | export interface TreeEntry { |
| 3e3af55 | | | 446 | name: string; |
| bf5fc33 | | | 447 | type: "file" | "tree"; |
| 3e3af55 | | | 448 | } |
| 3e3af55 | | | 449 | |
| bf5fc33 | | | 450 | export interface BridgeCommit { |
| 3e3af55 | | | 451 | hash: string; |
| bf5fc33 | | | 452 | git_sha: string | null; |
| 3e3af55 | | | 453 | author: string; |
| 3e3af55 | | | 454 | timestamp: number; |
| bf5fc33 | | | 455 | message: string; |
| 3e3af55 | | | 456 | parents: string[]; |
| 3e3af55 | | | 457 | } |
| 3e3af55 | | | 458 | |
| 3e3af55 | | | 459 | export interface BlameLine { |
| 3e3af55 | | | 460 | hash: string; |
| bf5fc33 | | | 461 | original_line: number; |
| 3e3af55 | | | 462 | author: string; |
| 3e3af55 | | | 463 | timestamp: number; |
| 3e3af55 | | | 464 | content: string; |
| 3e3af55 | | | 465 | } |
| 3e3af55 | | | 466 | |
| bf5fc33 | | | 467 | export interface Bookmark { |
| 3e3af55 | | | 468 | name: string; |
| bf5fc33 | | | 469 | commit_id: string; |
| 3e3af55 | | | 470 | } |
| 3e3af55 | | | 471 | |
| d12933e | | | 472 | export interface Diff { |
| 3e3af55 | | | 473 | id: number; |
| 3e3af55 | | | 474 | repo_id: number; |
| 3e3af55 | | | 475 | number: number; |
| 3e3af55 | | | 476 | title: string; |
| 3e3af55 | | | 477 | description: string; |
| 3e3af55 | | | 478 | author_id: number; |
| 3e3af55 | | | 479 | author_name: string; |
| 4a006da | | | 480 | author_display_name?: string; |
| 2ec6868 | | | 481 | head_commit: string; |
| 2ec6868 | | | 482 | base_commit: string | null; |
| 2ec6868 | | | 483 | status: "open" | "landed" | "closed"; |
| 3e3af55 | | | 484 | created_at: string; |
| 3e3af55 | | | 485 | updated_at: string; |
| 2ec6868 | | | 486 | landed_at: string | null; |
| 2ec6868 | | | 487 | landed_by: number | null; |
| 3e3af55 | | | 488 | } |
| 3e3af55 | | | 489 | |
| 3e3af55 | | | 490 | export interface Comment { |
| 3e3af55 | | | 491 | id: number; |
| d12933e | | | 492 | diff_id: number; |
| 3e3af55 | | | 493 | author_id: number; |
| 3e3af55 | | | 494 | author_name: string; |
| 4a006da | | | 495 | author_display_name?: string; |
| 3e3af55 | | | 496 | body: string; |
| 3e3af55 | | | 497 | file_path: string | null; |
| 3e3af55 | | | 498 | line_number: number | null; |
| 3e3af55 | | | 499 | side: "left" | "right" | null; |
| 3e3af55 | | | 500 | commit_sha: string | null; |
| 3e3af55 | | | 501 | parent_id: number | null; |
| 3e3af55 | | | 502 | created_at: string; |
| 3e3af55 | | | 503 | } |
| 3e3af55 | | | 504 | |
| 3e3af55 | | | 505 | export interface Review { |
| 3e3af55 | | | 506 | id: number; |
| d12933e | | | 507 | diff_id: number; |
| 3e3af55 | | | 508 | reviewer_id: number; |
| 3e3af55 | | | 509 | reviewer_name: string; |
| 4a006da | | | 510 | reviewer_display_name?: string; |
| 3e3af55 | | | 511 | status: "pending" | "approved" | "changes_requested"; |
| 3e3af55 | | | 512 | body: string | null; |
| 3e3af55 | | | 513 | created_at: string; |
| 3e3af55 | | | 514 | } |
| 12ffdd4 | | | 515 | |
| 12ffdd4 | | | 516 | export interface DiffFile { |
| 12ffdd4 | | | 517 | path: string; |
| 12ffdd4 | | | 518 | diff: string; |
| 12ffdd4 | | | 519 | is_binary: boolean; |
| 12ffdd4 | | | 520 | } |
| 80fafdf | | | 521 | |
| 80fafdf | | | 522 | // Canopy CI/CD |
| 80fafdf | | | 523 | |
| 80fafdf | | | 524 | export interface PipelineRun { |
| 80fafdf | | | 525 | id: number; |
| 80fafdf | | | 526 | repo_id: number; |
| 80fafdf | | | 527 | pipeline_name: string; |
| 80fafdf | | | 528 | pipeline_file: string; |
| d12933e | | | 529 | trigger_type: "push" | "diff" | "manual" | "schedule"; |
| 80fafdf | | | 530 | trigger_ref: string | null; |
| 80fafdf | | | 531 | commit_id: string | null; |
| da0f651 | | | 532 | commit_message: string | null; |
| 80fafdf | | | 533 | status: "pending" | "running" | "passed" | "failed" | "cancelled"; |
| 80fafdf | | | 534 | started_at: string | null; |
| 80fafdf | | | 535 | finished_at: string | null; |
| 80fafdf | | | 536 | duration_ms: number | null; |
| 80fafdf | | | 537 | created_at: string; |
| 80fafdf | | | 538 | } |
| 80fafdf | | | 539 | |
| 80fafdf | | | 540 | export interface PipelineStep { |
| 80fafdf | | | 541 | id: number; |
| 80fafdf | | | 542 | run_id: number; |
| 80fafdf | | | 543 | step_index: number; |
| 80fafdf | | | 544 | name: string; |
| 80fafdf | | | 545 | image: string; |
| 80fafdf | | | 546 | status: "pending" | "running" | "passed" | "failed" | "skipped"; |
| 80fafdf | | | 547 | exit_code: number | null; |
| 80fafdf | | | 548 | started_at: string | null; |
| 80fafdf | | | 549 | finished_at: string | null; |
| 80fafdf | | | 550 | duration_ms: number | null; |
| 80fafdf | | | 551 | } |
| 80fafdf | | | 552 | |
| 80fafdf | | | 553 | export interface StepLog { |
| 80fafdf | | | 554 | stream: "stdout" | "stderr"; |
| 80fafdf | | | 555 | content: string; |
| 80fafdf | | | 556 | created_at: string; |
| 80fafdf | | | 557 | } |
| 80fafdf | | | 558 | |
| 80fafdf | | | 559 | export const canopy = { |
| 80fafdf | | | 560 | listRuns: ( |
| 80fafdf | | | 561 | owner: string, |
| 80fafdf | | | 562 | repo: string, |
| 80fafdf | | | 563 | options?: { status?: string; limit?: number } |
| 80fafdf | | | 564 | ) => |
| f0bb192 | | | 565 | apiRequest<{ runs: PipelineRun[] }>( |
| 80fafdf | | | 566 | `/repos/${owner}/${repo}/canopy/runs?${new URLSearchParams({ |
| 80fafdf | | | 567 | ...(options?.status ? { status: options.status } : {}), |
| 80fafdf | | | 568 | limit: String(options?.limit ?? 20), |
| 73fdc9e | | | 569 | })}`, |
| 73fdc9e | | | 570 | { cache: "no-store" } |
| 80fafdf | | | 571 | ), |
| 80fafdf | | | 572 | |
| 80fafdf | | | 573 | getRun: (owner: string, repo: string, runId: number) => |
| f0bb192 | | | 574 | apiRequest<{ run: PipelineRun; steps: PipelineStep[] }>( |
| 73fdc9e | | | 575 | `/repos/${owner}/${repo}/canopy/runs/${runId}`, |
| 73fdc9e | | | 576 | { cache: "no-store" } |
| 80fafdf | | | 577 | ), |
| 80fafdf | | | 578 | |
| 80fafdf | | | 579 | getStepLogs: ( |
| 80fafdf | | | 580 | owner: string, |
| 80fafdf | | | 581 | repo: string, |
| 80fafdf | | | 582 | runId: number, |
| 80fafdf | | | 583 | stepIndex: number |
| 80fafdf | | | 584 | ) => |
| f0bb192 | | | 585 | apiRequest<{ logs: StepLog[] }>( |
| 73fdc9e | | | 586 | `/repos/${owner}/${repo}/canopy/runs/${runId}/logs/${stepIndex}`, |
| 73fdc9e | | | 587 | { cache: "no-store" } |
| 80fafdf | | | 588 | ), |
| 80fafdf | | | 589 | |
| 80fafdf | | | 590 | cancelRun: (owner: string, repo: string, runId: number) => |
| f0bb192 | | | 591 | apiRequest<{ run: PipelineRun }>( |
| 80fafdf | | | 592 | `/repos/${owner}/${repo}/canopy/runs/${runId}/cancel`, |
| 80fafdf | | | 593 | { method: "POST" } |
| 80fafdf | | | 594 | ), |
| 80fafdf | | | 595 | |
| 80fafdf | | | 596 | triggerRun: (owner: string, repo: string, data?: { ref?: string }) => |
| f0bb192 | | | 597 | apiRequest<{ triggered: boolean; branch: string; commit_id: string }>( |
| 80fafdf | | | 598 | `/repos/${owner}/${repo}/canopy/trigger`, |
| 80fafdf | | | 599 | { method: "POST", body: JSON.stringify(data ?? {}) } |
| 80fafdf | | | 600 | ), |
| 80fafdf | | | 601 | |
| 80fafdf | | | 602 | listSecrets: (owner: string, repo: string) => |
| f0bb192 | | | 603 | apiRequest<{ |
| 80fafdf | | | 604 | secrets: Array<{ name: string; created_at: string; updated_at: string }>; |
| 80fafdf | | | 605 | }>(`/repos/${owner}/${repo}/canopy/secrets`), |
| 80fafdf | | | 606 | |
| 80fafdf | | | 607 | createSecret: ( |
| 80fafdf | | | 608 | owner: string, |
| 80fafdf | | | 609 | repo: string, |
| 80fafdf | | | 610 | data: { name: string; value: string } |
| 80fafdf | | | 611 | ) => |
| f0bb192 | | | 612 | apiRequest<{ ok: true }>(`/repos/${owner}/${repo}/canopy/secrets`, { |
| 80fafdf | | | 613 | method: "POST", |
| 80fafdf | | | 614 | body: JSON.stringify(data), |
| 80fafdf | | | 615 | }), |
| 80fafdf | | | 616 | |
| 80fafdf | | | 617 | deleteSecret: (owner: string, repo: string, name: string) => |
| f0bb192 | | | 618 | apiRequest<void>(`/repos/${owner}/${repo}/canopy/secrets/${name}`, { |
| 80fafdf | | | 619 | method: "DELETE", |
| 80fafdf | | | 620 | }), |
| d1cff70 | | | 621 | |
| f4e5cf1 | | | 622 | recentRuns: (options?: { limit?: number; owner?: string }) => { |
| f4e5cf1 | | | 623 | const params: Record<string, string> = { limit: String(options?.limit ?? 20) }; |
| f4e5cf1 | | | 624 | if (options?.owner) params.owner = options.owner; |
| f4e5cf1 | | | 625 | return apiRequest<{ runs: Array<PipelineRun & { repo_name: string; owner_name: string }> }>( |
| f4e5cf1 | | | 626 | `/canopy/recent-runs?${new URLSearchParams(params)}`, |
| d1cff70 | | | 627 | { cache: "no-store" } |
| f4e5cf1 | | | 628 | ); |
| f4e5cf1 | | | 629 | }, |
| 80fafdf | | | 630 | }; |
| d9e3295 | | | 631 | |
| 3cbdca6 | | | 632 | export interface RingLogEntry { |
| 3cbdca6 | | | 633 | ts: string; |
| 3cbdca6 | | | 634 | source: string; |
| 3cbdca6 | | | 635 | level: string; |
| 3cbdca6 | | | 636 | message: string; |
| 3cbdca6 | | | 637 | payload: unknown; |
| 3cbdca6 | | | 638 | owner?: string; |
| 3cbdca6 | | | 639 | repo?: string; |
| 3cbdca6 | | | 640 | } |
| 3cbdca6 | | | 641 | |
| 3cbdca6 | | | 642 | export interface RingInstanceSummary { |
| 3cbdca6 | | | 643 | owner: string; |
| 3cbdca6 | | | 644 | repo: string; |
| 3cbdca6 | | | 645 | total: number; |
| 3cbdca6 | | | 646 | last_ts: string | null; |
| 3cbdca6 | | | 647 | last_level: string | null; |
| 3cbdca6 | | | 648 | last_message: string | null; |
| 3cbdca6 | | | 649 | } |
| 3cbdca6 | | | 650 | |
| 0b4b582 | | | 651 | // ── Collab (diagrams / notes) ── |
| 0b4b582 | | | 652 | |
| 0b4b582 | | | 653 | export interface CollabDiagram { |
| 0b4b582 | | | 654 | id: string; |
| 0b4b582 | | | 655 | title: string; |
| 0b4b582 | | | 656 | section?: string; |
| 0b4b582 | | | 657 | code: string; |
| 0b4b582 | | | 658 | } |
| 0b4b582 | | | 659 | |
| 0b4b582 | | | 660 | export interface CollabNote { |
| 0b4b582 | | | 661 | id: string; |
| 0b4b582 | | | 662 | diagramId: string; |
| 0b4b582 | | | 663 | diagramTitle?: string; |
| 0b4b582 | | | 664 | author: string; |
| 0b4b582 | | | 665 | text: string; |
| 0b4b582 | | | 666 | x?: number; |
| 0b4b582 | | | 667 | y?: number; |
| 0b4b582 | | | 668 | targetNode?: string | null; |
| 0b4b582 | | | 669 | timestamp: string; |
| 0b4b582 | | | 670 | editedAt?: string; |
| 0b4b582 | | | 671 | } |
| 0b4b582 | | | 672 | |
| 0b4b582 | | | 673 | export const collab = { |
| 0b4b582 | | | 674 | listDiagrams: (owner: string, repo: string) => |
| 0b4b582 | | | 675 | fetchApi<{ sections: string[]; diagrams: CollabDiagram[] }>( |
| 0b4b582 | | | 676 | API_BASE, |
| 0b4b582 | | | 677 | `/collab/repos/${owner}/${repo}/diagrams` |
| 0b4b582 | | | 678 | ), |
| 0b4b582 | | | 679 | |
| 0b4b582 | | | 680 | listNotes: (owner: string, repo: string) => |
| 0b4b582 | | | 681 | fetchApi<Record<string, CollabNote[]>>( |
| 0b4b582 | | | 682 | API_BASE, |
| 0b4b582 | | | 683 | `/collab/repos/${owner}/${repo}/notes` |
| 0b4b582 | | | 684 | ), |
| 0b4b582 | | | 685 | |
| 0b4b582 | | | 686 | exportNotes: async (owner: string, repo: string): Promise<string> => { |
| 0b4b582 | | | 687 | const token = getToken(); |
| 0b4b582 | | | 688 | const headers: Record<string, string> = token |
| 0b4b582 | | | 689 | ? { Authorization: `Bearer ${token}` } |
| 0b4b582 | | | 690 | : {}; |
| 0b4b582 | | | 691 | const res = await fetch( |
| 0b4b582 | | | 692 | `${API_BASE}/collab/repos/${owner}/${repo}/notes/llm`, |
| 0b4b582 | | | 693 | { headers } |
| 0b4b582 | | | 694 | ); |
| 0b4b582 | | | 695 | if (!res.ok) throw new ApiError(res.status, "Export failed"); |
| 0b4b582 | | | 696 | return res.text(); |
| 0b4b582 | | | 697 | }, |
| 0b4b582 | | | 698 | }; |
| 0b4b582 | | | 699 | |
| 3cbdca6 | | | 700 | export const ring = { |
| 3cbdca6 | | | 701 | listInstances: () => |
| 3cbdca6 | | | 702 | apiRequest<{ instances: RingInstanceSummary[] }>(`/ring/instances`), |
| 3cbdca6 | | | 703 | |
| 3cbdca6 | | | 704 | listLogs: (options?: { limit?: number }) => |
| 3cbdca6 | | | 705 | apiRequest<{ entries: RingLogEntry[]; total: number }>( |
| 3cbdca6 | | | 706 | `/ring/logs?${new URLSearchParams({ |
| 3cbdca6 | | | 707 | limit: String(options?.limit ?? 200), |
| 3cbdca6 | | | 708 | })}` |
| 3cbdca6 | | | 709 | ), |
| 3cbdca6 | | | 710 | |
| 3cbdca6 | | | 711 | listRepoLogs: (owner: string, repo: string, options?: { limit?: number }) => |
| 3cbdca6 | | | 712 | apiRequest<{ entries: RingLogEntry[]; total: number }>( |
| 3cbdca6 | | | 713 | `/repos/${owner}/${repo}/ring/logs?${new URLSearchParams({ |
| 3cbdca6 | | | 714 | limit: String(options?.limit ?? 200), |
| 3cbdca6 | | | 715 | })}` |
| 3cbdca6 | | | 716 | ), |
| 3cbdca6 | | | 717 | |
| 3cbdca6 | | | 718 | ingestLog: (data: unknown) => |
| 3cbdca6 | | | 719 | apiRequest<{ ok: true; entry: RingLogEntry }>(`/ring/logs`, { |
| 3cbdca6 | | | 720 | method: "POST", |
| 3cbdca6 | | | 721 | body: JSON.stringify(data ?? {}), |
| 3cbdca6 | | | 722 | }), |
| 3cbdca6 | | | 723 | |
| 3cbdca6 | | | 724 | ingestRepoLog: (owner: string, repo: string, data: unknown) => |
| 3cbdca6 | | | 725 | apiRequest<{ ok: true; entry: RingLogEntry }>(`/repos/${owner}/${repo}/ring/logs`, { |
| 3cbdca6 | | | 726 | method: "POST", |
| 3cbdca6 | | | 727 | body: JSON.stringify(data ?? {}), |
| 3cbdca6 | | | 728 | }), |
| 3cbdca6 | | | 729 | }; |