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