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.tsBlame1892 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
1791// ─── API Info ───────────────────────────────────────────────────────────────
1792
1793apiv2.get("/", (c) => {
1794 return c.json({
1795 name: "gluecron API",
1796 version: "2.0",
1797 documentation: "/api/docs",
1798 endpoints: {
46d6165Claude1799 auth: {
1800 "POST /api/v2/auth/install-token":
1801 "Mint a PAT for one-command install (session-cookie auth only)",
1802 },
45e31d0Claude1803 users: {
1804 "GET /api/v2/user": "Get authenticated user",
1805 "GET /api/v2/users/:username": "Get user by username",
1806 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude1807 "GET /api/v2/me/ai-savings":
1808 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude1809 },
1810 repositories: {
1811 "GET /api/v2/users/:username/repos": "List user repositories",
1812 "POST /api/v2/repos": "Create repository",
1813 "GET /api/v2/repos/:owner/:repo": "Get repository",
1814 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
1815 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
1816 },
1817 branches: {
1818 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
1819 },
1820 commits: {
1821 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
1822 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
1823 },
1824 files: {
052c2e6Claude1825 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
1826 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
1827 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
da50b83Claude1828 "DELETE /api/v2/repos/:owner/:repo/contents/:path": "Delete a file on a branch",
052c2e6Claude1829 },
1830 git: {
1831 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
da50b83Claude1832 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Get a branch ref",
1833 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Move a branch ref (fast-forward by default)",
1834 "GET /api/v2/repos/:owner/:repo/git/commits/:sha": "Get a raw git commit object",
1835 "POST /api/v2/repos/:owner/:repo/git/commits": "Create a commit from a tree + parents",
1836 "POST /api/v2/repos/:owner/:repo/git/blobs": "Write a blob from utf-8 or base64 content",
1837 "POST /api/v2/repos/:owner/:repo/git/trees": "Build a tree from entries (optionally based on base_tree)",
052c2e6Claude1838 },
1839 statuses: {
1840 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude1841 },
1842 issues: {
1843 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
1844 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
1845 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
1846 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
1847 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
1848 },
1849 pullRequests: {
1850 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
1851 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
1852 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude1853 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude1854 },
1855 stars: {
1856 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
1857 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
1858 },
1859 labels: {
1860 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
1861 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
1862 },
1863 search: {
1864 "GET /api/v2/search/repos": "Search repositories",
1865 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
1866 },
1867 topics: {
1868 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
1869 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
1870 },
1871 webhooks: {
1872 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
1873 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
1874 },
1875 activity: {
1876 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
1877 },
1878 },
1879 authentication: {
1880 method: "Bearer token",
1881 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]1882 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude1883 },
1884 rateLimit: {
1885 api: "100 requests/minute",
1886 search: "30 requests/minute",
1887 auth: "10 requests/minute",
1888 },
1889 });
1890});
1891
1892export default apiv2;