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