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