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.tsBlame1268 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";
10import { eq, and, desc, asc, sql, like, or } from "drizzle-orm";
11import { db } from "../db";
12import {
13 users,
14 repositories,
15 issues,
16 issueComments,
17 pullRequests,
18 prComments,
19 stars,
20 labels,
21 issueLabels,
22 activityFeed,
23 webhooks,
24 repoTopics,
25} from "../db/schema";
26import {
27 listBranches,
28 getDefaultBranch,
052c2e6Claude29 getDefaultBranchFresh,
45e31d0Claude30 getTree,
052c2e6Claude31 getTreeRecursive,
45e31d0Claude32 getBlob,
33 getCommit,
34 listCommits,
35 getDiff,
36 searchCode,
37 repoExists,
38 initBareRepo,
39 resolveRef,
052c2e6Claude40 catBlobBytes,
41 refExists,
42 objectExists,
43 updateRef,
44 createOrUpdateFileOnBranch,
45e31d0Claude45} from "../git/repository";
46import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
47import type { ApiAuthEnv } from "../middleware/api-auth";
48import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
052c2e6Claude49import { postCommitStatusHandler } from "./commit-statuses";
46d6165Claude50import { apiTokens } from "../db/schema";
51import { audit } from "../lib/notify";
52import {
53 computeAiSavingsForUser,
54 computeLifetimeAiSavingsForUser,
55} from "../lib/ai-hours-saved";
45e31d0Claude56
57const apiv2 = new Hono<ApiAuthEnv>().basePath("/api/v2");
58
59// Apply auth and rate limiting to all v2 routes
60apiv2.use("*", apiRateLimit);
61apiv2.use("*", apiAuth);
62
63// ─── Helper ─────────────────────────────────────────────────────────────────
64
65async function resolveRepo(ownerName: string, repoName: string) {
66 const [owner] = await db
67 .select()
68 .from(users)
69 .where(eq(users.username, ownerName))
70 .limit(1);
71 if (!owner) return null;
72
73 const [repo] = await db
74 .select()
75 .from(repositories)
76 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
77 .limit(1);
78 if (!repo) return null;
79
80 return { owner, repo };
81}
82
46d6165Claude83// ─── Auth (install-token) ───────────────────────────────────────────────────
84//
85// POST /api/v2/auth/install-token
86//
87// Mint a new personal access token from a one-command install script
88// (`scripts/install.sh`). Session-cookie auth ONLY — Bearer tokens are
89// explicitly rejected so existing PATs cannot escalate into a fan-out of new
90// PATs. The plaintext token value is returned exactly once and never persisted.
91//
92// Audit-logged as `auth.install_token.created`.
93
94function generateInstallToken(): string {
95 const bytes = crypto.getRandomValues(new Uint8Array(32));
96 return (
97 "glc_" +
98 Array.from(bytes)
99 .map((b) => b.toString(16).padStart(2, "0"))
100 .join("")
101 );
102}
103
104async function hashInstallToken(token: string): Promise<string> {
105 const data = new TextEncoder().encode(token);
106 const hash = await crypto.subtle.digest("SHA-256", data);
107 return Array.from(new Uint8Array(hash))
108 .map((b) => b.toString(16).padStart(2, "0"))
109 .join("");
110}
111
112apiv2.post("/auth/install-token", async (c) => {
113 // Reject Bearer-token callers outright. The whole point of this endpoint
114 // is preventing token escalation, so we check the raw header rather than
115 // relying on whatever the middleware decided.
116 const authHeader = c.req.header("Authorization");
117 if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
118 return c.json(
119 {
120 error: "Session authentication required",
121 hint: "This endpoint refuses Bearer tokens — sign in with a session cookie.",
122 },
123 401
124 );
125 }
126
127 const user = c.get("user");
128 const authMethod = c.get("authMethod");
129 if (!user || authMethod !== "session") {
130 return c.json(
131 {
132 error: "Session authentication required",
133 hint: "Sign in via /login first; install-token mints PATs only over the session cookie.",
134 },
135 401
136 );
137 }
138
139 let body: { name?: unknown; scope?: unknown } = {};
140 try {
141 body = await c.req.json();
142 } catch {
143 // Empty / unparseable body is allowed — we fall back to defaults.
144 body = {};
145 }
146
147 const shortStamp = Math.floor(Date.now() / 1000)
148 .toString(36)
149 .slice(-6);
150 const name =
151 typeof body.name === "string" && body.name.trim().length > 0
152 ? body.name.trim().slice(0, 80)
153 : `gluecron-install-${shortStamp}`;
154
155 const requested = typeof body.scope === "string" ? body.scope : "admin";
156 const scope = requested === "repo" ? "repo" : "admin";
157 // Mirror existing PAT semantics: a comma-separated list. `admin` implies
158 // everything; `repo` keeps it narrow.
159 const scopes = scope === "admin" ? "admin,repo,user" : "repo";
160
161 const token = generateInstallToken();
162 const tokenHash = await hashInstallToken(token);
163 const tokenPrefix = token.slice(0, 12);
164
165 const [row] = await db
166 .insert(apiTokens)
167 .values({
168 userId: user.id,
169 name,
170 tokenHash,
171 tokenPrefix,
172 scopes,
173 })
174 .returning();
175
176 await audit({
177 userId: user.id,
178 action: "auth.install_token.created",
179 targetType: "api_token",
180 targetId: row?.id,
181 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
182 userAgent: c.req.header("user-agent") || undefined,
183 metadata: { name, scope, prefix: tokenPrefix },
184 });
185
186 return c.json(
187 {
188 token,
189 id: row?.id,
190 name,
191 scope,
192 scopes,
193 expiresAt: row?.expiresAt ?? null,
194 },
195 201
196 );
197});
198
45e31d0Claude199// ─── Users ──────────────────────────────────────────────────────────────────
200
201apiv2.get("/user", requireApiAuth, (c) => {
202 const user = c.get("user")!;
203 return c.json({
204 id: user.id,
205 username: user.username,
206 email: user.email,
207 displayName: user.displayName,
208 bio: user.bio,
209 avatarUrl: user.avatarUrl,
210 createdAt: user.createdAt,
211 });
212});
213
214apiv2.get("/users/:username", async (c) => {
215 const { username } = c.req.param();
216 const [user] = await db
217 .select({
218 id: users.id,
219 username: users.username,
220 displayName: users.displayName,
221 bio: users.bio,
222 avatarUrl: users.avatarUrl,
223 createdAt: users.createdAt,
224 })
225 .from(users)
226 .where(eq(users.username, username))
227 .limit(1);
228 if (!user) return c.json({ error: "User not found" }, 404);
229 return c.json(user);
230});
231
46d6165Claude232/**
233 * Block L9 — AI hours-saved counter, exposed for the dashboard widget,
234 * VS Code extension, and CLI. Returns the same numbers the web UI shows.
235 * No special scope — any authenticated token can read its own counter.
236 */
237apiv2.get("/me/ai-savings", requireApiAuth, async (c) => {
238 const user = c.get("user")!;
239 const [window, lifetime] = await Promise.all([
240 computeAiSavingsForUser(user.id, { windowHours: 168 }),
241 computeLifetimeAiSavingsForUser(user.id),
242 ]);
243 return c.json({
244 window: {
245 hours: window.windowHours,
246 hoursSaved: window.hoursSaved,
247 breakdown: window.breakdown,
248 },
249 lifetime: {
250 hoursSaved: lifetime.hoursSaved,
251 breakdown: lifetime.breakdown,
252 sinceCreatedAt: lifetime.sinceCreatedAt.toISOString(),
253 },
254 });
255});
256
45e31d0Claude257apiv2.patch("/user", requireApiAuth, requireScope("user"), async (c) => {
258 const user = c.get("user")!;
259 const body = await c.req.json<{
260 displayName?: string;
261 bio?: string;
262 avatarUrl?: string;
263 }>();
264
265 const updates: Record<string, any> = { updatedAt: new Date() };
266 if (body.displayName !== undefined) updates.displayName = body.displayName;
267 if (body.bio !== undefined) updates.bio = body.bio;
268 if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
269
270 await db.update(users).set(updates).where(eq(users.id, user.id));
271 return c.json({ ok: true });
272});
273
274// ─── Repositories ───────────────────────────────────────────────────────────
275
276apiv2.get("/users/:username/repos", async (c) => {
277 const { username } = c.req.param();
278 const sort = c.req.query("sort") || "updated";
279 const [owner] = await db.select().from(users).where(eq(users.username, username)).limit(1);
280 if (!owner) return c.json({ error: "User not found" }, 404);
281
282 const currentUser = c.get("user");
283 const orderBy = sort === "stars" ? desc(repositories.starCount) :
284 sort === "name" ? asc(repositories.name) :
285 desc(repositories.updatedAt);
286
287 let repoList = await db
288 .select()
289 .from(repositories)
290 .where(eq(repositories.ownerId, owner.id))
291 .orderBy(orderBy);
292
293 // Only show private repos to owner
294 if (!currentUser || currentUser.id !== owner.id) {
295 repoList = repoList.filter((r: any) => !r.isPrivate);
296 }
297
298 return c.json(repoList);
299});
300
301apiv2.post("/repos", requireApiAuth, requireScope("repo"), async (c) => {
302 const user = c.get("user")!;
303 const body = await c.req.json<{
304 name: string;
305 description?: string;
306 isPrivate?: boolean;
307 }>();
308
309 if (!body.name || !/^[a-zA-Z0-9._-]+$/.test(body.name)) {
310 return c.json({ error: "Invalid repository name" }, 400);
311 }
312
313 if (await repoExists(user.username, body.name)) {
314 return c.json({ error: "Repository already exists" }, 409);
315 }
316
317 const diskPath = await initBareRepo(user.username, body.name);
318 const result = await db
319 .insert(repositories)
320 .values({
321 name: body.name,
322 ownerId: user.id,
323 description: body.description || null,
324 isPrivate: body.isPrivate || false,
325 diskPath,
326 })
327 .returning();
328
329 return c.json(result[0], 201);
330});
331
332apiv2.get("/repos/:owner/:repo", async (c) => {
333 const { owner, repo } = c.req.param();
334 const resolved = await resolveRepo(owner, repo);
335 if (!resolved) return c.json({ error: "Not found" }, 404);
336
337 const currentUser = c.get("user");
338 if ((resolved.repo as any).isPrivate && (!currentUser || currentUser.id !== resolved.owner.id)) {
339 return c.json({ error: "Not found" }, 404);
340 }
341
052c2e6Claude342 // Cache-free fresh read of HEAD's ref — needed by GateTest.
343 const defaultBranch = await getDefaultBranchFresh(owner, repo);
344
345 return c.json({
346 ...(resolved.repo as any),
347 defaultBranch,
348 owner: {
349 id: (resolved.owner as any).id,
350 login: (resolved.owner as any).username,
351 },
352 });
45e31d0Claude353});
354
355apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
356 const { owner, repo } = c.req.param();
357 const resolved = await resolveRepo(owner, repo);
358 if (!resolved) return c.json({ error: "Not found" }, 404);
359
360 const user = c.get("user")!;
361 if (user.id !== resolved.owner.id) {
362 return c.json({ error: "Permission denied" }, 403);
363 }
364
365 const body = await c.req.json<{
366 description?: string;
367 isPrivate?: boolean;
368 defaultBranch?: string;
369 }>();
370
371 const updates: Record<string, any> = { updatedAt: new Date() };
372 if (body.description !== undefined) updates.description = body.description;
373 if (body.isPrivate !== undefined) updates.isPrivate = body.isPrivate;
374 if (body.defaultBranch !== undefined) updates.defaultBranch = body.defaultBranch;
375
376 await db.update(repositories).set(updates).where(eq(repositories.id, (resolved.repo as any).id));
377 return c.json({ ok: true });
378});
379
380apiv2.delete("/repos/:owner/:repo", requireApiAuth, requireScope("admin"), async (c) => {
381 const { owner, repo } = c.req.param();
382 const resolved = await resolveRepo(owner, repo);
383 if (!resolved) return c.json({ error: "Not found" }, 404);
384
385 const user = c.get("user")!;
386 if (user.id !== resolved.owner.id) {
387 return c.json({ error: "Permission denied" }, 403);
388 }
389
390 await db.delete(repositories).where(eq(repositories.id, (resolved.repo as any).id));
391 return c.json({ ok: true });
392});
393
394// ─── Branches ───────────────────────────────────────────────────────────────
395
396apiv2.get("/repos/:owner/:repo/branches", async (c) => {
397 const { owner, repo } = c.req.param();
398 if (!(await repoExists(owner, repo))) {
399 return c.json({ error: "Not found" }, 404);
400 }
401
402 const branches = await listBranches(owner, repo);
403 const defaultBranch = await getDefaultBranch(owner, repo);
404
405 return c.json(
406 branches.map((name) => ({
407 name,
408 isDefault: name === defaultBranch,
409 }))
410 );
411});
412
413// ─── Commits ────────────────────────────────────────────────────────────────
414
415apiv2.get("/repos/:owner/:repo/commits", async (c) => {
416 const { owner, repo } = c.req.param();
417 const ref = c.req.query("ref") || c.req.query("sha") || "HEAD";
418 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
419 const offset = parseInt(c.req.query("offset") || "0");
420
421 if (!(await repoExists(owner, repo))) {
422 return c.json({ error: "Not found" }, 404);
423 }
424
425 const commits = await listCommits(owner, repo, ref, limit, offset);
426 return c.json(commits);
427});
428
429apiv2.get("/repos/:owner/:repo/commits/:sha", async (c) => {
430 const { owner, repo, sha } = c.req.param();
431 if (!(await repoExists(owner, repo))) {
432 return c.json({ error: "Not found" }, 404);
433 }
434
435 const commit = await getCommit(owner, repo, sha);
436 if (!commit) return c.json({ error: "Commit not found" }, 404);
437
438 const { files } = await getDiff(owner, repo, sha);
439 return c.json({ ...commit, files });
440});
441
442// ─── Tree & Files ───────────────────────────────────────────────────────────
443
444apiv2.get("/repos/:owner/:repo/tree/:ref", async (c) => {
445 const { owner, repo } = c.req.param();
446 const ref = c.req.param("ref");
447 const path = c.req.query("path") || "";
052c2e6Claude448 const recursive = c.req.query("recursive");
45e31d0Claude449
450 if (!(await repoExists(owner, repo))) {
451 return c.json({ error: "Not found" }, 404);
452 }
453
052c2e6Claude454 if (recursive === "1" || recursive === "true") {
455 const result = await getTreeRecursive(owner, repo, ref, 50_000);
456 if (!result) return c.json({ error: "Ref not found" }, 404);
457 return c.json(result);
458 }
459
45e31d0Claude460 const tree = await getTree(owner, repo, ref, path);
461 return c.json(tree);
462});
463
052c2e6Claude464const CONTENTS_MAX_BYTES = 10 * 1024 * 1024;
465
45e31d0Claude466apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
467 const { owner, repo } = c.req.param();
468 const filePath = c.req.param("path");
469 const ref = c.req.query("ref") || "HEAD";
052c2e6Claude470 const encoding = c.req.query("encoding") || "utf8";
45e31d0Claude471
472 if (!(await repoExists(owner, repo))) {
473 return c.json({ error: "Not found" }, 404);
474 }
475
052c2e6Claude476 if (encoding === "base64") {
477 const got = await catBlobBytes(owner, repo, ref, filePath);
478 if (!got) return c.json({ error: "File not found" }, 404);
479 if (got.size > CONTENTS_MAX_BYTES) {
480 return c.json(
481 { error: `File too large (${got.size} bytes, max ${CONTENTS_MAX_BYTES})` },
482 413
483 );
484 }
485 const content = Buffer.from(got.bytes).toString("base64");
486 return c.json({
487 path: filePath,
488 size: got.size,
489 sha: got.sha,
490 encoding: "base64",
491 content,
492 });
493 }
494
45e31d0Claude495 const blob = await getBlob(owner, repo, ref, filePath);
496 if (!blob) return c.json({ error: "File not found" }, 404);
052c2e6Claude497 if (blob.size > CONTENTS_MAX_BYTES) {
498 return c.json(
499 { error: `File too large (${blob.size} bytes, max ${CONTENTS_MAX_BYTES})` },
500 413
501 );
502 }
45e31d0Claude503
504 return c.json({
505 path: filePath,
506 size: blob.size,
507 isBinary: blob.isBinary,
508 content: blob.isBinary ? null : blob.content,
052c2e6Claude509 encoding: blob.isBinary ? null : "utf8",
45e31d0Claude510 });
511});
512
513// ─── Issues ─────────────────────────────────────────────────────────────────
514
515apiv2.get("/repos/:owner/:repo/issues", async (c) => {
516 const { owner, repo } = c.req.param();
517 const state = c.req.query("state") || "open";
518 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
519
520 const resolved = await resolveRepo(owner, repo);
521 if (!resolved) return c.json({ error: "Not found" }, 404);
522
523 const issueList = await db
524 .select({
525 issue: issues,
526 author: { username: users.username, id: users.id },
527 })
528 .from(issues)
529 .innerJoin(users, eq(issues.authorId, users.id))
530 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
531 .orderBy(desc(issues.createdAt))
532 .limit(limit);
533
534 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
535});
536
537apiv2.post("/repos/:owner/:repo/issues", requireApiAuth, requireScope("repo"), async (c) => {
538 const { owner, repo } = c.req.param();
539 const user = c.get("user")!;
540 const body = await c.req.json<{ title: string; body?: string }>();
541
542 if (!body.title?.trim()) {
543 return c.json({ error: "Title is required" }, 400);
544 }
545
546 const resolved = await resolveRepo(owner, repo);
547 if (!resolved) return c.json({ error: "Not found" }, 404);
548
549 const result = await db
550 .insert(issues)
551 .values({
552 repositoryId: (resolved.repo as any).id,
553 authorId: user.id,
554 title: body.title.trim(),
555 body: body.body?.trim() || null,
556 })
557 .returning();
558
559 return c.json(result[0], 201);
560});
561
562apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
563 const { owner, repo } = c.req.param();
564 const num = parseInt(c.req.param("number"), 10);
565
566 const resolved = await resolveRepo(owner, repo);
567 if (!resolved) return c.json({ error: "Not found" }, 404);
568
569 const [issue] = await db
570 .select()
571 .from(issues)
572 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
573 .limit(1);
574
575 if (!issue) return c.json({ error: "Issue not found" }, 404);
576
577 const comments = await db
578 .select({
579 comment: issueComments,
580 author: { username: users.username },
581 })
582 .from(issueComments)
583 .innerJoin(users, eq(issueComments.authorId, users.id))
584 .where(eq(issueComments.issueId, issue.id))
585 .orderBy(asc(issueComments.createdAt));
586
587 return c.json({
588 ...issue,
589 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
590 });
591});
592
593apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
594 const { owner, repo } = c.req.param();
595 const num = parseInt(c.req.param("number"), 10);
596 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
597
598 const resolved = await resolveRepo(owner, repo);
599 if (!resolved) return c.json({ error: "Not found" }, 404);
600
601 const updates: Record<string, any> = { updatedAt: new Date() };
602 if (body.title !== undefined) updates.title = body.title;
603 if (body.body !== undefined) updates.body = body.body;
604 if (body.state === "closed") {
605 updates.state = "closed";
606 updates.closedAt = new Date();
607 } else if (body.state === "open") {
608 updates.state = "open";
609 updates.closedAt = null;
610 }
611
612 await db
613 .update(issues)
614 .set(updates)
615 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
616
617 return c.json({ ok: true });
618});
619
620apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
621 const { owner, repo } = c.req.param();
622 const num = parseInt(c.req.param("number"), 10);
623 const user = c.get("user")!;
624 const body = await c.req.json<{ body: string }>();
625
626 if (!body.body?.trim()) {
627 return c.json({ error: "Comment body is required" }, 400);
628 }
629
630 const resolved = await resolveRepo(owner, repo);
631 if (!resolved) return c.json({ error: "Not found" }, 404);
632
633 const [issue] = await db
634 .select()
635 .from(issues)
636 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
637 .limit(1);
638
639 if (!issue) return c.json({ error: "Issue not found" }, 404);
640
641 const result = await db
642 .insert(issueComments)
643 .values({
644 issueId: issue.id,
645 authorId: user.id,
646 body: body.body.trim(),
647 })
648 .returning();
649
650 return c.json(result[0], 201);
651});
652
653// ─── Pull Requests ──────────────────────────────────────────────────────────
654
655apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
656 const { owner, repo } = c.req.param();
657 const state = c.req.query("state") || "open";
658
659 const resolved = await resolveRepo(owner, repo);
660 if (!resolved) return c.json({ error: "Not found" }, 404);
661
662 const prList = await db
663 .select({
664 pr: pullRequests,
665 author: { username: users.username },
666 })
667 .from(pullRequests)
668 .innerJoin(users, eq(pullRequests.authorId, users.id))
669 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
670 .orderBy(desc(pullRequests.createdAt));
671
672 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
673});
674
675apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
676 const { owner, repo } = c.req.param();
677 const user = c.get("user")!;
678 const body = await c.req.json<{
679 title: string;
680 body?: string;
681 baseBranch: string;
682 headBranch: string;
683 }>();
684
685 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
686 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
687 }
688
689 const resolved = await resolveRepo(owner, repo);
690 if (!resolved) return c.json({ error: "Not found" }, 404);
691
692 const result = await db
693 .insert(pullRequests)
694 .values({
695 repositoryId: (resolved.repo as any).id,
696 authorId: user.id,
697 title: body.title.trim(),
698 body: body.body?.trim() || null,
699 baseBranch: body.baseBranch,
700 headBranch: body.headBranch,
701 })
702 .returning();
703
704 return c.json(result[0], 201);
705});
706
707apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
708 const { owner, repo } = c.req.param();
709 const num = parseInt(c.req.param("number"), 10);
710
711 const resolved = await resolveRepo(owner, repo);
712 if (!resolved) return c.json({ error: "Not found" }, 404);
713
714 const [pr] = await db
715 .select()
716 .from(pullRequests)
717 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
718 .limit(1);
719
720 if (!pr) return c.json({ error: "PR not found" }, 404);
721
722 const comments = await db
723 .select({
724 comment: prComments,
725 author: { username: users.username },
726 })
727 .from(prComments)
728 .innerJoin(users, eq(prComments.authorId, users.id))
729 .where(eq(prComments.pullRequestId, pr.id))
730 .orderBy(asc(prComments.createdAt));
731
732 return c.json({
733 ...pr,
734 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
735 });
736});
737
052c2e6Claude738// ─── PR Comments (GateTest integration) ────────────────────────────────────
739
740apiv2.post(
741 "/repos/:owner/:repo/pulls/:number/comments",
742 requireApiAuth,
743 requireScope("repo"),
744 async (c) => {
745 const { owner, repo } = c.req.param();
746 const num = parseInt(c.req.param("number"), 10);
747 const user = c.get("user")!;
748
749 let body: { body?: string } = {};
750 try {
751 body = await c.req.json();
752 } catch {
753 body = {};
754 }
755 if (!body.body?.trim()) {
756 return c.json({ error: "Comment body is required" }, 400);
757 }
758
759 const resolved = await resolveRepo(owner, repo);
760 if (!resolved) return c.json({ error: "Not found" }, 404);
761 if (user.id !== (resolved.owner as any).id) {
762 return c.json({ error: "Forbidden" }, 403);
763 }
764
765 const [pr] = await db
766 .select()
767 .from(pullRequests)
768 .where(
769 and(
770 eq(pullRequests.repositoryId, (resolved.repo as any).id),
771 eq(pullRequests.number, num)
772 )
773 )
774 .limit(1);
775 if (!pr) return c.json({ error: "PR not found" }, 404);
776
777 const [comment] = await db
778 .insert(prComments)
779 .values({
780 pullRequestId: pr.id,
781 authorId: user.id,
782 body: body.body.trim(),
783 })
784 .returning();
785
786 return c.json({ ok: true, comment }, 201);
787 }
788);
789
790// ─── Git refs — create branch / tag from sha ────────────────────────────────
791
792apiv2.post(
793 "/repos/:owner/:repo/git/refs",
794 requireApiAuth,
795 requireScope("repo"),
796 async (c) => {
797 const { owner, repo } = c.req.param();
798 const user = c.get("user")!;
799
800 let body: { ref?: string; sha?: string } = {};
801 try {
802 body = await c.req.json();
803 } catch {
804 body = {};
805 }
806 const ref = body.ref?.trim();
807 const sha = body.sha?.trim();
808
809 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
810 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
811 }
812 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
813 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
814 }
815
816 const resolved = await resolveRepo(owner, repo);
817 if (!resolved) return c.json({ error: "Not found" }, 404);
818 if (user.id !== (resolved.owner as any).id) {
819 return c.json({ error: "Forbidden" }, 403);
820 }
821
822 // Verify sha reachable.
823 if (!(await objectExists(owner, repo, sha))) {
824 return c.json({ error: "sha not found in repository" }, 400);
825 }
826
827 // Conflict check: if ref already exists, the existing sha must match.
828 if (await refExists(owner, repo, ref)) {
829 const existing = await resolveRef(owner, repo, ref);
830 if (existing !== sha) {
831 return c.json({ error: "ref already exists", existing }, 409);
832 }
833 }
834
835 const ok = await updateRef(owner, repo, ref, sha);
836 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
837
838 return c.json({ ok: true, ref, sha }, 201);
839 }
840);
841
842// ─── Contents PUT — create/update a file via git plumbing ────────────────────
843
844apiv2.put(
845 "/repos/:owner/:repo/contents/:path{.+$}",
846 requireApiAuth,
847 requireScope("repo"),
848 async (c) => {
849 const { owner, repo } = c.req.param();
850 const filePath = c.req.param("path");
851 const user = c.get("user")!;
852
853 let body: {
854 message?: string;
855 content?: string;
856 branch?: string;
857 sha?: string | null;
858 } = {};
859 try {
860 body = await c.req.json();
861 } catch {
862 body = {};
863 }
864
865 const message = body.message?.trim();
866 const branch = body.branch?.trim();
867 const base64 = body.content;
868
869 if (!message) return c.json({ error: "message is required" }, 400);
870 if (!branch) return c.json({ error: "branch is required" }, 400);
871 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
872
873 let bytes: Uint8Array;
874 try {
875 bytes = new Uint8Array(Buffer.from(base64, "base64"));
876 } catch {
877 return c.json({ error: "content is not valid base64" }, 400);
878 }
879 if (bytes.length > CONTENTS_MAX_BYTES) {
880 return c.json({ error: "File too large" }, 413);
881 }
882
883 const resolved = await resolveRepo(owner, repo);
884 if (!resolved) return c.json({ error: "Not found" }, 404);
885 if (user.id !== (resolved.owner as any).id) {
886 return c.json({ error: "Forbidden" }, 403);
887 }
888
889 const result = await createOrUpdateFileOnBranch({
890 owner,
891 name: repo,
892 branch,
893 filePath,
894 bytes,
895 message,
896 authorName: (user as any).displayName || user.username,
897 authorEmail: user.email,
898 expectBlobSha: body.sha ?? null,
899 });
900
901 if ("error" in result) {
902 if (result.error === "sha-mismatch") {
903 return c.json({ error: "sha does not match current blob at path" }, 409);
904 }
905 return c.json({ error: "Failed to write file" }, 500);
906 }
907
908 return c.json(
909 {
910 ok: true,
911 commit: { sha: result.commitSha, message },
912 content: { path: filePath, sha: result.blobSha },
913 },
914 201
915 );
916 }
917);
918
919// ─── v2 alias for commit-status POST ─────────────────────────────────────────
920// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
921apiv2.post(
922 "/repos/:owner/:repo/statuses/:sha",
923 requireApiAuth,
924 requireScope("repo"),
925 postCommitStatusHandler
926);
927
45e31d0Claude928// ─── Stars ──────────────────────────────────────────────────────────────────
929
930apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
931 const { owner, repo } = c.req.param();
932 const user = c.get("user")!;
933
934 const resolved = await resolveRepo(owner, repo);
935 if (!resolved) return c.json({ error: "Not found" }, 404);
936
937 const repoId = (resolved.repo as any).id;
938 const [existing] = await db
939 .select()
940 .from(stars)
941 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
942 .limit(1);
943
944 if (!existing) {
945 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
946 await db
947 .update(repositories)
948 .set({ starCount: sql`${repositories.starCount} + 1` })
949 .where(eq(repositories.id, repoId));
950 }
951
952 return c.json({ starred: true });
953});
954
955apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
956 const { owner, repo } = c.req.param();
957 const user = c.get("user")!;
958
959 const resolved = await resolveRepo(owner, repo);
960 if (!resolved) return c.json({ error: "Not found" }, 404);
961
962 const repoId = (resolved.repo as any).id;
963 const [existing] = await db
964 .select()
965 .from(stars)
966 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
967 .limit(1);
968
969 if (existing) {
970 await db.delete(stars).where(eq(stars.id, existing.id));
971 await db
972 .update(repositories)
973 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
974 .where(eq(repositories.id, repoId));
975 }
976
977 return c.json({ starred: false });
978});
979
980// ─── Labels ─────────────────────────────────────────────────────────────────
981
982apiv2.get("/repos/:owner/:repo/labels", async (c) => {
983 const { owner, repo } = c.req.param();
984 const resolved = await resolveRepo(owner, repo);
985 if (!resolved) return c.json({ error: "Not found" }, 404);
986
987 const labelList = await db
988 .select()
989 .from(labels)
990 .where(eq(labels.repositoryId, (resolved.repo as any).id))
991 .orderBy(asc(labels.name));
992
993 return c.json(labelList);
994});
995
996apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
997 const { owner, repo } = c.req.param();
998 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
999
1000 if (!body.name?.trim()) {
1001 return c.json({ error: "Label name is required" }, 400);
1002 }
1003
1004 const resolved = await resolveRepo(owner, repo);
1005 if (!resolved) return c.json({ error: "Not found" }, 404);
1006
1007 const result = await db
1008 .insert(labels)
1009 .values({
1010 repositoryId: (resolved.repo as any).id,
1011 name: body.name.trim(),
1012 color: body.color || "#8b949e",
1013 description: body.description || null,
1014 })
1015 .returning();
1016
1017 return c.json(result[0], 201);
1018});
1019
1020// ─── Search ─────────────────────────────────────────────────────────────────
1021
1022apiv2.get("/search/repos", searchRateLimit, async (c) => {
1023 const q = c.req.query("q") || "";
1024 const sort = c.req.query("sort") || "stars";
1025 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1026
1027 if (!q.trim()) {
1028 return c.json({ error: "Query parameter 'q' is required" }, 400);
1029 }
1030
1031 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
1032 sort === "name" ? asc(repositories.name) :
1033 desc(repositories.starCount);
1034
1035 const results = await db
1036 .select({
1037 repo: repositories,
1038 owner: { username: users.username },
1039 })
1040 .from(repositories)
1041 .innerJoin(users, eq(repositories.ownerId, users.id))
1042 .where(
1043 and(
1044 eq(repositories.isPrivate, false),
1045 or(
1046 like(repositories.name, `%${q}%`),
1047 like(repositories.description, `%${q}%`)
1048 )
1049 )
1050 )
1051 .orderBy(orderBy)
1052 .limit(limit);
1053
1054 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
1055});
1056
1057apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
1058 const { owner, repo } = c.req.param();
1059 const q = c.req.query("q") || "";
1060
1061 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
1062 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
1063
1064 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1065 const results = await searchCode(owner, repo, defaultBranch, q.trim());
1066 return c.json(results);
1067});
1068
1069// ─── Activity Feed ──────────────────────────────────────────────────────────
1070
1071apiv2.get("/repos/:owner/:repo/activity", async (c) => {
1072 const { owner, repo } = c.req.param();
1073 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1074
1075 const resolved = await resolveRepo(owner, repo);
1076 if (!resolved) return c.json({ error: "Not found" }, 404);
1077
1078 const activity = await db
1079 .select()
1080 .from(activityFeed)
1081 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
1082 .orderBy(desc(activityFeed.createdAt))
1083 .limit(limit);
1084
1085 return c.json(activity);
1086});
1087
1088// ─── Topics ─────────────────────────────────────────────────────────────────
1089
1090apiv2.get("/repos/:owner/:repo/topics", async (c) => {
1091 const { owner, repo } = c.req.param();
1092 const resolved = await resolveRepo(owner, repo);
1093 if (!resolved) return c.json({ error: "Not found" }, 404);
1094
1095 const topicsList = await db
1096 .select()
1097 .from(repoTopics)
1098 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
1099
1100 return c.json(topicsList.map((t: any) => t.topic));
1101});
1102
1103apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
1104 const { owner, repo } = c.req.param();
1105 const user = c.get("user")!;
1106 const body = await c.req.json<{ topics: string[] }>();
1107
1108 const resolved = await resolveRepo(owner, repo);
1109 if (!resolved) return c.json({ error: "Not found" }, 404);
1110 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1111
1112 const repoId = (resolved.repo as any).id;
1113
1114 // Clear existing topics and insert new ones
1115 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
1116 if (body.topics && body.topics.length > 0) {
1117 await db.insert(repoTopics).values(
1118 body.topics.slice(0, 20).map((topic: string) => ({
1119 repositoryId: repoId,
1120 topic: topic.toLowerCase().trim(),
1121 }))
1122 );
1123 }
1124
1125 return c.json({ ok: true });
1126});
1127
1128// ─── Webhooks ───────────────────────────────────────────────────────────────
1129
1130apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
1131 const { owner, repo } = c.req.param();
1132 const user = c.get("user")!;
1133
1134 const resolved = await resolveRepo(owner, repo);
1135 if (!resolved) return c.json({ error: "Not found" }, 404);
1136 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1137
1138 const hookList = await db
1139 .select()
1140 .from(webhooks)
1141 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
1142
1143 return c.json(hookList);
1144});
1145
1146apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
1147 const { owner, repo } = c.req.param();
1148 const user = c.get("user")!;
1149 const body = await c.req.json<{
1150 url: string;
1151 secret?: string;
1152 events?: string;
1153 }>();
1154
1155 if (!body.url) return c.json({ error: "URL is required" }, 400);
1156
1157 const resolved = await resolveRepo(owner, repo);
1158 if (!resolved) return c.json({ error: "Not found" }, 404);
1159 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1160
1161 const result = await db
1162 .insert(webhooks)
1163 .values({
1164 repositoryId: (resolved.repo as any).id,
1165 url: body.url,
1166 secret: body.secret || null,
1167 events: body.events || "push",
1168 })
1169 .returning();
1170
1171 return c.json(result[0], 201);
1172});
1173
1174// ─── API Info ───────────────────────────────────────────────────────────────
1175
1176apiv2.get("/", (c) => {
1177 return c.json({
1178 name: "gluecron API",
1179 version: "2.0",
1180 documentation: "/api/docs",
1181 endpoints: {
46d6165Claude1182 auth: {
1183 "POST /api/v2/auth/install-token":
1184 "Mint a PAT for one-command install (session-cookie auth only)",
1185 },
45e31d0Claude1186 users: {
1187 "GET /api/v2/user": "Get authenticated user",
1188 "GET /api/v2/users/:username": "Get user by username",
1189 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude1190 "GET /api/v2/me/ai-savings":
1191 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude1192 },
1193 repositories: {
1194 "GET /api/v2/users/:username/repos": "List user repositories",
1195 "POST /api/v2/repos": "Create repository",
1196 "GET /api/v2/repos/:owner/:repo": "Get repository",
1197 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
1198 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
1199 },
1200 branches: {
1201 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
1202 },
1203 commits: {
1204 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
1205 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
1206 },
1207 files: {
052c2e6Claude1208 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
1209 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
1210 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
1211 },
1212 git: {
1213 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
1214 },
1215 statuses: {
1216 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude1217 },
1218 issues: {
1219 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
1220 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
1221 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
1222 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
1223 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
1224 },
1225 pullRequests: {
1226 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
1227 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
1228 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude1229 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude1230 },
1231 stars: {
1232 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
1233 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
1234 },
1235 labels: {
1236 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
1237 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
1238 },
1239 search: {
1240 "GET /api/v2/search/repos": "Search repositories",
1241 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
1242 },
1243 topics: {
1244 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
1245 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
1246 },
1247 webhooks: {
1248 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
1249 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
1250 },
1251 activity: {
1252 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
1253 },
1254 },
1255 authentication: {
1256 method: "Bearer token",
1257 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]1258 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude1259 },
1260 rateLimit: {
1261 api: "100 requests/minute",
1262 search: "30 requests/minute",
1263 auth: "10 requests/minute",
1264 },
1265 });
1266});
1267
1268export default apiv2;