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