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