Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

api-v2.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

api-v2.tsBlame3363 lines · 1 contributor
45e31d0Claude1/**
2 * Comprehensive REST API v2 — full CRUD for all resources.
3 *
4 * Authentication: Bearer token (API tokens) or session cookie.
5 * Rate limited: 100 requests/minute per IP.
6 * All responses are JSON.
7 */
8
9import { Hono } from "hono";
da50b83Claude10import { join } from "path";
9f29b65Claude11import { eq, and, desc, asc, sql, like, or, gte, lte, gt } from "drizzle-orm";
da50b83Claude12import { deflateRawSync } from "node:zlib";
45e31d0Claude13import { db } from "../db";
14import {
15 users,
16 repositories,
17 issues,
18 issueComments,
19 pullRequests,
20 prComments,
21 stars,
22 labels,
23 issueLabels,
24 activityFeed,
25 webhooks,
26 repoTopics,
da50b83Claude27 workflows,
28 workflowRuns,
29 workflowJobs,
9f29b65Claude30 auditLog,
45e31d0Claude31} from "../db/schema";
da50b83Claude32import { enqueueRun } from "../lib/workflow-runner";
4bbacbeClaude33import {
34 enqueuePreviewBuild,
35 listPreviewsForRepo,
36} from "../lib/branch-previews";
45e31d0Claude37import {
38 listBranches,
39 getDefaultBranch,
052c2e6Claude40 getDefaultBranchFresh,
45e31d0Claude41 getTree,
052c2e6Claude42 getTreeRecursive,
45e31d0Claude43 getBlob,
44 getCommit,
45 listCommits,
46 getDiff,
47 searchCode,
48 repoExists,
49 initBareRepo,
50 resolveRef,
052c2e6Claude51 catBlobBytes,
52 refExists,
53 objectExists,
54 updateRef,
da50b83Claude55 writeBlob,
56 getBlobShaAtPath,
57 getRepoPath,
052c2e6Claude58 createOrUpdateFileOnBranch,
45e31d0Claude59} from "../git/repository";
da50b83Claude60import { config } from "../lib/config";
45e31d0Claude61import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
62import type { ApiAuthEnv } from "../middleware/api-auth";
e75eddcClaude63import {
64 agentAuth,
65 enforceAgentBranchNamespace,
66} from "../middleware/agent-auth";
67import type { AgentAuthEnv } from "../middleware/agent-auth";
45e31d0Claude68import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
052c2e6Claude69import { postCommitStatusHandler } from "./commit-statuses";
46d6165Claude70import { apiTokens } from "../db/schema";
71import { audit } from "../lib/notify";
72import {
73 computeAiSavingsForUser,
74 computeLifetimeAiSavingsForUser,
75} from "../lib/ai-hours-saved";
8809b87Claude76import {
77 summarizeCostsForUser,
78 summarizeCostsForRepo,
79 startOfUtcMonth,
80} from "../lib/ai-cost-tracker";
9018b1fClaude81import {
82 generateCommitMessage,
83 DIFF_BYTE_CAP,
84} from "../lib/ai-commit-message";
45e31d0Claude85
e75eddcClaude86const apiv2 = new Hono<ApiAuthEnv & AgentAuthEnv>().basePath("/api/v2");
45e31d0Claude87
e75eddcClaude88// Apply auth and rate limiting to all v2 routes.
89//
90// Agent-multiplayer v1: `agentAuth` runs BEFORE `apiAuth` so that an
91// `agt_` Bearer token is detected and stashed at `c.get("agent")`
92// without consuming the token in apiAuth (which only recognises PAT
93// + session). Non-agent tokens fall straight through. The
94// branch-namespace guard runs on PATCH /git/refs/heads/* only.
45e31d0Claude95apiv2.use("*", apiRateLimit);
e75eddcClaude96apiv2.use("*", agentAuth);
45e31d0Claude97apiv2.use("*", apiAuth);
e75eddcClaude98apiv2.use(
99 "/repos/:owner/:repo/git/refs/heads/:branch",
100 enforceAgentBranchNamespace
101);
45e31d0Claude102
103// ─── Helper ─────────────────────────────────────────────────────────────────
104
105async function resolveRepo(ownerName: string, repoName: string) {
106 const [owner] = await db
107 .select()
108 .from(users)
109 .where(eq(users.username, ownerName))
110 .limit(1);
111 if (!owner) return null;
112
113 const [repo] = await db
114 .select()
115 .from(repositories)
116 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
117 .limit(1);
118 if (!repo) return null;
119
120 return { owner, repo };
121}
122
46d6165Claude123// ─── Auth (install-token) ───────────────────────────────────────────────────
124//
125// POST /api/v2/auth/install-token
126//
127// Mint a new personal access token from a one-command install script
128// (`scripts/install.sh`). Session-cookie auth ONLY — Bearer tokens are
129// explicitly rejected so existing PATs cannot escalate into a fan-out of new
130// PATs. The plaintext token value is returned exactly once and never persisted.
131//
132// Audit-logged as `auth.install_token.created`.
133
134function generateInstallToken(): string {
135 const bytes = crypto.getRandomValues(new Uint8Array(32));
136 return (
137 "glc_" +
138 Array.from(bytes)
139 .map((b) => b.toString(16).padStart(2, "0"))
140 .join("")
141 );
142}
143
144async function hashInstallToken(token: string): Promise<string> {
145 const data = new TextEncoder().encode(token);
146 const hash = await crypto.subtle.digest("SHA-256", data);
147 return Array.from(new Uint8Array(hash))
148 .map((b) => b.toString(16).padStart(2, "0"))
149 .join("");
150}
151
152apiv2.post("/auth/install-token", async (c) => {
153 // Reject Bearer-token callers outright. The whole point of this endpoint
154 // is preventing token escalation, so we check the raw header rather than
155 // relying on whatever the middleware decided.
156 const authHeader = c.req.header("Authorization");
157 if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
158 return c.json(
159 {
160 error: "Session authentication required",
161 hint: "This endpoint refuses Bearer tokens — sign in with a session cookie.",
162 },
163 401
164 );
165 }
166
167 const user = c.get("user");
168 const authMethod = c.get("authMethod");
169 if (!user || authMethod !== "session") {
170 return c.json(
171 {
172 error: "Session authentication required",
173 hint: "Sign in via /login first; install-token mints PATs only over the session cookie.",
174 },
175 401
176 );
177 }
178
179 let body: { name?: unknown; scope?: unknown } = {};
180 try {
181 body = await c.req.json();
182 } catch {
183 // Empty / unparseable body is allowed — we fall back to defaults.
184 body = {};
185 }
186
187 const shortStamp = Math.floor(Date.now() / 1000)
188 .toString(36)
189 .slice(-6);
190 const name =
191 typeof body.name === "string" && body.name.trim().length > 0
192 ? body.name.trim().slice(0, 80)
193 : `gluecron-install-${shortStamp}`;
194
195 const requested = typeof body.scope === "string" ? body.scope : "admin";
196 const scope = requested === "repo" ? "repo" : "admin";
197 // Mirror existing PAT semantics: a comma-separated list. `admin` implies
198 // everything; `repo` keeps it narrow.
199 const scopes = scope === "admin" ? "admin,repo,user" : "repo";
200
201 const token = generateInstallToken();
202 const tokenHash = await hashInstallToken(token);
203 const tokenPrefix = token.slice(0, 12);
204
205 const [row] = await db
206 .insert(apiTokens)
207 .values({
208 userId: user.id,
209 name,
210 tokenHash,
211 tokenPrefix,
212 scopes,
213 })
214 .returning();
215
216 await audit({
217 userId: user.id,
218 action: "auth.install_token.created",
219 targetType: "api_token",
220 targetId: row?.id,
221 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
222 userAgent: c.req.header("user-agent") || undefined,
223 metadata: { name, scope, prefix: tokenPrefix },
224 });
225
226 return c.json(
227 {
228 token,
229 id: row?.id,
230 name,
231 scope,
232 scopes,
233 expiresAt: row?.expiresAt ?? null,
234 },
235 201
236 );
237});
238
45e31d0Claude239// ─── Users ──────────────────────────────────────────────────────────────────
240
241apiv2.get("/user", requireApiAuth, (c) => {
242 const user = c.get("user")!;
243 return c.json({
244 id: user.id,
245 username: user.username,
246 email: user.email,
247 displayName: user.displayName,
248 bio: user.bio,
249 avatarUrl: user.avatarUrl,
250 createdAt: user.createdAt,
251 });
252});
253
254apiv2.get("/users/:username", async (c) => {
255 const { username } = c.req.param();
256 const [user] = await db
257 .select({
258 id: users.id,
259 username: users.username,
260 displayName: users.displayName,
261 bio: users.bio,
262 avatarUrl: users.avatarUrl,
263 createdAt: users.createdAt,
264 })
265 .from(users)
266 .where(eq(users.username, username))
267 .limit(1);
268 if (!user) return c.json({ error: "User not found" }, 404);
269 return c.json(user);
270});
271
46d6165Claude272/**
273 * Block L9 — AI hours-saved counter, exposed for the dashboard widget,
274 * VS Code extension, and CLI. Returns the same numbers the web UI shows.
275 * No special scope — any authenticated token can read its own counter.
276 */
8809b87Claude277/**
278 * Per-user AI cost summary. Same shape as the /billing/usage dashboard.
279 * Defaults to "this calendar month UTC" when no window is supplied.
280 * Query params: ?from=ISO8601&to=ISO8601 to override.
281 */
282apiv2.get("/usage/me", requireApiAuth, async (c) => {
283 const user = c.get("user")!;
284 const now = new Date();
285 const fromQ = c.req.query("from");
286 const toQ = c.req.query("to");
287 const fromDate = fromQ ? new Date(fromQ) : startOfUtcMonth(now);
288 const toDate = toQ ? new Date(toQ) : now;
289 const summary = await summarizeCostsForUser(user.id, { fromDate, toDate });
290 return c.json({
291 window: { fromDate: fromDate.toISOString(), toDate: toDate.toISOString() },
292 ...summary,
293 });
294});
295
296/**
297 * Per-repo AI cost summary. Public repos: any caller. Private repos: only
298 * the owner (matches the existing GET /repos/:owner/:repo behaviour). The
299 * window defaults to "this calendar month UTC" as above.
300 */
301apiv2.get("/usage/repo/:owner/:repo", requireApiAuth, async (c) => {
302 const { owner, repo } = c.req.param();
303 const resolved = await resolveRepo(owner, repo);
304 if (!resolved) return c.json({ error: "Not found" }, 404);
305 const user = c.get("user")!;
306 if ((resolved.repo as any).isPrivate && user.id !== resolved.owner.id) {
307 return c.json({ error: "Not found" }, 404);
308 }
309 const now = new Date();
310 const fromQ = c.req.query("from");
311 const toQ = c.req.query("to");
312 const fromDate = fromQ ? new Date(fromQ) : startOfUtcMonth(now);
313 const toDate = toQ ? new Date(toQ) : now;
314 const summary = await summarizeCostsForRepo((resolved.repo as any).id, {
315 fromDate,
316 toDate,
317 });
318 return c.json({
319 repository: { owner, repo, id: (resolved.repo as any).id },
320 window: { fromDate: fromDate.toISOString(), toDate: toDate.toISOString() },
321 ...summary,
322 });
323});
324
46d6165Claude325apiv2.get("/me/ai-savings", requireApiAuth, async (c) => {
326 const user = c.get("user")!;
327 const [window, lifetime] = await Promise.all([
328 computeAiSavingsForUser(user.id, { windowHours: 168 }),
329 computeLifetimeAiSavingsForUser(user.id),
330 ]);
331 return c.json({
332 window: {
333 hours: window.windowHours,
334 hoursSaved: window.hoursSaved,
335 breakdown: window.breakdown,
336 },
337 lifetime: {
338 hoursSaved: lifetime.hoursSaved,
339 breakdown: lifetime.breakdown,
340 sinceCreatedAt: lifetime.sinceCreatedAt.toISOString(),
341 },
342 });
343});
344
9018b1fClaude345// ─── AI: commit-message ─────────────────────────────────────────────────────
346//
347// POST /api/v2/ai/commit-message
348//
349// Body: { diff: string, style?: "conventional" | "plain" }
350// Returns: { subject, body }
351//
352// Powers the `gluecron commit` CLI + the `prepare-commit-msg` git hook.
353// Rate-limited to 60 req/min per token (this runs once per developer
354// commit; we don't want a runaway `git rebase -i exec` to burn $$$).
355// Falls back to a heuristic when ANTHROPIC_API_KEY is missing, so the
356// route never 5xx's on a misconfigured deploy — the CLI can always show
357// the developer something.
358
359interface AiCommitBucket {
360 count: number;
361 resetAt: number;
362}
363const _aiCommitBuckets = new Map<string, AiCommitBucket>();
364const AI_COMMIT_LIMIT = 60; // requests
365const AI_COMMIT_WINDOW_MS = 60_000; // per minute, per token
366
367function aiCommitRateLimit(c: {
368 req: { header: (k: string) => string | undefined };
369 get: (k: string) => unknown;
370}): { ok: true } | { ok: false; retryAfter: number } {
371 // Key on the raw bearer token where possible (so two CLIs sharing an
372 // IP each get their own bucket); fall back to user id for session
373 // callers (web UI) and to "anon" as a last resort.
374 const authHeader = c.req.header("Authorization") || "";
375 let key: string;
376 if (authHeader.toLowerCase().startsWith("bearer ")) {
377 // Hash the token so we don't keep the plaintext in memory forever.
378 const tok = authHeader.slice(7).trim();
379 const hasher = new Bun.CryptoHasher("sha256");
380 hasher.update(tok);
381 key = "tok:" + hasher.digest("hex").slice(0, 32);
382 } else {
383 const user = c.get("user") as { id?: string } | null | undefined;
384 key = user?.id ? `usr:${user.id}` : "anon";
385 }
386 const now = Date.now();
387 let bucket = _aiCommitBuckets.get(key);
388 if (!bucket || bucket.resetAt < now) {
389 bucket = { count: 0, resetAt: now + AI_COMMIT_WINDOW_MS };
390 _aiCommitBuckets.set(key, bucket);
391 }
392 bucket.count++;
393 if (bucket.count > AI_COMMIT_LIMIT) {
394 return {
395 ok: false,
396 retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
397 };
398 }
399 return { ok: true };
400}
401
402apiv2.post(
403 "/ai/commit-message",
404 requireApiAuth,
405 requireScope("repo"),
406 async (c) => {
407 // In test env we keep the bucket disabled (matches the global pattern
408 // in src/middleware/rate-limit.ts — see the isTestEnv branch there).
409 const isTestEnv =
410 process.env.NODE_ENV !== "production" &&
411 (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test");
412 if (!isTestEnv) {
413 const gate = aiCommitRateLimit(c);
414 if (!gate.ok) {
415 c.header("Retry-After", String(gate.retryAfter));
416 return c.json(
417 {
418 error: "Rate limit exceeded — 60 commit-message requests per minute per token.",
419 retryAfter: gate.retryAfter,
420 },
421 429
422 );
423 }
424 }
425
426 let body: { diff?: unknown; style?: unknown } = {};
427 try {
428 body = await c.req.json();
429 } catch {
430 return c.json({ error: "Invalid JSON body" }, 400);
431 }
432 const diff = typeof body.diff === "string" ? body.diff : "";
433 if (!diff.trim()) {
434 return c.json({ error: "diff is required" }, 400);
435 }
436 // Hard cap so we never blow memory even before the lib truncates.
437 // Library re-truncates as a defence-in-depth measure.
438 const capped = diff.length > DIFF_BYTE_CAP * 4 ? diff.slice(0, DIFF_BYTE_CAP * 4) : diff;
439
440 const style =
441 body.style === "plain" || body.style === "conventional"
442 ? body.style
443 : "conventional";
444
445 const result = await generateCommitMessage(capped, { style });
446 return c.json(result);
447 }
448);
449
45e31d0Claude450apiv2.patch("/user", requireApiAuth, requireScope("user"), async (c) => {
451 const user = c.get("user")!;
452 const body = await c.req.json<{
453 displayName?: string;
454 bio?: string;
455 avatarUrl?: string;
456 }>();
457
458 const updates: Record<string, any> = { updatedAt: new Date() };
459 if (body.displayName !== undefined) updates.displayName = body.displayName;
460 if (body.bio !== undefined) updates.bio = body.bio;
461 if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
462
463 await db.update(users).set(updates).where(eq(users.id, user.id));
464 return c.json({ ok: true });
465});
466
467// ─── Repositories ───────────────────────────────────────────────────────────
468
469apiv2.get("/users/:username/repos", async (c) => {
470 const { username } = c.req.param();
471 const sort = c.req.query("sort") || "updated";
472 const [owner] = await db.select().from(users).where(eq(users.username, username)).limit(1);
473 if (!owner) return c.json({ error: "User not found" }, 404);
474
475 const currentUser = c.get("user");
476 const orderBy = sort === "stars" ? desc(repositories.starCount) :
477 sort === "name" ? asc(repositories.name) :
478 desc(repositories.updatedAt);
479
480 let repoList = await db
481 .select()
482 .from(repositories)
483 .where(eq(repositories.ownerId, owner.id))
484 .orderBy(orderBy);
485
486 // Only show private repos to owner
487 if (!currentUser || currentUser.id !== owner.id) {
488 repoList = repoList.filter((r: any) => !r.isPrivate);
489 }
490
491 return c.json(repoList);
492});
493
494apiv2.post("/repos", requireApiAuth, requireScope("repo"), async (c) => {
495 const user = c.get("user")!;
496 const body = await c.req.json<{
497 name: string;
498 description?: string;
499 isPrivate?: boolean;
500 }>();
501
502 if (!body.name || !/^[a-zA-Z0-9._-]+$/.test(body.name)) {
503 return c.json({ error: "Invalid repository name" }, 400);
504 }
505
c63b860Claude506 // P4 — plan-quota gate. 402 Payment Required is the canonical HTTP
507 // signal that the client should branch on (e.g. show an upgrade CTA).
508 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
509 const gate = await checkRepoCreateAllowed(user.id);
510 if (!gate.ok) {
511 return c.json({ error: gate.reason, upgrade_url: gate.upgradeUrl }, 402);
512 }
513
45e31d0Claude514 if (await repoExists(user.username, body.name)) {
515 return c.json({ error: "Repository already exists" }, 409);
516 }
517
518 const diskPath = await initBareRepo(user.username, body.name);
519 const result = await db
520 .insert(repositories)
521 .values({
522 name: body.name,
523 ownerId: user.id,
524 description: body.description || null,
525 isPrivate: body.isPrivate || false,
526 diskPath,
527 })
528 .returning();
529
530 return c.json(result[0], 201);
531});
532
533apiv2.get("/repos/:owner/:repo", async (c) => {
534 const { owner, repo } = c.req.param();
535 const resolved = await resolveRepo(owner, repo);
536 if (!resolved) return c.json({ error: "Not found" }, 404);
537
538 const currentUser = c.get("user");
539 if ((resolved.repo as any).isPrivate && (!currentUser || currentUser.id !== resolved.owner.id)) {
540 return c.json({ error: "Not found" }, 404);
541 }
542
052c2e6Claude543 // Cache-free fresh read of HEAD's ref — needed by GateTest.
544 const defaultBranch = await getDefaultBranchFresh(owner, repo);
545
546 return c.json({
547 ...(resolved.repo as any),
548 defaultBranch,
549 owner: {
550 id: (resolved.owner as any).id,
551 login: (resolved.owner as any).username,
552 },
553 });
45e31d0Claude554});
555
556apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
557 const { owner, repo } = c.req.param();
558 const resolved = await resolveRepo(owner, repo);
559 if (!resolved) return c.json({ error: "Not found" }, 404);
560
561 const user = c.get("user")!;
562 if (user.id !== resolved.owner.id) {
563 return c.json({ error: "Permission denied" }, 403);
564 }
565
566 const body = await c.req.json<{
567 description?: string;
568 isPrivate?: boolean;
569 defaultBranch?: string;
570 }>();
571
572 const updates: Record<string, any> = { updatedAt: new Date() };
573 if (body.description !== undefined) updates.description = body.description;
574 if (body.isPrivate !== undefined) updates.isPrivate = body.isPrivate;
575 if (body.defaultBranch !== undefined) updates.defaultBranch = body.defaultBranch;
576
577 await db.update(repositories).set(updates).where(eq(repositories.id, (resolved.repo as any).id));
578 return c.json({ ok: true });
579});
580
581apiv2.delete("/repos/:owner/:repo", requireApiAuth, requireScope("admin"), async (c) => {
582 const { owner, repo } = c.req.param();
583 const resolved = await resolveRepo(owner, repo);
584 if (!resolved) return c.json({ error: "Not found" }, 404);
585
586 const user = c.get("user")!;
587 if (user.id !== resolved.owner.id) {
588 return c.json({ error: "Permission denied" }, 403);
589 }
590
591 await db.delete(repositories).where(eq(repositories.id, (resolved.repo as any).id));
592 return c.json({ ok: true });
593});
594
595// ─── Branches ───────────────────────────────────────────────────────────────
596
597apiv2.get("/repos/:owner/:repo/branches", async (c) => {
598 const { owner, repo } = c.req.param();
599 if (!(await repoExists(owner, repo))) {
600 return c.json({ error: "Not found" }, 404);
601 }
602
603 const branches = await listBranches(owner, repo);
604 const defaultBranch = await getDefaultBranch(owner, repo);
605
606 return c.json(
607 branches.map((name) => ({
608 name,
609 isDefault: name === defaultBranch,
610 }))
611 );
612});
613
614// ─── Commits ────────────────────────────────────────────────────────────────
615
616apiv2.get("/repos/:owner/:repo/commits", async (c) => {
617 const { owner, repo } = c.req.param();
618 const ref = c.req.query("ref") || c.req.query("sha") || "HEAD";
619 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
620 const offset = parseInt(c.req.query("offset") || "0");
621
622 if (!(await repoExists(owner, repo))) {
623 return c.json({ error: "Not found" }, 404);
624 }
625
626 const commits = await listCommits(owner, repo, ref, limit, offset);
627 return c.json(commits);
628});
629
630apiv2.get("/repos/:owner/:repo/commits/:sha", async (c) => {
631 const { owner, repo, sha } = c.req.param();
632 if (!(await repoExists(owner, repo))) {
633 return c.json({ error: "Not found" }, 404);
634 }
635
636 const commit = await getCommit(owner, repo, sha);
637 if (!commit) return c.json({ error: "Commit not found" }, 404);
638
639 const { files } = await getDiff(owner, repo, sha);
640 return c.json({ ...commit, files });
641});
642
643// ─── Tree & Files ───────────────────────────────────────────────────────────
644
645apiv2.get("/repos/:owner/:repo/tree/:ref", async (c) => {
646 const { owner, repo } = c.req.param();
647 const ref = c.req.param("ref");
648 const path = c.req.query("path") || "";
052c2e6Claude649 const recursive = c.req.query("recursive");
45e31d0Claude650
651 if (!(await repoExists(owner, repo))) {
652 return c.json({ error: "Not found" }, 404);
653 }
654
052c2e6Claude655 if (recursive === "1" || recursive === "true") {
656 const result = await getTreeRecursive(owner, repo, ref, 50_000);
657 if (!result) return c.json({ error: "Ref not found" }, 404);
658 return c.json(result);
659 }
660
45e31d0Claude661 const tree = await getTree(owner, repo, ref, path);
662 return c.json(tree);
663});
664
052c2e6Claude665const CONTENTS_MAX_BYTES = 10 * 1024 * 1024;
666
45e31d0Claude667apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
668 const { owner, repo } = c.req.param();
669 const filePath = c.req.param("path");
670 const ref = c.req.query("ref") || "HEAD";
052c2e6Claude671 const encoding = c.req.query("encoding") || "utf8";
45e31d0Claude672
673 if (!(await repoExists(owner, repo))) {
674 return c.json({ error: "Not found" }, 404);
675 }
676
052c2e6Claude677 if (encoding === "base64") {
678 const got = await catBlobBytes(owner, repo, ref, filePath);
679 if (!got) return c.json({ error: "File not found" }, 404);
680 if (got.size > CONTENTS_MAX_BYTES) {
681 return c.json(
682 { error: `File too large (${got.size} bytes, max ${CONTENTS_MAX_BYTES})` },
683 413
684 );
685 }
686 const content = Buffer.from(got.bytes).toString("base64");
687 return c.json({
688 path: filePath,
689 size: got.size,
690 sha: got.sha,
691 encoding: "base64",
692 content,
693 });
694 }
695
45e31d0Claude696 const blob = await getBlob(owner, repo, ref, filePath);
697 if (!blob) return c.json({ error: "File not found" }, 404);
052c2e6Claude698 if (blob.size > CONTENTS_MAX_BYTES) {
699 return c.json(
700 { error: `File too large (${blob.size} bytes, max ${CONTENTS_MAX_BYTES})` },
701 413
702 );
703 }
45e31d0Claude704
705 return c.json({
706 path: filePath,
707 size: blob.size,
708 isBinary: blob.isBinary,
709 content: blob.isBinary ? null : blob.content,
052c2e6Claude710 encoding: blob.isBinary ? null : "utf8",
45e31d0Claude711 });
712});
713
a686079Claude714// ─── Semantic search ────────────────────────────────────────────────────────
715//
716// Continuous semantic index — see src/lib/semantic-index.ts. Every push
717// embeds the changed files into pgvector. This endpoint ranks them by
718// cosine similarity to the query.
719//
720// Public repos: anonymous read is allowed.
721// Private repos: requireApiAuth + requireScope("repo") (plus the same
722// owner-only check the rest of /repos/:owner/:repo enforces).
723//
724// Returns `[]` (not 5xx) when pgvector is missing or the index is empty,
725// so callers can treat absence as "no hits" rather than a hard error.
726
727apiv2.get("/repos/:owner/:repo/semantic-search", async (c) => {
728 const { owner, repo } = c.req.param();
729 const q = (c.req.query("q") || "").trim();
730 const limit = Math.max(1, Math.min(parseInt(c.req.query("limit") || "20"), 100));
731
732 const resolved = await resolveRepo(owner, repo);
733 if (!resolved) return c.json({ error: "Not found" }, 404);
734
735 // Private-repo gate: requireApiAuth + requireScope("repo") + owner match.
736 // We can't compose Hono middleware conditionally per-request, so we
737 // inline the same checks the apiv2 middleware would have applied.
738 if ((resolved.repo as any).isPrivate) {
739 const user = c.get("user");
740 if (!user) {
741 return c.json(
742 { error: "Authentication required", hint: "Use Authorization: Bearer <token> header" },
743 401
744 );
745 }
746 const scopes = (c.get("tokenScopes") as string[] | undefined) || [];
747 // tokenScopes is [] for unauthenticated; ["repo","user","admin"] for
748 // session-cookie auth (web UI); whatever scopes the PAT carries
749 // otherwise. The "admin" wildcard skips the scope check.
750 if (
751 scopes.length > 0 &&
752 !scopes.includes("repo") &&
753 !scopes.includes("admin")
754 ) {
755 return c.json({ error: "Insufficient scope. Required: repo" }, 403);
756 }
757 if (user.id !== resolved.owner.id) {
758 return c.json({ error: "Not found" }, 404);
759 }
760 }
761
762 if (!q) return c.json([]);
763
764 const { searchSemantic, semanticIndexProvider } = await import(
765 "../lib/semantic-index"
766 );
767 const hits = await searchSemantic({
768 repositoryId: (resolved.repo as any).id,
769 query: q,
770 limit,
771 });
772
773 // Shape matches the task spec: { file_path, snippet, score, blob_sha }.
774 const payload = hits.map((h) => ({
775 file_path: h.filePath,
776 snippet: h.snippet,
777 score: h.score,
778 blob_sha: h.blobSha,
779 }));
780
781 // Surface the provider so clients can detect graceful-degrade mode.
782 c.header("X-Gluecron-Semantic-Provider", semanticIndexProvider());
783 return c.json(payload);
784});
785
38d31d3Claude786// ─── Repo chat — streaming SSE ──────────────────────────────────────────────
787//
788// POST /api/v2/repos/:owner/:repo/chat/messages
789//
790// Body (application/json): { chat_id?: string, message: string }
791//
792// Behaviour:
793// - If no `chat_id`, creates a new chat row scoped to the caller.
794// - Streams assistant text deltas via SSE (`event: token`, data is the
795// raw delta). When the stream closes, a final `event: done` carries
796// `{ chat_id, message_id, citations, token_cost }`.
797// - On any internal failure we emit `event: error` and end the stream
798// cleanly so the client can render an inline message.
799//
800// Auth: requireApiAuth + requireScope("repo") so PATs need the right
801// scope, and session-cookie auth (web UI) works out of the box.
802
803apiv2.post(
804 "/repos/:owner/:repo/chat/messages",
805 requireApiAuth,
806 requireScope("repo"),
807 async (c) => {
808 const { owner, repo } = c.req.param();
809 const user = c.get("user")!;
810 const resolved = await resolveRepo(owner, repo);
811 if (!resolved) return c.json({ error: "Not found" }, 404);
812
813 // Private-repo: only the owner (or admin scope) may chat.
814 if ((resolved.repo as any).isPrivate && user.id !== resolved.owner.id) {
815 const scopes = (c.get("tokenScopes") as string[] | undefined) || [];
816 if (!scopes.includes("admin")) {
817 return c.json({ error: "Not found" }, 404);
818 }
819 }
820
821 let body: { chat_id?: string; message?: string };
822 try {
823 body = (await c.req.json()) as { chat_id?: string; message?: string };
824 } catch {
825 return c.json({ error: "Invalid JSON body" }, 400);
826 }
827
828 const userMessage = String(body?.message || "").trim();
829 if (!userMessage) {
830 return c.json({ error: "message is required" }, 400);
831 }
832
833 const {
834 appendUserMessage,
835 createChat,
836 getChatForUser,
837 streamAssistantReply,
838 } = await import("../lib/repo-chat");
839
840 let chatId = String(body?.chat_id || "").trim();
841 const repoId = (resolved.repo as any).id as string;
842
843 if (!chatId) {
844 const created = await createChat({
845 repositoryId: repoId,
846 ownerUserId: user.id,
847 title: userMessage.slice(0, 80),
848 });
849 if (!created) {
850 return c.json({ error: "Failed to create chat" }, 500);
851 }
852 chatId = created.id;
853 } else {
854 const existing = await getChatForUser(chatId, user.id);
855 if (!existing || existing.repositoryId !== repoId) {
856 return c.json({ error: "Not found" }, 404);
857 }
858 }
859
860 await appendUserMessage(chatId, userMessage);
861
862 const encoder = new TextEncoder();
863 const stream = new ReadableStream<Uint8Array>({
864 async start(controller) {
865 let closed = false;
866 const safeEnqueue = (chunk: string) => {
867 if (closed) return;
868 try {
869 controller.enqueue(encoder.encode(chunk));
870 } catch {
871 closed = true;
872 }
873 };
874 const sseEvent = (event: string, data: string) => {
875 let payload = `event: ${event}\n`;
876 for (const line of data.split("\n")) {
877 payload += `data: ${line}\n`;
878 }
879 payload += "\n";
880 safeEnqueue(payload);
881 };
882
883 // Initial comment flushes proxy headers.
884 safeEnqueue(": open\n\n");
885
886 try {
887 const stored = await streamAssistantReply({
888 chatId,
889 repoId,
890 userMessage,
891 onChunk: (chunk) => {
892 sseEvent("token", chunk);
893 },
894 });
895
896 sseEvent(
897 "done",
898 JSON.stringify({
899 chat_id: chatId,
900 message_id: stored?.id || null,
901 citations: stored?.citations ?? [],
902 token_cost: stored?.tokenCost ?? 0,
903 })
904 );
905 } catch (err) {
906 const msg = err instanceof Error ? err.message : "unknown error";
907 sseEvent("error", JSON.stringify({ error: msg }));
908 } finally {
909 closed = true;
910 try {
911 controller.close();
912 } catch {
913 /* already closed */
914 }
915 }
916 },
917 });
918
ee7e577Claude919 return new Response(stream, {
920 status: 200,
921 headers: {
922 "Content-Type": "text/event-stream; charset=utf-8",
923 "Cache-Control": "no-cache, no-transform",
924 Connection: "keep-alive",
925 "X-Accel-Buffering": "no",
926 },
927 });
928 }
929);
930
931// ─── Personal cross-repo chat — streaming SSE ───────────────────────────────
932//
933// POST /api/v2/me/chat/messages
934//
935// Body (application/json): { chat_id?: string, message: string }
936//
937// Behaviour mirrors /repos/:owner/:repo/chat/messages but the retrieval
938// layer crosses every repo the user has access to instead of being pinned
939// to a single repo. Hard refusal if
940// `users.personal_semantic_index_enabled` is false; in that case we
941// return 403 rather than streaming an empty-context reply.
942//
943// Per-message audit log: `ai.personal.chat` — privacy requirement.
944
945apiv2.post(
946 "/me/chat/messages",
947 requireApiAuth,
948 requireScope("repo"),
949 async (c) => {
950 const user = c.get("user")!;
951
952 let body: { chat_id?: string; message?: string };
953 try {
954 body = (await c.req.json()) as { chat_id?: string; message?: string };
955 } catch {
956 return c.json({ error: "Invalid JSON body" }, 400);
957 }
958
959 const userMessage = String(body?.message || "").trim();
960 if (!userMessage) {
961 return c.json({ error: "message is required" }, 400);
962 }
963
964 // Hard opt-in gate — refuse before even touching the chat tables.
965 const { isPersonalSemanticEnabled } = await import(
966 "../lib/personal-semantic"
967 );
968 const enabled = await isPersonalSemanticEnabled(user.id);
969 if (!enabled) {
970 return c.json(
971 {
972 error:
973 "Personal cross-repo chat is disabled. Enable it at /chat (the opt-in toggle).",
974 },
975 403
976 );
977 }
978
979 const {
980 appendPersonalUserMessage,
981 createPersonalChat,
982 getPersonalChatForUser,
983 streamPersonalAssistantReply,
984 } = await import("../lib/personal-chat");
985
986 let chatId = String(body?.chat_id || "").trim();
987 if (!chatId) {
988 const created = await createPersonalChat({
989 ownerUserId: user.id,
990 title: userMessage.slice(0, 80),
991 });
992 if (!created) {
993 return c.json({ error: "Failed to create chat" }, 500);
994 }
995 chatId = created.id;
996 } else {
997 const existing = await getPersonalChatForUser(chatId, user.id);
998 if (!existing) {
999 return c.json({ error: "Not found" }, 404);
1000 }
1001 }
1002
1003 await appendPersonalUserMessage(chatId, userMessage);
1004
1005 // Audit log — one entry per personal chat message.
1006 try {
1007 const { audit } = await import("../lib/notify");
1008 void audit({
1009 userId: user.id,
1010 action: "ai.personal.chat",
1011 targetType: "personal_chat",
1012 targetId: chatId,
1013 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip"),
1014 userAgent: c.req.header("user-agent"),
1015 metadata: { surface: "api" },
1016 });
1017 } catch {
1018 /* best-effort */
1019 }
1020
1021 const encoder = new TextEncoder();
1022 const stream = new ReadableStream<Uint8Array>({
1023 async start(controller) {
1024 let closed = false;
1025 const safeEnqueue = (chunk: string) => {
1026 if (closed) return;
1027 try {
1028 controller.enqueue(encoder.encode(chunk));
1029 } catch {
1030 closed = true;
1031 }
1032 };
1033 const sseEvent = (event: string, data: string) => {
1034 let payload = `event: ${event}\n`;
1035 for (const line of data.split("\n")) {
1036 payload += `data: ${line}\n`;
1037 }
1038 payload += "\n";
1039 safeEnqueue(payload);
1040 };
1041
1042 safeEnqueue(": open\n\n");
1043
1044 try {
1045 const stored = await streamPersonalAssistantReply({
1046 chatId,
1047 userId: user.id,
1048 userMessage,
1049 onChunk: (chunk) => {
1050 sseEvent("token", chunk);
1051 },
1052 });
1053
1054 sseEvent(
1055 "done",
1056 JSON.stringify({
1057 chat_id: chatId,
1058 message_id: stored?.id || null,
1059 citations: stored?.citations ?? [],
1060 token_cost: stored?.tokenCost ?? 0,
1061 })
1062 );
1063 } catch (err) {
1064 const msg = err instanceof Error ? err.message : "unknown error";
1065 sseEvent("error", JSON.stringify({ error: msg }));
1066 } finally {
1067 closed = true;
1068 try {
1069 controller.close();
1070 } catch {
1071 /* already closed */
1072 }
1073 }
1074 },
1075 });
1076
38d31d3Claude1077 return new Response(stream, {
1078 status: 200,
1079 headers: {
1080 "Content-Type": "text/event-stream; charset=utf-8",
1081 "Cache-Control": "no-cache, no-transform",
1082 Connection: "keep-alive",
1083 "X-Accel-Buffering": "no",
1084 },
1085 });
1086 }
1087);
1088
45e31d0Claude1089// ─── Issues ─────────────────────────────────────────────────────────────────
1090
1091apiv2.get("/repos/:owner/:repo/issues", async (c) => {
1092 const { owner, repo } = c.req.param();
1093 const state = c.req.query("state") || "open";
1094 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1095
1096 const resolved = await resolveRepo(owner, repo);
1097 if (!resolved) return c.json({ error: "Not found" }, 404);
1098
1099 const issueList = await db
1100 .select({
1101 issue: issues,
1102 author: { username: users.username, id: users.id },
1103 })
1104 .from(issues)
1105 .innerJoin(users, eq(issues.authorId, users.id))
1106 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
1107 .orderBy(desc(issues.createdAt))
1108 .limit(limit);
1109
1110 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
1111});
1112
1113apiv2.post("/repos/:owner/:repo/issues", requireApiAuth, requireScope("repo"), async (c) => {
1114 const { owner, repo } = c.req.param();
1115 const user = c.get("user")!;
1116 const body = await c.req.json<{ title: string; body?: string }>();
1117
1118 if (!body.title?.trim()) {
1119 return c.json({ error: "Title is required" }, 400);
1120 }
1121
1122 const resolved = await resolveRepo(owner, repo);
1123 if (!resolved) return c.json({ error: "Not found" }, 404);
1124
1125 const result = await db
1126 .insert(issues)
1127 .values({
1128 repositoryId: (resolved.repo as any).id,
1129 authorId: user.id,
1130 title: body.title.trim(),
1131 body: body.body?.trim() || null,
1132 })
1133 .returning();
1134
1135 return c.json(result[0], 201);
1136});
1137
1138apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
1139 const { owner, repo } = c.req.param();
1140 const num = parseInt(c.req.param("number"), 10);
1141
1142 const resolved = await resolveRepo(owner, repo);
1143 if (!resolved) return c.json({ error: "Not found" }, 404);
1144
1145 const [issue] = await db
1146 .select()
1147 .from(issues)
1148 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
1149 .limit(1);
1150
1151 if (!issue) return c.json({ error: "Issue not found" }, 404);
1152
cb5a796Claude1153 // Only return approved comments through the public API surface —
1154 // pending/rejected/spam should never leak via JSON. Moderation
1155 // queue lives at the HTML `/comments/pending` page (owner only).
45e31d0Claude1156 const comments = await db
1157 .select({
1158 comment: issueComments,
1159 author: { username: users.username },
1160 })
1161 .from(issueComments)
1162 .innerJoin(users, eq(issueComments.authorId, users.id))
cb5a796Claude1163 .where(
1164 and(
1165 eq(issueComments.issueId, issue.id),
1166 eq(issueComments.moderationStatus, "approved")
1167 )
1168 )
45e31d0Claude1169 .orderBy(asc(issueComments.createdAt));
1170
1171 return c.json({
1172 ...issue,
1173 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
1174 });
1175});
1176
1177apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
1178 const { owner, repo } = c.req.param();
1179 const num = parseInt(c.req.param("number"), 10);
1180 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
1181
1182 const resolved = await resolveRepo(owner, repo);
1183 if (!resolved) return c.json({ error: "Not found" }, 404);
1184
1185 const updates: Record<string, any> = { updatedAt: new Date() };
1186 if (body.title !== undefined) updates.title = body.title;
1187 if (body.body !== undefined) updates.body = body.body;
1188 if (body.state === "closed") {
1189 updates.state = "closed";
1190 updates.closedAt = new Date();
1191 } else if (body.state === "open") {
1192 updates.state = "open";
1193 updates.closedAt = null;
1194 }
1195
1196 await db
1197 .update(issues)
1198 .set(updates)
1199 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
1200
1201 return c.json({ ok: true });
1202});
1203
1204apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
1205 const { owner, repo } = c.req.param();
1206 const num = parseInt(c.req.param("number"), 10);
1207 const user = c.get("user")!;
1208 const body = await c.req.json<{ body: string }>();
1209
1210 if (!body.body?.trim()) {
1211 return c.json({ error: "Comment body is required" }, 400);
1212 }
1213
1214 const resolved = await resolveRepo(owner, repo);
1215 if (!resolved) return c.json({ error: "Not found" }, 404);
1216
1217 const [issue] = await db
1218 .select()
1219 .from(issues)
1220 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
1221 .limit(1);
1222
1223 if (!issue) return c.json({ error: "Issue not found" }, 404);
1224
1225 const result = await db
1226 .insert(issueComments)
1227 .values({
1228 issueId: issue.id,
1229 authorId: user.id,
1230 body: body.body.trim(),
1231 })
1232 .returning();
1233
1234 return c.json(result[0], 201);
1235});
1236
1237// ─── Pull Requests ──────────────────────────────────────────────────────────
1238
1239apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
1240 const { owner, repo } = c.req.param();
1241 const state = c.req.query("state") || "open";
8c09fb9Claude1242 // Match the issue-list pagination contract: default 30, max 100,
1243 // 0-indexed offset for cursor-style scrolling. Bounded so a buggy
1244 // client can't accidentally pull the whole table.
1245 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit")) || 30));
1246 const offset = Math.max(0, Number(c.req.query("offset")) || 0);
45e31d0Claude1247
1248 const resolved = await resolveRepo(owner, repo);
1249 if (!resolved) return c.json({ error: "Not found" }, 404);
1250
1251 const prList = await db
1252 .select({
1253 pr: pullRequests,
1254 author: { username: users.username },
1255 })
1256 .from(pullRequests)
1257 .innerJoin(users, eq(pullRequests.authorId, users.id))
1258 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
8c09fb9Claude1259 .orderBy(desc(pullRequests.createdAt))
1260 .limit(limit)
1261 .offset(offset);
45e31d0Claude1262
1263 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
1264});
1265
1266apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
1267 const { owner, repo } = c.req.param();
1268 const user = c.get("user")!;
1269 const body = await c.req.json<{
1270 title: string;
1271 body?: string;
1272 baseBranch: string;
1273 headBranch: string;
1274 }>();
1275
1276 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
1277 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
1278 }
1279
1280 const resolved = await resolveRepo(owner, repo);
1281 if (!resolved) return c.json({ error: "Not found" }, 404);
1282
1283 const result = await db
1284 .insert(pullRequests)
1285 .values({
1286 repositoryId: (resolved.repo as any).id,
1287 authorId: user.id,
1288 title: body.title.trim(),
1289 body: body.body?.trim() || null,
1290 baseBranch: body.baseBranch,
1291 headBranch: body.headBranch,
1292 })
1293 .returning();
1294
1295 return c.json(result[0], 201);
1296});
1297
1298apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
1299 const { owner, repo } = c.req.param();
1300 const num = parseInt(c.req.param("number"), 10);
1301
1302 const resolved = await resolveRepo(owner, repo);
1303 if (!resolved) return c.json({ error: "Not found" }, 404);
1304
1305 const [pr] = await db
1306 .select()
1307 .from(pullRequests)
1308 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
1309 .limit(1);
1310
1311 if (!pr) return c.json({ error: "PR not found" }, 404);
1312
cb5a796Claude1313 // Only return approved comments through the public API surface.
45e31d0Claude1314 const comments = await db
1315 .select({
1316 comment: prComments,
1317 author: { username: users.username },
1318 })
1319 .from(prComments)
1320 .innerJoin(users, eq(prComments.authorId, users.id))
cb5a796Claude1321 .where(
1322 and(
1323 eq(prComments.pullRequestId, pr.id),
1324 eq(prComments.moderationStatus, "approved")
1325 )
1326 )
45e31d0Claude1327 .orderBy(asc(prComments.createdAt));
1328
1329 return c.json({
1330 ...pr,
1331 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
1332 });
1333});
1334
4bbacbeClaude1335// ─── Branch previews (migration 0062) ──────────────────────────────────────
1336//
1337// GET /api/v2/repos/:owner/:repo/previews
1338// → list every active preview row, newest first.
1339//
1340// POST /api/v2/repos/:owner/:repo/previews/:branch/refresh
1341// → force a fresh build for the given branch. The branch must already
1342// exist and not be the default. The current HEAD of the branch is
1343// used as the commit_sha.
1344
1345apiv2.get("/repos/:owner/:repo/previews", async (c) => {
1346 const { owner, repo } = c.req.param();
1347 const resolved = await resolveRepo(owner, repo);
1348 if (!resolved) return c.json({ error: "Not found" }, 404);
1349 const rows = await listPreviewsForRepo((resolved.repo as { id: string }).id);
1350 return c.json({
1351 previews: rows.map((p) => ({
1352 id: p.id,
1353 branchName: p.branchName,
1354 commitSha: p.commitSha,
1355 previewUrl: p.previewUrl,
1356 status: p.status,
1357 buildStartedAt: p.buildStartedAt,
1358 buildCompletedAt: p.buildCompletedAt,
1359 expiresAt: p.expiresAt,
1360 errorMessage: p.errorMessage,
1361 })),
1362 });
1363});
1364
1365apiv2.post(
1366 "/repos/:owner/:repo/previews/:branch/refresh",
1367 requireApiAuth,
1368 requireScope("repo"),
1369 async (c) => {
1370 const { owner, repo } = c.req.param();
1371 const branch = c.req.param("branch");
1372 if (!branch) return c.json({ error: "branch is required" }, 400);
1373
1374 const resolved = await resolveRepo(owner, repo);
1375 if (!resolved) return c.json({ error: "Not found" }, 404);
1376
1377 // Refuse to "preview" the default branch — by design previews exist
1378 // only for non-default branches.
1379 if ((resolved.repo as { defaultBranch: string }).defaultBranch === branch) {
1380 return c.json(
1381 {
1382 error: "Default branch has no preview — previews are for non-default branches",
1383 },
1384 400
1385 );
1386 }
1387
1388 // Resolve the branch to its current SHA so the new row points at HEAD.
1389 let sha: string | null = null;
1390 try {
1391 sha = await resolveRef(owner, repo, branch);
1392 } catch {
1393 sha = null;
1394 }
1395 if (!sha) return c.json({ error: "Branch not found" }, 404);
1396
1397 const row = await enqueuePreviewBuild({
1398 repositoryId: (resolved.repo as { id: string }).id,
1399 ownerName: owner,
1400 repoName: repo,
1401 branchName: branch,
1402 commitSha: sha,
1403 });
1404 if (!row) {
1405 return c.json({ error: "Could not enqueue preview build" }, 503);
1406 }
1407 return c.json({
1408 id: row.id,
1409 branchName: row.branchName,
1410 commitSha: row.commitSha,
1411 previewUrl: row.previewUrl,
1412 status: row.status,
1413 expiresAt: row.expiresAt,
1414 }, 202);
1415 }
1416);
1417
052c2e6Claude1418// ─── PR Comments (GateTest integration) ────────────────────────────────────
1419
1420apiv2.post(
1421 "/repos/:owner/:repo/pulls/:number/comments",
1422 requireApiAuth,
1423 requireScope("repo"),
1424 async (c) => {
1425 const { owner, repo } = c.req.param();
1426 const num = parseInt(c.req.param("number"), 10);
1427 const user = c.get("user")!;
1428
1429 let body: { body?: string } = {};
1430 try {
1431 body = await c.req.json();
1432 } catch {
1433 body = {};
1434 }
1435 if (!body.body?.trim()) {
1436 return c.json({ error: "Comment body is required" }, 400);
1437 }
1438
1439 const resolved = await resolveRepo(owner, repo);
1440 if (!resolved) return c.json({ error: "Not found" }, 404);
1441 if (user.id !== (resolved.owner as any).id) {
1442 return c.json({ error: "Forbidden" }, 403);
1443 }
1444
1445 const [pr] = await db
1446 .select()
1447 .from(pullRequests)
1448 .where(
1449 and(
1450 eq(pullRequests.repositoryId, (resolved.repo as any).id),
1451 eq(pullRequests.number, num)
1452 )
1453 )
1454 .limit(1);
1455 if (!pr) return c.json({ error: "PR not found" }, 404);
1456
1457 const [comment] = await db
1458 .insert(prComments)
1459 .values({
1460 pullRequestId: pr.id,
1461 authorId: user.id,
1462 body: body.body.trim(),
1463 })
1464 .returning();
1465
1466 return c.json({ ok: true, comment }, 201);
1467 }
1468);
1469
1470// ─── Git refs — create branch / tag from sha ────────────────────────────────
1471
1472apiv2.post(
1473 "/repos/:owner/:repo/git/refs",
1474 requireApiAuth,
1475 requireScope("repo"),
1476 async (c) => {
1477 const { owner, repo } = c.req.param();
1478 const user = c.get("user")!;
1479
1480 let body: { ref?: string; sha?: string } = {};
1481 try {
1482 body = await c.req.json();
1483 } catch {
1484 body = {};
1485 }
1486 const ref = body.ref?.trim();
1487 const sha = body.sha?.trim();
1488
1489 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
1490 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
1491 }
1492 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
1493 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
1494 }
1495
1496 const resolved = await resolveRepo(owner, repo);
1497 if (!resolved) return c.json({ error: "Not found" }, 404);
1498 if (user.id !== (resolved.owner as any).id) {
1499 return c.json({ error: "Forbidden" }, 403);
1500 }
1501
1502 // Verify sha reachable.
1503 if (!(await objectExists(owner, repo, sha))) {
1504 return c.json({ error: "sha not found in repository" }, 400);
1505 }
1506
1507 // Conflict check: if ref already exists, the existing sha must match.
1508 if (await refExists(owner, repo, ref)) {
1509 const existing = await resolveRef(owner, repo, ref);
1510 if (existing !== sha) {
1511 return c.json({ error: "ref already exists", existing }, 409);
1512 }
1513 }
1514
1515 const ok = await updateRef(owner, repo, ref, sha);
1516 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
1517
1518 return c.json({ ok: true, ref, sha }, 201);
1519 }
1520);
1521
1522// ─── Contents PUT — create/update a file via git plumbing ────────────────────
1523
1524apiv2.put(
1525 "/repos/:owner/:repo/contents/:path{.+$}",
1526 requireApiAuth,
1527 requireScope("repo"),
1528 async (c) => {
1529 const { owner, repo } = c.req.param();
1530 const filePath = c.req.param("path");
1531 const user = c.get("user")!;
1532
1533 let body: {
1534 message?: string;
1535 content?: string;
1536 branch?: string;
1537 sha?: string | null;
1538 } = {};
1539 try {
1540 body = await c.req.json();
1541 } catch {
1542 body = {};
1543 }
1544
1545 const message = body.message?.trim();
1546 const branch = body.branch?.trim();
1547 const base64 = body.content;
1548
1549 if (!message) return c.json({ error: "message is required" }, 400);
1550 if (!branch) return c.json({ error: "branch is required" }, 400);
1551 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
1552
1553 let bytes: Uint8Array;
1554 try {
1555 bytes = new Uint8Array(Buffer.from(base64, "base64"));
1556 } catch {
1557 return c.json({ error: "content is not valid base64" }, 400);
1558 }
1559 if (bytes.length > CONTENTS_MAX_BYTES) {
1560 return c.json({ error: "File too large" }, 413);
1561 }
1562
1563 const resolved = await resolveRepo(owner, repo);
1564 if (!resolved) return c.json({ error: "Not found" }, 404);
1565 if (user.id !== (resolved.owner as any).id) {
1566 return c.json({ error: "Forbidden" }, 403);
1567 }
1568
1569 const result = await createOrUpdateFileOnBranch({
1570 owner,
1571 name: repo,
1572 branch,
1573 filePath,
1574 bytes,
1575 message,
1576 authorName: (user as any).displayName || user.username,
1577 authorEmail: user.email,
1578 expectBlobSha: body.sha ?? null,
1579 });
1580
1581 if ("error" in result) {
1582 if (result.error === "sha-mismatch") {
1583 return c.json({ error: "sha does not match current blob at path" }, 409);
1584 }
1585 return c.json({ error: "Failed to write file" }, 500);
1586 }
1587
1588 return c.json(
1589 {
1590 ok: true,
1591 commit: { sha: result.commitSha, message },
1592 content: { path: filePath, sha: result.blobSha },
1593 },
1594 201
1595 );
1596 }
1597);
1598
da50b83Claude1599// ─── Helper: shell out to git in a bare repo ─────────────────────────────────
1600//
1601// Mirrors the pattern in src/git/repository.ts. Kept inline here so the
1602// plumbing endpoints below don't have to leak through the helper module.
1603
1604async function runGit(
1605 cmd: string[],
1606 opts: { cwd: string; env?: Record<string, string>; stdin?: Uint8Array }
1607): Promise<{ stdout: string; stderr: string; exitCode: number }> {
1608 const proc = Bun.spawn(cmd, {
1609 cwd: opts.cwd,
1610 env: { ...process.env, ...opts.env },
1611 stdin: opts.stdin ? "pipe" : "ignore",
1612 stdout: "pipe",
1613 stderr: "pipe",
1614 });
1615 if (opts.stdin && proc.stdin) {
1616 proc.stdin.write(opts.stdin);
1617 proc.stdin.end();
1618 }
1619 const [stdout, stderr] = await Promise.all([
1620 new Response(proc.stdout).text(),
1621 new Response(proc.stderr).text(),
1622 ]);
1623 const exitCode = await proc.exited;
1624 return { stdout, stderr, exitCode };
1625}
1626
1627function htmlUrlForCommit(owner: string, repo: string, sha: string): string {
1628 return `${config.appBaseUrl}/${owner}/${repo}/commit/${sha}`;
1629}
1630
1631// ─── Contents DELETE — remove a file via git plumbing ────────────────────────
1632//
1633// Body: { message, sha, branch? }
1634// - `sha` is the current blob sha at `:path`; mismatch → 409 (optimistic
1635// concurrency, matches GitHub's `DELETE /repos/.../contents/...` semantics).
1636// - `branch` defaults to the repo's default branch.
1637
1638apiv2.delete(
1639 "/repos/:owner/:repo/contents/:path{.+$}",
1640 requireApiAuth,
1641 requireScope("repo"),
1642 async (c) => {
1643 const { owner, repo } = c.req.param();
1644 const filePath = c.req.param("path");
1645 const user = c.get("user")!;
1646
1647 let body: { message?: string; sha?: string; branch?: string } = {};
1648 try {
1649 body = await c.req.json();
1650 } catch {
1651 body = {};
1652 }
1653
1654 const message = body.message?.trim();
1655 const expectSha = body.sha?.trim();
1656 if (!message) return c.json({ error: "message is required" }, 400);
1657 if (!expectSha || !/^[0-9a-f]{40}$/.test(expectSha)) {
1658 return c.json({ error: "sha is required (40-hex)" }, 400);
1659 }
1660
1661 const resolved = await resolveRepo(owner, repo);
1662 if (!resolved) return c.json({ error: "Not found" }, 404);
1663 if (user.id !== (resolved.owner as any).id) {
1664 return c.json({ error: "Forbidden" }, 403);
1665 }
1666
1667 const branch =
1668 body.branch?.trim() ||
1669 (await getDefaultBranchFresh(owner, repo)) ||
1670 "main";
1671 const fullRef = `refs/heads/${branch}`;
1672 const repoDir = getRepoPath(owner, repo);
1673
1674 // Resolve current parent + existing blob sha at that path.
1675 const parentSha = await resolveRef(owner, repo, fullRef);
1676 if (!parentSha) return c.json({ error: "Branch not found" }, 404);
1677
1678 const existingBlobSha = await getBlobShaAtPath(owner, repo, branch, filePath);
1679 if (!existingBlobSha) return c.json({ error: "File not found" }, 404);
1680 if (existingBlobSha !== expectSha) {
1681 return c.json({ error: "sha does not match current blob at path" }, 409);
1682 }
1683
1684 const tmpIndex = join(
1685 repoDir,
1686 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1687 );
296ee49Claude1688 // `update-index --remove` checks `is_inside_work_tree()`, so a bare
1689 // repo needs a transient stand-in. Empty directory is sufficient —
1690 // git only consults it for safety checks, never writes blobs through it.
1691 const tmpWorkTree = join(
1692 repoDir,
1693 `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1694 );
1695 const { mkdir } = await import("fs/promises");
1696 await mkdir(tmpWorkTree, { recursive: true });
1697
da50b83Claude1698 const authorName = (user as any).displayName || user.username;
1699 const authorEmail = user.email;
1700 const env = {
1701 GIT_INDEX_FILE: tmpIndex,
296ee49Claude1702 GIT_DIR: repoDir,
1703 GIT_WORK_TREE: tmpWorkTree,
da50b83Claude1704 GIT_AUTHOR_NAME: authorName,
1705 GIT_AUTHOR_EMAIL: authorEmail,
1706 GIT_COMMITTER_NAME: authorName,
1707 GIT_COMMITTER_EMAIL: authorEmail,
1708 };
1709
1710 const cleanup = async () => {
1711 try {
296ee49Claude1712 const { unlink, rm } = await import("fs/promises");
1713 await unlink(tmpIndex).catch(() => {});
1714 await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {});
da50b83Claude1715 } catch {
1716 /* ignore */
1717 }
1718 };
1719
1720 try {
1721 const rt = await runGit(["git", "read-tree", parentSha], {
1722 cwd: repoDir,
1723 env,
1724 });
1725 if (rt.exitCode !== 0) {
1726 await cleanup();
1727 return c.json({ error: "Failed to read base tree" }, 500);
1728 }
1729
1730 const ui = await runGit(
1731 ["git", "update-index", "--remove", filePath],
1732 { cwd: repoDir, env }
1733 );
1734 if (ui.exitCode !== 0) {
1735 await cleanup();
1736 return c.json({ error: "Failed to remove path from index" }, 500);
1737 }
1738
1739 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1740 const newTreeSha = wt.stdout.trim();
1741 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(newTreeSha)) {
1742 await cleanup();
1743 return c.json({ error: "Failed to write tree" }, 500);
1744 }
1745
1746 const ct = await runGit(
1747 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1748 { cwd: repoDir, env }
1749 );
1750 const commitSha = ct.stdout.trim();
1751 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1752 await cleanup();
1753 return c.json({ error: "Failed to create commit" }, 500);
1754 }
1755
1756 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha);
1757 if (!ok) {
1758 await cleanup();
1759 return c.json({ error: "Failed to update ref" }, 500);
1760 }
1761
1762 await cleanup();
1763 return c.json({
1764 commit: {
1765 sha: commitSha,
1766 message,
1767 html_url: htmlUrlForCommit(owner, repo, commitSha),
1768 author: { name: authorName, email: authorEmail },
1769 },
1770 });
1771 } catch {
1772 await cleanup();
1773 return c.json({ error: "Failed to delete file" }, 500);
1774 }
1775 }
1776);
1777
1778// ─── Git plumbing: refs / commits / blobs / trees ────────────────────────────
1779
1780// GET /repos/:owner/:repo/git/refs/heads/:branch
1781apiv2.get(
1782 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1783 async (c) => {
1784 const { owner, repo } = c.req.param();
1785 const branch = c.req.param("branch");
1786 if (!(await repoExists(owner, repo))) {
1787 return c.json({ error: "Not found" }, 404);
1788 }
1789 const fullRef = `refs/heads/${branch}`;
1790 const sha = await resolveRef(owner, repo, fullRef);
1791 if (!sha) return c.json({ error: "Reference not found" }, 404);
1792 return c.json({
1793 ref: fullRef,
1794 object: { sha, type: "commit" },
1795 });
1796 }
1797);
1798
1799// GET /repos/:owner/:repo/git/commits/:sha
1800apiv2.get("/repos/:owner/:repo/git/commits/:sha", async (c) => {
1801 const { owner, repo, sha } = c.req.param();
1802 if (!(await repoExists(owner, repo))) {
1803 return c.json({ error: "Not found" }, 404);
1804 }
1805 const commit = await getCommit(owner, repo, sha);
1806 if (!commit) return c.json({ error: "Commit not found" }, 404);
1807
1808 // Resolve tree sha for the commit (cat-file <sha>^{tree}).
1809 const repoDir = getRepoPath(owner, repo);
1810 const { stdout: treeOut } = await runGit(
1811 ["git", "rev-parse", `${commit.sha}^{tree}`],
1812 { cwd: repoDir }
1813 );
1814 const treeSha = treeOut.trim();
1815
1816 return c.json({
1817 sha: commit.sha,
1818 tree: { sha: treeSha },
1819 parents: commit.parentShas.map((p) => ({ sha: p })),
1820 message: commit.message,
1821 author: {
1822 name: commit.author,
1823 email: commit.authorEmail,
1824 date: commit.date,
1825 },
1826 });
1827});
1828
1829// POST /repos/:owner/:repo/git/blobs
1830apiv2.post(
1831 "/repos/:owner/:repo/git/blobs",
1832 requireApiAuth,
1833 requireScope("repo"),
1834 async (c) => {
1835 const { owner, repo } = c.req.param();
1836 const user = c.get("user")!;
1837
1838 let body: { content?: string; encoding?: string } = {};
1839 try {
1840 body = await c.req.json();
1841 } catch {
1842 body = {};
1843 }
1844 const content = body.content;
1845 const encoding = (body.encoding || "utf-8").toLowerCase();
1846 if (typeof content !== "string") {
1847 return c.json({ error: "content is required" }, 400);
1848 }
1849 if (encoding !== "utf-8" && encoding !== "utf8" && encoding !== "base64") {
1850 return c.json({ error: "encoding must be 'utf-8' or 'base64'" }, 400);
1851 }
1852
1853 const resolved = await resolveRepo(owner, repo);
1854 if (!resolved) return c.json({ error: "Not found" }, 404);
1855 if (user.id !== (resolved.owner as any).id) {
1856 return c.json({ error: "Forbidden" }, 403);
1857 }
1858
1859 let bytes: Uint8Array;
1860 try {
1861 if (encoding === "base64") {
1862 bytes = new Uint8Array(Buffer.from(content, "base64"));
1863 } else {
1864 bytes = new TextEncoder().encode(content);
1865 }
1866 } catch {
1867 return c.json({ error: "Failed to decode content" }, 400);
1868 }
1869
1870 const sha = await writeBlob(owner, repo, bytes);
1871 if (!sha) return c.json({ error: "Failed to write blob" }, 500);
1872
1873 return c.json(
1874 {
1875 sha,
1876 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/blobs/${sha}`,
1877 size: bytes.length,
1878 },
1879 201
1880 );
1881 }
1882);
1883
1884// POST /repos/:owner/:repo/git/trees
1885//
1886// Body: { base_tree?: <tree_sha>, tree: [{ path, mode, type: "blob", sha: <blob_sha> | null }] }
1887// `sha: null` removes the entry from base_tree.
1888apiv2.post(
1889 "/repos/:owner/:repo/git/trees",
1890 requireApiAuth,
1891 requireScope("repo"),
1892 async (c) => {
1893 const { owner, repo } = c.req.param();
1894 const user = c.get("user")!;
1895
1896 let body: {
1897 base_tree?: string;
1898 tree?: Array<{
1899 path?: string;
1900 mode?: string;
1901 type?: string;
1902 sha?: string | null;
1903 }>;
1904 } = {};
1905 try {
1906 body = await c.req.json();
1907 } catch {
1908 body = {};
1909 }
1910
1911 if (!Array.isArray(body.tree)) {
1912 return c.json({ error: "tree array is required" }, 400);
1913 }
1914 for (const entry of body.tree) {
1915 if (!entry.path || typeof entry.path !== "string") {
1916 return c.json({ error: "each tree entry needs a path" }, 400);
1917 }
1918 if (!entry.mode || typeof entry.mode !== "string") {
1919 return c.json({ error: "each tree entry needs a mode" }, 400);
1920 }
1921 if (entry.sha !== null && typeof entry.sha !== "string") {
1922 return c.json(
1923 { error: "each tree entry needs sha (40-hex) or null to delete" },
1924 400
1925 );
1926 }
1927 if (typeof entry.sha === "string" && !/^[0-9a-f]{40}$/.test(entry.sha)) {
1928 return c.json({ error: "sha must be 40-hex" }, 400);
1929 }
1930 }
1931 if (body.base_tree && !/^[0-9a-f]{40}$/.test(body.base_tree)) {
1932 return c.json({ error: "base_tree must be 40-hex" }, 400);
1933 }
1934
1935 const resolved = await resolveRepo(owner, repo);
1936 if (!resolved) return c.json({ error: "Not found" }, 404);
1937 if (user.id !== (resolved.owner as any).id) {
1938 return c.json({ error: "Forbidden" }, 403);
1939 }
1940
1941 const repoDir = getRepoPath(owner, repo);
1942 const tmpIndex = join(
1943 repoDir,
1944 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1945 );
1946 const env = { GIT_INDEX_FILE: tmpIndex };
1947
1948 const cleanup = async () => {
1949 try {
1950 const { unlink } = await import("fs/promises");
1951 await unlink(tmpIndex);
1952 } catch {
1953 /* ignore */
1954 }
1955 };
1956
1957 try {
1958 if (body.base_tree) {
1959 const rt = await runGit(["git", "read-tree", body.base_tree], {
1960 cwd: repoDir,
1961 env,
1962 });
1963 if (rt.exitCode !== 0) {
1964 await cleanup();
1965 return c.json({ error: "base_tree not found" }, 404);
1966 }
1967 }
1968
1969 for (const entry of body.tree) {
1970 if (entry.sha === null) {
1971 const r = await runGit(
1972 ["git", "update-index", "--remove", entry.path!],
1973 { cwd: repoDir, env }
1974 );
1975 if (r.exitCode !== 0) {
1976 await cleanup();
1977 return c.json(
1978 { error: `Failed to remove ${entry.path}` },
1979 422
1980 );
1981 }
1982 } else {
1983 const r = await runGit(
1984 [
1985 "git",
1986 "update-index",
1987 "--add",
1988 "--cacheinfo",
1989 `${entry.mode},${entry.sha},${entry.path}`,
1990 ],
1991 { cwd: repoDir, env }
1992 );
1993 if (r.exitCode !== 0) {
1994 await cleanup();
1995 return c.json(
1996 { error: `Failed to add ${entry.path}` },
1997 422
1998 );
1999 }
2000 }
2001 }
2002
2003 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
2004 const treeSha = wt.stdout.trim();
2005 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(treeSha)) {
2006 await cleanup();
2007 return c.json({ error: "Failed to write tree" }, 500);
2008 }
2009
2010 // List entries in the new tree (one level deep, matching GitHub's
2011 // POST /git/trees response shape).
2012 const ls = await runGit(["git", "ls-tree", treeSha], { cwd: repoDir });
2013 const entries: Array<{
2014 path: string;
2015 mode: string;
2016 type: string;
2017 sha: string;
2018 }> = [];
2019 for (const line of ls.stdout.split("\n").filter(Boolean)) {
2020 const m = line.match(
2021 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\t(.+)$/
2022 );
2023 if (m) {
2024 entries.push({
2025 mode: m[1],
2026 type: m[2],
2027 sha: m[3],
2028 path: m[4],
2029 });
2030 }
2031 }
2032
2033 await cleanup();
2034 return c.json(
2035 {
2036 sha: treeSha,
2037 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/trees/${treeSha}`,
2038 tree: entries,
2039 },
2040 201
2041 );
2042 } catch {
2043 await cleanup();
2044 return c.json({ error: "Failed to write tree" }, 500);
2045 }
2046 }
2047);
2048
2049// POST /repos/:owner/:repo/git/commits
2050//
2051// Body: { message, tree, parents: [<sha>] }
2052apiv2.post(
2053 "/repos/:owner/:repo/git/commits",
2054 requireApiAuth,
2055 requireScope("repo"),
2056 async (c) => {
2057 const { owner, repo } = c.req.param();
2058 const user = c.get("user")!;
2059
2060 let body: { message?: string; tree?: string; parents?: string[] } = {};
2061 try {
2062 body = await c.req.json();
2063 } catch {
2064 body = {};
2065 }
2066
2067 const message = body.message?.trim();
2068 const tree = body.tree?.trim();
2069 const parents = Array.isArray(body.parents) ? body.parents : [];
2070
2071 if (!message) return c.json({ error: "message is required" }, 400);
2072 if (!tree || !/^[0-9a-f]{40}$/.test(tree)) {
2073 return c.json({ error: "tree must be 40-hex" }, 400);
2074 }
2075 for (const p of parents) {
2076 if (typeof p !== "string" || !/^[0-9a-f]{40}$/.test(p)) {
2077 return c.json({ error: "each parent must be 40-hex" }, 400);
2078 }
2079 }
2080
2081 const resolved = await resolveRepo(owner, repo);
2082 if (!resolved) return c.json({ error: "Not found" }, 404);
2083 if (user.id !== (resolved.owner as any).id) {
2084 return c.json({ error: "Forbidden" }, 403);
2085 }
2086
2087 // Verify tree object exists.
2088 if (!(await objectExists(owner, repo, tree))) {
2089 return c.json({ error: "tree not found in repository" }, 422);
2090 }
2091 for (const p of parents) {
2092 if (!(await objectExists(owner, repo, p))) {
2093 return c.json({ error: `parent ${p} not found in repository` }, 422);
2094 }
2095 }
2096
2097 const repoDir = getRepoPath(owner, repo);
2098 const authorName = (user as any).displayName || user.username;
2099 const authorEmail = user.email;
2100 const env = {
2101 GIT_AUTHOR_NAME: authorName,
2102 GIT_AUTHOR_EMAIL: authorEmail,
2103 GIT_COMMITTER_NAME: authorName,
2104 GIT_COMMITTER_EMAIL: authorEmail,
2105 };
2106
2107 const args = ["git", "commit-tree", tree];
2108 for (const p of parents) {
2109 args.push("-p", p);
2110 }
2111 args.push("-m", message);
2112
2113 const ct = await runGit(args, { cwd: repoDir, env });
2114 const commitSha = ct.stdout.trim();
2115 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
2116 return c.json({ error: "Failed to create commit" }, 500);
2117 }
2118
2119 // Re-read the new commit's recorded date so the response carries the
2120 // exact ISO timestamp git wrote into the object.
2121 const recorded = await getCommit(owner, repo, commitSha);
2122 const date = recorded?.date ?? new Date().toISOString();
2123
2124 return c.json(
2125 {
2126 sha: commitSha,
2127 tree: { sha: tree },
2128 message,
2129 parents: parents.map((p) => ({ sha: p })),
2130 author: { name: authorName, email: authorEmail, date },
2131 html_url: htmlUrlForCommit(owner, repo, commitSha),
2132 },
2133 201
2134 );
2135 }
2136);
2137
2138// PATCH /repos/:owner/:repo/git/refs/heads/:branch
2139//
2140// Body: { sha: <new_commit>, force?: false }
2141apiv2.patch(
2142 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
2143 requireApiAuth,
2144 requireScope("repo"),
2145 async (c) => {
2146 const { owner, repo } = c.req.param();
2147 const branch = c.req.param("branch");
2148 const user = c.get("user")!;
2149
2150 let body: { sha?: string; force?: boolean } = {};
2151 try {
2152 body = await c.req.json();
2153 } catch {
2154 body = {};
2155 }
2156
2157 const newSha = body.sha?.trim();
2158 const force = body.force === true;
2159 if (!newSha || !/^[0-9a-f]{40}$/.test(newSha)) {
2160 return c.json({ error: "sha must be 40-hex" }, 400);
2161 }
2162
2163 const resolved = await resolveRepo(owner, repo);
2164 if (!resolved) return c.json({ error: "Not found" }, 404);
2165 if (user.id !== (resolved.owner as any).id) {
2166 return c.json({ error: "Forbidden" }, 403);
2167 }
2168
2169 const fullRef = `refs/heads/${branch}`;
2170 const currentSha = await resolveRef(owner, repo, fullRef);
2171 if (!currentSha) return c.json({ error: "Reference not found" }, 404);
2172
2173 if (!(await objectExists(owner, repo, newSha))) {
2174 return c.json({ error: "sha not found in repository" }, 422);
2175 }
2176
2177 if (!force) {
2178 // Fast-forward check: currentSha must be an ancestor of newSha.
2179 const repoDir = getRepoPath(owner, repo);
2180 const ff = await runGit(
2181 ["git", "merge-base", "--is-ancestor", currentSha, newSha],
2182 { cwd: repoDir }
2183 );
2184 if (ff.exitCode !== 0) {
2185 return c.json(
2186 { error: "Update is not a fast-forward" },
2187 422
2188 );
2189 }
2190 }
2191
2192 const ok = force
2193 ? await updateRef(owner, repo, fullRef, newSha)
2194 : await updateRef(owner, repo, fullRef, newSha, currentSha);
2195 if (!ok) return c.json({ error: "Failed to update ref" }, 500);
2196
2197 return c.json({
2198 ref: fullRef,
2199 object: { sha: newSha, type: "commit" },
2200 });
2201 }
2202);
2203
052c2e6Claude2204// ─── v2 alias for commit-status POST ─────────────────────────────────────────
2205// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
2206apiv2.post(
2207 "/repos/:owner/:repo/statuses/:sha",
2208 requireApiAuth,
2209 requireScope("repo"),
2210 postCommitStatusHandler
2211);
2212
45e31d0Claude2213// ─── Stars ──────────────────────────────────────────────────────────────────
2214
2215apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
2216 const { owner, repo } = c.req.param();
2217 const user = c.get("user")!;
2218
2219 const resolved = await resolveRepo(owner, repo);
2220 if (!resolved) return c.json({ error: "Not found" }, 404);
2221
2222 const repoId = (resolved.repo as any).id;
2223 const [existing] = await db
2224 .select()
2225 .from(stars)
2226 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
2227 .limit(1);
2228
2229 if (!existing) {
2230 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
2231 await db
2232 .update(repositories)
2233 .set({ starCount: sql`${repositories.starCount} + 1` })
2234 .where(eq(repositories.id, repoId));
2235 }
2236
2237 return c.json({ starred: true });
2238});
2239
2240apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
2241 const { owner, repo } = c.req.param();
2242 const user = c.get("user")!;
2243
2244 const resolved = await resolveRepo(owner, repo);
2245 if (!resolved) return c.json({ error: "Not found" }, 404);
2246
2247 const repoId = (resolved.repo as any).id;
2248 const [existing] = await db
2249 .select()
2250 .from(stars)
2251 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
2252 .limit(1);
2253
2254 if (existing) {
2255 await db.delete(stars).where(eq(stars.id, existing.id));
2256 await db
2257 .update(repositories)
2258 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
2259 .where(eq(repositories.id, repoId));
2260 }
2261
2262 return c.json({ starred: false });
2263});
2264
2265// ─── Labels ─────────────────────────────────────────────────────────────────
2266
2267apiv2.get("/repos/:owner/:repo/labels", async (c) => {
2268 const { owner, repo } = c.req.param();
2269 const resolved = await resolveRepo(owner, repo);
2270 if (!resolved) return c.json({ error: "Not found" }, 404);
2271
2272 const labelList = await db
2273 .select()
2274 .from(labels)
2275 .where(eq(labels.repositoryId, (resolved.repo as any).id))
2276 .orderBy(asc(labels.name));
2277
2278 return c.json(labelList);
2279});
2280
2281apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
2282 const { owner, repo } = c.req.param();
2283 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
2284
2285 if (!body.name?.trim()) {
2286 return c.json({ error: "Label name is required" }, 400);
2287 }
2288
2289 const resolved = await resolveRepo(owner, repo);
2290 if (!resolved) return c.json({ error: "Not found" }, 404);
2291
2292 const result = await db
2293 .insert(labels)
2294 .values({
2295 repositoryId: (resolved.repo as any).id,
2296 name: body.name.trim(),
2297 color: body.color || "#8b949e",
2298 description: body.description || null,
2299 })
2300 .returning();
2301
2302 return c.json(result[0], 201);
2303});
2304
2305// ─── Search ─────────────────────────────────────────────────────────────────
2306
2307apiv2.get("/search/repos", searchRateLimit, async (c) => {
2308 const q = c.req.query("q") || "";
2309 const sort = c.req.query("sort") || "stars";
2310 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
2311
2312 if (!q.trim()) {
2313 return c.json({ error: "Query parameter 'q' is required" }, 400);
2314 }
2315
2316 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
2317 sort === "name" ? asc(repositories.name) :
2318 desc(repositories.starCount);
2319
2320 const results = await db
2321 .select({
2322 repo: repositories,
2323 owner: { username: users.username },
2324 })
2325 .from(repositories)
2326 .innerJoin(users, eq(repositories.ownerId, users.id))
2327 .where(
2328 and(
2329 eq(repositories.isPrivate, false),
2330 or(
2331 like(repositories.name, `%${q}%`),
2332 like(repositories.description, `%${q}%`)
2333 )
2334 )
2335 )
2336 .orderBy(orderBy)
2337 .limit(limit);
2338
2339 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
2340});
2341
2342apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
2343 const { owner, repo } = c.req.param();
2344 const q = c.req.query("q") || "";
2345
2346 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
2347 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
2348
2349 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
2350 const results = await searchCode(owner, repo, defaultBranch, q.trim());
2351 return c.json(results);
2352});
2353
2354// ─── Activity Feed ──────────────────────────────────────────────────────────
2355
2356apiv2.get("/repos/:owner/:repo/activity", async (c) => {
2357 const { owner, repo } = c.req.param();
2358 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
2359
2360 const resolved = await resolveRepo(owner, repo);
2361 if (!resolved) return c.json({ error: "Not found" }, 404);
2362
2363 const activity = await db
2364 .select()
2365 .from(activityFeed)
2366 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
2367 .orderBy(desc(activityFeed.createdAt))
2368 .limit(limit);
2369
2370 return c.json(activity);
2371});
2372
2373// ─── Topics ─────────────────────────────────────────────────────────────────
2374
2375apiv2.get("/repos/:owner/:repo/topics", async (c) => {
2376 const { owner, repo } = c.req.param();
2377 const resolved = await resolveRepo(owner, repo);
2378 if (!resolved) return c.json({ error: "Not found" }, 404);
2379
2380 const topicsList = await db
2381 .select()
2382 .from(repoTopics)
2383 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
2384
2385 return c.json(topicsList.map((t: any) => t.topic));
2386});
2387
2388apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
2389 const { owner, repo } = c.req.param();
2390 const user = c.get("user")!;
2391 const body = await c.req.json<{ topics: string[] }>();
2392
2393 const resolved = await resolveRepo(owner, repo);
2394 if (!resolved) return c.json({ error: "Not found" }, 404);
2395 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2396
2397 const repoId = (resolved.repo as any).id;
2398
2399 // Clear existing topics and insert new ones
2400 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
2401 if (body.topics && body.topics.length > 0) {
2402 await db.insert(repoTopics).values(
2403 body.topics.slice(0, 20).map((topic: string) => ({
2404 repositoryId: repoId,
2405 topic: topic.toLowerCase().trim(),
2406 }))
2407 );
2408 }
2409
2410 return c.json({ ok: true });
2411});
2412
2413// ─── Webhooks ───────────────────────────────────────────────────────────────
2414
2415apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
2416 const { owner, repo } = c.req.param();
2417 const user = c.get("user")!;
2418
2419 const resolved = await resolveRepo(owner, repo);
2420 if (!resolved) return c.json({ error: "Not found" }, 404);
2421 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2422
2423 const hookList = await db
2424 .select()
2425 .from(webhooks)
2426 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
2427
2428 return c.json(hookList);
2429});
2430
2431apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
2432 const { owner, repo } = c.req.param();
2433 const user = c.get("user")!;
2434 const body = await c.req.json<{
2435 url: string;
2436 secret?: string;
2437 events?: string;
2438 }>();
2439
2440 if (!body.url) return c.json({ error: "URL is required" }, 400);
2441
2442 const resolved = await resolveRepo(owner, repo);
2443 if (!resolved) return c.json({ error: "Not found" }, 404);
2444 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2445
2446 const result = await db
2447 .insert(webhooks)
2448 .values({
2449 repositoryId: (resolved.repo as any).id,
2450 url: body.url,
2451 secret: body.secret || null,
2452 events: body.events || "push",
2453 })
2454 .returning();
2455
2456 return c.json(result[0], 201);
2457});
2458
4366c70Claude2459// ─── Actions / Workflows ────────────────────────────────────────────────────
2460//
2461// GitHub-Actions-compatible REST surface (subset).
2462//
2463// POST /repos/:owner/:repo/actions/workflows/:filename/dispatches
2464// GET /repos/:owner/:repo/actions/workflows/:filename/runs
2465// GET /repos/:owner/:repo/actions/runs/:run_id
2466// GET /repos/:owner/:repo/actions/runs/:run_id/logs (.zip)
2467// POST /repos/:owner/:repo/actions/runs/:run_id/cancel
2468//
2469// Shapes follow GitHub REST v3 — snake_case fields, HTML URLs back to the
2470// gluecron run page, identical status-code semantics (204 for dispatch, 202
2471// for cancel, 409 for already-terminal, 422 for bad inputs).
2472
2473type ParsedOn =
2474 | string
2475 | string[]
2476 | Record<string, unknown>
2477 | null
2478 | undefined;
2479
2480type DispatchInputSpec = {
2481 type?: string;
2482 required?: boolean;
2483 default?: unknown;
2484 options?: unknown[];
2485 description?: string;
2486};
2487
2488/**
2489 * Pull the workflow_dispatch slice out of whatever shape `parsed.on` happens
2490 * to be. The v1 parser normalises `on` to a `string[]`, but the extended
2491 * parser may store an object — and YAML in the wild can be either form. We
2492 * accept all three: scalar string, array of event names, mapping keyed by
2493 * event name.
2494 */
2495function extractDispatchSpec(rawOn: ParsedOn): {
2496 enabled: boolean;
2497 inputs: Record<string, DispatchInputSpec> | null;
2498} {
2499 if (rawOn == null) return { enabled: false, inputs: null };
2500 if (typeof rawOn === "string") {
2501 return { enabled: rawOn === "workflow_dispatch", inputs: null };
2502 }
2503 if (Array.isArray(rawOn)) {
2504 return { enabled: rawOn.includes("workflow_dispatch"), inputs: null };
2505 }
2506 if (typeof rawOn === "object") {
2507 const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"];
2508 if (slot === undefined) return { enabled: false, inputs: null };
2509 // `workflow_dispatch:` with no children is a valid trigger declaration.
2510 if (slot == null || typeof slot !== "object") {
2511 return { enabled: true, inputs: null };
2512 }
2513 const inputs = (slot as Record<string, unknown>).inputs;
2514 if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
2515 return { enabled: true, inputs: null };
2516 }
2517 return {
2518 enabled: true,
2519 inputs: inputs as Record<string, DispatchInputSpec>,
2520 };
2521 }
2522 return { enabled: false, inputs: null };
2523}
2524
2525function validateDispatchInputs(
2526 schema: Record<string, DispatchInputSpec> | null,
2527 provided: Record<string, unknown> | undefined
2528): { ok: true } | { ok: false; details: string[] } {
2529 if (!schema) return { ok: true };
2530 const details: string[] = [];
2531 const supplied = provided ?? {};
2532 for (const [name, spec] of Object.entries(schema)) {
2533 if (!spec || typeof spec !== "object") continue;
2534 const present =
2535 Object.prototype.hasOwnProperty.call(supplied, name) &&
2536 supplied[name] !== undefined &&
2537 supplied[name] !== null;
2538 if (spec.required && !present) {
2539 // No default → required input is missing.
2540 if (spec.default === undefined) {
2541 details.push(`Missing required input: ${name}`);
2542 }
2543 }
2544 }
2545 if (details.length) return { ok: false, details };
2546 return { ok: true };
2547}
2548
2549/**
2550 * Look up a workflow row by repository + the basename of its `path` column.
2551 * Stored path is `.gluecron/workflows/<filename>`; we match the trailing
2552 * segment so callers don't need to know our on-disk layout.
2553 */
2554async function findWorkflowByFilename(
2555 repositoryId: string,
2556 filename: string
2557): Promise<typeof workflows.$inferSelect | null> {
2558 const rows = await db
2559 .select()
2560 .from(workflows)
2561 .where(eq(workflows.repositoryId, repositoryId));
2562 for (const row of rows) {
2563 const idx = row.path.lastIndexOf("/");
2564 const base = idx >= 0 ? row.path.slice(idx + 1) : row.path;
2565 if (base === filename) return row;
2566 }
2567 return null;
2568}
2569
2570function runHtmlUrl(owner: string, repo: string, runId: string): string {
2571 return `https://gluecron.com/${owner}/${repo}/actions/runs/${runId}`;
2572}
2573
2574function toIso(d: Date | string | null | undefined): string | null {
2575 if (!d) return null;
2576 return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
2577}
2578
2579function serializeRun(
2580 run: typeof workflowRuns.$inferSelect,
2581 workflowName: string,
2582 owner: string,
2583 repo: string
2584): Record<string, unknown> {
2585 // GitHub returns `head_branch` as the short branch name (no refs/heads/).
2586 let head_branch: string | null = null;
2587 if (run.ref) {
2588 head_branch = run.ref.startsWith("refs/heads/")
2589 ? run.ref.slice("refs/heads/".length)
2590 : run.ref;
2591 }
2592 return {
2593 id: run.id,
2594 name: workflowName,
2595 head_branch,
2596 head_sha: run.commitSha,
2597 status: run.status,
2598 conclusion: run.conclusion,
2599 event: run.event,
2600 created_at: toIso(run.queuedAt),
2601 updated_at:
2602 toIso(run.finishedAt) ?? toIso(run.startedAt) ?? toIso(run.queuedAt),
2603 run_started_at: toIso(run.startedAt),
2604 html_url: runHtmlUrl(owner, repo, run.id),
2605 };
2606}
2607
2608// ─── 1. POST /actions/workflows/:filename/dispatches ────────────────────────
2609
2610apiv2.post(
2611 "/repos/:owner/:repo/actions/workflows/:filename/dispatches",
2612 requireApiAuth,
2613 requireScope("repo"),
2614 async (c) => {
2615 const { owner, repo, filename } = c.req.param();
2616 const user = c.get("user")!;
2617
2618 let body: { ref?: unknown; inputs?: unknown } = {};
2619 try {
2620 body = await c.req.json();
2621 } catch {
2622 body = {};
2623 }
2624
2625 const resolved = await resolveRepo(owner, repo);
2626 if (!resolved) return c.json({ error: "Not Found" }, 404);
2627
2628 const repoRow = resolved.repo as any;
2629 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2630 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2631
2632 // Parse the stored workflow JSON to look at its triggers + input schema.
2633 let parsedObj: Record<string, unknown> = {};
2634 try {
2635 const v = JSON.parse(workflowRow.parsed);
2636 if (v && typeof v === "object" && !Array.isArray(v)) {
2637 parsedObj = v as Record<string, unknown>;
2638 }
2639 } catch {
2640 // Treat unparseable parsed-blob as no triggers — falls through to 422.
2641 }
2642
2643 const spec = extractDispatchSpec(parsedObj.on as ParsedOn);
2644 if (!spec.enabled) {
2645 return c.json(
2646 { error: "Workflow does not have a 'workflow_dispatch' trigger." },
2647 422
2648 );
2649 }
2650
2651 const providedInputs =
2652 body.inputs && typeof body.inputs === "object" && !Array.isArray(body.inputs)
2653 ? (body.inputs as Record<string, unknown>)
2654 : undefined;
2655 const inputCheck = validateDispatchInputs(spec.inputs, providedInputs);
2656 if (!inputCheck.ok) {
2657 return c.json(
2658 { error: "Invalid workflow inputs", details: inputCheck.details },
2659 422
2660 );
2661 }
2662
2663 // Resolve the ref → commit SHA. Default to repo default_branch.
2664 const refIn =
2665 typeof body.ref === "string" && body.ref.trim().length > 0
2666 ? body.ref.trim()
2667 : repoRow.defaultBranch || "main";
2668 const commitSha = await resolveRef(owner, repo, refIn);
2669 if (!commitSha) {
2670 return c.json({ error: `Ref not found: ${refIn}` }, 422);
2671 }
2672
2673 const runId = await enqueueRun({
2674 workflowId: workflowRow.id,
2675 repositoryId: repoRow.id,
2676 event: "workflow_dispatch",
2677 ref: refIn,
2678 commitSha,
2679 triggeredBy: user.id,
2680 });
2681 if (!runId) {
2682 return c.json({ error: "Failed to enqueue run" }, 500);
2683 }
2684
2685 // GitHub returns 204 No Content with no body on success.
2686 return c.body(null, 204);
2687 }
2688);
2689
2690// ─── 2. GET /actions/workflows/:filename/runs ───────────────────────────────
2691
2692apiv2.get(
2693 "/repos/:owner/:repo/actions/workflows/:filename/runs",
2694 async (c) => {
2695 const { owner, repo, filename } = c.req.param();
2696 const resolved = await resolveRepo(owner, repo);
2697 if (!resolved) return c.json({ error: "Not Found" }, 404);
2698
2699 const repoRow = resolved.repo as any;
2700 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2701 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2702
2703 const perPage = Math.min(
2704 100,
2705 Math.max(1, parseInt(c.req.query("per_page") || "30", 10) || 30)
2706 );
2707 const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
2708 const offset = (page - 1) * perPage;
2709 const branch = c.req.query("branch");
2710 const headSha = c.req.query("head_sha");
2711
2712 const conditions = [eq(workflowRuns.workflowId, workflowRow.id)];
2713 if (branch) {
2714 // Accept either short branch name or fully-qualified refs/heads/...
2715 const refValue = branch.startsWith("refs/")
2716 ? branch
2717 : `refs/heads/${branch}`;
2718 conditions.push(eq(workflowRuns.ref, refValue));
2719 }
2720 if (headSha) conditions.push(eq(workflowRuns.commitSha, headSha));
2721
2722 const where = conditions.length === 1 ? conditions[0] : and(...conditions);
2723
2724 const [{ n }] = await db
2725 .select({ n: sql<number>`count(*)::int` })
2726 .from(workflowRuns)
2727 .where(where);
2728 const total_count = Number(n) || 0;
2729
2730 const rows = await db
2731 .select()
2732 .from(workflowRuns)
2733 .where(where)
2734 .orderBy(desc(workflowRuns.queuedAt))
2735 .limit(perPage)
2736 .offset(offset);
2737
2738 return c.json({
2739 total_count,
2740 workflow_runs: rows.map((r) =>
2741 serializeRun(r, workflowRow.name, owner, repo)
2742 ),
2743 });
2744 }
2745);
2746
2747// ─── 3. GET /actions/runs/:run_id ───────────────────────────────────────────
2748
2749apiv2.get("/repos/:owner/:repo/actions/runs/:run_id", async (c) => {
2750 const { owner, repo, run_id } = c.req.param();
2751 const resolved = await resolveRepo(owner, repo);
2752 if (!resolved) return c.json({ error: "Not Found" }, 404);
2753
2754 const repoRow = resolved.repo as any;
2755 const [run] = await db
2756 .select()
2757 .from(workflowRuns)
2758 .where(eq(workflowRuns.id, run_id))
2759 .limit(1);
2760 if (!run) return c.json({ error: "Not Found" }, 404);
2761 // Don't leak runs across repos.
2762 if (run.repositoryId !== repoRow.id) {
2763 return c.json({ error: "Not Found" }, 404);
2764 }
2765
2766 const [wf] = await db
2767 .select()
2768 .from(workflows)
2769 .where(eq(workflows.id, run.workflowId))
2770 .limit(1);
2771 const workflowName = wf?.name ?? "";
2772
2773 return c.json(serializeRun(run, workflowName, owner, repo));
2774});
2775
2776// ─── 4. GET /actions/runs/:run_id/logs — ZIP of per-job logs ────────────────
2777//
2778// Reuses the same in-process zip writer pattern as `connect-claude.tsx` —
2779// PKZIP 2.0, no zip64, deflateRawSync with STORED fallback when compression
2780// would inflate. The handler is self-contained so the dxt downloader stays
2781// the canonical reference.
2782
2783function crc32(buf: Uint8Array): number {
2784 let c = 0xffffffff;
2785 for (let i = 0; i < buf.length; i++) {
2786 c ^= buf[i]!;
2787 for (let k = 0; k < 8; k++) {
2788 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
2789 }
2790 }
2791 return (c ^ 0xffffffff) >>> 0;
2792}
2793
2794type ZipEntry = { name: string; data: Uint8Array };
2795
2796function buildZip(entries: ZipEntry[]): Uint8Array {
2797 const localParts: Uint8Array[] = [];
2798 const centralParts: Uint8Array[] = [];
2799 let offset = 0;
2800
2801 for (const entry of entries) {
2802 const nameBytes = new TextEncoder().encode(entry.name);
2803 const crc = crc32(entry.data);
2804 const uncompressedSize = entry.data.length;
2805
2806 let method = 8;
2807 let compressed: Uint8Array;
2808 try {
2809 const out = deflateRawSync(entry.data);
2810 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
2811 if (compressed.length >= uncompressedSize) {
2812 method = 0;
2813 compressed = entry.data;
2814 }
2815 } catch {
2816 method = 0;
2817 compressed = entry.data;
2818 }
2819 const compressedSize = compressed.length;
2820
2821 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
2822 const lv = new DataView(local.buffer);
2823 lv.setUint32(0, 0x04034b50, true);
2824 lv.setUint16(4, 20, true);
2825 lv.setUint16(6, 0, true);
2826 lv.setUint16(8, method, true);
2827 lv.setUint16(10, 0, true);
2828 lv.setUint16(12, 0, true);
2829 lv.setUint32(14, crc, true);
2830 lv.setUint32(18, compressedSize, true);
2831 lv.setUint32(22, uncompressedSize, true);
2832 lv.setUint16(26, nameBytes.length, true);
2833 lv.setUint16(28, 0, true);
2834 local.set(nameBytes, 30);
2835 local.set(compressed, 30 + nameBytes.length);
2836 localParts.push(local);
2837
2838 const central = new Uint8Array(46 + nameBytes.length);
2839 const cv = new DataView(central.buffer);
2840 cv.setUint32(0, 0x02014b50, true);
2841 cv.setUint16(4, 20, true);
2842 cv.setUint16(6, 20, true);
2843 cv.setUint16(8, 0, true);
2844 cv.setUint16(10, method, true);
2845 cv.setUint16(12, 0, true);
2846 cv.setUint16(14, 0, true);
2847 cv.setUint32(16, crc, true);
2848 cv.setUint32(20, compressedSize, true);
2849 cv.setUint32(24, uncompressedSize, true);
2850 cv.setUint16(28, nameBytes.length, true);
2851 cv.setUint16(30, 0, true);
2852 cv.setUint16(32, 0, true);
2853 cv.setUint16(34, 0, true);
2854 cv.setUint16(36, 0, true);
2855 cv.setUint32(38, 0, true);
2856 cv.setUint32(42, offset, true);
2857 central.set(nameBytes, 46);
2858 centralParts.push(central);
2859
2860 offset += local.length;
2861 }
2862
2863 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
2864 const centralOffset = offset;
2865
2866 const end = new Uint8Array(22);
2867 const ev = new DataView(end.buffer);
2868 ev.setUint32(0, 0x06054b50, true);
2869 ev.setUint16(4, 0, true);
2870 ev.setUint16(6, 0, true);
2871 ev.setUint16(8, entries.length, true);
2872 ev.setUint16(10, entries.length, true);
2873 ev.setUint32(12, centralSize, true);
2874 ev.setUint32(16, centralOffset, true);
2875 ev.setUint16(20, 0, true);
2876
2877 const total =
2878 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
2879 const out = new Uint8Array(total);
2880 let pos = 0;
2881 for (const p of localParts) {
2882 out.set(p, pos);
2883 pos += p.length;
2884 }
2885 for (const p of centralParts) {
2886 out.set(p, pos);
2887 pos += p.length;
2888 }
2889 out.set(end, pos);
2890 return out;
2891}
2892
2893apiv2.get("/repos/:owner/:repo/actions/runs/:run_id/logs", async (c) => {
2894 const { owner, repo, run_id } = c.req.param();
2895 const resolved = await resolveRepo(owner, repo);
2896 if (!resolved) return c.json({ error: "Not Found" }, 404);
2897
2898 const repoRow = resolved.repo as any;
2899 const [run] = await db
2900 .select()
2901 .from(workflowRuns)
2902 .where(eq(workflowRuns.id, run_id))
2903 .limit(1);
2904 if (!run || run.repositoryId !== repoRow.id) {
2905 return c.json({ error: "Not Found" }, 404);
2906 }
2907
2908 const jobs = await db
2909 .select()
2910 .from(workflowJobs)
2911 .where(eq(workflowJobs.runId, run.id))
2912 .orderBy(asc(workflowJobs.jobOrder));
2913
2914 // 404 when there's truly nothing to package up — matches GitHub's behaviour
2915 // for runs that never produced logs.
2916 const usable = jobs.filter(
2917 (j) => typeof j.logs === "string" && j.logs.length > 0
2918 );
2919 if (usable.length === 0) {
2920 return c.json({ error: "Not Found" }, 404);
2921 }
2922
2923 // Make filenames safe + unique. Collisions get a numeric suffix.
2924 const seen = new Set<string>();
2925 const entries: ZipEntry[] = [];
2926 for (const job of usable) {
2927 let base = (job.name || "job").replace(/[^a-zA-Z0-9_.-]+/g, "_");
2928 if (!base) base = "job";
2929 let filename = `${base}.log`;
2930 let n = 1;
2931 while (seen.has(filename)) {
2932 filename = `${base}-${++n}.log`;
2933 }
2934 seen.add(filename);
2935 entries.push({
2936 name: filename,
2937 data: new TextEncoder().encode(job.logs),
2938 });
2939 }
2940
2941 const zip = buildZip(entries);
2942 // Wrap the bytes in a Blob so Response's BodyInit type is happy across
2943 // both Bun's `globalThis.Response` and Hono's. Uint8Array works at
2944 // runtime but trips strict TS — cast to BlobPart resolves the
2945 // ArrayBufferLike/ArrayBuffer mismatch on `.buffer`.
2946 return new Response(new Blob([zip as BlobPart], { type: "application/zip" }), {
2947 status: 200,
2948 headers: {
2949 "Content-Disposition": `attachment; filename="run-${run.id}-logs.zip"`,
2950 "Content-Length": String(zip.length),
2951 },
2952 });
2953});
2954
2955// ─── 5. POST /actions/runs/:run_id/cancel ───────────────────────────────────
2956
2957apiv2.post(
2958 "/repos/:owner/:repo/actions/runs/:run_id/cancel",
2959 requireApiAuth,
2960 requireScope("repo"),
2961 async (c) => {
2962 const { owner, repo, run_id } = c.req.param();
2963 const resolved = await resolveRepo(owner, repo);
2964 if (!resolved) return c.json({ error: "Not Found" }, 404);
2965
2966 const repoRow = resolved.repo as any;
2967 const [run] = await db
2968 .select()
2969 .from(workflowRuns)
2970 .where(eq(workflowRuns.id, run_id))
2971 .limit(1);
2972 if (!run || run.repositoryId !== repoRow.id) {
2973 return c.json({ error: "Not Found" }, 404);
2974 }
2975
2976 // Already-terminal states are a 409 Conflict per GitHub semantics. We
2977 // only allow queued → cancelled and running → cancelled transitions.
2978 if (run.status !== "queued" && run.status !== "running") {
2979 return c.json(
2980 {
2981 error:
2982 "Cannot cancel workflow run in its current state",
2983 status: run.status,
2984 },
2985 409
2986 );
2987 }
2988
2989 const now = new Date();
2990 await db
2991 .update(workflowRuns)
2992 .set({
2993 status: "cancelled",
2994 conclusion: "cancelled",
2995 finishedAt: now,
2996 })
2997 .where(
2998 and(
2999 eq(workflowRuns.id, run.id),
3000 eq(workflowRuns.repositoryId, repoRow.id)
3001 )
3002 );
3003 await db
3004 .update(workflowJobs)
3005 .set({
3006 status: "cancelled",
3007 conclusion: "cancelled",
3008 finishedAt: now,
3009 })
3010 .where(eq(workflowJobs.runId, run.id));
3011
3012 return c.json({}, 202);
3013 }
3014);
3015
424eb72Claude3016// ─── Release notes (AI-generated) ───────────────────────────────────────────
3017//
3018// POST /api/v2/repos/:owner/:repo/releases/notes
3019// Body: { from_tag?: string | null, to_tag: string }
3020// Returns: { markdown, sections, headline, summary, prCount, aiUsed }
3021//
3022// Used by the release-form `Generate notes` button and external
3023// automation (e.g. CI scripts that auto-fill release bodies).
3024//
3025// Auth: read on the repo (lazy — public repos are open, private ones
3026// 404 from the resolver above). Generates notes; never persists them
3027// (caller decides). The autopilot watcher persists via a separate path.
3028
3029apiv2.post("/repos/:owner/:repo/releases/notes", async (c) => {
3030 const { owner, repo } = c.req.param();
3031 const resolved = await resolveRepo(owner, repo);
3032 if (!resolved) return c.json({ error: "Not Found" }, 404);
3033
3034 let body: { from_tag?: unknown; to_tag?: unknown } = {};
3035 try {
3036 body = await c.req.json();
3037 } catch {
3038 return c.json({ error: "Invalid JSON body" }, 400);
3039 }
3040 const toTag = typeof body.to_tag === "string" ? body.to_tag.trim() : "";
3041 if (!toTag) return c.json({ error: "to_tag is required" }, 400);
3042 const fromTag =
3043 typeof body.from_tag === "string" && body.from_tag.trim()
3044 ? body.from_tag.trim()
3045 : null;
3046
3047 const { generateReleaseNotes } = await import("../lib/ai-release-notes");
3048 const result = await generateReleaseNotes({
3049 repositoryId: (resolved.repo as any).id,
3050 fromTag,
3051 toTag,
3052 });
3053 return c.json(result);
3054});
3055
45e31d0Claude3056// ─── API Info ───────────────────────────────────────────────────────────────
3057
3058apiv2.get("/", (c) => {
3059 return c.json({
3060 name: "gluecron API",
3061 version: "2.0",
3062 documentation: "/api/docs",
3063 endpoints: {
46d6165Claude3064 auth: {
3065 "POST /api/v2/auth/install-token":
3066 "Mint a PAT for one-command install (session-cookie auth only)",
3067 },
45e31d0Claude3068 users: {
3069 "GET /api/v2/user": "Get authenticated user",
3070 "GET /api/v2/users/:username": "Get user by username",
3071 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude3072 "GET /api/v2/me/ai-savings":
3073 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude3074 },
3075 repositories: {
3076 "GET /api/v2/users/:username/repos": "List user repositories",
3077 "POST /api/v2/repos": "Create repository",
3078 "GET /api/v2/repos/:owner/:repo": "Get repository",
3079 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
3080 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
3081 },
3082 branches: {
3083 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
3084 },
3085 commits: {
3086 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
3087 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
3088 },
3089 files: {
052c2e6Claude3090 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
3091 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
3092 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
da50b83Claude3093 "DELETE /api/v2/repos/:owner/:repo/contents/:path": "Delete a file on a branch",
052c2e6Claude3094 },
3095 git: {
3096 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
da50b83Claude3097 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Get a branch ref",
3098 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Move a branch ref (fast-forward by default)",
3099 "GET /api/v2/repos/:owner/:repo/git/commits/:sha": "Get a raw git commit object",
3100 "POST /api/v2/repos/:owner/:repo/git/commits": "Create a commit from a tree + parents",
3101 "POST /api/v2/repos/:owner/:repo/git/blobs": "Write a blob from utf-8 or base64 content",
3102 "POST /api/v2/repos/:owner/:repo/git/trees": "Build a tree from entries (optionally based on base_tree)",
052c2e6Claude3103 },
3104 statuses: {
3105 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude3106 },
3107 issues: {
3108 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
3109 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
3110 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
3111 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
3112 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
3113 },
3114 pullRequests: {
3115 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
3116 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
3117 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude3118 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude3119 },
424eb72Claude3120 releases: {
3121 "POST /api/v2/repos/:owner/:repo/releases/notes":
3122 "Generate AI release notes for from_tag → to_tag (markdown + structured sections)",
3123 },
45e31d0Claude3124 stars: {
3125 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
3126 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
3127 },
3128 labels: {
3129 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
3130 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
3131 },
3132 search: {
3133 "GET /api/v2/search/repos": "Search repositories",
3134 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
3135 },
3136 topics: {
3137 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
3138 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
3139 },
3140 webhooks: {
3141 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
3142 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
3143 },
3144 activity: {
3145 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
3146 },
9018b1fClaude3147 ai: {
3148 "POST /api/v2/ai/commit-message":
3149 "Generate a Conventional Commits message from a staged diff (60/min/token)",
3150 },
4366c70Claude3151 actions: {
3152 "POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches":
3153 "Dispatch a workflow run (204 No Content)",
3154 "GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs":
3155 "List runs of a workflow (paginated: per_page, page; filters: branch, head_sha)",
3156 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id":
3157 "Get a single workflow run",
3158 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs":
3159 "Download per-job logs as a .zip archive",
3160 "POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel":
3161 "Cancel a queued or running workflow run (202 Accepted)",
3162 },
45e31d0Claude3163 },
3164 authentication: {
3165 method: "Bearer token",
3166 header: "Authorization: Bearer <your-token>",
b5dd694Claude3167 createApiKey: "Visit /settings/tokens to create a personal access key", // secrets-ok: UI guidance text, not a credential
45e31d0Claude3168 },
3169 rateLimit: {
3170 api: "100 requests/minute",
3171 search: "30 requests/minute",
3172 auth: "10 requests/minute",
3173 },
3174 });
3175});
3176
9f29b65Claude3177// ─── SIEM Audit Log Export ──────────────────────────────────────────────────
3178//
3179// GET /api/v2/audit
3180//
3181// Export the platform audit log in JSON format for SIEM ingestion.
3182//
3183// Authentication
3184// Bearer token required. The token must belong to a site-admin
3185// (users.isAdmin = true) OR the request is scoped to the caller's own
3186// org (future: orgAdmin scope). Session-cookie callers with isAdmin also pass.
3187//
3188// Query parameters
3189// since=<ISO 8601> — include only events at or after this timestamp
3190// until=<ISO 8601> — include only events before or at this timestamp
3191// limit=<number> — max rows to return; capped at 1000, default 100
3192// cursor=<uuid> — pagination cursor: start after this event id (exclusive)
3193// actor=<username> — filter by actor username
3194// action=<prefix> — filter by action prefix (e.g. "repo.", "token.")
3195// resource_type=<str> — filter by target_type field
3196// format=json — (reserved; currently only JSON is supported)
3197//
3198// Response
3199// 200 { events: AuditEvent[], nextCursor: string | null, hasMore: boolean }
3200// X-Total-Count: <approximate total for the current filter set>
3201//
3202// Each AuditEvent
3203// { id, action, actor_id, actor_username, resource_type, resource_id,
3204// metadata, created_at, ip_address }
3205//
3206// Example
3207// GET /api/v2/audit?since=2026-01-01T00:00:00Z&limit=50
3208// Authorization: Bearer glc_<token>
3209
3210apiv2.get("/audit", requireApiAuth, async (c) => {
3211 const caller = c.get("user")!;
3212
3213 // Only site-admins may export the full audit log.
3214 if (!caller.isAdmin) {
3215 return c.json({ error: "Admin scope required to export audit log" }, 403);
3216 }
3217
3218 // Parse query params ---------------------------------------------------
3219 const q = c.req.query;
3220
3221 const sinceRaw = q("since");
3222 const untilRaw = q("until");
3223 const limitRaw = q("limit");
3224 const cursor = q("cursor");
3225 const actorQ = q("actor");
3226 const actionQ = q("action");
3227 const resTypeQ = q("resource_type");
3228
3229 const limit = Math.min(1000, Math.max(1, parseInt(limitRaw ?? "100", 10) || 100));
3230
3231 const since = sinceRaw ? new Date(sinceRaw) : null;
3232 const until = untilRaw ? new Date(untilRaw) : null;
3233
3234 if (since && isNaN(since.getTime())) {
3235 return c.json({ error: "Invalid `since` timestamp" }, 400);
3236 }
3237 if (until && isNaN(until.getTime())) {
3238 return c.json({ error: "Invalid `until` timestamp" }, 400);
3239 }
3240
3241 try {
3242 // Build WHERE clauses --------------------------------------------------
3243 // We always join users to get the actor username.
3244 // Conditions are accumulated so we can add them based on query params.
3245 const conditions = [];
3246
3247 if (since) conditions.push(gte(auditLog.createdAt, since));
3248 if (until) conditions.push(lte(auditLog.createdAt, until));
3249
3250 // cursor-based pagination: skip rows with createdAt < cursorRow.createdAt,
3251 // or equal createdAt but id <= cursorRow.id (stable ordering).
3252 // For simplicity we use id-based pagination with UUID ordering via
3253 // a sub-query on the audit_log table itself.
3254 let cursorCondition = null;
3255 if (cursor) {
3256 const [cursorRow] = await db
3257 .select({ createdAt: auditLog.createdAt })
3258 .from(auditLog)
3259 .where(eq(auditLog.id, cursor))
3260 .limit(1);
3261 if (cursorRow) {
3262 cursorCondition = lte(auditLog.createdAt, cursorRow.createdAt);
3263 }
3264 }
3265 if (cursorCondition) conditions.push(cursorCondition);
3266
3267 if (actionQ) {
3268 conditions.push(like(auditLog.action, `${actionQ}%`));
3269 }
3270 if (resTypeQ) {
3271 conditions.push(eq(auditLog.targetType, resTypeQ));
3272 }
3273
3274 // Actor filter: resolve username → userId
3275 let actorUserId: string | null = null;
3276 if (actorQ) {
3277 const [actorRow] = await db
3278 .select({ id: users.id })
3279 .from(users)
3280 .where(eq(users.username, actorQ))
3281 .limit(1);
3282 if (!actorRow) {
3283 // Unknown actor — return empty result
3284 return c.json({ events: [], nextCursor: null, hasMore: false }, 200, {
3285 "X-Total-Count": "0",
3286 });
3287 }
3288 actorUserId = actorRow.id;
3289 }
3290 if (actorUserId) {
3291 conditions.push(eq(auditLog.userId, actorUserId));
3292 }
3293
3294 const where = conditions.length > 0 ? and(...conditions) : undefined;
3295
3296 // Approximate total count — intentionally approximate (not locking)
3297 const [countRow] = await db
3298 .select({ cnt: sql<number>`count(*)::int` })
3299 .from(auditLog)
3300 .where(where);
3301 const totalCount = countRow?.cnt ?? 0;
3302
3303 // Fetch limit+1 rows to detect hasMore
3304 const rows = await db
3305 .select({
3306 id: auditLog.id,
3307 action: auditLog.action,
3308 actorId: auditLog.userId,
3309 targetType: auditLog.targetType,
3310 targetId: auditLog.targetId,
3311 metadata: auditLog.metadata,
3312 createdAt: auditLog.createdAt,
3313 ip: auditLog.ip,
3314 })
3315 .from(auditLog)
3316 .where(where)
3317 .orderBy(desc(auditLog.createdAt))
3318 .limit(limit + 1);
3319
3320 // Skip rows before the cursor (same createdAt bucket with id after cursor)
3321 let sliced = rows;
3322 if (cursor && rows.length > 0 && rows[0].id === cursor) {
3323 sliced = rows.slice(1);
3324 }
3325
3326 const hasMore = sliced.length > limit;
3327 const page = sliced.slice(0, limit);
3328 const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null;
3329
3330 // Resolve actor usernames in one batch query
3331 const actorIds = [...new Set(page.map((r) => r.actorId).filter(Boolean))] as string[];
3332 const actorMap: Record<string, string> = {};
3333 if (actorIds.length > 0) {
3334 const actorRows = await db
3335 .select({ id: users.id, username: users.username })
3336 .from(users)
3337 .where(sql`${users.id} = ANY(ARRAY[${sql.join(actorIds.map((id) => sql`${id}::uuid`), sql`, `)}])`);
3338 for (const a of actorRows) {
3339 actorMap[a.id] = a.username;
3340 }
3341 }
3342
3343 const events = page.map((row) => ({
3344 id: row.id,
3345 action: row.action,
3346 actor_id: row.actorId ?? null,
3347 actor_username: row.actorId ? (actorMap[row.actorId] ?? null) : null,
3348 resource_type: row.targetType ?? null,
3349 resource_id: row.targetId ?? null,
3350 metadata: row.metadata ? (() => { try { return JSON.parse(row.metadata!); } catch { return row.metadata; } })() : null,
3351 created_at: row.createdAt.toISOString(),
3352 ip_address: row.ip ?? null,
3353 }));
3354
3355 c.header("X-Total-Count", String(totalCount));
3356 return c.json({ events, nextCursor, hasMore });
3357 } catch (err) {
3358 console.error("[api/v2/audit] error:", err);
3359 return c.json({ error: "Service unavailable" }, 503);
3360 }
3361});
3362
45e31d0Claude3363export default apiv2;