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.tsBlame2461 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";
45e31d0Claude32import {
33 listBranches,
34 getDefaultBranch,
052c2e6Claude35 getDefaultBranchFresh,
45e31d0Claude36 getTree,
052c2e6Claude37 getTreeRecursive,
45e31d0Claude38 getBlob,
39 getCommit,
40 listCommits,
41 getDiff,
42 searchCode,
43 repoExists,
44 initBareRepo,
45 resolveRef,
052c2e6Claude46 catBlobBytes,
47 refExists,
48 objectExists,
49 updateRef,
da50b83Claude50 writeBlob,
51 getBlobShaAtPath,
52 getRepoPath,
052c2e6Claude53 createOrUpdateFileOnBranch,
45e31d0Claude54} from "../git/repository";
da50b83Claude55import { config } from "../lib/config";
45e31d0Claude56import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
57import type { ApiAuthEnv } from "../middleware/api-auth";
58import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
052c2e6Claude59import { postCommitStatusHandler } from "./commit-statuses";
46d6165Claude60import { apiTokens } from "../db/schema";
61import { audit } from "../lib/notify";
62import {
63 computeAiSavingsForUser,
64 computeLifetimeAiSavingsForUser,
65} from "../lib/ai-hours-saved";
45e31d0Claude66
67const apiv2 = new Hono<ApiAuthEnv>().basePath("/api/v2");
68
69// Apply auth and rate limiting to all v2 routes
70apiv2.use("*", apiRateLimit);
71apiv2.use("*", apiAuth);
72
73// ─── Helper ─────────────────────────────────────────────────────────────────
74
75async function resolveRepo(ownerName: string, repoName: string) {
76 const [owner] = await db
77 .select()
78 .from(users)
79 .where(eq(users.username, ownerName))
80 .limit(1);
81 if (!owner) return null;
82
83 const [repo] = await db
84 .select()
85 .from(repositories)
86 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
87 .limit(1);
88 if (!repo) return null;
89
90 return { owner, repo };
91}
92
46d6165Claude93// ─── Auth (install-token) ───────────────────────────────────────────────────
94//
95// POST /api/v2/auth/install-token
96//
97// Mint a new personal access token from a one-command install script
98// (`scripts/install.sh`). Session-cookie auth ONLY — Bearer tokens are
99// explicitly rejected so existing PATs cannot escalate into a fan-out of new
100// PATs. The plaintext token value is returned exactly once and never persisted.
101//
102// Audit-logged as `auth.install_token.created`.
103
104function generateInstallToken(): string {
105 const bytes = crypto.getRandomValues(new Uint8Array(32));
106 return (
107 "glc_" +
108 Array.from(bytes)
109 .map((b) => b.toString(16).padStart(2, "0"))
110 .join("")
111 );
112}
113
114async function hashInstallToken(token: string): Promise<string> {
115 const data = new TextEncoder().encode(token);
116 const hash = await crypto.subtle.digest("SHA-256", data);
117 return Array.from(new Uint8Array(hash))
118 .map((b) => b.toString(16).padStart(2, "0"))
119 .join("");
120}
121
122apiv2.post("/auth/install-token", async (c) => {
123 // Reject Bearer-token callers outright. The whole point of this endpoint
124 // is preventing token escalation, so we check the raw header rather than
125 // relying on whatever the middleware decided.
126 const authHeader = c.req.header("Authorization");
127 if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
128 return c.json(
129 {
130 error: "Session authentication required",
131 hint: "This endpoint refuses Bearer tokens — sign in with a session cookie.",
132 },
133 401
134 );
135 }
136
137 const user = c.get("user");
138 const authMethod = c.get("authMethod");
139 if (!user || authMethod !== "session") {
140 return c.json(
141 {
142 error: "Session authentication required",
143 hint: "Sign in via /login first; install-token mints PATs only over the session cookie.",
144 },
145 401
146 );
147 }
148
149 let body: { name?: unknown; scope?: unknown } = {};
150 try {
151 body = await c.req.json();
152 } catch {
153 // Empty / unparseable body is allowed — we fall back to defaults.
154 body = {};
155 }
156
157 const shortStamp = Math.floor(Date.now() / 1000)
158 .toString(36)
159 .slice(-6);
160 const name =
161 typeof body.name === "string" && body.name.trim().length > 0
162 ? body.name.trim().slice(0, 80)
163 : `gluecron-install-${shortStamp}`;
164
165 const requested = typeof body.scope === "string" ? body.scope : "admin";
166 const scope = requested === "repo" ? "repo" : "admin";
167 // Mirror existing PAT semantics: a comma-separated list. `admin` implies
168 // everything; `repo` keeps it narrow.
169 const scopes = scope === "admin" ? "admin,repo,user" : "repo";
170
171 const token = generateInstallToken();
172 const tokenHash = await hashInstallToken(token);
173 const tokenPrefix = token.slice(0, 12);
174
175 const [row] = await db
176 .insert(apiTokens)
177 .values({
178 userId: user.id,
179 name,
180 tokenHash,
181 tokenPrefix,
182 scopes,
183 })
184 .returning();
185
186 await audit({
187 userId: user.id,
188 action: "auth.install_token.created",
189 targetType: "api_token",
190 targetId: row?.id,
191 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
192 userAgent: c.req.header("user-agent") || undefined,
193 metadata: { name, scope, prefix: tokenPrefix },
194 });
195
196 return c.json(
197 {
198 token,
199 id: row?.id,
200 name,
201 scope,
202 scopes,
203 expiresAt: row?.expiresAt ?? null,
204 },
205 201
206 );
207});
208
45e31d0Claude209// ─── Users ──────────────────────────────────────────────────────────────────
210
211apiv2.get("/user", requireApiAuth, (c) => {
212 const user = c.get("user")!;
213 return c.json({
214 id: user.id,
215 username: user.username,
216 email: user.email,
217 displayName: user.displayName,
218 bio: user.bio,
219 avatarUrl: user.avatarUrl,
220 createdAt: user.createdAt,
221 });
222});
223
224apiv2.get("/users/:username", async (c) => {
225 const { username } = c.req.param();
226 const [user] = await db
227 .select({
228 id: users.id,
229 username: users.username,
230 displayName: users.displayName,
231 bio: users.bio,
232 avatarUrl: users.avatarUrl,
233 createdAt: users.createdAt,
234 })
235 .from(users)
236 .where(eq(users.username, username))
237 .limit(1);
238 if (!user) return c.json({ error: "User not found" }, 404);
239 return c.json(user);
240});
241
46d6165Claude242/**
243 * Block L9 — AI hours-saved counter, exposed for the dashboard widget,
244 * VS Code extension, and CLI. Returns the same numbers the web UI shows.
245 * No special scope — any authenticated token can read its own counter.
246 */
247apiv2.get("/me/ai-savings", requireApiAuth, async (c) => {
248 const user = c.get("user")!;
249 const [window, lifetime] = await Promise.all([
250 computeAiSavingsForUser(user.id, { windowHours: 168 }),
251 computeLifetimeAiSavingsForUser(user.id),
252 ]);
253 return c.json({
254 window: {
255 hours: window.windowHours,
256 hoursSaved: window.hoursSaved,
257 breakdown: window.breakdown,
258 },
259 lifetime: {
260 hoursSaved: lifetime.hoursSaved,
261 breakdown: lifetime.breakdown,
262 sinceCreatedAt: lifetime.sinceCreatedAt.toISOString(),
263 },
264 });
265});
266
45e31d0Claude267apiv2.patch("/user", requireApiAuth, requireScope("user"), async (c) => {
268 const user = c.get("user")!;
269 const body = await c.req.json<{
270 displayName?: string;
271 bio?: string;
272 avatarUrl?: string;
273 }>();
274
275 const updates: Record<string, any> = { updatedAt: new Date() };
276 if (body.displayName !== undefined) updates.displayName = body.displayName;
277 if (body.bio !== undefined) updates.bio = body.bio;
278 if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
279
280 await db.update(users).set(updates).where(eq(users.id, user.id));
281 return c.json({ ok: true });
282});
283
284// ─── Repositories ───────────────────────────────────────────────────────────
285
286apiv2.get("/users/:username/repos", async (c) => {
287 const { username } = c.req.param();
288 const sort = c.req.query("sort") || "updated";
289 const [owner] = await db.select().from(users).where(eq(users.username, username)).limit(1);
290 if (!owner) return c.json({ error: "User not found" }, 404);
291
292 const currentUser = c.get("user");
293 const orderBy = sort === "stars" ? desc(repositories.starCount) :
294 sort === "name" ? asc(repositories.name) :
295 desc(repositories.updatedAt);
296
297 let repoList = await db
298 .select()
299 .from(repositories)
300 .where(eq(repositories.ownerId, owner.id))
301 .orderBy(orderBy);
302
303 // Only show private repos to owner
304 if (!currentUser || currentUser.id !== owner.id) {
305 repoList = repoList.filter((r: any) => !r.isPrivate);
306 }
307
308 return c.json(repoList);
309});
310
311apiv2.post("/repos", requireApiAuth, requireScope("repo"), async (c) => {
312 const user = c.get("user")!;
313 const body = await c.req.json<{
314 name: string;
315 description?: string;
316 isPrivate?: boolean;
317 }>();
318
319 if (!body.name || !/^[a-zA-Z0-9._-]+$/.test(body.name)) {
320 return c.json({ error: "Invalid repository name" }, 400);
321 }
322
c63b860Claude323 // P4 — plan-quota gate. 402 Payment Required is the canonical HTTP
324 // signal that the client should branch on (e.g. show an upgrade CTA).
325 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
326 const gate = await checkRepoCreateAllowed(user.id);
327 if (!gate.ok) {
328 return c.json({ error: gate.reason, upgrade_url: gate.upgradeUrl }, 402);
329 }
330
45e31d0Claude331 if (await repoExists(user.username, body.name)) {
332 return c.json({ error: "Repository already exists" }, 409);
333 }
334
335 const diskPath = await initBareRepo(user.username, body.name);
336 const result = await db
337 .insert(repositories)
338 .values({
339 name: body.name,
340 ownerId: user.id,
341 description: body.description || null,
342 isPrivate: body.isPrivate || false,
343 diskPath,
344 })
345 .returning();
346
347 return c.json(result[0], 201);
348});
349
350apiv2.get("/repos/:owner/:repo", async (c) => {
351 const { owner, repo } = c.req.param();
352 const resolved = await resolveRepo(owner, repo);
353 if (!resolved) return c.json({ error: "Not found" }, 404);
354
355 const currentUser = c.get("user");
356 if ((resolved.repo as any).isPrivate && (!currentUser || currentUser.id !== resolved.owner.id)) {
357 return c.json({ error: "Not found" }, 404);
358 }
359
052c2e6Claude360 // Cache-free fresh read of HEAD's ref — needed by GateTest.
361 const defaultBranch = await getDefaultBranchFresh(owner, repo);
362
363 return c.json({
364 ...(resolved.repo as any),
365 defaultBranch,
366 owner: {
367 id: (resolved.owner as any).id,
368 login: (resolved.owner as any).username,
369 },
370 });
45e31d0Claude371});
372
373apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
374 const { owner, repo } = c.req.param();
375 const resolved = await resolveRepo(owner, repo);
376 if (!resolved) return c.json({ error: "Not found" }, 404);
377
378 const user = c.get("user")!;
379 if (user.id !== resolved.owner.id) {
380 return c.json({ error: "Permission denied" }, 403);
381 }
382
383 const body = await c.req.json<{
384 description?: string;
385 isPrivate?: boolean;
386 defaultBranch?: string;
387 }>();
388
389 const updates: Record<string, any> = { updatedAt: new Date() };
390 if (body.description !== undefined) updates.description = body.description;
391 if (body.isPrivate !== undefined) updates.isPrivate = body.isPrivate;
392 if (body.defaultBranch !== undefined) updates.defaultBranch = body.defaultBranch;
393
394 await db.update(repositories).set(updates).where(eq(repositories.id, (resolved.repo as any).id));
395 return c.json({ ok: true });
396});
397
398apiv2.delete("/repos/:owner/:repo", requireApiAuth, requireScope("admin"), async (c) => {
399 const { owner, repo } = c.req.param();
400 const resolved = await resolveRepo(owner, repo);
401 if (!resolved) return c.json({ error: "Not found" }, 404);
402
403 const user = c.get("user")!;
404 if (user.id !== resolved.owner.id) {
405 return c.json({ error: "Permission denied" }, 403);
406 }
407
408 await db.delete(repositories).where(eq(repositories.id, (resolved.repo as any).id));
409 return c.json({ ok: true });
410});
411
412// ─── Branches ───────────────────────────────────────────────────────────────
413
414apiv2.get("/repos/:owner/:repo/branches", async (c) => {
415 const { owner, repo } = c.req.param();
416 if (!(await repoExists(owner, repo))) {
417 return c.json({ error: "Not found" }, 404);
418 }
419
420 const branches = await listBranches(owner, repo);
421 const defaultBranch = await getDefaultBranch(owner, repo);
422
423 return c.json(
424 branches.map((name) => ({
425 name,
426 isDefault: name === defaultBranch,
427 }))
428 );
429});
430
431// ─── Commits ────────────────────────────────────────────────────────────────
432
433apiv2.get("/repos/:owner/:repo/commits", async (c) => {
434 const { owner, repo } = c.req.param();
435 const ref = c.req.query("ref") || c.req.query("sha") || "HEAD";
436 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
437 const offset = parseInt(c.req.query("offset") || "0");
438
439 if (!(await repoExists(owner, repo))) {
440 return c.json({ error: "Not found" }, 404);
441 }
442
443 const commits = await listCommits(owner, repo, ref, limit, offset);
444 return c.json(commits);
445});
446
447apiv2.get("/repos/:owner/:repo/commits/:sha", async (c) => {
448 const { owner, repo, sha } = c.req.param();
449 if (!(await repoExists(owner, repo))) {
450 return c.json({ error: "Not found" }, 404);
451 }
452
453 const commit = await getCommit(owner, repo, sha);
454 if (!commit) return c.json({ error: "Commit not found" }, 404);
455
456 const { files } = await getDiff(owner, repo, sha);
457 return c.json({ ...commit, files });
458});
459
460// ─── Tree & Files ───────────────────────────────────────────────────────────
461
462apiv2.get("/repos/:owner/:repo/tree/:ref", async (c) => {
463 const { owner, repo } = c.req.param();
464 const ref = c.req.param("ref");
465 const path = c.req.query("path") || "";
052c2e6Claude466 const recursive = c.req.query("recursive");
45e31d0Claude467
468 if (!(await repoExists(owner, repo))) {
469 return c.json({ error: "Not found" }, 404);
470 }
471
052c2e6Claude472 if (recursive === "1" || recursive === "true") {
473 const result = await getTreeRecursive(owner, repo, ref, 50_000);
474 if (!result) return c.json({ error: "Ref not found" }, 404);
475 return c.json(result);
476 }
477
45e31d0Claude478 const tree = await getTree(owner, repo, ref, path);
479 return c.json(tree);
480});
481
052c2e6Claude482const CONTENTS_MAX_BYTES = 10 * 1024 * 1024;
483
45e31d0Claude484apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
485 const { owner, repo } = c.req.param();
486 const filePath = c.req.param("path");
487 const ref = c.req.query("ref") || "HEAD";
052c2e6Claude488 const encoding = c.req.query("encoding") || "utf8";
45e31d0Claude489
490 if (!(await repoExists(owner, repo))) {
491 return c.json({ error: "Not found" }, 404);
492 }
493
052c2e6Claude494 if (encoding === "base64") {
495 const got = await catBlobBytes(owner, repo, ref, filePath);
496 if (!got) return c.json({ error: "File not found" }, 404);
497 if (got.size > CONTENTS_MAX_BYTES) {
498 return c.json(
499 { error: `File too large (${got.size} bytes, max ${CONTENTS_MAX_BYTES})` },
500 413
501 );
502 }
503 const content = Buffer.from(got.bytes).toString("base64");
504 return c.json({
505 path: filePath,
506 size: got.size,
507 sha: got.sha,
508 encoding: "base64",
509 content,
510 });
511 }
512
45e31d0Claude513 const blob = await getBlob(owner, repo, ref, filePath);
514 if (!blob) return c.json({ error: "File not found" }, 404);
052c2e6Claude515 if (blob.size > CONTENTS_MAX_BYTES) {
516 return c.json(
517 { error: `File too large (${blob.size} bytes, max ${CONTENTS_MAX_BYTES})` },
518 413
519 );
520 }
45e31d0Claude521
522 return c.json({
523 path: filePath,
524 size: blob.size,
525 isBinary: blob.isBinary,
526 content: blob.isBinary ? null : blob.content,
052c2e6Claude527 encoding: blob.isBinary ? null : "utf8",
45e31d0Claude528 });
529});
530
531// ─── Issues ─────────────────────────────────────────────────────────────────
532
533apiv2.get("/repos/:owner/:repo/issues", async (c) => {
534 const { owner, repo } = c.req.param();
535 const state = c.req.query("state") || "open";
536 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
537
538 const resolved = await resolveRepo(owner, repo);
539 if (!resolved) return c.json({ error: "Not found" }, 404);
540
541 const issueList = await db
542 .select({
543 issue: issues,
544 author: { username: users.username, id: users.id },
545 })
546 .from(issues)
547 .innerJoin(users, eq(issues.authorId, users.id))
548 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
549 .orderBy(desc(issues.createdAt))
550 .limit(limit);
551
552 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
553});
554
555apiv2.post("/repos/:owner/:repo/issues", requireApiAuth, requireScope("repo"), async (c) => {
556 const { owner, repo } = c.req.param();
557 const user = c.get("user")!;
558 const body = await c.req.json<{ title: string; body?: string }>();
559
560 if (!body.title?.trim()) {
561 return c.json({ error: "Title is required" }, 400);
562 }
563
564 const resolved = await resolveRepo(owner, repo);
565 if (!resolved) return c.json({ error: "Not found" }, 404);
566
567 const result = await db
568 .insert(issues)
569 .values({
570 repositoryId: (resolved.repo as any).id,
571 authorId: user.id,
572 title: body.title.trim(),
573 body: body.body?.trim() || null,
574 })
575 .returning();
576
577 return c.json(result[0], 201);
578});
579
580apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
581 const { owner, repo } = c.req.param();
582 const num = parseInt(c.req.param("number"), 10);
583
584 const resolved = await resolveRepo(owner, repo);
585 if (!resolved) return c.json({ error: "Not found" }, 404);
586
587 const [issue] = await db
588 .select()
589 .from(issues)
590 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
591 .limit(1);
592
593 if (!issue) return c.json({ error: "Issue not found" }, 404);
594
595 const comments = await db
596 .select({
597 comment: issueComments,
598 author: { username: users.username },
599 })
600 .from(issueComments)
601 .innerJoin(users, eq(issueComments.authorId, users.id))
602 .where(eq(issueComments.issueId, issue.id))
603 .orderBy(asc(issueComments.createdAt));
604
605 return c.json({
606 ...issue,
607 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
608 });
609});
610
611apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
612 const { owner, repo } = c.req.param();
613 const num = parseInt(c.req.param("number"), 10);
614 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
615
616 const resolved = await resolveRepo(owner, repo);
617 if (!resolved) return c.json({ error: "Not found" }, 404);
618
619 const updates: Record<string, any> = { updatedAt: new Date() };
620 if (body.title !== undefined) updates.title = body.title;
621 if (body.body !== undefined) updates.body = body.body;
622 if (body.state === "closed") {
623 updates.state = "closed";
624 updates.closedAt = new Date();
625 } else if (body.state === "open") {
626 updates.state = "open";
627 updates.closedAt = null;
628 }
629
630 await db
631 .update(issues)
632 .set(updates)
633 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
634
635 return c.json({ ok: true });
636});
637
638apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
639 const { owner, repo } = c.req.param();
640 const num = parseInt(c.req.param("number"), 10);
641 const user = c.get("user")!;
642 const body = await c.req.json<{ body: string }>();
643
644 if (!body.body?.trim()) {
645 return c.json({ error: "Comment body is required" }, 400);
646 }
647
648 const resolved = await resolveRepo(owner, repo);
649 if (!resolved) return c.json({ error: "Not found" }, 404);
650
651 const [issue] = await db
652 .select()
653 .from(issues)
654 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
655 .limit(1);
656
657 if (!issue) return c.json({ error: "Issue not found" }, 404);
658
659 const result = await db
660 .insert(issueComments)
661 .values({
662 issueId: issue.id,
663 authorId: user.id,
664 body: body.body.trim(),
665 })
666 .returning();
667
668 return c.json(result[0], 201);
669});
670
671// ─── Pull Requests ──────────────────────────────────────────────────────────
672
673apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
674 const { owner, repo } = c.req.param();
675 const state = c.req.query("state") || "open";
8c09fb9Claude676 // Match the issue-list pagination contract: default 30, max 100,
677 // 0-indexed offset for cursor-style scrolling. Bounded so a buggy
678 // client can't accidentally pull the whole table.
679 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit")) || 30));
680 const offset = Math.max(0, Number(c.req.query("offset")) || 0);
45e31d0Claude681
682 const resolved = await resolveRepo(owner, repo);
683 if (!resolved) return c.json({ error: "Not found" }, 404);
684
685 const prList = await db
686 .select({
687 pr: pullRequests,
688 author: { username: users.username },
689 })
690 .from(pullRequests)
691 .innerJoin(users, eq(pullRequests.authorId, users.id))
692 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
8c09fb9Claude693 .orderBy(desc(pullRequests.createdAt))
694 .limit(limit)
695 .offset(offset);
45e31d0Claude696
697 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
698});
699
700apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
701 const { owner, repo } = c.req.param();
702 const user = c.get("user")!;
703 const body = await c.req.json<{
704 title: string;
705 body?: string;
706 baseBranch: string;
707 headBranch: string;
708 }>();
709
710 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
711 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
712 }
713
714 const resolved = await resolveRepo(owner, repo);
715 if (!resolved) return c.json({ error: "Not found" }, 404);
716
717 const result = await db
718 .insert(pullRequests)
719 .values({
720 repositoryId: (resolved.repo as any).id,
721 authorId: user.id,
722 title: body.title.trim(),
723 body: body.body?.trim() || null,
724 baseBranch: body.baseBranch,
725 headBranch: body.headBranch,
726 })
727 .returning();
728
729 return c.json(result[0], 201);
730});
731
732apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
733 const { owner, repo } = c.req.param();
734 const num = parseInt(c.req.param("number"), 10);
735
736 const resolved = await resolveRepo(owner, repo);
737 if (!resolved) return c.json({ error: "Not found" }, 404);
738
739 const [pr] = await db
740 .select()
741 .from(pullRequests)
742 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
743 .limit(1);
744
745 if (!pr) return c.json({ error: "PR not found" }, 404);
746
747 const comments = await db
748 .select({
749 comment: prComments,
750 author: { username: users.username },
751 })
752 .from(prComments)
753 .innerJoin(users, eq(prComments.authorId, users.id))
754 .where(eq(prComments.pullRequestId, pr.id))
755 .orderBy(asc(prComments.createdAt));
756
757 return c.json({
758 ...pr,
759 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
760 });
761});
762
052c2e6Claude763// ─── PR Comments (GateTest integration) ────────────────────────────────────
764
765apiv2.post(
766 "/repos/:owner/:repo/pulls/:number/comments",
767 requireApiAuth,
768 requireScope("repo"),
769 async (c) => {
770 const { owner, repo } = c.req.param();
771 const num = parseInt(c.req.param("number"), 10);
772 const user = c.get("user")!;
773
774 let body: { body?: string } = {};
775 try {
776 body = await c.req.json();
777 } catch {
778 body = {};
779 }
780 if (!body.body?.trim()) {
781 return c.json({ error: "Comment body is required" }, 400);
782 }
783
784 const resolved = await resolveRepo(owner, repo);
785 if (!resolved) return c.json({ error: "Not found" }, 404);
786 if (user.id !== (resolved.owner as any).id) {
787 return c.json({ error: "Forbidden" }, 403);
788 }
789
790 const [pr] = await db
791 .select()
792 .from(pullRequests)
793 .where(
794 and(
795 eq(pullRequests.repositoryId, (resolved.repo as any).id),
796 eq(pullRequests.number, num)
797 )
798 )
799 .limit(1);
800 if (!pr) return c.json({ error: "PR not found" }, 404);
801
802 const [comment] = await db
803 .insert(prComments)
804 .values({
805 pullRequestId: pr.id,
806 authorId: user.id,
807 body: body.body.trim(),
808 })
809 .returning();
810
811 return c.json({ ok: true, comment }, 201);
812 }
813);
814
815// ─── Git refs — create branch / tag from sha ────────────────────────────────
816
817apiv2.post(
818 "/repos/:owner/:repo/git/refs",
819 requireApiAuth,
820 requireScope("repo"),
821 async (c) => {
822 const { owner, repo } = c.req.param();
823 const user = c.get("user")!;
824
825 let body: { ref?: string; sha?: string } = {};
826 try {
827 body = await c.req.json();
828 } catch {
829 body = {};
830 }
831 const ref = body.ref?.trim();
832 const sha = body.sha?.trim();
833
834 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
835 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
836 }
837 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
838 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
839 }
840
841 const resolved = await resolveRepo(owner, repo);
842 if (!resolved) return c.json({ error: "Not found" }, 404);
843 if (user.id !== (resolved.owner as any).id) {
844 return c.json({ error: "Forbidden" }, 403);
845 }
846
847 // Verify sha reachable.
848 if (!(await objectExists(owner, repo, sha))) {
849 return c.json({ error: "sha not found in repository" }, 400);
850 }
851
852 // Conflict check: if ref already exists, the existing sha must match.
853 if (await refExists(owner, repo, ref)) {
854 const existing = await resolveRef(owner, repo, ref);
855 if (existing !== sha) {
856 return c.json({ error: "ref already exists", existing }, 409);
857 }
858 }
859
860 const ok = await updateRef(owner, repo, ref, sha);
861 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
862
863 return c.json({ ok: true, ref, sha }, 201);
864 }
865);
866
867// ─── Contents PUT — create/update a file via git plumbing ────────────────────
868
869apiv2.put(
870 "/repos/:owner/:repo/contents/:path{.+$}",
871 requireApiAuth,
872 requireScope("repo"),
873 async (c) => {
874 const { owner, repo } = c.req.param();
875 const filePath = c.req.param("path");
876 const user = c.get("user")!;
877
878 let body: {
879 message?: string;
880 content?: string;
881 branch?: string;
882 sha?: string | null;
883 } = {};
884 try {
885 body = await c.req.json();
886 } catch {
887 body = {};
888 }
889
890 const message = body.message?.trim();
891 const branch = body.branch?.trim();
892 const base64 = body.content;
893
894 if (!message) return c.json({ error: "message is required" }, 400);
895 if (!branch) return c.json({ error: "branch is required" }, 400);
896 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
897
898 let bytes: Uint8Array;
899 try {
900 bytes = new Uint8Array(Buffer.from(base64, "base64"));
901 } catch {
902 return c.json({ error: "content is not valid base64" }, 400);
903 }
904 if (bytes.length > CONTENTS_MAX_BYTES) {
905 return c.json({ error: "File too large" }, 413);
906 }
907
908 const resolved = await resolveRepo(owner, repo);
909 if (!resolved) return c.json({ error: "Not found" }, 404);
910 if (user.id !== (resolved.owner as any).id) {
911 return c.json({ error: "Forbidden" }, 403);
912 }
913
914 const result = await createOrUpdateFileOnBranch({
915 owner,
916 name: repo,
917 branch,
918 filePath,
919 bytes,
920 message,
921 authorName: (user as any).displayName || user.username,
922 authorEmail: user.email,
923 expectBlobSha: body.sha ?? null,
924 });
925
926 if ("error" in result) {
927 if (result.error === "sha-mismatch") {
928 return c.json({ error: "sha does not match current blob at path" }, 409);
929 }
930 return c.json({ error: "Failed to write file" }, 500);
931 }
932
933 return c.json(
934 {
935 ok: true,
936 commit: { sha: result.commitSha, message },
937 content: { path: filePath, sha: result.blobSha },
938 },
939 201
940 );
941 }
942);
943
da50b83Claude944// ─── Helper: shell out to git in a bare repo ─────────────────────────────────
945//
946// Mirrors the pattern in src/git/repository.ts. Kept inline here so the
947// plumbing endpoints below don't have to leak through the helper module.
948
949async function runGit(
950 cmd: string[],
951 opts: { cwd: string; env?: Record<string, string>; stdin?: Uint8Array }
952): Promise<{ stdout: string; stderr: string; exitCode: number }> {
953 const proc = Bun.spawn(cmd, {
954 cwd: opts.cwd,
955 env: { ...process.env, ...opts.env },
956 stdin: opts.stdin ? "pipe" : "ignore",
957 stdout: "pipe",
958 stderr: "pipe",
959 });
960 if (opts.stdin && proc.stdin) {
961 proc.stdin.write(opts.stdin);
962 proc.stdin.end();
963 }
964 const [stdout, stderr] = await Promise.all([
965 new Response(proc.stdout).text(),
966 new Response(proc.stderr).text(),
967 ]);
968 const exitCode = await proc.exited;
969 return { stdout, stderr, exitCode };
970}
971
972function htmlUrlForCommit(owner: string, repo: string, sha: string): string {
973 return `${config.appBaseUrl}/${owner}/${repo}/commit/${sha}`;
974}
975
976// ─── Contents DELETE — remove a file via git plumbing ────────────────────────
977//
978// Body: { message, sha, branch? }
979// - `sha` is the current blob sha at `:path`; mismatch → 409 (optimistic
980// concurrency, matches GitHub's `DELETE /repos/.../contents/...` semantics).
981// - `branch` defaults to the repo's default branch.
982
983apiv2.delete(
984 "/repos/:owner/:repo/contents/:path{.+$}",
985 requireApiAuth,
986 requireScope("repo"),
987 async (c) => {
988 const { owner, repo } = c.req.param();
989 const filePath = c.req.param("path");
990 const user = c.get("user")!;
991
992 let body: { message?: string; sha?: string; branch?: string } = {};
993 try {
994 body = await c.req.json();
995 } catch {
996 body = {};
997 }
998
999 const message = body.message?.trim();
1000 const expectSha = body.sha?.trim();
1001 if (!message) return c.json({ error: "message is required" }, 400);
1002 if (!expectSha || !/^[0-9a-f]{40}$/.test(expectSha)) {
1003 return c.json({ error: "sha is required (40-hex)" }, 400);
1004 }
1005
1006 const resolved = await resolveRepo(owner, repo);
1007 if (!resolved) return c.json({ error: "Not found" }, 404);
1008 if (user.id !== (resolved.owner as any).id) {
1009 return c.json({ error: "Forbidden" }, 403);
1010 }
1011
1012 const branch =
1013 body.branch?.trim() ||
1014 (await getDefaultBranchFresh(owner, repo)) ||
1015 "main";
1016 const fullRef = `refs/heads/${branch}`;
1017 const repoDir = getRepoPath(owner, repo);
1018
1019 // Resolve current parent + existing blob sha at that path.
1020 const parentSha = await resolveRef(owner, repo, fullRef);
1021 if (!parentSha) return c.json({ error: "Branch not found" }, 404);
1022
1023 const existingBlobSha = await getBlobShaAtPath(owner, repo, branch, filePath);
1024 if (!existingBlobSha) return c.json({ error: "File not found" }, 404);
1025 if (existingBlobSha !== expectSha) {
1026 return c.json({ error: "sha does not match current blob at path" }, 409);
1027 }
1028
1029 const tmpIndex = join(
1030 repoDir,
1031 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1032 );
1033 const authorName = (user as any).displayName || user.username;
1034 const authorEmail = user.email;
1035 const env = {
1036 GIT_INDEX_FILE: tmpIndex,
1037 GIT_AUTHOR_NAME: authorName,
1038 GIT_AUTHOR_EMAIL: authorEmail,
1039 GIT_COMMITTER_NAME: authorName,
1040 GIT_COMMITTER_EMAIL: authorEmail,
1041 };
1042
1043 const cleanup = async () => {
1044 try {
1045 const { unlink } = await import("fs/promises");
1046 await unlink(tmpIndex);
1047 } catch {
1048 /* ignore */
1049 }
1050 };
1051
1052 try {
1053 const rt = await runGit(["git", "read-tree", parentSha], {
1054 cwd: repoDir,
1055 env,
1056 });
1057 if (rt.exitCode !== 0) {
1058 await cleanup();
1059 return c.json({ error: "Failed to read base tree" }, 500);
1060 }
1061
1062 const ui = await runGit(
1063 ["git", "update-index", "--remove", filePath],
1064 { cwd: repoDir, env }
1065 );
1066 if (ui.exitCode !== 0) {
1067 await cleanup();
1068 return c.json({ error: "Failed to remove path from index" }, 500);
1069 }
1070
1071 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1072 const newTreeSha = wt.stdout.trim();
1073 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(newTreeSha)) {
1074 await cleanup();
1075 return c.json({ error: "Failed to write tree" }, 500);
1076 }
1077
1078 const ct = await runGit(
1079 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1080 { cwd: repoDir, env }
1081 );
1082 const commitSha = ct.stdout.trim();
1083 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1084 await cleanup();
1085 return c.json({ error: "Failed to create commit" }, 500);
1086 }
1087
1088 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha);
1089 if (!ok) {
1090 await cleanup();
1091 return c.json({ error: "Failed to update ref" }, 500);
1092 }
1093
1094 await cleanup();
1095 return c.json({
1096 commit: {
1097 sha: commitSha,
1098 message,
1099 html_url: htmlUrlForCommit(owner, repo, commitSha),
1100 author: { name: authorName, email: authorEmail },
1101 },
1102 });
1103 } catch {
1104 await cleanup();
1105 return c.json({ error: "Failed to delete file" }, 500);
1106 }
1107 }
1108);
1109
1110// ─── Git plumbing: refs / commits / blobs / trees ────────────────────────────
1111
1112// GET /repos/:owner/:repo/git/refs/heads/:branch
1113apiv2.get(
1114 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1115 async (c) => {
1116 const { owner, repo } = c.req.param();
1117 const branch = c.req.param("branch");
1118 if (!(await repoExists(owner, repo))) {
1119 return c.json({ error: "Not found" }, 404);
1120 }
1121 const fullRef = `refs/heads/${branch}`;
1122 const sha = await resolveRef(owner, repo, fullRef);
1123 if (!sha) return c.json({ error: "Reference not found" }, 404);
1124 return c.json({
1125 ref: fullRef,
1126 object: { sha, type: "commit" },
1127 });
1128 }
1129);
1130
1131// GET /repos/:owner/:repo/git/commits/:sha
1132apiv2.get("/repos/:owner/:repo/git/commits/:sha", async (c) => {
1133 const { owner, repo, sha } = c.req.param();
1134 if (!(await repoExists(owner, repo))) {
1135 return c.json({ error: "Not found" }, 404);
1136 }
1137 const commit = await getCommit(owner, repo, sha);
1138 if (!commit) return c.json({ error: "Commit not found" }, 404);
1139
1140 // Resolve tree sha for the commit (cat-file <sha>^{tree}).
1141 const repoDir = getRepoPath(owner, repo);
1142 const { stdout: treeOut } = await runGit(
1143 ["git", "rev-parse", `${commit.sha}^{tree}`],
1144 { cwd: repoDir }
1145 );
1146 const treeSha = treeOut.trim();
1147
1148 return c.json({
1149 sha: commit.sha,
1150 tree: { sha: treeSha },
1151 parents: commit.parentShas.map((p) => ({ sha: p })),
1152 message: commit.message,
1153 author: {
1154 name: commit.author,
1155 email: commit.authorEmail,
1156 date: commit.date,
1157 },
1158 });
1159});
1160
1161// POST /repos/:owner/:repo/git/blobs
1162apiv2.post(
1163 "/repos/:owner/:repo/git/blobs",
1164 requireApiAuth,
1165 requireScope("repo"),
1166 async (c) => {
1167 const { owner, repo } = c.req.param();
1168 const user = c.get("user")!;
1169
1170 let body: { content?: string; encoding?: string } = {};
1171 try {
1172 body = await c.req.json();
1173 } catch {
1174 body = {};
1175 }
1176 const content = body.content;
1177 const encoding = (body.encoding || "utf-8").toLowerCase();
1178 if (typeof content !== "string") {
1179 return c.json({ error: "content is required" }, 400);
1180 }
1181 if (encoding !== "utf-8" && encoding !== "utf8" && encoding !== "base64") {
1182 return c.json({ error: "encoding must be 'utf-8' or 'base64'" }, 400);
1183 }
1184
1185 const resolved = await resolveRepo(owner, repo);
1186 if (!resolved) return c.json({ error: "Not found" }, 404);
1187 if (user.id !== (resolved.owner as any).id) {
1188 return c.json({ error: "Forbidden" }, 403);
1189 }
1190
1191 let bytes: Uint8Array;
1192 try {
1193 if (encoding === "base64") {
1194 bytes = new Uint8Array(Buffer.from(content, "base64"));
1195 } else {
1196 bytes = new TextEncoder().encode(content);
1197 }
1198 } catch {
1199 return c.json({ error: "Failed to decode content" }, 400);
1200 }
1201
1202 const sha = await writeBlob(owner, repo, bytes);
1203 if (!sha) return c.json({ error: "Failed to write blob" }, 500);
1204
1205 return c.json(
1206 {
1207 sha,
1208 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/blobs/${sha}`,
1209 size: bytes.length,
1210 },
1211 201
1212 );
1213 }
1214);
1215
1216// POST /repos/:owner/:repo/git/trees
1217//
1218// Body: { base_tree?: <tree_sha>, tree: [{ path, mode, type: "blob", sha: <blob_sha> | null }] }
1219// `sha: null` removes the entry from base_tree.
1220apiv2.post(
1221 "/repos/:owner/:repo/git/trees",
1222 requireApiAuth,
1223 requireScope("repo"),
1224 async (c) => {
1225 const { owner, repo } = c.req.param();
1226 const user = c.get("user")!;
1227
1228 let body: {
1229 base_tree?: string;
1230 tree?: Array<{
1231 path?: string;
1232 mode?: string;
1233 type?: string;
1234 sha?: string | null;
1235 }>;
1236 } = {};
1237 try {
1238 body = await c.req.json();
1239 } catch {
1240 body = {};
1241 }
1242
1243 if (!Array.isArray(body.tree)) {
1244 return c.json({ error: "tree array is required" }, 400);
1245 }
1246 for (const entry of body.tree) {
1247 if (!entry.path || typeof entry.path !== "string") {
1248 return c.json({ error: "each tree entry needs a path" }, 400);
1249 }
1250 if (!entry.mode || typeof entry.mode !== "string") {
1251 return c.json({ error: "each tree entry needs a mode" }, 400);
1252 }
1253 if (entry.sha !== null && typeof entry.sha !== "string") {
1254 return c.json(
1255 { error: "each tree entry needs sha (40-hex) or null to delete" },
1256 400
1257 );
1258 }
1259 if (typeof entry.sha === "string" && !/^[0-9a-f]{40}$/.test(entry.sha)) {
1260 return c.json({ error: "sha must be 40-hex" }, 400);
1261 }
1262 }
1263 if (body.base_tree && !/^[0-9a-f]{40}$/.test(body.base_tree)) {
1264 return c.json({ error: "base_tree must be 40-hex" }, 400);
1265 }
1266
1267 const resolved = await resolveRepo(owner, repo);
1268 if (!resolved) return c.json({ error: "Not found" }, 404);
1269 if (user.id !== (resolved.owner as any).id) {
1270 return c.json({ error: "Forbidden" }, 403);
1271 }
1272
1273 const repoDir = getRepoPath(owner, repo);
1274 const tmpIndex = join(
1275 repoDir,
1276 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1277 );
1278 const env = { GIT_INDEX_FILE: tmpIndex };
1279
1280 const cleanup = async () => {
1281 try {
1282 const { unlink } = await import("fs/promises");
1283 await unlink(tmpIndex);
1284 } catch {
1285 /* ignore */
1286 }
1287 };
1288
1289 try {
1290 if (body.base_tree) {
1291 const rt = await runGit(["git", "read-tree", body.base_tree], {
1292 cwd: repoDir,
1293 env,
1294 });
1295 if (rt.exitCode !== 0) {
1296 await cleanup();
1297 return c.json({ error: "base_tree not found" }, 404);
1298 }
1299 }
1300
1301 for (const entry of body.tree) {
1302 if (entry.sha === null) {
1303 const r = await runGit(
1304 ["git", "update-index", "--remove", entry.path!],
1305 { cwd: repoDir, env }
1306 );
1307 if (r.exitCode !== 0) {
1308 await cleanup();
1309 return c.json(
1310 { error: `Failed to remove ${entry.path}` },
1311 422
1312 );
1313 }
1314 } else {
1315 const r = await runGit(
1316 [
1317 "git",
1318 "update-index",
1319 "--add",
1320 "--cacheinfo",
1321 `${entry.mode},${entry.sha},${entry.path}`,
1322 ],
1323 { cwd: repoDir, env }
1324 );
1325 if (r.exitCode !== 0) {
1326 await cleanup();
1327 return c.json(
1328 { error: `Failed to add ${entry.path}` },
1329 422
1330 );
1331 }
1332 }
1333 }
1334
1335 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1336 const treeSha = wt.stdout.trim();
1337 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(treeSha)) {
1338 await cleanup();
1339 return c.json({ error: "Failed to write tree" }, 500);
1340 }
1341
1342 // List entries in the new tree (one level deep, matching GitHub's
1343 // POST /git/trees response shape).
1344 const ls = await runGit(["git", "ls-tree", treeSha], { cwd: repoDir });
1345 const entries: Array<{
1346 path: string;
1347 mode: string;
1348 type: string;
1349 sha: string;
1350 }> = [];
1351 for (const line of ls.stdout.split("\n").filter(Boolean)) {
1352 const m = line.match(
1353 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\t(.+)$/
1354 );
1355 if (m) {
1356 entries.push({
1357 mode: m[1],
1358 type: m[2],
1359 sha: m[3],
1360 path: m[4],
1361 });
1362 }
1363 }
1364
1365 await cleanup();
1366 return c.json(
1367 {
1368 sha: treeSha,
1369 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/trees/${treeSha}`,
1370 tree: entries,
1371 },
1372 201
1373 );
1374 } catch {
1375 await cleanup();
1376 return c.json({ error: "Failed to write tree" }, 500);
1377 }
1378 }
1379);
1380
1381// POST /repos/:owner/:repo/git/commits
1382//
1383// Body: { message, tree, parents: [<sha>] }
1384apiv2.post(
1385 "/repos/:owner/:repo/git/commits",
1386 requireApiAuth,
1387 requireScope("repo"),
1388 async (c) => {
1389 const { owner, repo } = c.req.param();
1390 const user = c.get("user")!;
1391
1392 let body: { message?: string; tree?: string; parents?: string[] } = {};
1393 try {
1394 body = await c.req.json();
1395 } catch {
1396 body = {};
1397 }
1398
1399 const message = body.message?.trim();
1400 const tree = body.tree?.trim();
1401 const parents = Array.isArray(body.parents) ? body.parents : [];
1402
1403 if (!message) return c.json({ error: "message is required" }, 400);
1404 if (!tree || !/^[0-9a-f]{40}$/.test(tree)) {
1405 return c.json({ error: "tree must be 40-hex" }, 400);
1406 }
1407 for (const p of parents) {
1408 if (typeof p !== "string" || !/^[0-9a-f]{40}$/.test(p)) {
1409 return c.json({ error: "each parent must be 40-hex" }, 400);
1410 }
1411 }
1412
1413 const resolved = await resolveRepo(owner, repo);
1414 if (!resolved) return c.json({ error: "Not found" }, 404);
1415 if (user.id !== (resolved.owner as any).id) {
1416 return c.json({ error: "Forbidden" }, 403);
1417 }
1418
1419 // Verify tree object exists.
1420 if (!(await objectExists(owner, repo, tree))) {
1421 return c.json({ error: "tree not found in repository" }, 422);
1422 }
1423 for (const p of parents) {
1424 if (!(await objectExists(owner, repo, p))) {
1425 return c.json({ error: `parent ${p} not found in repository` }, 422);
1426 }
1427 }
1428
1429 const repoDir = getRepoPath(owner, repo);
1430 const authorName = (user as any).displayName || user.username;
1431 const authorEmail = user.email;
1432 const env = {
1433 GIT_AUTHOR_NAME: authorName,
1434 GIT_AUTHOR_EMAIL: authorEmail,
1435 GIT_COMMITTER_NAME: authorName,
1436 GIT_COMMITTER_EMAIL: authorEmail,
1437 };
1438
1439 const args = ["git", "commit-tree", tree];
1440 for (const p of parents) {
1441 args.push("-p", p);
1442 }
1443 args.push("-m", message);
1444
1445 const ct = await runGit(args, { cwd: repoDir, env });
1446 const commitSha = ct.stdout.trim();
1447 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1448 return c.json({ error: "Failed to create commit" }, 500);
1449 }
1450
1451 // Re-read the new commit's recorded date so the response carries the
1452 // exact ISO timestamp git wrote into the object.
1453 const recorded = await getCommit(owner, repo, commitSha);
1454 const date = recorded?.date ?? new Date().toISOString();
1455
1456 return c.json(
1457 {
1458 sha: commitSha,
1459 tree: { sha: tree },
1460 message,
1461 parents: parents.map((p) => ({ sha: p })),
1462 author: { name: authorName, email: authorEmail, date },
1463 html_url: htmlUrlForCommit(owner, repo, commitSha),
1464 },
1465 201
1466 );
1467 }
1468);
1469
1470// PATCH /repos/:owner/:repo/git/refs/heads/:branch
1471//
1472// Body: { sha: <new_commit>, force?: false }
1473apiv2.patch(
1474 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1475 requireApiAuth,
1476 requireScope("repo"),
1477 async (c) => {
1478 const { owner, repo } = c.req.param();
1479 const branch = c.req.param("branch");
1480 const user = c.get("user")!;
1481
1482 let body: { sha?: string; force?: boolean } = {};
1483 try {
1484 body = await c.req.json();
1485 } catch {
1486 body = {};
1487 }
1488
1489 const newSha = body.sha?.trim();
1490 const force = body.force === true;
1491 if (!newSha || !/^[0-9a-f]{40}$/.test(newSha)) {
1492 return c.json({ error: "sha must be 40-hex" }, 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 const fullRef = `refs/heads/${branch}`;
1502 const currentSha = await resolveRef(owner, repo, fullRef);
1503 if (!currentSha) return c.json({ error: "Reference not found" }, 404);
1504
1505 if (!(await objectExists(owner, repo, newSha))) {
1506 return c.json({ error: "sha not found in repository" }, 422);
1507 }
1508
1509 if (!force) {
1510 // Fast-forward check: currentSha must be an ancestor of newSha.
1511 const repoDir = getRepoPath(owner, repo);
1512 const ff = await runGit(
1513 ["git", "merge-base", "--is-ancestor", currentSha, newSha],
1514 { cwd: repoDir }
1515 );
1516 if (ff.exitCode !== 0) {
1517 return c.json(
1518 { error: "Update is not a fast-forward" },
1519 422
1520 );
1521 }
1522 }
1523
1524 const ok = force
1525 ? await updateRef(owner, repo, fullRef, newSha)
1526 : await updateRef(owner, repo, fullRef, newSha, currentSha);
1527 if (!ok) return c.json({ error: "Failed to update ref" }, 500);
1528
1529 return c.json({
1530 ref: fullRef,
1531 object: { sha: newSha, type: "commit" },
1532 });
1533 }
1534);
1535
052c2e6Claude1536// ─── v2 alias for commit-status POST ─────────────────────────────────────────
1537// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
1538apiv2.post(
1539 "/repos/:owner/:repo/statuses/:sha",
1540 requireApiAuth,
1541 requireScope("repo"),
1542 postCommitStatusHandler
1543);
1544
45e31d0Claude1545// ─── Stars ──────────────────────────────────────────────────────────────────
1546
1547apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
1548 const { owner, repo } = c.req.param();
1549 const user = c.get("user")!;
1550
1551 const resolved = await resolveRepo(owner, repo);
1552 if (!resolved) return c.json({ error: "Not found" }, 404);
1553
1554 const repoId = (resolved.repo as any).id;
1555 const [existing] = await db
1556 .select()
1557 .from(stars)
1558 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
1559 .limit(1);
1560
1561 if (!existing) {
1562 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
1563 await db
1564 .update(repositories)
1565 .set({ starCount: sql`${repositories.starCount} + 1` })
1566 .where(eq(repositories.id, repoId));
1567 }
1568
1569 return c.json({ starred: true });
1570});
1571
1572apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
1573 const { owner, repo } = c.req.param();
1574 const user = c.get("user")!;
1575
1576 const resolved = await resolveRepo(owner, repo);
1577 if (!resolved) return c.json({ error: "Not found" }, 404);
1578
1579 const repoId = (resolved.repo as any).id;
1580 const [existing] = await db
1581 .select()
1582 .from(stars)
1583 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
1584 .limit(1);
1585
1586 if (existing) {
1587 await db.delete(stars).where(eq(stars.id, existing.id));
1588 await db
1589 .update(repositories)
1590 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
1591 .where(eq(repositories.id, repoId));
1592 }
1593
1594 return c.json({ starred: false });
1595});
1596
1597// ─── Labels ─────────────────────────────────────────────────────────────────
1598
1599apiv2.get("/repos/:owner/:repo/labels", async (c) => {
1600 const { owner, repo } = c.req.param();
1601 const resolved = await resolveRepo(owner, repo);
1602 if (!resolved) return c.json({ error: "Not found" }, 404);
1603
1604 const labelList = await db
1605 .select()
1606 .from(labels)
1607 .where(eq(labels.repositoryId, (resolved.repo as any).id))
1608 .orderBy(asc(labels.name));
1609
1610 return c.json(labelList);
1611});
1612
1613apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
1614 const { owner, repo } = c.req.param();
1615 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
1616
1617 if (!body.name?.trim()) {
1618 return c.json({ error: "Label name is required" }, 400);
1619 }
1620
1621 const resolved = await resolveRepo(owner, repo);
1622 if (!resolved) return c.json({ error: "Not found" }, 404);
1623
1624 const result = await db
1625 .insert(labels)
1626 .values({
1627 repositoryId: (resolved.repo as any).id,
1628 name: body.name.trim(),
1629 color: body.color || "#8b949e",
1630 description: body.description || null,
1631 })
1632 .returning();
1633
1634 return c.json(result[0], 201);
1635});
1636
1637// ─── Search ─────────────────────────────────────────────────────────────────
1638
1639apiv2.get("/search/repos", searchRateLimit, async (c) => {
1640 const q = c.req.query("q") || "";
1641 const sort = c.req.query("sort") || "stars";
1642 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1643
1644 if (!q.trim()) {
1645 return c.json({ error: "Query parameter 'q' is required" }, 400);
1646 }
1647
1648 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
1649 sort === "name" ? asc(repositories.name) :
1650 desc(repositories.starCount);
1651
1652 const results = await db
1653 .select({
1654 repo: repositories,
1655 owner: { username: users.username },
1656 })
1657 .from(repositories)
1658 .innerJoin(users, eq(repositories.ownerId, users.id))
1659 .where(
1660 and(
1661 eq(repositories.isPrivate, false),
1662 or(
1663 like(repositories.name, `%${q}%`),
1664 like(repositories.description, `%${q}%`)
1665 )
1666 )
1667 )
1668 .orderBy(orderBy)
1669 .limit(limit);
1670
1671 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
1672});
1673
1674apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
1675 const { owner, repo } = c.req.param();
1676 const q = c.req.query("q") || "";
1677
1678 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
1679 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
1680
1681 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1682 const results = await searchCode(owner, repo, defaultBranch, q.trim());
1683 return c.json(results);
1684});
1685
1686// ─── Activity Feed ──────────────────────────────────────────────────────────
1687
1688apiv2.get("/repos/:owner/:repo/activity", async (c) => {
1689 const { owner, repo } = c.req.param();
1690 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1691
1692 const resolved = await resolveRepo(owner, repo);
1693 if (!resolved) return c.json({ error: "Not found" }, 404);
1694
1695 const activity = await db
1696 .select()
1697 .from(activityFeed)
1698 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
1699 .orderBy(desc(activityFeed.createdAt))
1700 .limit(limit);
1701
1702 return c.json(activity);
1703});
1704
1705// ─── Topics ─────────────────────────────────────────────────────────────────
1706
1707apiv2.get("/repos/:owner/:repo/topics", async (c) => {
1708 const { owner, repo } = c.req.param();
1709 const resolved = await resolveRepo(owner, repo);
1710 if (!resolved) return c.json({ error: "Not found" }, 404);
1711
1712 const topicsList = await db
1713 .select()
1714 .from(repoTopics)
1715 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
1716
1717 return c.json(topicsList.map((t: any) => t.topic));
1718});
1719
1720apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
1721 const { owner, repo } = c.req.param();
1722 const user = c.get("user")!;
1723 const body = await c.req.json<{ topics: string[] }>();
1724
1725 const resolved = await resolveRepo(owner, repo);
1726 if (!resolved) return c.json({ error: "Not found" }, 404);
1727 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1728
1729 const repoId = (resolved.repo as any).id;
1730
1731 // Clear existing topics and insert new ones
1732 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
1733 if (body.topics && body.topics.length > 0) {
1734 await db.insert(repoTopics).values(
1735 body.topics.slice(0, 20).map((topic: string) => ({
1736 repositoryId: repoId,
1737 topic: topic.toLowerCase().trim(),
1738 }))
1739 );
1740 }
1741
1742 return c.json({ ok: true });
1743});
1744
1745// ─── Webhooks ───────────────────────────────────────────────────────────────
1746
1747apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
1748 const { owner, repo } = c.req.param();
1749 const user = c.get("user")!;
1750
1751 const resolved = await resolveRepo(owner, repo);
1752 if (!resolved) return c.json({ error: "Not found" }, 404);
1753 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1754
1755 const hookList = await db
1756 .select()
1757 .from(webhooks)
1758 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
1759
1760 return c.json(hookList);
1761});
1762
1763apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
1764 const { owner, repo } = c.req.param();
1765 const user = c.get("user")!;
1766 const body = await c.req.json<{
1767 url: string;
1768 secret?: string;
1769 events?: string;
1770 }>();
1771
1772 if (!body.url) return c.json({ error: "URL is required" }, 400);
1773
1774 const resolved = await resolveRepo(owner, repo);
1775 if (!resolved) return c.json({ error: "Not found" }, 404);
1776 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1777
1778 const result = await db
1779 .insert(webhooks)
1780 .values({
1781 repositoryId: (resolved.repo as any).id,
1782 url: body.url,
1783 secret: body.secret || null,
1784 events: body.events || "push",
1785 })
1786 .returning();
1787
1788 return c.json(result[0], 201);
1789});
1790
4366c70Claude1791// ─── Actions / Workflows ────────────────────────────────────────────────────
1792//
1793// GitHub-Actions-compatible REST surface (subset).
1794//
1795// POST /repos/:owner/:repo/actions/workflows/:filename/dispatches
1796// GET /repos/:owner/:repo/actions/workflows/:filename/runs
1797// GET /repos/:owner/:repo/actions/runs/:run_id
1798// GET /repos/:owner/:repo/actions/runs/:run_id/logs (.zip)
1799// POST /repos/:owner/:repo/actions/runs/:run_id/cancel
1800//
1801// Shapes follow GitHub REST v3 — snake_case fields, HTML URLs back to the
1802// gluecron run page, identical status-code semantics (204 for dispatch, 202
1803// for cancel, 409 for already-terminal, 422 for bad inputs).
1804
1805type ParsedOn =
1806 | string
1807 | string[]
1808 | Record<string, unknown>
1809 | null
1810 | undefined;
1811
1812type DispatchInputSpec = {
1813 type?: string;
1814 required?: boolean;
1815 default?: unknown;
1816 options?: unknown[];
1817 description?: string;
1818};
1819
1820/**
1821 * Pull the workflow_dispatch slice out of whatever shape `parsed.on` happens
1822 * to be. The v1 parser normalises `on` to a `string[]`, but the extended
1823 * parser may store an object — and YAML in the wild can be either form. We
1824 * accept all three: scalar string, array of event names, mapping keyed by
1825 * event name.
1826 */
1827function extractDispatchSpec(rawOn: ParsedOn): {
1828 enabled: boolean;
1829 inputs: Record<string, DispatchInputSpec> | null;
1830} {
1831 if (rawOn == null) return { enabled: false, inputs: null };
1832 if (typeof rawOn === "string") {
1833 return { enabled: rawOn === "workflow_dispatch", inputs: null };
1834 }
1835 if (Array.isArray(rawOn)) {
1836 return { enabled: rawOn.includes("workflow_dispatch"), inputs: null };
1837 }
1838 if (typeof rawOn === "object") {
1839 const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"];
1840 if (slot === undefined) return { enabled: false, inputs: null };
1841 // `workflow_dispatch:` with no children is a valid trigger declaration.
1842 if (slot == null || typeof slot !== "object") {
1843 return { enabled: true, inputs: null };
1844 }
1845 const inputs = (slot as Record<string, unknown>).inputs;
1846 if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
1847 return { enabled: true, inputs: null };
1848 }
1849 return {
1850 enabled: true,
1851 inputs: inputs as Record<string, DispatchInputSpec>,
1852 };
1853 }
1854 return { enabled: false, inputs: null };
1855}
1856
1857function validateDispatchInputs(
1858 schema: Record<string, DispatchInputSpec> | null,
1859 provided: Record<string, unknown> | undefined
1860): { ok: true } | { ok: false; details: string[] } {
1861 if (!schema) return { ok: true };
1862 const details: string[] = [];
1863 const supplied = provided ?? {};
1864 for (const [name, spec] of Object.entries(schema)) {
1865 if (!spec || typeof spec !== "object") continue;
1866 const present =
1867 Object.prototype.hasOwnProperty.call(supplied, name) &&
1868 supplied[name] !== undefined &&
1869 supplied[name] !== null;
1870 if (spec.required && !present) {
1871 // No default → required input is missing.
1872 if (spec.default === undefined) {
1873 details.push(`Missing required input: ${name}`);
1874 }
1875 }
1876 }
1877 if (details.length) return { ok: false, details };
1878 return { ok: true };
1879}
1880
1881/**
1882 * Look up a workflow row by repository + the basename of its `path` column.
1883 * Stored path is `.gluecron/workflows/<filename>`; we match the trailing
1884 * segment so callers don't need to know our on-disk layout.
1885 */
1886async function findWorkflowByFilename(
1887 repositoryId: string,
1888 filename: string
1889): Promise<typeof workflows.$inferSelect | null> {
1890 const rows = await db
1891 .select()
1892 .from(workflows)
1893 .where(eq(workflows.repositoryId, repositoryId));
1894 for (const row of rows) {
1895 const idx = row.path.lastIndexOf("/");
1896 const base = idx >= 0 ? row.path.slice(idx + 1) : row.path;
1897 if (base === filename) return row;
1898 }
1899 return null;
1900}
1901
1902function runHtmlUrl(owner: string, repo: string, runId: string): string {
1903 return `https://gluecron.com/${owner}/${repo}/actions/runs/${runId}`;
1904}
1905
1906function toIso(d: Date | string | null | undefined): string | null {
1907 if (!d) return null;
1908 return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
1909}
1910
1911function serializeRun(
1912 run: typeof workflowRuns.$inferSelect,
1913 workflowName: string,
1914 owner: string,
1915 repo: string
1916): Record<string, unknown> {
1917 // GitHub returns `head_branch` as the short branch name (no refs/heads/).
1918 let head_branch: string | null = null;
1919 if (run.ref) {
1920 head_branch = run.ref.startsWith("refs/heads/")
1921 ? run.ref.slice("refs/heads/".length)
1922 : run.ref;
1923 }
1924 return {
1925 id: run.id,
1926 name: workflowName,
1927 head_branch,
1928 head_sha: run.commitSha,
1929 status: run.status,
1930 conclusion: run.conclusion,
1931 event: run.event,
1932 created_at: toIso(run.queuedAt),
1933 updated_at:
1934 toIso(run.finishedAt) ?? toIso(run.startedAt) ?? toIso(run.queuedAt),
1935 run_started_at: toIso(run.startedAt),
1936 html_url: runHtmlUrl(owner, repo, run.id),
1937 };
1938}
1939
1940// ─── 1. POST /actions/workflows/:filename/dispatches ────────────────────────
1941
1942apiv2.post(
1943 "/repos/:owner/:repo/actions/workflows/:filename/dispatches",
1944 requireApiAuth,
1945 requireScope("repo"),
1946 async (c) => {
1947 const { owner, repo, filename } = c.req.param();
1948 const user = c.get("user")!;
1949
1950 let body: { ref?: unknown; inputs?: unknown } = {};
1951 try {
1952 body = await c.req.json();
1953 } catch {
1954 body = {};
1955 }
1956
1957 const resolved = await resolveRepo(owner, repo);
1958 if (!resolved) return c.json({ error: "Not Found" }, 404);
1959
1960 const repoRow = resolved.repo as any;
1961 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
1962 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
1963
1964 // Parse the stored workflow JSON to look at its triggers + input schema.
1965 let parsedObj: Record<string, unknown> = {};
1966 try {
1967 const v = JSON.parse(workflowRow.parsed);
1968 if (v && typeof v === "object" && !Array.isArray(v)) {
1969 parsedObj = v as Record<string, unknown>;
1970 }
1971 } catch {
1972 // Treat unparseable parsed-blob as no triggers — falls through to 422.
1973 }
1974
1975 const spec = extractDispatchSpec(parsedObj.on as ParsedOn);
1976 if (!spec.enabled) {
1977 return c.json(
1978 { error: "Workflow does not have a 'workflow_dispatch' trigger." },
1979 422
1980 );
1981 }
1982
1983 const providedInputs =
1984 body.inputs && typeof body.inputs === "object" && !Array.isArray(body.inputs)
1985 ? (body.inputs as Record<string, unknown>)
1986 : undefined;
1987 const inputCheck = validateDispatchInputs(spec.inputs, providedInputs);
1988 if (!inputCheck.ok) {
1989 return c.json(
1990 { error: "Invalid workflow inputs", details: inputCheck.details },
1991 422
1992 );
1993 }
1994
1995 // Resolve the ref → commit SHA. Default to repo default_branch.
1996 const refIn =
1997 typeof body.ref === "string" && body.ref.trim().length > 0
1998 ? body.ref.trim()
1999 : repoRow.defaultBranch || "main";
2000 const commitSha = await resolveRef(owner, repo, refIn);
2001 if (!commitSha) {
2002 return c.json({ error: `Ref not found: ${refIn}` }, 422);
2003 }
2004
2005 const runId = await enqueueRun({
2006 workflowId: workflowRow.id,
2007 repositoryId: repoRow.id,
2008 event: "workflow_dispatch",
2009 ref: refIn,
2010 commitSha,
2011 triggeredBy: user.id,
2012 });
2013 if (!runId) {
2014 return c.json({ error: "Failed to enqueue run" }, 500);
2015 }
2016
2017 // GitHub returns 204 No Content with no body on success.
2018 return c.body(null, 204);
2019 }
2020);
2021
2022// ─── 2. GET /actions/workflows/:filename/runs ───────────────────────────────
2023
2024apiv2.get(
2025 "/repos/:owner/:repo/actions/workflows/:filename/runs",
2026 async (c) => {
2027 const { owner, repo, filename } = c.req.param();
2028 const resolved = await resolveRepo(owner, repo);
2029 if (!resolved) return c.json({ error: "Not Found" }, 404);
2030
2031 const repoRow = resolved.repo as any;
2032 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2033 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2034
2035 const perPage = Math.min(
2036 100,
2037 Math.max(1, parseInt(c.req.query("per_page") || "30", 10) || 30)
2038 );
2039 const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
2040 const offset = (page - 1) * perPage;
2041 const branch = c.req.query("branch");
2042 const headSha = c.req.query("head_sha");
2043
2044 const conditions = [eq(workflowRuns.workflowId, workflowRow.id)];
2045 if (branch) {
2046 // Accept either short branch name or fully-qualified refs/heads/...
2047 const refValue = branch.startsWith("refs/")
2048 ? branch
2049 : `refs/heads/${branch}`;
2050 conditions.push(eq(workflowRuns.ref, refValue));
2051 }
2052 if (headSha) conditions.push(eq(workflowRuns.commitSha, headSha));
2053
2054 const where = conditions.length === 1 ? conditions[0] : and(...conditions);
2055
2056 const [{ n }] = await db
2057 .select({ n: sql<number>`count(*)::int` })
2058 .from(workflowRuns)
2059 .where(where);
2060 const total_count = Number(n) || 0;
2061
2062 const rows = await db
2063 .select()
2064 .from(workflowRuns)
2065 .where(where)
2066 .orderBy(desc(workflowRuns.queuedAt))
2067 .limit(perPage)
2068 .offset(offset);
2069
2070 return c.json({
2071 total_count,
2072 workflow_runs: rows.map((r) =>
2073 serializeRun(r, workflowRow.name, owner, repo)
2074 ),
2075 });
2076 }
2077);
2078
2079// ─── 3. GET /actions/runs/:run_id ───────────────────────────────────────────
2080
2081apiv2.get("/repos/:owner/:repo/actions/runs/:run_id", async (c) => {
2082 const { owner, repo, run_id } = c.req.param();
2083 const resolved = await resolveRepo(owner, repo);
2084 if (!resolved) return c.json({ error: "Not Found" }, 404);
2085
2086 const repoRow = resolved.repo as any;
2087 const [run] = await db
2088 .select()
2089 .from(workflowRuns)
2090 .where(eq(workflowRuns.id, run_id))
2091 .limit(1);
2092 if (!run) return c.json({ error: "Not Found" }, 404);
2093 // Don't leak runs across repos.
2094 if (run.repositoryId !== repoRow.id) {
2095 return c.json({ error: "Not Found" }, 404);
2096 }
2097
2098 const [wf] = await db
2099 .select()
2100 .from(workflows)
2101 .where(eq(workflows.id, run.workflowId))
2102 .limit(1);
2103 const workflowName = wf?.name ?? "";
2104
2105 return c.json(serializeRun(run, workflowName, owner, repo));
2106});
2107
2108// ─── 4. GET /actions/runs/:run_id/logs — ZIP of per-job logs ────────────────
2109//
2110// Reuses the same in-process zip writer pattern as `connect-claude.tsx` —
2111// PKZIP 2.0, no zip64, deflateRawSync with STORED fallback when compression
2112// would inflate. The handler is self-contained so the dxt downloader stays
2113// the canonical reference.
2114
2115function crc32(buf: Uint8Array): number {
2116 let c = 0xffffffff;
2117 for (let i = 0; i < buf.length; i++) {
2118 c ^= buf[i]!;
2119 for (let k = 0; k < 8; k++) {
2120 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
2121 }
2122 }
2123 return (c ^ 0xffffffff) >>> 0;
2124}
2125
2126type ZipEntry = { name: string; data: Uint8Array };
2127
2128function buildZip(entries: ZipEntry[]): Uint8Array {
2129 const localParts: Uint8Array[] = [];
2130 const centralParts: Uint8Array[] = [];
2131 let offset = 0;
2132
2133 for (const entry of entries) {
2134 const nameBytes = new TextEncoder().encode(entry.name);
2135 const crc = crc32(entry.data);
2136 const uncompressedSize = entry.data.length;
2137
2138 let method = 8;
2139 let compressed: Uint8Array;
2140 try {
2141 const out = deflateRawSync(entry.data);
2142 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
2143 if (compressed.length >= uncompressedSize) {
2144 method = 0;
2145 compressed = entry.data;
2146 }
2147 } catch {
2148 method = 0;
2149 compressed = entry.data;
2150 }
2151 const compressedSize = compressed.length;
2152
2153 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
2154 const lv = new DataView(local.buffer);
2155 lv.setUint32(0, 0x04034b50, true);
2156 lv.setUint16(4, 20, true);
2157 lv.setUint16(6, 0, true);
2158 lv.setUint16(8, method, true);
2159 lv.setUint16(10, 0, true);
2160 lv.setUint16(12, 0, true);
2161 lv.setUint32(14, crc, true);
2162 lv.setUint32(18, compressedSize, true);
2163 lv.setUint32(22, uncompressedSize, true);
2164 lv.setUint16(26, nameBytes.length, true);
2165 lv.setUint16(28, 0, true);
2166 local.set(nameBytes, 30);
2167 local.set(compressed, 30 + nameBytes.length);
2168 localParts.push(local);
2169
2170 const central = new Uint8Array(46 + nameBytes.length);
2171 const cv = new DataView(central.buffer);
2172 cv.setUint32(0, 0x02014b50, true);
2173 cv.setUint16(4, 20, true);
2174 cv.setUint16(6, 20, true);
2175 cv.setUint16(8, 0, true);
2176 cv.setUint16(10, method, true);
2177 cv.setUint16(12, 0, true);
2178 cv.setUint16(14, 0, true);
2179 cv.setUint32(16, crc, true);
2180 cv.setUint32(20, compressedSize, true);
2181 cv.setUint32(24, uncompressedSize, true);
2182 cv.setUint16(28, nameBytes.length, true);
2183 cv.setUint16(30, 0, true);
2184 cv.setUint16(32, 0, true);
2185 cv.setUint16(34, 0, true);
2186 cv.setUint16(36, 0, true);
2187 cv.setUint32(38, 0, true);
2188 cv.setUint32(42, offset, true);
2189 central.set(nameBytes, 46);
2190 centralParts.push(central);
2191
2192 offset += local.length;
2193 }
2194
2195 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
2196 const centralOffset = offset;
2197
2198 const end = new Uint8Array(22);
2199 const ev = new DataView(end.buffer);
2200 ev.setUint32(0, 0x06054b50, true);
2201 ev.setUint16(4, 0, true);
2202 ev.setUint16(6, 0, true);
2203 ev.setUint16(8, entries.length, true);
2204 ev.setUint16(10, entries.length, true);
2205 ev.setUint32(12, centralSize, true);
2206 ev.setUint32(16, centralOffset, true);
2207 ev.setUint16(20, 0, true);
2208
2209 const total =
2210 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
2211 const out = new Uint8Array(total);
2212 let pos = 0;
2213 for (const p of localParts) {
2214 out.set(p, pos);
2215 pos += p.length;
2216 }
2217 for (const p of centralParts) {
2218 out.set(p, pos);
2219 pos += p.length;
2220 }
2221 out.set(end, pos);
2222 return out;
2223}
2224
2225apiv2.get("/repos/:owner/:repo/actions/runs/:run_id/logs", async (c) => {
2226 const { owner, repo, run_id } = c.req.param();
2227 const resolved = await resolveRepo(owner, repo);
2228 if (!resolved) return c.json({ error: "Not Found" }, 404);
2229
2230 const repoRow = resolved.repo as any;
2231 const [run] = await db
2232 .select()
2233 .from(workflowRuns)
2234 .where(eq(workflowRuns.id, run_id))
2235 .limit(1);
2236 if (!run || run.repositoryId !== repoRow.id) {
2237 return c.json({ error: "Not Found" }, 404);
2238 }
2239
2240 const jobs = await db
2241 .select()
2242 .from(workflowJobs)
2243 .where(eq(workflowJobs.runId, run.id))
2244 .orderBy(asc(workflowJobs.jobOrder));
2245
2246 // 404 when there's truly nothing to package up — matches GitHub's behaviour
2247 // for runs that never produced logs.
2248 const usable = jobs.filter(
2249 (j) => typeof j.logs === "string" && j.logs.length > 0
2250 );
2251 if (usable.length === 0) {
2252 return c.json({ error: "Not Found" }, 404);
2253 }
2254
2255 // Make filenames safe + unique. Collisions get a numeric suffix.
2256 const seen = new Set<string>();
2257 const entries: ZipEntry[] = [];
2258 for (const job of usable) {
2259 let base = (job.name || "job").replace(/[^a-zA-Z0-9_.-]+/g, "_");
2260 if (!base) base = "job";
2261 let filename = `${base}.log`;
2262 let n = 1;
2263 while (seen.has(filename)) {
2264 filename = `${base}-${++n}.log`;
2265 }
2266 seen.add(filename);
2267 entries.push({
2268 name: filename,
2269 data: new TextEncoder().encode(job.logs),
2270 });
2271 }
2272
2273 const zip = buildZip(entries);
2274 // Wrap the bytes in a Blob so Response's BodyInit type is happy across
2275 // both Bun's `globalThis.Response` and Hono's. Uint8Array works at
2276 // runtime but trips strict TS — cast to BlobPart resolves the
2277 // ArrayBufferLike/ArrayBuffer mismatch on `.buffer`.
2278 return new Response(new Blob([zip as BlobPart], { type: "application/zip" }), {
2279 status: 200,
2280 headers: {
2281 "Content-Disposition": `attachment; filename="run-${run.id}-logs.zip"`,
2282 "Content-Length": String(zip.length),
2283 },
2284 });
2285});
2286
2287// ─── 5. POST /actions/runs/:run_id/cancel ───────────────────────────────────
2288
2289apiv2.post(
2290 "/repos/:owner/:repo/actions/runs/:run_id/cancel",
2291 requireApiAuth,
2292 requireScope("repo"),
2293 async (c) => {
2294 const { owner, repo, run_id } = c.req.param();
2295 const resolved = await resolveRepo(owner, repo);
2296 if (!resolved) return c.json({ error: "Not Found" }, 404);
2297
2298 const repoRow = resolved.repo as any;
2299 const [run] = await db
2300 .select()
2301 .from(workflowRuns)
2302 .where(eq(workflowRuns.id, run_id))
2303 .limit(1);
2304 if (!run || run.repositoryId !== repoRow.id) {
2305 return c.json({ error: "Not Found" }, 404);
2306 }
2307
2308 // Already-terminal states are a 409 Conflict per GitHub semantics. We
2309 // only allow queued → cancelled and running → cancelled transitions.
2310 if (run.status !== "queued" && run.status !== "running") {
2311 return c.json(
2312 {
2313 error:
2314 "Cannot cancel workflow run in its current state",
2315 status: run.status,
2316 },
2317 409
2318 );
2319 }
2320
2321 const now = new Date();
2322 await db
2323 .update(workflowRuns)
2324 .set({
2325 status: "cancelled",
2326 conclusion: "cancelled",
2327 finishedAt: now,
2328 })
2329 .where(
2330 and(
2331 eq(workflowRuns.id, run.id),
2332 eq(workflowRuns.repositoryId, repoRow.id)
2333 )
2334 );
2335 await db
2336 .update(workflowJobs)
2337 .set({
2338 status: "cancelled",
2339 conclusion: "cancelled",
2340 finishedAt: now,
2341 })
2342 .where(eq(workflowJobs.runId, run.id));
2343
2344 return c.json({}, 202);
2345 }
2346);
2347
45e31d0Claude2348// ─── API Info ───────────────────────────────────────────────────────────────
2349
2350apiv2.get("/", (c) => {
2351 return c.json({
2352 name: "gluecron API",
2353 version: "2.0",
2354 documentation: "/api/docs",
2355 endpoints: {
46d6165Claude2356 auth: {
2357 "POST /api/v2/auth/install-token":
2358 "Mint a PAT for one-command install (session-cookie auth only)",
2359 },
45e31d0Claude2360 users: {
2361 "GET /api/v2/user": "Get authenticated user",
2362 "GET /api/v2/users/:username": "Get user by username",
2363 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude2364 "GET /api/v2/me/ai-savings":
2365 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude2366 },
2367 repositories: {
2368 "GET /api/v2/users/:username/repos": "List user repositories",
2369 "POST /api/v2/repos": "Create repository",
2370 "GET /api/v2/repos/:owner/:repo": "Get repository",
2371 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
2372 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
2373 },
2374 branches: {
2375 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
2376 },
2377 commits: {
2378 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
2379 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
2380 },
2381 files: {
052c2e6Claude2382 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
2383 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
2384 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
da50b83Claude2385 "DELETE /api/v2/repos/:owner/:repo/contents/:path": "Delete a file on a branch",
052c2e6Claude2386 },
2387 git: {
2388 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
da50b83Claude2389 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Get a branch ref",
2390 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Move a branch ref (fast-forward by default)",
2391 "GET /api/v2/repos/:owner/:repo/git/commits/:sha": "Get a raw git commit object",
2392 "POST /api/v2/repos/:owner/:repo/git/commits": "Create a commit from a tree + parents",
2393 "POST /api/v2/repos/:owner/:repo/git/blobs": "Write a blob from utf-8 or base64 content",
2394 "POST /api/v2/repos/:owner/:repo/git/trees": "Build a tree from entries (optionally based on base_tree)",
052c2e6Claude2395 },
2396 statuses: {
2397 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude2398 },
2399 issues: {
2400 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
2401 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
2402 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
2403 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
2404 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
2405 },
2406 pullRequests: {
2407 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
2408 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
2409 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude2410 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude2411 },
2412 stars: {
2413 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
2414 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
2415 },
2416 labels: {
2417 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
2418 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
2419 },
2420 search: {
2421 "GET /api/v2/search/repos": "Search repositories",
2422 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
2423 },
2424 topics: {
2425 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
2426 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
2427 },
2428 webhooks: {
2429 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
2430 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
2431 },
2432 activity: {
2433 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
2434 },
4366c70Claude2435 actions: {
2436 "POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches":
2437 "Dispatch a workflow run (204 No Content)",
2438 "GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs":
2439 "List runs of a workflow (paginated: per_page, page; filters: branch, head_sha)",
2440 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id":
2441 "Get a single workflow run",
2442 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs":
2443 "Download per-job logs as a .zip archive",
2444 "POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel":
2445 "Cancel a queued or running workflow run (202 Accepted)",
2446 },
45e31d0Claude2447 },
2448 authentication: {
2449 method: "Bearer token",
2450 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]2451 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude2452 },
2453 rateLimit: {
2454 api: "100 requests/minute",
2455 search: "30 requests/minute",
2456 auth: "10 requests/minute",
2457 },
2458 });
2459});
2460
2461export default apiv2;