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.tsBlame2474 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 );
296ee49Claude1033 // `update-index --remove` checks `is_inside_work_tree()`, so a bare
1034 // repo needs a transient stand-in. Empty directory is sufficient —
1035 // git only consults it for safety checks, never writes blobs through it.
1036 const tmpWorkTree = join(
1037 repoDir,
1038 `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1039 );
1040 const { mkdir } = await import("fs/promises");
1041 await mkdir(tmpWorkTree, { recursive: true });
1042
da50b83Claude1043 const authorName = (user as any).displayName || user.username;
1044 const authorEmail = user.email;
1045 const env = {
1046 GIT_INDEX_FILE: tmpIndex,
296ee49Claude1047 GIT_DIR: repoDir,
1048 GIT_WORK_TREE: tmpWorkTree,
da50b83Claude1049 GIT_AUTHOR_NAME: authorName,
1050 GIT_AUTHOR_EMAIL: authorEmail,
1051 GIT_COMMITTER_NAME: authorName,
1052 GIT_COMMITTER_EMAIL: authorEmail,
1053 };
1054
1055 const cleanup = async () => {
1056 try {
296ee49Claude1057 const { unlink, rm } = await import("fs/promises");
1058 await unlink(tmpIndex).catch(() => {});
1059 await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {});
da50b83Claude1060 } catch {
1061 /* ignore */
1062 }
1063 };
1064
1065 try {
1066 const rt = await runGit(["git", "read-tree", parentSha], {
1067 cwd: repoDir,
1068 env,
1069 });
1070 if (rt.exitCode !== 0) {
1071 await cleanup();
1072 return c.json({ error: "Failed to read base tree" }, 500);
1073 }
1074
1075 const ui = await runGit(
1076 ["git", "update-index", "--remove", filePath],
1077 { cwd: repoDir, env }
1078 );
1079 if (ui.exitCode !== 0) {
1080 await cleanup();
1081 return c.json({ error: "Failed to remove path from index" }, 500);
1082 }
1083
1084 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1085 const newTreeSha = wt.stdout.trim();
1086 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(newTreeSha)) {
1087 await cleanup();
1088 return c.json({ error: "Failed to write tree" }, 500);
1089 }
1090
1091 const ct = await runGit(
1092 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1093 { cwd: repoDir, env }
1094 );
1095 const commitSha = ct.stdout.trim();
1096 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1097 await cleanup();
1098 return c.json({ error: "Failed to create commit" }, 500);
1099 }
1100
1101 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha);
1102 if (!ok) {
1103 await cleanup();
1104 return c.json({ error: "Failed to update ref" }, 500);
1105 }
1106
1107 await cleanup();
1108 return c.json({
1109 commit: {
1110 sha: commitSha,
1111 message,
1112 html_url: htmlUrlForCommit(owner, repo, commitSha),
1113 author: { name: authorName, email: authorEmail },
1114 },
1115 });
1116 } catch {
1117 await cleanup();
1118 return c.json({ error: "Failed to delete file" }, 500);
1119 }
1120 }
1121);
1122
1123// ─── Git plumbing: refs / commits / blobs / trees ────────────────────────────
1124
1125// GET /repos/:owner/:repo/git/refs/heads/:branch
1126apiv2.get(
1127 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1128 async (c) => {
1129 const { owner, repo } = c.req.param();
1130 const branch = c.req.param("branch");
1131 if (!(await repoExists(owner, repo))) {
1132 return c.json({ error: "Not found" }, 404);
1133 }
1134 const fullRef = `refs/heads/${branch}`;
1135 const sha = await resolveRef(owner, repo, fullRef);
1136 if (!sha) return c.json({ error: "Reference not found" }, 404);
1137 return c.json({
1138 ref: fullRef,
1139 object: { sha, type: "commit" },
1140 });
1141 }
1142);
1143
1144// GET /repos/:owner/:repo/git/commits/:sha
1145apiv2.get("/repos/:owner/:repo/git/commits/:sha", async (c) => {
1146 const { owner, repo, sha } = c.req.param();
1147 if (!(await repoExists(owner, repo))) {
1148 return c.json({ error: "Not found" }, 404);
1149 }
1150 const commit = await getCommit(owner, repo, sha);
1151 if (!commit) return c.json({ error: "Commit not found" }, 404);
1152
1153 // Resolve tree sha for the commit (cat-file <sha>^{tree}).
1154 const repoDir = getRepoPath(owner, repo);
1155 const { stdout: treeOut } = await runGit(
1156 ["git", "rev-parse", `${commit.sha}^{tree}`],
1157 { cwd: repoDir }
1158 );
1159 const treeSha = treeOut.trim();
1160
1161 return c.json({
1162 sha: commit.sha,
1163 tree: { sha: treeSha },
1164 parents: commit.parentShas.map((p) => ({ sha: p })),
1165 message: commit.message,
1166 author: {
1167 name: commit.author,
1168 email: commit.authorEmail,
1169 date: commit.date,
1170 },
1171 });
1172});
1173
1174// POST /repos/:owner/:repo/git/blobs
1175apiv2.post(
1176 "/repos/:owner/:repo/git/blobs",
1177 requireApiAuth,
1178 requireScope("repo"),
1179 async (c) => {
1180 const { owner, repo } = c.req.param();
1181 const user = c.get("user")!;
1182
1183 let body: { content?: string; encoding?: string } = {};
1184 try {
1185 body = await c.req.json();
1186 } catch {
1187 body = {};
1188 }
1189 const content = body.content;
1190 const encoding = (body.encoding || "utf-8").toLowerCase();
1191 if (typeof content !== "string") {
1192 return c.json({ error: "content is required" }, 400);
1193 }
1194 if (encoding !== "utf-8" && encoding !== "utf8" && encoding !== "base64") {
1195 return c.json({ error: "encoding must be 'utf-8' or 'base64'" }, 400);
1196 }
1197
1198 const resolved = await resolveRepo(owner, repo);
1199 if (!resolved) return c.json({ error: "Not found" }, 404);
1200 if (user.id !== (resolved.owner as any).id) {
1201 return c.json({ error: "Forbidden" }, 403);
1202 }
1203
1204 let bytes: Uint8Array;
1205 try {
1206 if (encoding === "base64") {
1207 bytes = new Uint8Array(Buffer.from(content, "base64"));
1208 } else {
1209 bytes = new TextEncoder().encode(content);
1210 }
1211 } catch {
1212 return c.json({ error: "Failed to decode content" }, 400);
1213 }
1214
1215 const sha = await writeBlob(owner, repo, bytes);
1216 if (!sha) return c.json({ error: "Failed to write blob" }, 500);
1217
1218 return c.json(
1219 {
1220 sha,
1221 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/blobs/${sha}`,
1222 size: bytes.length,
1223 },
1224 201
1225 );
1226 }
1227);
1228
1229// POST /repos/:owner/:repo/git/trees
1230//
1231// Body: { base_tree?: <tree_sha>, tree: [{ path, mode, type: "blob", sha: <blob_sha> | null }] }
1232// `sha: null` removes the entry from base_tree.
1233apiv2.post(
1234 "/repos/:owner/:repo/git/trees",
1235 requireApiAuth,
1236 requireScope("repo"),
1237 async (c) => {
1238 const { owner, repo } = c.req.param();
1239 const user = c.get("user")!;
1240
1241 let body: {
1242 base_tree?: string;
1243 tree?: Array<{
1244 path?: string;
1245 mode?: string;
1246 type?: string;
1247 sha?: string | null;
1248 }>;
1249 } = {};
1250 try {
1251 body = await c.req.json();
1252 } catch {
1253 body = {};
1254 }
1255
1256 if (!Array.isArray(body.tree)) {
1257 return c.json({ error: "tree array is required" }, 400);
1258 }
1259 for (const entry of body.tree) {
1260 if (!entry.path || typeof entry.path !== "string") {
1261 return c.json({ error: "each tree entry needs a path" }, 400);
1262 }
1263 if (!entry.mode || typeof entry.mode !== "string") {
1264 return c.json({ error: "each tree entry needs a mode" }, 400);
1265 }
1266 if (entry.sha !== null && typeof entry.sha !== "string") {
1267 return c.json(
1268 { error: "each tree entry needs sha (40-hex) or null to delete" },
1269 400
1270 );
1271 }
1272 if (typeof entry.sha === "string" && !/^[0-9a-f]{40}$/.test(entry.sha)) {
1273 return c.json({ error: "sha must be 40-hex" }, 400);
1274 }
1275 }
1276 if (body.base_tree && !/^[0-9a-f]{40}$/.test(body.base_tree)) {
1277 return c.json({ error: "base_tree must be 40-hex" }, 400);
1278 }
1279
1280 const resolved = await resolveRepo(owner, repo);
1281 if (!resolved) return c.json({ error: "Not found" }, 404);
1282 if (user.id !== (resolved.owner as any).id) {
1283 return c.json({ error: "Forbidden" }, 403);
1284 }
1285
1286 const repoDir = getRepoPath(owner, repo);
1287 const tmpIndex = join(
1288 repoDir,
1289 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1290 );
1291 const env = { GIT_INDEX_FILE: tmpIndex };
1292
1293 const cleanup = async () => {
1294 try {
1295 const { unlink } = await import("fs/promises");
1296 await unlink(tmpIndex);
1297 } catch {
1298 /* ignore */
1299 }
1300 };
1301
1302 try {
1303 if (body.base_tree) {
1304 const rt = await runGit(["git", "read-tree", body.base_tree], {
1305 cwd: repoDir,
1306 env,
1307 });
1308 if (rt.exitCode !== 0) {
1309 await cleanup();
1310 return c.json({ error: "base_tree not found" }, 404);
1311 }
1312 }
1313
1314 for (const entry of body.tree) {
1315 if (entry.sha === null) {
1316 const r = await runGit(
1317 ["git", "update-index", "--remove", entry.path!],
1318 { cwd: repoDir, env }
1319 );
1320 if (r.exitCode !== 0) {
1321 await cleanup();
1322 return c.json(
1323 { error: `Failed to remove ${entry.path}` },
1324 422
1325 );
1326 }
1327 } else {
1328 const r = await runGit(
1329 [
1330 "git",
1331 "update-index",
1332 "--add",
1333 "--cacheinfo",
1334 `${entry.mode},${entry.sha},${entry.path}`,
1335 ],
1336 { cwd: repoDir, env }
1337 );
1338 if (r.exitCode !== 0) {
1339 await cleanup();
1340 return c.json(
1341 { error: `Failed to add ${entry.path}` },
1342 422
1343 );
1344 }
1345 }
1346 }
1347
1348 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1349 const treeSha = wt.stdout.trim();
1350 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(treeSha)) {
1351 await cleanup();
1352 return c.json({ error: "Failed to write tree" }, 500);
1353 }
1354
1355 // List entries in the new tree (one level deep, matching GitHub's
1356 // POST /git/trees response shape).
1357 const ls = await runGit(["git", "ls-tree", treeSha], { cwd: repoDir });
1358 const entries: Array<{
1359 path: string;
1360 mode: string;
1361 type: string;
1362 sha: string;
1363 }> = [];
1364 for (const line of ls.stdout.split("\n").filter(Boolean)) {
1365 const m = line.match(
1366 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\t(.+)$/
1367 );
1368 if (m) {
1369 entries.push({
1370 mode: m[1],
1371 type: m[2],
1372 sha: m[3],
1373 path: m[4],
1374 });
1375 }
1376 }
1377
1378 await cleanup();
1379 return c.json(
1380 {
1381 sha: treeSha,
1382 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/trees/${treeSha}`,
1383 tree: entries,
1384 },
1385 201
1386 );
1387 } catch {
1388 await cleanup();
1389 return c.json({ error: "Failed to write tree" }, 500);
1390 }
1391 }
1392);
1393
1394// POST /repos/:owner/:repo/git/commits
1395//
1396// Body: { message, tree, parents: [<sha>] }
1397apiv2.post(
1398 "/repos/:owner/:repo/git/commits",
1399 requireApiAuth,
1400 requireScope("repo"),
1401 async (c) => {
1402 const { owner, repo } = c.req.param();
1403 const user = c.get("user")!;
1404
1405 let body: { message?: string; tree?: string; parents?: string[] } = {};
1406 try {
1407 body = await c.req.json();
1408 } catch {
1409 body = {};
1410 }
1411
1412 const message = body.message?.trim();
1413 const tree = body.tree?.trim();
1414 const parents = Array.isArray(body.parents) ? body.parents : [];
1415
1416 if (!message) return c.json({ error: "message is required" }, 400);
1417 if (!tree || !/^[0-9a-f]{40}$/.test(tree)) {
1418 return c.json({ error: "tree must be 40-hex" }, 400);
1419 }
1420 for (const p of parents) {
1421 if (typeof p !== "string" || !/^[0-9a-f]{40}$/.test(p)) {
1422 return c.json({ error: "each parent must be 40-hex" }, 400);
1423 }
1424 }
1425
1426 const resolved = await resolveRepo(owner, repo);
1427 if (!resolved) return c.json({ error: "Not found" }, 404);
1428 if (user.id !== (resolved.owner as any).id) {
1429 return c.json({ error: "Forbidden" }, 403);
1430 }
1431
1432 // Verify tree object exists.
1433 if (!(await objectExists(owner, repo, tree))) {
1434 return c.json({ error: "tree not found in repository" }, 422);
1435 }
1436 for (const p of parents) {
1437 if (!(await objectExists(owner, repo, p))) {
1438 return c.json({ error: `parent ${p} not found in repository` }, 422);
1439 }
1440 }
1441
1442 const repoDir = getRepoPath(owner, repo);
1443 const authorName = (user as any).displayName || user.username;
1444 const authorEmail = user.email;
1445 const env = {
1446 GIT_AUTHOR_NAME: authorName,
1447 GIT_AUTHOR_EMAIL: authorEmail,
1448 GIT_COMMITTER_NAME: authorName,
1449 GIT_COMMITTER_EMAIL: authorEmail,
1450 };
1451
1452 const args = ["git", "commit-tree", tree];
1453 for (const p of parents) {
1454 args.push("-p", p);
1455 }
1456 args.push("-m", message);
1457
1458 const ct = await runGit(args, { cwd: repoDir, env });
1459 const commitSha = ct.stdout.trim();
1460 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1461 return c.json({ error: "Failed to create commit" }, 500);
1462 }
1463
1464 // Re-read the new commit's recorded date so the response carries the
1465 // exact ISO timestamp git wrote into the object.
1466 const recorded = await getCommit(owner, repo, commitSha);
1467 const date = recorded?.date ?? new Date().toISOString();
1468
1469 return c.json(
1470 {
1471 sha: commitSha,
1472 tree: { sha: tree },
1473 message,
1474 parents: parents.map((p) => ({ sha: p })),
1475 author: { name: authorName, email: authorEmail, date },
1476 html_url: htmlUrlForCommit(owner, repo, commitSha),
1477 },
1478 201
1479 );
1480 }
1481);
1482
1483// PATCH /repos/:owner/:repo/git/refs/heads/:branch
1484//
1485// Body: { sha: <new_commit>, force?: false }
1486apiv2.patch(
1487 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1488 requireApiAuth,
1489 requireScope("repo"),
1490 async (c) => {
1491 const { owner, repo } = c.req.param();
1492 const branch = c.req.param("branch");
1493 const user = c.get("user")!;
1494
1495 let body: { sha?: string; force?: boolean } = {};
1496 try {
1497 body = await c.req.json();
1498 } catch {
1499 body = {};
1500 }
1501
1502 const newSha = body.sha?.trim();
1503 const force = body.force === true;
1504 if (!newSha || !/^[0-9a-f]{40}$/.test(newSha)) {
1505 return c.json({ error: "sha must be 40-hex" }, 400);
1506 }
1507
1508 const resolved = await resolveRepo(owner, repo);
1509 if (!resolved) return c.json({ error: "Not found" }, 404);
1510 if (user.id !== (resolved.owner as any).id) {
1511 return c.json({ error: "Forbidden" }, 403);
1512 }
1513
1514 const fullRef = `refs/heads/${branch}`;
1515 const currentSha = await resolveRef(owner, repo, fullRef);
1516 if (!currentSha) return c.json({ error: "Reference not found" }, 404);
1517
1518 if (!(await objectExists(owner, repo, newSha))) {
1519 return c.json({ error: "sha not found in repository" }, 422);
1520 }
1521
1522 if (!force) {
1523 // Fast-forward check: currentSha must be an ancestor of newSha.
1524 const repoDir = getRepoPath(owner, repo);
1525 const ff = await runGit(
1526 ["git", "merge-base", "--is-ancestor", currentSha, newSha],
1527 { cwd: repoDir }
1528 );
1529 if (ff.exitCode !== 0) {
1530 return c.json(
1531 { error: "Update is not a fast-forward" },
1532 422
1533 );
1534 }
1535 }
1536
1537 const ok = force
1538 ? await updateRef(owner, repo, fullRef, newSha)
1539 : await updateRef(owner, repo, fullRef, newSha, currentSha);
1540 if (!ok) return c.json({ error: "Failed to update ref" }, 500);
1541
1542 return c.json({
1543 ref: fullRef,
1544 object: { sha: newSha, type: "commit" },
1545 });
1546 }
1547);
1548
052c2e6Claude1549// ─── v2 alias for commit-status POST ─────────────────────────────────────────
1550// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
1551apiv2.post(
1552 "/repos/:owner/:repo/statuses/:sha",
1553 requireApiAuth,
1554 requireScope("repo"),
1555 postCommitStatusHandler
1556);
1557
45e31d0Claude1558// ─── Stars ──────────────────────────────────────────────────────────────────
1559
1560apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
1561 const { owner, repo } = c.req.param();
1562 const user = c.get("user")!;
1563
1564 const resolved = await resolveRepo(owner, repo);
1565 if (!resolved) return c.json({ error: "Not found" }, 404);
1566
1567 const repoId = (resolved.repo as any).id;
1568 const [existing] = await db
1569 .select()
1570 .from(stars)
1571 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
1572 .limit(1);
1573
1574 if (!existing) {
1575 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
1576 await db
1577 .update(repositories)
1578 .set({ starCount: sql`${repositories.starCount} + 1` })
1579 .where(eq(repositories.id, repoId));
1580 }
1581
1582 return c.json({ starred: true });
1583});
1584
1585apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
1586 const { owner, repo } = c.req.param();
1587 const user = c.get("user")!;
1588
1589 const resolved = await resolveRepo(owner, repo);
1590 if (!resolved) return c.json({ error: "Not found" }, 404);
1591
1592 const repoId = (resolved.repo as any).id;
1593 const [existing] = await db
1594 .select()
1595 .from(stars)
1596 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
1597 .limit(1);
1598
1599 if (existing) {
1600 await db.delete(stars).where(eq(stars.id, existing.id));
1601 await db
1602 .update(repositories)
1603 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
1604 .where(eq(repositories.id, repoId));
1605 }
1606
1607 return c.json({ starred: false });
1608});
1609
1610// ─── Labels ─────────────────────────────────────────────────────────────────
1611
1612apiv2.get("/repos/:owner/:repo/labels", async (c) => {
1613 const { owner, repo } = c.req.param();
1614 const resolved = await resolveRepo(owner, repo);
1615 if (!resolved) return c.json({ error: "Not found" }, 404);
1616
1617 const labelList = await db
1618 .select()
1619 .from(labels)
1620 .where(eq(labels.repositoryId, (resolved.repo as any).id))
1621 .orderBy(asc(labels.name));
1622
1623 return c.json(labelList);
1624});
1625
1626apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
1627 const { owner, repo } = c.req.param();
1628 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
1629
1630 if (!body.name?.trim()) {
1631 return c.json({ error: "Label name is required" }, 400);
1632 }
1633
1634 const resolved = await resolveRepo(owner, repo);
1635 if (!resolved) return c.json({ error: "Not found" }, 404);
1636
1637 const result = await db
1638 .insert(labels)
1639 .values({
1640 repositoryId: (resolved.repo as any).id,
1641 name: body.name.trim(),
1642 color: body.color || "#8b949e",
1643 description: body.description || null,
1644 })
1645 .returning();
1646
1647 return c.json(result[0], 201);
1648});
1649
1650// ─── Search ─────────────────────────────────────────────────────────────────
1651
1652apiv2.get("/search/repos", searchRateLimit, async (c) => {
1653 const q = c.req.query("q") || "";
1654 const sort = c.req.query("sort") || "stars";
1655 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1656
1657 if (!q.trim()) {
1658 return c.json({ error: "Query parameter 'q' is required" }, 400);
1659 }
1660
1661 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
1662 sort === "name" ? asc(repositories.name) :
1663 desc(repositories.starCount);
1664
1665 const results = await db
1666 .select({
1667 repo: repositories,
1668 owner: { username: users.username },
1669 })
1670 .from(repositories)
1671 .innerJoin(users, eq(repositories.ownerId, users.id))
1672 .where(
1673 and(
1674 eq(repositories.isPrivate, false),
1675 or(
1676 like(repositories.name, `%${q}%`),
1677 like(repositories.description, `%${q}%`)
1678 )
1679 )
1680 )
1681 .orderBy(orderBy)
1682 .limit(limit);
1683
1684 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
1685});
1686
1687apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
1688 const { owner, repo } = c.req.param();
1689 const q = c.req.query("q") || "";
1690
1691 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
1692 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
1693
1694 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1695 const results = await searchCode(owner, repo, defaultBranch, q.trim());
1696 return c.json(results);
1697});
1698
1699// ─── Activity Feed ──────────────────────────────────────────────────────────
1700
1701apiv2.get("/repos/:owner/:repo/activity", async (c) => {
1702 const { owner, repo } = c.req.param();
1703 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1704
1705 const resolved = await resolveRepo(owner, repo);
1706 if (!resolved) return c.json({ error: "Not found" }, 404);
1707
1708 const activity = await db
1709 .select()
1710 .from(activityFeed)
1711 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
1712 .orderBy(desc(activityFeed.createdAt))
1713 .limit(limit);
1714
1715 return c.json(activity);
1716});
1717
1718// ─── Topics ─────────────────────────────────────────────────────────────────
1719
1720apiv2.get("/repos/:owner/:repo/topics", async (c) => {
1721 const { owner, repo } = c.req.param();
1722 const resolved = await resolveRepo(owner, repo);
1723 if (!resolved) return c.json({ error: "Not found" }, 404);
1724
1725 const topicsList = await db
1726 .select()
1727 .from(repoTopics)
1728 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
1729
1730 return c.json(topicsList.map((t: any) => t.topic));
1731});
1732
1733apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
1734 const { owner, repo } = c.req.param();
1735 const user = c.get("user")!;
1736 const body = await c.req.json<{ topics: string[] }>();
1737
1738 const resolved = await resolveRepo(owner, repo);
1739 if (!resolved) return c.json({ error: "Not found" }, 404);
1740 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1741
1742 const repoId = (resolved.repo as any).id;
1743
1744 // Clear existing topics and insert new ones
1745 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
1746 if (body.topics && body.topics.length > 0) {
1747 await db.insert(repoTopics).values(
1748 body.topics.slice(0, 20).map((topic: string) => ({
1749 repositoryId: repoId,
1750 topic: topic.toLowerCase().trim(),
1751 }))
1752 );
1753 }
1754
1755 return c.json({ ok: true });
1756});
1757
1758// ─── Webhooks ───────────────────────────────────────────────────────────────
1759
1760apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
1761 const { owner, repo } = c.req.param();
1762 const user = c.get("user")!;
1763
1764 const resolved = await resolveRepo(owner, repo);
1765 if (!resolved) return c.json({ error: "Not found" }, 404);
1766 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1767
1768 const hookList = await db
1769 .select()
1770 .from(webhooks)
1771 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
1772
1773 return c.json(hookList);
1774});
1775
1776apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
1777 const { owner, repo } = c.req.param();
1778 const user = c.get("user")!;
1779 const body = await c.req.json<{
1780 url: string;
1781 secret?: string;
1782 events?: string;
1783 }>();
1784
1785 if (!body.url) return c.json({ error: "URL is required" }, 400);
1786
1787 const resolved = await resolveRepo(owner, repo);
1788 if (!resolved) return c.json({ error: "Not found" }, 404);
1789 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1790
1791 const result = await db
1792 .insert(webhooks)
1793 .values({
1794 repositoryId: (resolved.repo as any).id,
1795 url: body.url,
1796 secret: body.secret || null,
1797 events: body.events || "push",
1798 })
1799 .returning();
1800
1801 return c.json(result[0], 201);
1802});
1803
4366c70Claude1804// ─── Actions / Workflows ────────────────────────────────────────────────────
1805//
1806// GitHub-Actions-compatible REST surface (subset).
1807//
1808// POST /repos/:owner/:repo/actions/workflows/:filename/dispatches
1809// GET /repos/:owner/:repo/actions/workflows/:filename/runs
1810// GET /repos/:owner/:repo/actions/runs/:run_id
1811// GET /repos/:owner/:repo/actions/runs/:run_id/logs (.zip)
1812// POST /repos/:owner/:repo/actions/runs/:run_id/cancel
1813//
1814// Shapes follow GitHub REST v3 — snake_case fields, HTML URLs back to the
1815// gluecron run page, identical status-code semantics (204 for dispatch, 202
1816// for cancel, 409 for already-terminal, 422 for bad inputs).
1817
1818type ParsedOn =
1819 | string
1820 | string[]
1821 | Record<string, unknown>
1822 | null
1823 | undefined;
1824
1825type DispatchInputSpec = {
1826 type?: string;
1827 required?: boolean;
1828 default?: unknown;
1829 options?: unknown[];
1830 description?: string;
1831};
1832
1833/**
1834 * Pull the workflow_dispatch slice out of whatever shape `parsed.on` happens
1835 * to be. The v1 parser normalises `on` to a `string[]`, but the extended
1836 * parser may store an object — and YAML in the wild can be either form. We
1837 * accept all three: scalar string, array of event names, mapping keyed by
1838 * event name.
1839 */
1840function extractDispatchSpec(rawOn: ParsedOn): {
1841 enabled: boolean;
1842 inputs: Record<string, DispatchInputSpec> | null;
1843} {
1844 if (rawOn == null) return { enabled: false, inputs: null };
1845 if (typeof rawOn === "string") {
1846 return { enabled: rawOn === "workflow_dispatch", inputs: null };
1847 }
1848 if (Array.isArray(rawOn)) {
1849 return { enabled: rawOn.includes("workflow_dispatch"), inputs: null };
1850 }
1851 if (typeof rawOn === "object") {
1852 const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"];
1853 if (slot === undefined) return { enabled: false, inputs: null };
1854 // `workflow_dispatch:` with no children is a valid trigger declaration.
1855 if (slot == null || typeof slot !== "object") {
1856 return { enabled: true, inputs: null };
1857 }
1858 const inputs = (slot as Record<string, unknown>).inputs;
1859 if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
1860 return { enabled: true, inputs: null };
1861 }
1862 return {
1863 enabled: true,
1864 inputs: inputs as Record<string, DispatchInputSpec>,
1865 };
1866 }
1867 return { enabled: false, inputs: null };
1868}
1869
1870function validateDispatchInputs(
1871 schema: Record<string, DispatchInputSpec> | null,
1872 provided: Record<string, unknown> | undefined
1873): { ok: true } | { ok: false; details: string[] } {
1874 if (!schema) return { ok: true };
1875 const details: string[] = [];
1876 const supplied = provided ?? {};
1877 for (const [name, spec] of Object.entries(schema)) {
1878 if (!spec || typeof spec !== "object") continue;
1879 const present =
1880 Object.prototype.hasOwnProperty.call(supplied, name) &&
1881 supplied[name] !== undefined &&
1882 supplied[name] !== null;
1883 if (spec.required && !present) {
1884 // No default → required input is missing.
1885 if (spec.default === undefined) {
1886 details.push(`Missing required input: ${name}`);
1887 }
1888 }
1889 }
1890 if (details.length) return { ok: false, details };
1891 return { ok: true };
1892}
1893
1894/**
1895 * Look up a workflow row by repository + the basename of its `path` column.
1896 * Stored path is `.gluecron/workflows/<filename>`; we match the trailing
1897 * segment so callers don't need to know our on-disk layout.
1898 */
1899async function findWorkflowByFilename(
1900 repositoryId: string,
1901 filename: string
1902): Promise<typeof workflows.$inferSelect | null> {
1903 const rows = await db
1904 .select()
1905 .from(workflows)
1906 .where(eq(workflows.repositoryId, repositoryId));
1907 for (const row of rows) {
1908 const idx = row.path.lastIndexOf("/");
1909 const base = idx >= 0 ? row.path.slice(idx + 1) : row.path;
1910 if (base === filename) return row;
1911 }
1912 return null;
1913}
1914
1915function runHtmlUrl(owner: string, repo: string, runId: string): string {
1916 return `https://gluecron.com/${owner}/${repo}/actions/runs/${runId}`;
1917}
1918
1919function toIso(d: Date | string | null | undefined): string | null {
1920 if (!d) return null;
1921 return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
1922}
1923
1924function serializeRun(
1925 run: typeof workflowRuns.$inferSelect,
1926 workflowName: string,
1927 owner: string,
1928 repo: string
1929): Record<string, unknown> {
1930 // GitHub returns `head_branch` as the short branch name (no refs/heads/).
1931 let head_branch: string | null = null;
1932 if (run.ref) {
1933 head_branch = run.ref.startsWith("refs/heads/")
1934 ? run.ref.slice("refs/heads/".length)
1935 : run.ref;
1936 }
1937 return {
1938 id: run.id,
1939 name: workflowName,
1940 head_branch,
1941 head_sha: run.commitSha,
1942 status: run.status,
1943 conclusion: run.conclusion,
1944 event: run.event,
1945 created_at: toIso(run.queuedAt),
1946 updated_at:
1947 toIso(run.finishedAt) ?? toIso(run.startedAt) ?? toIso(run.queuedAt),
1948 run_started_at: toIso(run.startedAt),
1949 html_url: runHtmlUrl(owner, repo, run.id),
1950 };
1951}
1952
1953// ─── 1. POST /actions/workflows/:filename/dispatches ────────────────────────
1954
1955apiv2.post(
1956 "/repos/:owner/:repo/actions/workflows/:filename/dispatches",
1957 requireApiAuth,
1958 requireScope("repo"),
1959 async (c) => {
1960 const { owner, repo, filename } = c.req.param();
1961 const user = c.get("user")!;
1962
1963 let body: { ref?: unknown; inputs?: unknown } = {};
1964 try {
1965 body = await c.req.json();
1966 } catch {
1967 body = {};
1968 }
1969
1970 const resolved = await resolveRepo(owner, repo);
1971 if (!resolved) return c.json({ error: "Not Found" }, 404);
1972
1973 const repoRow = resolved.repo as any;
1974 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
1975 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
1976
1977 // Parse the stored workflow JSON to look at its triggers + input schema.
1978 let parsedObj: Record<string, unknown> = {};
1979 try {
1980 const v = JSON.parse(workflowRow.parsed);
1981 if (v && typeof v === "object" && !Array.isArray(v)) {
1982 parsedObj = v as Record<string, unknown>;
1983 }
1984 } catch {
1985 // Treat unparseable parsed-blob as no triggers — falls through to 422.
1986 }
1987
1988 const spec = extractDispatchSpec(parsedObj.on as ParsedOn);
1989 if (!spec.enabled) {
1990 return c.json(
1991 { error: "Workflow does not have a 'workflow_dispatch' trigger." },
1992 422
1993 );
1994 }
1995
1996 const providedInputs =
1997 body.inputs && typeof body.inputs === "object" && !Array.isArray(body.inputs)
1998 ? (body.inputs as Record<string, unknown>)
1999 : undefined;
2000 const inputCheck = validateDispatchInputs(spec.inputs, providedInputs);
2001 if (!inputCheck.ok) {
2002 return c.json(
2003 { error: "Invalid workflow inputs", details: inputCheck.details },
2004 422
2005 );
2006 }
2007
2008 // Resolve the ref → commit SHA. Default to repo default_branch.
2009 const refIn =
2010 typeof body.ref === "string" && body.ref.trim().length > 0
2011 ? body.ref.trim()
2012 : repoRow.defaultBranch || "main";
2013 const commitSha = await resolveRef(owner, repo, refIn);
2014 if (!commitSha) {
2015 return c.json({ error: `Ref not found: ${refIn}` }, 422);
2016 }
2017
2018 const runId = await enqueueRun({
2019 workflowId: workflowRow.id,
2020 repositoryId: repoRow.id,
2021 event: "workflow_dispatch",
2022 ref: refIn,
2023 commitSha,
2024 triggeredBy: user.id,
2025 });
2026 if (!runId) {
2027 return c.json({ error: "Failed to enqueue run" }, 500);
2028 }
2029
2030 // GitHub returns 204 No Content with no body on success.
2031 return c.body(null, 204);
2032 }
2033);
2034
2035// ─── 2. GET /actions/workflows/:filename/runs ───────────────────────────────
2036
2037apiv2.get(
2038 "/repos/:owner/:repo/actions/workflows/:filename/runs",
2039 async (c) => {
2040 const { owner, repo, filename } = c.req.param();
2041 const resolved = await resolveRepo(owner, repo);
2042 if (!resolved) return c.json({ error: "Not Found" }, 404);
2043
2044 const repoRow = resolved.repo as any;
2045 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2046 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2047
2048 const perPage = Math.min(
2049 100,
2050 Math.max(1, parseInt(c.req.query("per_page") || "30", 10) || 30)
2051 );
2052 const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
2053 const offset = (page - 1) * perPage;
2054 const branch = c.req.query("branch");
2055 const headSha = c.req.query("head_sha");
2056
2057 const conditions = [eq(workflowRuns.workflowId, workflowRow.id)];
2058 if (branch) {
2059 // Accept either short branch name or fully-qualified refs/heads/...
2060 const refValue = branch.startsWith("refs/")
2061 ? branch
2062 : `refs/heads/${branch}`;
2063 conditions.push(eq(workflowRuns.ref, refValue));
2064 }
2065 if (headSha) conditions.push(eq(workflowRuns.commitSha, headSha));
2066
2067 const where = conditions.length === 1 ? conditions[0] : and(...conditions);
2068
2069 const [{ n }] = await db
2070 .select({ n: sql<number>`count(*)::int` })
2071 .from(workflowRuns)
2072 .where(where);
2073 const total_count = Number(n) || 0;
2074
2075 const rows = await db
2076 .select()
2077 .from(workflowRuns)
2078 .where(where)
2079 .orderBy(desc(workflowRuns.queuedAt))
2080 .limit(perPage)
2081 .offset(offset);
2082
2083 return c.json({
2084 total_count,
2085 workflow_runs: rows.map((r) =>
2086 serializeRun(r, workflowRow.name, owner, repo)
2087 ),
2088 });
2089 }
2090);
2091
2092// ─── 3. GET /actions/runs/:run_id ───────────────────────────────────────────
2093
2094apiv2.get("/repos/:owner/:repo/actions/runs/:run_id", async (c) => {
2095 const { owner, repo, run_id } = c.req.param();
2096 const resolved = await resolveRepo(owner, repo);
2097 if (!resolved) return c.json({ error: "Not Found" }, 404);
2098
2099 const repoRow = resolved.repo as any;
2100 const [run] = await db
2101 .select()
2102 .from(workflowRuns)
2103 .where(eq(workflowRuns.id, run_id))
2104 .limit(1);
2105 if (!run) return c.json({ error: "Not Found" }, 404);
2106 // Don't leak runs across repos.
2107 if (run.repositoryId !== repoRow.id) {
2108 return c.json({ error: "Not Found" }, 404);
2109 }
2110
2111 const [wf] = await db
2112 .select()
2113 .from(workflows)
2114 .where(eq(workflows.id, run.workflowId))
2115 .limit(1);
2116 const workflowName = wf?.name ?? "";
2117
2118 return c.json(serializeRun(run, workflowName, owner, repo));
2119});
2120
2121// ─── 4. GET /actions/runs/:run_id/logs — ZIP of per-job logs ────────────────
2122//
2123// Reuses the same in-process zip writer pattern as `connect-claude.tsx` —
2124// PKZIP 2.0, no zip64, deflateRawSync with STORED fallback when compression
2125// would inflate. The handler is self-contained so the dxt downloader stays
2126// the canonical reference.
2127
2128function crc32(buf: Uint8Array): number {
2129 let c = 0xffffffff;
2130 for (let i = 0; i < buf.length; i++) {
2131 c ^= buf[i]!;
2132 for (let k = 0; k < 8; k++) {
2133 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
2134 }
2135 }
2136 return (c ^ 0xffffffff) >>> 0;
2137}
2138
2139type ZipEntry = { name: string; data: Uint8Array };
2140
2141function buildZip(entries: ZipEntry[]): Uint8Array {
2142 const localParts: Uint8Array[] = [];
2143 const centralParts: Uint8Array[] = [];
2144 let offset = 0;
2145
2146 for (const entry of entries) {
2147 const nameBytes = new TextEncoder().encode(entry.name);
2148 const crc = crc32(entry.data);
2149 const uncompressedSize = entry.data.length;
2150
2151 let method = 8;
2152 let compressed: Uint8Array;
2153 try {
2154 const out = deflateRawSync(entry.data);
2155 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
2156 if (compressed.length >= uncompressedSize) {
2157 method = 0;
2158 compressed = entry.data;
2159 }
2160 } catch {
2161 method = 0;
2162 compressed = entry.data;
2163 }
2164 const compressedSize = compressed.length;
2165
2166 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
2167 const lv = new DataView(local.buffer);
2168 lv.setUint32(0, 0x04034b50, true);
2169 lv.setUint16(4, 20, true);
2170 lv.setUint16(6, 0, true);
2171 lv.setUint16(8, method, true);
2172 lv.setUint16(10, 0, true);
2173 lv.setUint16(12, 0, true);
2174 lv.setUint32(14, crc, true);
2175 lv.setUint32(18, compressedSize, true);
2176 lv.setUint32(22, uncompressedSize, true);
2177 lv.setUint16(26, nameBytes.length, true);
2178 lv.setUint16(28, 0, true);
2179 local.set(nameBytes, 30);
2180 local.set(compressed, 30 + nameBytes.length);
2181 localParts.push(local);
2182
2183 const central = new Uint8Array(46 + nameBytes.length);
2184 const cv = new DataView(central.buffer);
2185 cv.setUint32(0, 0x02014b50, true);
2186 cv.setUint16(4, 20, true);
2187 cv.setUint16(6, 20, true);
2188 cv.setUint16(8, 0, true);
2189 cv.setUint16(10, method, true);
2190 cv.setUint16(12, 0, true);
2191 cv.setUint16(14, 0, true);
2192 cv.setUint32(16, crc, true);
2193 cv.setUint32(20, compressedSize, true);
2194 cv.setUint32(24, uncompressedSize, true);
2195 cv.setUint16(28, nameBytes.length, true);
2196 cv.setUint16(30, 0, true);
2197 cv.setUint16(32, 0, true);
2198 cv.setUint16(34, 0, true);
2199 cv.setUint16(36, 0, true);
2200 cv.setUint32(38, 0, true);
2201 cv.setUint32(42, offset, true);
2202 central.set(nameBytes, 46);
2203 centralParts.push(central);
2204
2205 offset += local.length;
2206 }
2207
2208 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
2209 const centralOffset = offset;
2210
2211 const end = new Uint8Array(22);
2212 const ev = new DataView(end.buffer);
2213 ev.setUint32(0, 0x06054b50, true);
2214 ev.setUint16(4, 0, true);
2215 ev.setUint16(6, 0, true);
2216 ev.setUint16(8, entries.length, true);
2217 ev.setUint16(10, entries.length, true);
2218 ev.setUint32(12, centralSize, true);
2219 ev.setUint32(16, centralOffset, true);
2220 ev.setUint16(20, 0, true);
2221
2222 const total =
2223 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
2224 const out = new Uint8Array(total);
2225 let pos = 0;
2226 for (const p of localParts) {
2227 out.set(p, pos);
2228 pos += p.length;
2229 }
2230 for (const p of centralParts) {
2231 out.set(p, pos);
2232 pos += p.length;
2233 }
2234 out.set(end, pos);
2235 return out;
2236}
2237
2238apiv2.get("/repos/:owner/:repo/actions/runs/:run_id/logs", async (c) => {
2239 const { owner, repo, run_id } = c.req.param();
2240 const resolved = await resolveRepo(owner, repo);
2241 if (!resolved) return c.json({ error: "Not Found" }, 404);
2242
2243 const repoRow = resolved.repo as any;
2244 const [run] = await db
2245 .select()
2246 .from(workflowRuns)
2247 .where(eq(workflowRuns.id, run_id))
2248 .limit(1);
2249 if (!run || run.repositoryId !== repoRow.id) {
2250 return c.json({ error: "Not Found" }, 404);
2251 }
2252
2253 const jobs = await db
2254 .select()
2255 .from(workflowJobs)
2256 .where(eq(workflowJobs.runId, run.id))
2257 .orderBy(asc(workflowJobs.jobOrder));
2258
2259 // 404 when there's truly nothing to package up — matches GitHub's behaviour
2260 // for runs that never produced logs.
2261 const usable = jobs.filter(
2262 (j) => typeof j.logs === "string" && j.logs.length > 0
2263 );
2264 if (usable.length === 0) {
2265 return c.json({ error: "Not Found" }, 404);
2266 }
2267
2268 // Make filenames safe + unique. Collisions get a numeric suffix.
2269 const seen = new Set<string>();
2270 const entries: ZipEntry[] = [];
2271 for (const job of usable) {
2272 let base = (job.name || "job").replace(/[^a-zA-Z0-9_.-]+/g, "_");
2273 if (!base) base = "job";
2274 let filename = `${base}.log`;
2275 let n = 1;
2276 while (seen.has(filename)) {
2277 filename = `${base}-${++n}.log`;
2278 }
2279 seen.add(filename);
2280 entries.push({
2281 name: filename,
2282 data: new TextEncoder().encode(job.logs),
2283 });
2284 }
2285
2286 const zip = buildZip(entries);
2287 // Wrap the bytes in a Blob so Response's BodyInit type is happy across
2288 // both Bun's `globalThis.Response` and Hono's. Uint8Array works at
2289 // runtime but trips strict TS — cast to BlobPart resolves the
2290 // ArrayBufferLike/ArrayBuffer mismatch on `.buffer`.
2291 return new Response(new Blob([zip as BlobPart], { type: "application/zip" }), {
2292 status: 200,
2293 headers: {
2294 "Content-Disposition": `attachment; filename="run-${run.id}-logs.zip"`,
2295 "Content-Length": String(zip.length),
2296 },
2297 });
2298});
2299
2300// ─── 5. POST /actions/runs/:run_id/cancel ───────────────────────────────────
2301
2302apiv2.post(
2303 "/repos/:owner/:repo/actions/runs/:run_id/cancel",
2304 requireApiAuth,
2305 requireScope("repo"),
2306 async (c) => {
2307 const { owner, repo, run_id } = c.req.param();
2308 const resolved = await resolveRepo(owner, repo);
2309 if (!resolved) return c.json({ error: "Not Found" }, 404);
2310
2311 const repoRow = resolved.repo as any;
2312 const [run] = await db
2313 .select()
2314 .from(workflowRuns)
2315 .where(eq(workflowRuns.id, run_id))
2316 .limit(1);
2317 if (!run || run.repositoryId !== repoRow.id) {
2318 return c.json({ error: "Not Found" }, 404);
2319 }
2320
2321 // Already-terminal states are a 409 Conflict per GitHub semantics. We
2322 // only allow queued → cancelled and running → cancelled transitions.
2323 if (run.status !== "queued" && run.status !== "running") {
2324 return c.json(
2325 {
2326 error:
2327 "Cannot cancel workflow run in its current state",
2328 status: run.status,
2329 },
2330 409
2331 );
2332 }
2333
2334 const now = new Date();
2335 await db
2336 .update(workflowRuns)
2337 .set({
2338 status: "cancelled",
2339 conclusion: "cancelled",
2340 finishedAt: now,
2341 })
2342 .where(
2343 and(
2344 eq(workflowRuns.id, run.id),
2345 eq(workflowRuns.repositoryId, repoRow.id)
2346 )
2347 );
2348 await db
2349 .update(workflowJobs)
2350 .set({
2351 status: "cancelled",
2352 conclusion: "cancelled",
2353 finishedAt: now,
2354 })
2355 .where(eq(workflowJobs.runId, run.id));
2356
2357 return c.json({}, 202);
2358 }
2359);
2360
45e31d0Claude2361// ─── API Info ───────────────────────────────────────────────────────────────
2362
2363apiv2.get("/", (c) => {
2364 return c.json({
2365 name: "gluecron API",
2366 version: "2.0",
2367 documentation: "/api/docs",
2368 endpoints: {
46d6165Claude2369 auth: {
2370 "POST /api/v2/auth/install-token":
2371 "Mint a PAT for one-command install (session-cookie auth only)",
2372 },
45e31d0Claude2373 users: {
2374 "GET /api/v2/user": "Get authenticated user",
2375 "GET /api/v2/users/:username": "Get user by username",
2376 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude2377 "GET /api/v2/me/ai-savings":
2378 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude2379 },
2380 repositories: {
2381 "GET /api/v2/users/:username/repos": "List user repositories",
2382 "POST /api/v2/repos": "Create repository",
2383 "GET /api/v2/repos/:owner/:repo": "Get repository",
2384 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
2385 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
2386 },
2387 branches: {
2388 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
2389 },
2390 commits: {
2391 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
2392 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
2393 },
2394 files: {
052c2e6Claude2395 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
2396 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
2397 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
da50b83Claude2398 "DELETE /api/v2/repos/:owner/:repo/contents/:path": "Delete a file on a branch",
052c2e6Claude2399 },
2400 git: {
2401 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
da50b83Claude2402 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Get a branch ref",
2403 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Move a branch ref (fast-forward by default)",
2404 "GET /api/v2/repos/:owner/:repo/git/commits/:sha": "Get a raw git commit object",
2405 "POST /api/v2/repos/:owner/:repo/git/commits": "Create a commit from a tree + parents",
2406 "POST /api/v2/repos/:owner/:repo/git/blobs": "Write a blob from utf-8 or base64 content",
2407 "POST /api/v2/repos/:owner/:repo/git/trees": "Build a tree from entries (optionally based on base_tree)",
052c2e6Claude2408 },
2409 statuses: {
2410 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude2411 },
2412 issues: {
2413 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
2414 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
2415 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
2416 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
2417 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
2418 },
2419 pullRequests: {
2420 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
2421 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
2422 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude2423 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude2424 },
2425 stars: {
2426 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
2427 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
2428 },
2429 labels: {
2430 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
2431 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
2432 },
2433 search: {
2434 "GET /api/v2/search/repos": "Search repositories",
2435 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
2436 },
2437 topics: {
2438 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
2439 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
2440 },
2441 webhooks: {
2442 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
2443 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
2444 },
2445 activity: {
2446 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
2447 },
4366c70Claude2448 actions: {
2449 "POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches":
2450 "Dispatch a workflow run (204 No Content)",
2451 "GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs":
2452 "List runs of a workflow (paginated: per_page, page; filters: branch, head_sha)",
2453 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id":
2454 "Get a single workflow run",
2455 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs":
2456 "Download per-job logs as a .zip archive",
2457 "POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel":
2458 "Cancel a queued or running workflow run (202 Accepted)",
2459 },
45e31d0Claude2460 },
2461 authentication: {
2462 method: "Bearer token",
2463 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]2464 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude2465 },
2466 rateLimit: {
2467 api: "100 requests/minute",
2468 search: "30 requests/minute",
2469 auth: "10 requests/minute",
2470 },
2471 });
2472});
2473
2474export default apiv2;