web/lib/api.tsblame
View source
3e3af551/**
3c994d32 * Grove API client — used by the Next.js frontend.
3c994d33 *
f0bb1924 * Auth and instance requests go to hub-api.
d12933e5 * Repo, diff, and Canopy requests go directly to grove-api.
f0bb1926 * In production, Caddy routes /api/* to the correct backend.
f0bb1927 * In local dev, Next.js rewrites handle the routing.
3e3af558 */
3e3af559
f0bb19210const API_BASE = "/api";
3e3af5511
3c994d312function getToken(): string | null {
3c994d313 return typeof window !== "undefined"
3c994d314 ? localStorage.getItem("grove_hub_token")
3c994d315 : null;
3c994d316}
3c994d317
a9b286018// ── Token refresh infrastructure ──────────────────────────────────
a9b286019
a9b286020let refreshInFlight: Promise<string | null> | null = null;
a9b286021
a9b286022/** Write token + user to localStorage and cross-subdomain cookies. */
a9b286023function persistToken(token: string, user: { id: number; username: string; display_name: string }) {
a9b286024 localStorage.setItem("grove_hub_token", token);
a9b286025 localStorage.setItem("grove_hub_user", JSON.stringify(user));
a9b286026
a9b286027 const hostname = window.location.hostname;
a9b286028 const secure = window.location.protocol === "https:" ? "; Secure" : "";
a9b286029 const maxAge = 60 * 60 * 24 * 30;
a9b286030
a9b286031 const domains: string[] = [];
a9b286032 if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
a9b286033 // no domain cookies for IPs
a9b286034 } else if (hostname === "localhost" || hostname.endsWith(".localhost")) {
a9b286035 domains.push("localhost", ".localhost");
a9b286036 } else {
a9b286037 const parts = hostname.split(".");
a9b286038 if (parts.length <= 2) domains.push(`.${hostname}`);
a9b286039 else domains.push(`.${parts.slice(-2).join(".")}`);
a9b286040 }
a9b286041
a9b286042 const base = (name: string, val: string) =>
a9b286043 `${name}=${encodeURIComponent(val)}; path=/; SameSite=Lax; max-age=${maxAge}${secure}`;
a9b286044
a9b286045 document.cookie = base("grove_hub_token", token);
a9b286046 document.cookie = base("grove_hub_user", JSON.stringify(user));
a9b286047 for (const domain of domains) {
a9b286048 document.cookie = `${base("grove_hub_token", token)}; domain=${domain}`;
a9b286049 document.cookie = `${base("grove_hub_user", JSON.stringify(user))}; domain=${domain}`;
a9b286050 }
a9b286051
a9b286052 window.dispatchEvent(new CustomEvent("grove:token-refreshed", { detail: { token, user } }));
a9b286053}
a9b286054
a9b286055/**
a9b286056 * Refresh the current session token.
a9b286057 * Deduplicates concurrent calls so only one /auth/refresh request is in-flight.
a9b286058 */
a9b286059export function refreshToken(): Promise<string | null> {
a9b286060 if (refreshInFlight) return refreshInFlight;
a9b286061
a9b286062 refreshInFlight = (async () => {
a9b286063 const currentToken = getToken();
a9b286064 if (!currentToken) return null;
a9b286065
a9b286066 try {
a9b286067 const res = await fetch(`${API_BASE}/auth/refresh`, {
a9b286068 method: "POST",
a9b286069 headers: { Authorization: `Bearer ${currentToken}` },
a9b286070 });
a9b286071
a9b286072 if (!res.ok) return null;
a9b286073
a9b286074 const data = await res.json();
a9b286075 if (!data.token) return null;
a9b286076
a9b286077 persistToken(data.token, data.user);
a9b286078 return data.token as string;
a9b286079 } catch {
a9b286080 return null;
a9b286081 } finally {
a9b286082 refreshInFlight = null;
a9b286083 }
a9b286084 })();
a9b286085
a9b286086 return refreshInFlight;
a9b286087}
a9b286088
a9b286089// ── Core fetch ────────────────────────────────────────────────────
a9b286090
3c994d391async function fetchApi<T>(
3c994d392 base: string,
3e3af5593 path: string,
3e3af5594 options: RequestInit = {}
3e3af5595): Promise<T> {
3c994d396 const token = getToken();
3e3af5597
ab61b9d98 const headers: Record<string, string> = {
ab61b9d99 ...(token ? { Authorization: `Bearer ${token}` } : {}),
ab61b9d100 };
ab61b9d101 if (options.body) {
ab61b9d102 headers["Content-Type"] = "application/json";
ab61b9d103 }
ab61b9d104
3c994d3105 const res = await fetch(`${base}${path}`, {
3e3af55106 ...options,
3e3af55107 headers: {
ab61b9d108 ...headers,
3e3af55109 ...options.headers,
3e3af55110 },
3e3af55111 });
3e3af55112
3e3af55113 if (!res.ok) {
a9b2860114 // On 401, attempt a single token refresh and retry
a9b2860115 if (res.status === 401 && token && !path.startsWith("/auth/")) {
a9b2860116 const newToken = await refreshToken();
a9b2860117 if (newToken) {
a9b2860118 const retryHeaders: Record<string, string> = {
a9b2860119 Authorization: `Bearer ${newToken}`,
a9b2860120 };
a9b2860121 if (options.body) {
a9b2860122 retryHeaders["Content-Type"] = "application/json";
a9b2860123 }
a9b2860124
a9b2860125 const retryRes = await fetch(`${base}${path}`, {
a9b2860126 ...options,
a9b2860127 headers: {
a9b2860128 ...retryHeaders,
a9b2860129 ...options.headers,
a9b2860130 },
a9b2860131 });
a9b2860132
a9b2860133 if (!retryRes.ok) {
a9b2860134 const body = await retryRes.json().catch(() => ({}));
a9b2860135 throw new ApiError(retryRes.status, body.error ?? "Request failed");
a9b2860136 }
a9b2860137
a9b2860138 if (retryRes.status === 204) return null as T;
a9b2860139 return retryRes.json();
a9b2860140 }
a9b2860141 }
a9b2860142
3e3af55143 const body = await res.json().catch(() => ({}));
3e3af55144 throw new ApiError(res.status, body.error ?? "Request failed");
3e3af55145 }
3e3af55146
4a006da147 if (res.status === 204) return null as T;
3e3af55148 return res.json();
3e3af55149}
3e3af55150
f0bb192151function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
f0bb192152 return fetchApi(API_BASE, path, options);
3c994d3153}
3c994d3154
3e3af55155export class ApiError extends Error {
3e3af55156 constructor(
3e3af55157 public status: number,
3e3af55158 message: string
3e3af55159 ) {
3e3af55160 super(message);
3e3af55161 }
3e3af55162}
3e3af55163
f0bb192164// Auth (WebAuthn passkeys)
3e3af55165export const auth = {
4a006da166 registerBegin: (data: { username: string; display_name?: string }) =>
f0bb192167 apiRequest<{ options: any }>(
4a006da168 "/auth/register/begin",
4a006da169 { method: "POST", body: JSON.stringify(data) }
4a006da170 ),
4a006da171
4a006da172 registerComplete: (data: { response: unknown; challenge: string }) =>
f0bb192173 apiRequest<{ token: string; user: User }>("/auth/register/complete", {
4a006da174 method: "POST",
4a006da175 body: JSON.stringify(data),
4a006da176 }),
4a006da177
4a006da178 loginBegin: (data?: { username?: string }) =>
f0bb192179 apiRequest<{ options: any }>(
4a006da180 "/auth/login/begin",
4a006da181 { method: "POST", body: JSON.stringify(data ?? {}) }
4a006da182 ),
4a006da183
4a006da184 loginComplete: (data: { response: unknown; challenge: string }) =>
f0bb192185 apiRequest<{ token: string; user: User }>("/auth/login/complete", {
3e3af55186 method: "POST",
3e3af55187 body: JSON.stringify(data),
3e3af55188 }),
3e3af55189
f0bb192190 me: () => apiRequest<{ user: User }>("/auth/me"),
3c994d3191
3c994d3192 // Personal Access Tokens
3c994d3193 listTokens: () =>
f0bb192194 apiRequest<{ tokens: ApiToken[] }>("/auth/tokens"),
3c994d3195
3c994d3196 createToken: (data: { name: string; expires_in?: "30d" | "90d" | "1y" }) =>
f0bb192197 apiRequest<{ token: string; api_token: ApiToken }>("/auth/tokens", {
3c994d3198 method: "POST",
3c994d3199 body: JSON.stringify(data),
3c994d3200 }),
3c994d3201
3c994d3202 deleteToken: (id: number) =>
f0bb192203 apiRequest<void>(`/auth/tokens/${id}`, { method: "DELETE" }),
3e3af55204};
3e3af55205
f0bb192206// Instances
4a006da207export const instances = {
f0bb192208 list: () => apiRequest<{ instances: Instance[] }>("/instances"),
4a006da209
966d71f210 create: (data: { name?: string; domain?: string }) =>
f0bb192211 apiRequest<{ instance: Instance }>("/instances", {
4a006da212 method: "POST",
4a006da213 body: JSON.stringify(data),
4a006da214 }),
4a006da215
4a006da216 update: (id: number, data: { status?: string; ip?: string }) =>
f0bb192217 apiRequest<{ instance: Instance }>(`/instances/${id}`, {
4a006da218 method: "PATCH",
4a006da219 body: JSON.stringify(data),
4a006da220 }),
4a006da221
4a006da222 ready: (id: number, data?: { ip?: string; token?: string }) =>
f0bb192223 apiRequest<{ instance: Instance }>(`/instances/${id}/ready`, {
4a006da224 method: "POST",
4a006da225 body: JSON.stringify(data ?? {}),
4a006da226 }),
4a006da227
4a006da228 delete: (id: number) =>
f0bb192229 apiRequest<void>(`/instances/${id}`, { method: "DELETE" }),
4a006da230};
4a006da231
79efd41232// Organizations
79efd41233export const orgs = {
79efd41234 list: () => apiRequest<{ orgs: Org[] }>("/orgs"),
79efd41235
79efd41236 get: (name: string) =>
79efd41237 apiRequest<{ org: Org; members: OrgMember[] }>(`/orgs/${name}`),
79efd41238
79efd41239 create: (data: { name: string; display_name?: string }) =>
79efd41240 apiRequest<{ org: Org }>("/orgs", {
79efd41241 method: "POST",
79efd41242 body: JSON.stringify(data),
79efd41243 }),
79efd41244
79efd41245 addMember: (orgName: string, data: { username: string }) =>
79efd41246 apiRequest<{ member: OrgMember }>(`/orgs/${orgName}/members`, {
79efd41247 method: "POST",
79efd41248 body: JSON.stringify(data),
79efd41249 }),
79efd41250
79efd41251 removeMember: (orgName: string, username: string) =>
79efd41252 apiRequest<void>(`/orgs/${orgName}/members/${username}`, {
79efd41253 method: "DELETE",
79efd41254 }),
79efd41255
79efd41256 delete: (name: string) =>
79efd41257 apiRequest<void>(`/orgs/${name}`, { method: "DELETE" }),
79efd41258};
79efd41259
f0bb192260// Repos
3e3af55261export const repos = {
f0bb192262 list: () => apiRequest<{ repos: Repo[] }>("/repos"),
3e3af55263
8d8e815264 create: (data: { name: string; description?: string; default_branch?: string; owner?: string; is_private?: boolean }) =>
f0bb192265 apiRequest<{ repo: Repo }>("/repos", {
4a006da266 method: "POST",
4a006da267 body: JSON.stringify(data),
4a006da268 }),
4a006da269
3e3af55270 get: (owner: string, repo: string) =>
f0bb192271 apiRequest<{ repo: Repo; readme: string | null; branches: Bookmark[]; clone_url: string }>(
3e3af55272 `/repos/${owner}/${repo}`
3e3af55273 ),
3e3af55274
e5b523e275 update: (owner: string, repo: string, data: { description?: string; is_private?: boolean; require_diffs?: boolean; pages_enabled?: boolean; pages_domain?: string | null }) =>
8d8e815276 apiRequest<{ repo: Repo }>(`/repos/${owner}/${repo}`, {
8d8e815277 method: "PATCH",
8d8e815278 body: JSON.stringify(data),
8d8e815279 }),
8d8e815280
ab61b9d281 delete: (owner: string, repo: string) =>
ab61b9d282 apiRequest<void>(`/repos/${owner}/${repo}`, { method: "DELETE" }),
ab61b9d283
3e3af55284 tree: (owner: string, repo: string, ref: string, path: string = "") =>
f0bb192285 apiRequest<{ path: string; ref: string; entries: TreeEntry[] }>(
80fafdf286 `/repos/${owner}/${repo}/tree/${ref}${path ? `/${path}` : ""}`
3e3af55287 ),
3e3af55288
3e3af55289 blob: (owner: string, repo: string, ref: string, path: string) =>
f0bb192290 apiRequest<{ path: string; ref: string; content: string; size: number }>(
80fafdf291 `/repos/${owner}/${repo}/blob/${ref}/${path}`
3e3af55292 ),
3e3af55293
3e3af55294 commits: (
3e3af55295 owner: string,
3e3af55296 repo: string,
3e3af55297 ref: string,
4a006da298 options?: { limit?: number }
3e3af55299 ) =>
f0bb192300 apiRequest<{ ref: string; commits: BridgeCommit[] }>(
80fafdf301 `/repos/${owner}/${repo}/commits/${ref}?limit=${options?.limit ?? 30}`
3e3af55302 ),
3e3af55303
3e3af55304 blame: (owner: string, repo: string, ref: string, path: string) =>
f0bb192305 apiRequest<{ path: string; ref: string; blame: BlameLine[] }>(
80fafdf306 `/repos/${owner}/${repo}/blame/${ref}/${path}`
3e3af55307 ),
3e3af55308
bf5fc33309 bookmarks: (owner: string, repo: string) =>
f0bb192310 apiRequest<{ bookmarks: Bookmark[] }>(`/repos/${owner}/${repo}/branches`),
3e3af55311
3e3af55312 diff: (owner: string, repo: string, base: string, head: string) =>
f0bb192313 apiRequest<{ diffs: DiffFile[] }>(
80fafdf314 `/repos/${owner}/${repo}/diff?base=${base}&head=${head}`
3e3af55315 ),
3e3af55316};
3e3af55317
d12933e318// Diffs
d12933e319export const diffs = {
3e3af55320 list: (owner: string, repo: string, status: string = "open") =>
d12933e321 apiRequest<{ diffs: Diff[] }>(
d12933e322 `/repos/${owner}/${repo}/diffs?status=${status}`
3e3af55323 ),
3e3af55324
3e3af55325 get: (owner: string, repo: string, number: number) =>
f0bb192326 apiRequest<{
d12933e327 diff: Diff;
3e3af55328 comments: Comment[];
3e3af55329 reviews: Review[];
d12933e330 }>(`/repos/${owner}/${repo}/diffs/${number}`),
3e3af55331
3e3af55332 create: (
3e3af55333 owner: string,
3e3af55334 repo: string,
2ec6868335 data: { title: string; head_commit: string; description?: string; base_commit?: string }
3e3af55336 ) =>
d12933e337 apiRequest<{ diff: Diff }>(
d12933e338 `/repos/${owner}/${repo}/diffs`,
3e3af55339 { method: "POST", body: JSON.stringify(data) }
3e3af55340 ),
3e3af55341
3e3af55342 update: (
3e3af55343 owner: string,
3e3af55344 repo: string,
3e3af55345 number: number,
2ec6868346 data: { title?: string; description?: string; status?: string; head_commit?: string }
3e3af55347 ) =>
d12933e348 apiRequest<{ diff: Diff }>(
d12933e349 `/repos/${owner}/${repo}/diffs/${number}`,
3e3af55350 { method: "PATCH", body: JSON.stringify(data) }
3e3af55351 ),
3e3af55352
2ec6868353 land: (owner: string, repo: string, number: number) =>
d12933e354 apiRequest<{ diff: Diff }>(
2ec6868355 `/repos/${owner}/${repo}/diffs/${number}/land`,
3e3af55356 { method: "POST" }
3e3af55357 ),
3e3af55358
3e3af55359 comment: (
3e3af55360 owner: string,
3e3af55361 repo: string,
3e3af55362 number: number,
4a006da363 data: { body: string; file_path?: string; line_number?: number; side?: "left" | "right"; commit_sha?: string; parent_id?: number }
3e3af55364 ) =>
f0bb192365 apiRequest<{ comment: Comment }>(
d12933e366 `/repos/${owner}/${repo}/diffs/${number}/comments`,
3e3af55367 { method: "POST", body: JSON.stringify(data) }
3e3af55368 ),
3e3af55369
3e3af55370 review: (
3e3af55371 owner: string,
3e3af55372 repo: string,
3e3af55373 number: number,
3e3af55374 data: { status: "approved" | "changes_requested"; body?: string }
3e3af55375 ) =>
f0bb192376 apiRequest<{ review: Review }>(
d12933e377 `/repos/${owner}/${repo}/diffs/${number}/reviews`,
3e3af55378 { method: "POST", body: JSON.stringify(data) }
3e3af55379 ),
3e3af55380};
3e3af55381
3e3af55382// Types
4a006da383
3e3af55384export interface User {
3e3af55385 id: number;
3e3af55386 username: string;
3e3af55387 display_name: string;
4a006da388 created_at?: string;
4a006da389}
4a006da390
3c994d3391export interface ApiToken {
3c994d3392 id: number;
3c994d3393 name: string;
3c994d3394 expires_at: string;
3c994d3395 last_used_at: string | null;
3c994d3396 created_at: string;
3c994d3397}
3c994d3398
79efd41399export interface Org {
79efd41400 id: number;
79efd41401 name: string;
79efd41402 display_name: string;
79efd41403 created_by: number;
79efd41404 created_at: string;
79efd41405 updated_at: string;
79efd41406}
79efd41407
79efd41408export interface OrgMember {
79efd41409 user_id: number;
79efd41410 username: string;
79efd41411 display_name: string;
79efd41412 created_at: string;
79efd41413}
79efd41414
4a006da415export interface Instance {
4a006da416 id: number;
4a006da417 user_id: number;
4a006da418 name: string;
4a006da419 ip: string | null;
4a006da420 domain: string | null;
4a006da421 region: string;
4a006da422 size: string;
4a006da423 status: "creating" | "active" | "error" | "destroyed";
4a006da424 created_at: string;
4a006da425 updated_at: string;
3e3af55426}
3e3af55427
3e3af55428export interface Repo {
3e3af55429 id: number;
3e3af55430 owner_id: number;
3e3af55431 owner_name: string;
79efd41432 owner_type: "user" | "org";
3e3af55433 name: string;
3e3af55434 description: string | null;
3e3af55435 default_branch: string;
3e3af55436 is_private: boolean;
8d8e815437 require_diffs: boolean;
e5b523e438 pages_enabled: boolean;
e5b523e439 pages_domain: string | null;
3e3af55440 created_at: string;
3e3af55441 updated_at: string;
bc2f205442 last_commit_ts?: number | null;
3e3af55443}
3e3af55444
3e3af55445export interface TreeEntry {
3e3af55446 name: string;
bf5fc33447 type: "file" | "tree";
3e3af55448}
3e3af55449
bf5fc33450export interface BridgeCommit {
3e3af55451 hash: string;
bf5fc33452 git_sha: string | null;
3e3af55453 author: string;
3e3af55454 timestamp: number;
bf5fc33455 message: string;
3e3af55456 parents: string[];
3e3af55457}
3e3af55458
3e3af55459export interface BlameLine {
3e3af55460 hash: string;
bf5fc33461 original_line: number;
3e3af55462 author: string;
3e3af55463 timestamp: number;
3e3af55464 content: string;
3e3af55465}
3e3af55466
bf5fc33467export interface Bookmark {
3e3af55468 name: string;
bf5fc33469 commit_id: string;
3e3af55470}
3e3af55471
d12933e472export interface Diff {
3e3af55473 id: number;
3e3af55474 repo_id: number;
3e3af55475 number: number;
3e3af55476 title: string;
3e3af55477 description: string;
3e3af55478 author_id: number;
3e3af55479 author_name: string;
4a006da480 author_display_name?: string;
2ec6868481 head_commit: string;
2ec6868482 base_commit: string | null;
2ec6868483 status: "open" | "landed" | "closed";
3e3af55484 created_at: string;
3e3af55485 updated_at: string;
2ec6868486 landed_at: string | null;
2ec6868487 landed_by: number | null;
3e3af55488}
3e3af55489
3e3af55490export interface Comment {
3e3af55491 id: number;
d12933e492 diff_id: number;
3e3af55493 author_id: number;
3e3af55494 author_name: string;
4a006da495 author_display_name?: string;
3e3af55496 body: string;
3e3af55497 file_path: string | null;
3e3af55498 line_number: number | null;
3e3af55499 side: "left" | "right" | null;
3e3af55500 commit_sha: string | null;
3e3af55501 parent_id: number | null;
3e3af55502 created_at: string;
3e3af55503}
3e3af55504
3e3af55505export interface Review {
3e3af55506 id: number;
d12933e507 diff_id: number;
3e3af55508 reviewer_id: number;
3e3af55509 reviewer_name: string;
4a006da510 reviewer_display_name?: string;
3e3af55511 status: "pending" | "approved" | "changes_requested";
3e3af55512 body: string | null;
3e3af55513 created_at: string;
3e3af55514}
12ffdd4515
12ffdd4516export interface DiffFile {
12ffdd4517 path: string;
12ffdd4518 diff: string;
12ffdd4519 is_binary: boolean;
12ffdd4520}
80fafdf521
80fafdf522// Canopy CI/CD
80fafdf523
80fafdf524export interface PipelineRun {
80fafdf525 id: number;
80fafdf526 repo_id: number;
80fafdf527 pipeline_name: string;
80fafdf528 pipeline_file: string;
d12933e529 trigger_type: "push" | "diff" | "manual" | "schedule";
80fafdf530 trigger_ref: string | null;
80fafdf531 commit_id: string | null;
da0f651532 commit_message: string | null;
80fafdf533 status: "pending" | "running" | "passed" | "failed" | "cancelled";
80fafdf534 started_at: string | null;
80fafdf535 finished_at: string | null;
80fafdf536 duration_ms: number | null;
80fafdf537 created_at: string;
80fafdf538}
80fafdf539
80fafdf540export interface PipelineStep {
80fafdf541 id: number;
80fafdf542 run_id: number;
80fafdf543 step_index: number;
80fafdf544 name: string;
80fafdf545 image: string;
80fafdf546 status: "pending" | "running" | "passed" | "failed" | "skipped";
80fafdf547 exit_code: number | null;
80fafdf548 started_at: string | null;
80fafdf549 finished_at: string | null;
80fafdf550 duration_ms: number | null;
80fafdf551}
80fafdf552
80fafdf553export interface StepLog {
80fafdf554 stream: "stdout" | "stderr";
80fafdf555 content: string;
80fafdf556 created_at: string;
80fafdf557}
80fafdf558
80fafdf559export const canopy = {
80fafdf560 listRuns: (
80fafdf561 owner: string,
80fafdf562 repo: string,
80fafdf563 options?: { status?: string; limit?: number }
80fafdf564 ) =>
f0bb192565 apiRequest<{ runs: PipelineRun[] }>(
80fafdf566 `/repos/${owner}/${repo}/canopy/runs?${new URLSearchParams({
80fafdf567 ...(options?.status ? { status: options.status } : {}),
80fafdf568 limit: String(options?.limit ?? 20),
73fdc9e569 })}`,
73fdc9e570 { cache: "no-store" }
80fafdf571 ),
80fafdf572
80fafdf573 getRun: (owner: string, repo: string, runId: number) =>
f0bb192574 apiRequest<{ run: PipelineRun; steps: PipelineStep[] }>(
73fdc9e575 `/repos/${owner}/${repo}/canopy/runs/${runId}`,
73fdc9e576 { cache: "no-store" }
80fafdf577 ),
80fafdf578
80fafdf579 getStepLogs: (
80fafdf580 owner: string,
80fafdf581 repo: string,
80fafdf582 runId: number,
80fafdf583 stepIndex: number
80fafdf584 ) =>
f0bb192585 apiRequest<{ logs: StepLog[] }>(
73fdc9e586 `/repos/${owner}/${repo}/canopy/runs/${runId}/logs/${stepIndex}`,
73fdc9e587 { cache: "no-store" }
80fafdf588 ),
80fafdf589
80fafdf590 cancelRun: (owner: string, repo: string, runId: number) =>
f0bb192591 apiRequest<{ run: PipelineRun }>(
80fafdf592 `/repos/${owner}/${repo}/canopy/runs/${runId}/cancel`,
80fafdf593 { method: "POST" }
80fafdf594 ),
80fafdf595
80fafdf596 triggerRun: (owner: string, repo: string, data?: { ref?: string }) =>
f0bb192597 apiRequest<{ triggered: boolean; branch: string; commit_id: string }>(
80fafdf598 `/repos/${owner}/${repo}/canopy/trigger`,
80fafdf599 { method: "POST", body: JSON.stringify(data ?? {}) }
80fafdf600 ),
80fafdf601
80fafdf602 listSecrets: (owner: string, repo: string) =>
f0bb192603 apiRequest<{
80fafdf604 secrets: Array<{ name: string; created_at: string; updated_at: string }>;
80fafdf605 }>(`/repos/${owner}/${repo}/canopy/secrets`),
80fafdf606
80fafdf607 createSecret: (
80fafdf608 owner: string,
80fafdf609 repo: string,
80fafdf610 data: { name: string; value: string }
80fafdf611 ) =>
f0bb192612 apiRequest<{ ok: true }>(`/repos/${owner}/${repo}/canopy/secrets`, {
80fafdf613 method: "POST",
80fafdf614 body: JSON.stringify(data),
80fafdf615 }),
80fafdf616
80fafdf617 deleteSecret: (owner: string, repo: string, name: string) =>
f0bb192618 apiRequest<void>(`/repos/${owner}/${repo}/canopy/secrets/${name}`, {
80fafdf619 method: "DELETE",
80fafdf620 }),
d1cff70621
f4e5cf1622 recentRuns: (options?: { limit?: number; owner?: string }) => {
f4e5cf1623 const params: Record<string, string> = { limit: String(options?.limit ?? 20) };
f4e5cf1624 if (options?.owner) params.owner = options.owner;
f4e5cf1625 return apiRequest<{ runs: Array<PipelineRun & { repo_name: string; owner_name: string }> }>(
f4e5cf1626 `/canopy/recent-runs?${new URLSearchParams(params)}`,
d1cff70627 { cache: "no-store" }
f4e5cf1628 );
f4e5cf1629 },
80fafdf630};
d9e3295631
3cbdca6632export interface RingLogEntry {
3cbdca6633 ts: string;
3cbdca6634 source: string;
3cbdca6635 level: string;
3cbdca6636 message: string;
3cbdca6637 payload: unknown;
3cbdca6638 owner?: string;
3cbdca6639 repo?: string;
3cbdca6640}
3cbdca6641
3cbdca6642export interface RingInstanceSummary {
3cbdca6643 owner: string;
3cbdca6644 repo: string;
3cbdca6645 total: number;
3cbdca6646 last_ts: string | null;
3cbdca6647 last_level: string | null;
3cbdca6648 last_message: string | null;
3cbdca6649}
3cbdca6650
0b4b582651// ── Collab (diagrams / notes) ──
0b4b582652
0b4b582653export interface CollabDiagram {
0b4b582654 id: string;
0b4b582655 title: string;
0b4b582656 section?: string;
0b4b582657 code: string;
0b4b582658}
0b4b582659
0b4b582660export interface CollabNote {
0b4b582661 id: string;
0b4b582662 diagramId: string;
0b4b582663 diagramTitle?: string;
0b4b582664 author: string;
0b4b582665 text: string;
0b4b582666 x?: number;
0b4b582667 y?: number;
0b4b582668 targetNode?: string | null;
0b4b582669 timestamp: string;
0b4b582670 editedAt?: string;
0b4b582671}
0b4b582672
0b4b582673export const collab = {
0b4b582674 listDiagrams: (owner: string, repo: string) =>
0b4b582675 fetchApi<{ sections: string[]; diagrams: CollabDiagram[] }>(
0b4b582676 API_BASE,
0b4b582677 `/collab/repos/${owner}/${repo}/diagrams`
0b4b582678 ),
0b4b582679
0b4b582680 listNotes: (owner: string, repo: string) =>
0b4b582681 fetchApi<Record<string, CollabNote[]>>(
0b4b582682 API_BASE,
0b4b582683 `/collab/repos/${owner}/${repo}/notes`
0b4b582684 ),
0b4b582685
0b4b582686 exportNotes: async (owner: string, repo: string): Promise<string> => {
0b4b582687 const token = getToken();
0b4b582688 const headers: Record<string, string> = token
0b4b582689 ? { Authorization: `Bearer ${token}` }
0b4b582690 : {};
0b4b582691 const res = await fetch(
0b4b582692 `${API_BASE}/collab/repos/${owner}/${repo}/notes/llm`,
0b4b582693 { headers }
0b4b582694 );
0b4b582695 if (!res.ok) throw new ApiError(res.status, "Export failed");
0b4b582696 return res.text();
0b4b582697 },
0b4b582698};
0b4b582699
3cbdca6700export const ring = {
3cbdca6701 listInstances: () =>
3cbdca6702 apiRequest<{ instances: RingInstanceSummary[] }>(`/ring/instances`),
3cbdca6703
3cbdca6704 listLogs: (options?: { limit?: number }) =>
3cbdca6705 apiRequest<{ entries: RingLogEntry[]; total: number }>(
3cbdca6706 `/ring/logs?${new URLSearchParams({
3cbdca6707 limit: String(options?.limit ?? 200),
3cbdca6708 })}`
3cbdca6709 ),
3cbdca6710
3cbdca6711 listRepoLogs: (owner: string, repo: string, options?: { limit?: number }) =>
3cbdca6712 apiRequest<{ entries: RingLogEntry[]; total: number }>(
3cbdca6713 `/repos/${owner}/${repo}/ring/logs?${new URLSearchParams({
3cbdca6714 limit: String(options?.limit ?? 200),
3cbdca6715 })}`
3cbdca6716 ),
3cbdca6717
3cbdca6718 ingestLog: (data: unknown) =>
3cbdca6719 apiRequest<{ ok: true; entry: RingLogEntry }>(`/ring/logs`, {
3cbdca6720 method: "POST",
3cbdca6721 body: JSON.stringify(data ?? {}),
3cbdca6722 }),
3cbdca6723
3cbdca6724 ingestRepoLog: (owner: string, repo: string, data: unknown) =>
3cbdca6725 apiRequest<{ ok: true; entry: RingLogEntry }>(`/repos/${owner}/${repo}/ring/logs`, {
3cbdca6726 method: "POST",
3cbdca6727 body: JSON.stringify(data ?? {}),
3cbdca6728 }),
3cbdca6729};