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.tsBlame1283 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
c63b860Claude313 // P4 — plan-quota gate. 402 Payment Required is the canonical HTTP
314 // signal that the client should branch on (e.g. show an upgrade CTA).
315 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
316 const gate = await checkRepoCreateAllowed(user.id);
317 if (!gate.ok) {
318 return c.json({ error: gate.reason, upgrade_url: gate.upgradeUrl }, 402);
319 }
320
45e31d0Claude321 if (await repoExists(user.username, body.name)) {
322 return c.json({ error: "Repository already exists" }, 409);
323 }
324
325 const diskPath = await initBareRepo(user.username, body.name);
326 const result = await db
327 .insert(repositories)
328 .values({
329 name: body.name,
330 ownerId: user.id,
331 description: body.description || null,
332 isPrivate: body.isPrivate || false,
333 diskPath,
334 })
335 .returning();
336
337 return c.json(result[0], 201);
338});
339
340apiv2.get("/repos/:owner/:repo", async (c) => {
341 const { owner, repo } = c.req.param();
342 const resolved = await resolveRepo(owner, repo);
343 if (!resolved) return c.json({ error: "Not found" }, 404);
344
345 const currentUser = c.get("user");
346 if ((resolved.repo as any).isPrivate && (!currentUser || currentUser.id !== resolved.owner.id)) {
347 return c.json({ error: "Not found" }, 404);
348 }
349
052c2e6Claude350 // Cache-free fresh read of HEAD's ref — needed by GateTest.
351 const defaultBranch = await getDefaultBranchFresh(owner, repo);
352
353 return c.json({
354 ...(resolved.repo as any),
355 defaultBranch,
356 owner: {
357 id: (resolved.owner as any).id,
358 login: (resolved.owner as any).username,
359 },
360 });
45e31d0Claude361});
362
363apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
364 const { owner, repo } = c.req.param();
365 const resolved = await resolveRepo(owner, repo);
366 if (!resolved) return c.json({ error: "Not found" }, 404);
367
368 const user = c.get("user")!;
369 if (user.id !== resolved.owner.id) {
370 return c.json({ error: "Permission denied" }, 403);
371 }
372
373 const body = await c.req.json<{
374 description?: string;
375 isPrivate?: boolean;
376 defaultBranch?: string;
377 }>();
378
379 const updates: Record<string, any> = { updatedAt: new Date() };
380 if (body.description !== undefined) updates.description = body.description;
381 if (body.isPrivate !== undefined) updates.isPrivate = body.isPrivate;
382 if (body.defaultBranch !== undefined) updates.defaultBranch = body.defaultBranch;
383
384 await db.update(repositories).set(updates).where(eq(repositories.id, (resolved.repo as any).id));
385 return c.json({ ok: true });
386});
387
388apiv2.delete("/repos/:owner/:repo", requireApiAuth, requireScope("admin"), async (c) => {
389 const { owner, repo } = c.req.param();
390 const resolved = await resolveRepo(owner, repo);
391 if (!resolved) return c.json({ error: "Not found" }, 404);
392
393 const user = c.get("user")!;
394 if (user.id !== resolved.owner.id) {
395 return c.json({ error: "Permission denied" }, 403);
396 }
397
398 await db.delete(repositories).where(eq(repositories.id, (resolved.repo as any).id));
399 return c.json({ ok: true });
400});
401
402// ─── Branches ───────────────────────────────────────────────────────────────
403
404apiv2.get("/repos/:owner/:repo/branches", async (c) => {
405 const { owner, repo } = c.req.param();
406 if (!(await repoExists(owner, repo))) {
407 return c.json({ error: "Not found" }, 404);
408 }
409
410 const branches = await listBranches(owner, repo);
411 const defaultBranch = await getDefaultBranch(owner, repo);
412
413 return c.json(
414 branches.map((name) => ({
415 name,
416 isDefault: name === defaultBranch,
417 }))
418 );
419});
420
421// ─── Commits ────────────────────────────────────────────────────────────────
422
423apiv2.get("/repos/:owner/:repo/commits", async (c) => {
424 const { owner, repo } = c.req.param();
425 const ref = c.req.query("ref") || c.req.query("sha") || "HEAD";
426 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
427 const offset = parseInt(c.req.query("offset") || "0");
428
429 if (!(await repoExists(owner, repo))) {
430 return c.json({ error: "Not found" }, 404);
431 }
432
433 const commits = await listCommits(owner, repo, ref, limit, offset);
434 return c.json(commits);
435});
436
437apiv2.get("/repos/:owner/:repo/commits/:sha", async (c) => {
438 const { owner, repo, sha } = c.req.param();
439 if (!(await repoExists(owner, repo))) {
440 return c.json({ error: "Not found" }, 404);
441 }
442
443 const commit = await getCommit(owner, repo, sha);
444 if (!commit) return c.json({ error: "Commit not found" }, 404);
445
446 const { files } = await getDiff(owner, repo, sha);
447 return c.json({ ...commit, files });
448});
449
450// ─── Tree & Files ───────────────────────────────────────────────────────────
451
452apiv2.get("/repos/:owner/:repo/tree/:ref", async (c) => {
453 const { owner, repo } = c.req.param();
454 const ref = c.req.param("ref");
455 const path = c.req.query("path") || "";
052c2e6Claude456 const recursive = c.req.query("recursive");
45e31d0Claude457
458 if (!(await repoExists(owner, repo))) {
459 return c.json({ error: "Not found" }, 404);
460 }
461
052c2e6Claude462 if (recursive === "1" || recursive === "true") {
463 const result = await getTreeRecursive(owner, repo, ref, 50_000);
464 if (!result) return c.json({ error: "Ref not found" }, 404);
465 return c.json(result);
466 }
467
45e31d0Claude468 const tree = await getTree(owner, repo, ref, path);
469 return c.json(tree);
470});
471
052c2e6Claude472const CONTENTS_MAX_BYTES = 10 * 1024 * 1024;
473
45e31d0Claude474apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
475 const { owner, repo } = c.req.param();
476 const filePath = c.req.param("path");
477 const ref = c.req.query("ref") || "HEAD";
052c2e6Claude478 const encoding = c.req.query("encoding") || "utf8";
45e31d0Claude479
480 if (!(await repoExists(owner, repo))) {
481 return c.json({ error: "Not found" }, 404);
482 }
483
052c2e6Claude484 if (encoding === "base64") {
485 const got = await catBlobBytes(owner, repo, ref, filePath);
486 if (!got) return c.json({ error: "File not found" }, 404);
487 if (got.size > CONTENTS_MAX_BYTES) {
488 return c.json(
489 { error: `File too large (${got.size} bytes, max ${CONTENTS_MAX_BYTES})` },
490 413
491 );
492 }
493 const content = Buffer.from(got.bytes).toString("base64");
494 return c.json({
495 path: filePath,
496 size: got.size,
497 sha: got.sha,
498 encoding: "base64",
499 content,
500 });
501 }
502
45e31d0Claude503 const blob = await getBlob(owner, repo, ref, filePath);
504 if (!blob) return c.json({ error: "File not found" }, 404);
052c2e6Claude505 if (blob.size > CONTENTS_MAX_BYTES) {
506 return c.json(
507 { error: `File too large (${blob.size} bytes, max ${CONTENTS_MAX_BYTES})` },
508 413
509 );
510 }
45e31d0Claude511
512 return c.json({
513 path: filePath,
514 size: blob.size,
515 isBinary: blob.isBinary,
516 content: blob.isBinary ? null : blob.content,
052c2e6Claude517 encoding: blob.isBinary ? null : "utf8",
45e31d0Claude518 });
519});
520
521// ─── Issues ─────────────────────────────────────────────────────────────────
522
523apiv2.get("/repos/:owner/:repo/issues", async (c) => {
524 const { owner, repo } = c.req.param();
525 const state = c.req.query("state") || "open";
526 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
527
528 const resolved = await resolveRepo(owner, repo);
529 if (!resolved) return c.json({ error: "Not found" }, 404);
530
531 const issueList = await db
532 .select({
533 issue: issues,
534 author: { username: users.username, id: users.id },
535 })
536 .from(issues)
537 .innerJoin(users, eq(issues.authorId, users.id))
538 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
539 .orderBy(desc(issues.createdAt))
540 .limit(limit);
541
542 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
543});
544
545apiv2.post("/repos/:owner/:repo/issues", requireApiAuth, requireScope("repo"), async (c) => {
546 const { owner, repo } = c.req.param();
547 const user = c.get("user")!;
548 const body = await c.req.json<{ title: string; body?: string }>();
549
550 if (!body.title?.trim()) {
551 return c.json({ error: "Title is required" }, 400);
552 }
553
554 const resolved = await resolveRepo(owner, repo);
555 if (!resolved) return c.json({ error: "Not found" }, 404);
556
557 const result = await db
558 .insert(issues)
559 .values({
560 repositoryId: (resolved.repo as any).id,
561 authorId: user.id,
562 title: body.title.trim(),
563 body: body.body?.trim() || null,
564 })
565 .returning();
566
567 return c.json(result[0], 201);
568});
569
570apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
571 const { owner, repo } = c.req.param();
572 const num = parseInt(c.req.param("number"), 10);
573
574 const resolved = await resolveRepo(owner, repo);
575 if (!resolved) return c.json({ error: "Not found" }, 404);
576
577 const [issue] = await db
578 .select()
579 .from(issues)
580 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
581 .limit(1);
582
583 if (!issue) return c.json({ error: "Issue not found" }, 404);
584
585 const comments = await db
586 .select({
587 comment: issueComments,
588 author: { username: users.username },
589 })
590 .from(issueComments)
591 .innerJoin(users, eq(issueComments.authorId, users.id))
592 .where(eq(issueComments.issueId, issue.id))
593 .orderBy(asc(issueComments.createdAt));
594
595 return c.json({
596 ...issue,
597 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
598 });
599});
600
601apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
602 const { owner, repo } = c.req.param();
603 const num = parseInt(c.req.param("number"), 10);
604 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
605
606 const resolved = await resolveRepo(owner, repo);
607 if (!resolved) return c.json({ error: "Not found" }, 404);
608
609 const updates: Record<string, any> = { updatedAt: new Date() };
610 if (body.title !== undefined) updates.title = body.title;
611 if (body.body !== undefined) updates.body = body.body;
612 if (body.state === "closed") {
613 updates.state = "closed";
614 updates.closedAt = new Date();
615 } else if (body.state === "open") {
616 updates.state = "open";
617 updates.closedAt = null;
618 }
619
620 await db
621 .update(issues)
622 .set(updates)
623 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
624
625 return c.json({ ok: true });
626});
627
628apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
629 const { owner, repo } = c.req.param();
630 const num = parseInt(c.req.param("number"), 10);
631 const user = c.get("user")!;
632 const body = await c.req.json<{ body: string }>();
633
634 if (!body.body?.trim()) {
635 return c.json({ error: "Comment body is required" }, 400);
636 }
637
638 const resolved = await resolveRepo(owner, repo);
639 if (!resolved) return c.json({ error: "Not found" }, 404);
640
641 const [issue] = await db
642 .select()
643 .from(issues)
644 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
645 .limit(1);
646
647 if (!issue) return c.json({ error: "Issue not found" }, 404);
648
649 const result = await db
650 .insert(issueComments)
651 .values({
652 issueId: issue.id,
653 authorId: user.id,
654 body: body.body.trim(),
655 })
656 .returning();
657
658 return c.json(result[0], 201);
659});
660
661// ─── Pull Requests ──────────────────────────────────────────────────────────
662
663apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
664 const { owner, repo } = c.req.param();
665 const state = c.req.query("state") || "open";
8c09fb9Claude666 // Match the issue-list pagination contract: default 30, max 100,
667 // 0-indexed offset for cursor-style scrolling. Bounded so a buggy
668 // client can't accidentally pull the whole table.
669 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit")) || 30));
670 const offset = Math.max(0, Number(c.req.query("offset")) || 0);
45e31d0Claude671
672 const resolved = await resolveRepo(owner, repo);
673 if (!resolved) return c.json({ error: "Not found" }, 404);
674
675 const prList = await db
676 .select({
677 pr: pullRequests,
678 author: { username: users.username },
679 })
680 .from(pullRequests)
681 .innerJoin(users, eq(pullRequests.authorId, users.id))
682 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
8c09fb9Claude683 .orderBy(desc(pullRequests.createdAt))
684 .limit(limit)
685 .offset(offset);
45e31d0Claude686
687 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
688});
689
690apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
691 const { owner, repo } = c.req.param();
692 const user = c.get("user")!;
693 const body = await c.req.json<{
694 title: string;
695 body?: string;
696 baseBranch: string;
697 headBranch: string;
698 }>();
699
700 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
701 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
702 }
703
704 const resolved = await resolveRepo(owner, repo);
705 if (!resolved) return c.json({ error: "Not found" }, 404);
706
707 const result = await db
708 .insert(pullRequests)
709 .values({
710 repositoryId: (resolved.repo as any).id,
711 authorId: user.id,
712 title: body.title.trim(),
713 body: body.body?.trim() || null,
714 baseBranch: body.baseBranch,
715 headBranch: body.headBranch,
716 })
717 .returning();
718
719 return c.json(result[0], 201);
720});
721
722apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
723 const { owner, repo } = c.req.param();
724 const num = parseInt(c.req.param("number"), 10);
725
726 const resolved = await resolveRepo(owner, repo);
727 if (!resolved) return c.json({ error: "Not found" }, 404);
728
729 const [pr] = await db
730 .select()
731 .from(pullRequests)
732 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
733 .limit(1);
734
735 if (!pr) return c.json({ error: "PR not found" }, 404);
736
737 const comments = await db
738 .select({
739 comment: prComments,
740 author: { username: users.username },
741 })
742 .from(prComments)
743 .innerJoin(users, eq(prComments.authorId, users.id))
744 .where(eq(prComments.pullRequestId, pr.id))
745 .orderBy(asc(prComments.createdAt));
746
747 return c.json({
748 ...pr,
749 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
750 });
751});
752
052c2e6Claude753// ─── PR Comments (GateTest integration) ────────────────────────────────────
754
755apiv2.post(
756 "/repos/:owner/:repo/pulls/:number/comments",
757 requireApiAuth,
758 requireScope("repo"),
759 async (c) => {
760 const { owner, repo } = c.req.param();
761 const num = parseInt(c.req.param("number"), 10);
762 const user = c.get("user")!;
763
764 let body: { body?: string } = {};
765 try {
766 body = await c.req.json();
767 } catch {
768 body = {};
769 }
770 if (!body.body?.trim()) {
771 return c.json({ error: "Comment body is required" }, 400);
772 }
773
774 const resolved = await resolveRepo(owner, repo);
775 if (!resolved) return c.json({ error: "Not found" }, 404);
776 if (user.id !== (resolved.owner as any).id) {
777 return c.json({ error: "Forbidden" }, 403);
778 }
779
780 const [pr] = await db
781 .select()
782 .from(pullRequests)
783 .where(
784 and(
785 eq(pullRequests.repositoryId, (resolved.repo as any).id),
786 eq(pullRequests.number, num)
787 )
788 )
789 .limit(1);
790 if (!pr) return c.json({ error: "PR not found" }, 404);
791
792 const [comment] = await db
793 .insert(prComments)
794 .values({
795 pullRequestId: pr.id,
796 authorId: user.id,
797 body: body.body.trim(),
798 })
799 .returning();
800
801 return c.json({ ok: true, comment }, 201);
802 }
803);
804
805// ─── Git refs — create branch / tag from sha ────────────────────────────────
806
807apiv2.post(
808 "/repos/:owner/:repo/git/refs",
809 requireApiAuth,
810 requireScope("repo"),
811 async (c) => {
812 const { owner, repo } = c.req.param();
813 const user = c.get("user")!;
814
815 let body: { ref?: string; sha?: string } = {};
816 try {
817 body = await c.req.json();
818 } catch {
819 body = {};
820 }
821 const ref = body.ref?.trim();
822 const sha = body.sha?.trim();
823
824 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
825 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
826 }
827 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
828 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
829 }
830
831 const resolved = await resolveRepo(owner, repo);
832 if (!resolved) return c.json({ error: "Not found" }, 404);
833 if (user.id !== (resolved.owner as any).id) {
834 return c.json({ error: "Forbidden" }, 403);
835 }
836
837 // Verify sha reachable.
838 if (!(await objectExists(owner, repo, sha))) {
839 return c.json({ error: "sha not found in repository" }, 400);
840 }
841
842 // Conflict check: if ref already exists, the existing sha must match.
843 if (await refExists(owner, repo, ref)) {
844 const existing = await resolveRef(owner, repo, ref);
845 if (existing !== sha) {
846 return c.json({ error: "ref already exists", existing }, 409);
847 }
848 }
849
850 const ok = await updateRef(owner, repo, ref, sha);
851 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
852
853 return c.json({ ok: true, ref, sha }, 201);
854 }
855);
856
857// ─── Contents PUT — create/update a file via git plumbing ────────────────────
858
859apiv2.put(
860 "/repos/:owner/:repo/contents/:path{.+$}",
861 requireApiAuth,
862 requireScope("repo"),
863 async (c) => {
864 const { owner, repo } = c.req.param();
865 const filePath = c.req.param("path");
866 const user = c.get("user")!;
867
868 let body: {
869 message?: string;
870 content?: string;
871 branch?: string;
872 sha?: string | null;
873 } = {};
874 try {
875 body = await c.req.json();
876 } catch {
877 body = {};
878 }
879
880 const message = body.message?.trim();
881 const branch = body.branch?.trim();
882 const base64 = body.content;
883
884 if (!message) return c.json({ error: "message is required" }, 400);
885 if (!branch) return c.json({ error: "branch is required" }, 400);
886 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
887
888 let bytes: Uint8Array;
889 try {
890 bytes = new Uint8Array(Buffer.from(base64, "base64"));
891 } catch {
892 return c.json({ error: "content is not valid base64" }, 400);
893 }
894 if (bytes.length > CONTENTS_MAX_BYTES) {
895 return c.json({ error: "File too large" }, 413);
896 }
897
898 const resolved = await resolveRepo(owner, repo);
899 if (!resolved) return c.json({ error: "Not found" }, 404);
900 if (user.id !== (resolved.owner as any).id) {
901 return c.json({ error: "Forbidden" }, 403);
902 }
903
904 const result = await createOrUpdateFileOnBranch({
905 owner,
906 name: repo,
907 branch,
908 filePath,
909 bytes,
910 message,
911 authorName: (user as any).displayName || user.username,
912 authorEmail: user.email,
913 expectBlobSha: body.sha ?? null,
914 });
915
916 if ("error" in result) {
917 if (result.error === "sha-mismatch") {
918 return c.json({ error: "sha does not match current blob at path" }, 409);
919 }
920 return c.json({ error: "Failed to write file" }, 500);
921 }
922
923 return c.json(
924 {
925 ok: true,
926 commit: { sha: result.commitSha, message },
927 content: { path: filePath, sha: result.blobSha },
928 },
929 201
930 );
931 }
932);
933
934// ─── v2 alias for commit-status POST ─────────────────────────────────────────
935// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
936apiv2.post(
937 "/repos/:owner/:repo/statuses/:sha",
938 requireApiAuth,
939 requireScope("repo"),
940 postCommitStatusHandler
941);
942
45e31d0Claude943// ─── Stars ──────────────────────────────────────────────────────────────────
944
945apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
946 const { owner, repo } = c.req.param();
947 const user = c.get("user")!;
948
949 const resolved = await resolveRepo(owner, repo);
950 if (!resolved) return c.json({ error: "Not found" }, 404);
951
952 const repoId = (resolved.repo as any).id;
953 const [existing] = await db
954 .select()
955 .from(stars)
956 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
957 .limit(1);
958
959 if (!existing) {
960 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
961 await db
962 .update(repositories)
963 .set({ starCount: sql`${repositories.starCount} + 1` })
964 .where(eq(repositories.id, repoId));
965 }
966
967 return c.json({ starred: true });
968});
969
970apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
971 const { owner, repo } = c.req.param();
972 const user = c.get("user")!;
973
974 const resolved = await resolveRepo(owner, repo);
975 if (!resolved) return c.json({ error: "Not found" }, 404);
976
977 const repoId = (resolved.repo as any).id;
978 const [existing] = await db
979 .select()
980 .from(stars)
981 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
982 .limit(1);
983
984 if (existing) {
985 await db.delete(stars).where(eq(stars.id, existing.id));
986 await db
987 .update(repositories)
988 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
989 .where(eq(repositories.id, repoId));
990 }
991
992 return c.json({ starred: false });
993});
994
995// ─── Labels ─────────────────────────────────────────────────────────────────
996
997apiv2.get("/repos/:owner/:repo/labels", async (c) => {
998 const { owner, repo } = c.req.param();
999 const resolved = await resolveRepo(owner, repo);
1000 if (!resolved) return c.json({ error: "Not found" }, 404);
1001
1002 const labelList = await db
1003 .select()
1004 .from(labels)
1005 .where(eq(labels.repositoryId, (resolved.repo as any).id))
1006 .orderBy(asc(labels.name));
1007
1008 return c.json(labelList);
1009});
1010
1011apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
1012 const { owner, repo } = c.req.param();
1013 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
1014
1015 if (!body.name?.trim()) {
1016 return c.json({ error: "Label name is required" }, 400);
1017 }
1018
1019 const resolved = await resolveRepo(owner, repo);
1020 if (!resolved) return c.json({ error: "Not found" }, 404);
1021
1022 const result = await db
1023 .insert(labels)
1024 .values({
1025 repositoryId: (resolved.repo as any).id,
1026 name: body.name.trim(),
1027 color: body.color || "#8b949e",
1028 description: body.description || null,
1029 })
1030 .returning();
1031
1032 return c.json(result[0], 201);
1033});
1034
1035// ─── Search ─────────────────────────────────────────────────────────────────
1036
1037apiv2.get("/search/repos", searchRateLimit, async (c) => {
1038 const q = c.req.query("q") || "";
1039 const sort = c.req.query("sort") || "stars";
1040 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1041
1042 if (!q.trim()) {
1043 return c.json({ error: "Query parameter 'q' is required" }, 400);
1044 }
1045
1046 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
1047 sort === "name" ? asc(repositories.name) :
1048 desc(repositories.starCount);
1049
1050 const results = await db
1051 .select({
1052 repo: repositories,
1053 owner: { username: users.username },
1054 })
1055 .from(repositories)
1056 .innerJoin(users, eq(repositories.ownerId, users.id))
1057 .where(
1058 and(
1059 eq(repositories.isPrivate, false),
1060 or(
1061 like(repositories.name, `%${q}%`),
1062 like(repositories.description, `%${q}%`)
1063 )
1064 )
1065 )
1066 .orderBy(orderBy)
1067 .limit(limit);
1068
1069 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
1070});
1071
1072apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
1073 const { owner, repo } = c.req.param();
1074 const q = c.req.query("q") || "";
1075
1076 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
1077 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
1078
1079 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1080 const results = await searchCode(owner, repo, defaultBranch, q.trim());
1081 return c.json(results);
1082});
1083
1084// ─── Activity Feed ──────────────────────────────────────────────────────────
1085
1086apiv2.get("/repos/:owner/:repo/activity", async (c) => {
1087 const { owner, repo } = c.req.param();
1088 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1089
1090 const resolved = await resolveRepo(owner, repo);
1091 if (!resolved) return c.json({ error: "Not found" }, 404);
1092
1093 const activity = await db
1094 .select()
1095 .from(activityFeed)
1096 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
1097 .orderBy(desc(activityFeed.createdAt))
1098 .limit(limit);
1099
1100 return c.json(activity);
1101});
1102
1103// ─── Topics ─────────────────────────────────────────────────────────────────
1104
1105apiv2.get("/repos/:owner/:repo/topics", async (c) => {
1106 const { owner, repo } = c.req.param();
1107 const resolved = await resolveRepo(owner, repo);
1108 if (!resolved) return c.json({ error: "Not found" }, 404);
1109
1110 const topicsList = await db
1111 .select()
1112 .from(repoTopics)
1113 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
1114
1115 return c.json(topicsList.map((t: any) => t.topic));
1116});
1117
1118apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
1119 const { owner, repo } = c.req.param();
1120 const user = c.get("user")!;
1121 const body = await c.req.json<{ topics: string[] }>();
1122
1123 const resolved = await resolveRepo(owner, repo);
1124 if (!resolved) return c.json({ error: "Not found" }, 404);
1125 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1126
1127 const repoId = (resolved.repo as any).id;
1128
1129 // Clear existing topics and insert new ones
1130 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
1131 if (body.topics && body.topics.length > 0) {
1132 await db.insert(repoTopics).values(
1133 body.topics.slice(0, 20).map((topic: string) => ({
1134 repositoryId: repoId,
1135 topic: topic.toLowerCase().trim(),
1136 }))
1137 );
1138 }
1139
1140 return c.json({ ok: true });
1141});
1142
1143// ─── Webhooks ───────────────────────────────────────────────────────────────
1144
1145apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
1146 const { owner, repo } = c.req.param();
1147 const user = c.get("user")!;
1148
1149 const resolved = await resolveRepo(owner, repo);
1150 if (!resolved) return c.json({ error: "Not found" }, 404);
1151 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1152
1153 const hookList = await db
1154 .select()
1155 .from(webhooks)
1156 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
1157
1158 return c.json(hookList);
1159});
1160
1161apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
1162 const { owner, repo } = c.req.param();
1163 const user = c.get("user")!;
1164 const body = await c.req.json<{
1165 url: string;
1166 secret?: string;
1167 events?: string;
1168 }>();
1169
1170 if (!body.url) return c.json({ error: "URL is required" }, 400);
1171
1172 const resolved = await resolveRepo(owner, repo);
1173 if (!resolved) return c.json({ error: "Not found" }, 404);
1174 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1175
1176 const result = await db
1177 .insert(webhooks)
1178 .values({
1179 repositoryId: (resolved.repo as any).id,
1180 url: body.url,
1181 secret: body.secret || null,
1182 events: body.events || "push",
1183 })
1184 .returning();
1185
1186 return c.json(result[0], 201);
1187});
1188
1189// ─── API Info ───────────────────────────────────────────────────────────────
1190
1191apiv2.get("/", (c) => {
1192 return c.json({
1193 name: "gluecron API",
1194 version: "2.0",
1195 documentation: "/api/docs",
1196 endpoints: {
46d6165Claude1197 auth: {
1198 "POST /api/v2/auth/install-token":
1199 "Mint a PAT for one-command install (session-cookie auth only)",
1200 },
45e31d0Claude1201 users: {
1202 "GET /api/v2/user": "Get authenticated user",
1203 "GET /api/v2/users/:username": "Get user by username",
1204 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude1205 "GET /api/v2/me/ai-savings":
1206 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude1207 },
1208 repositories: {
1209 "GET /api/v2/users/:username/repos": "List user repositories",
1210 "POST /api/v2/repos": "Create repository",
1211 "GET /api/v2/repos/:owner/:repo": "Get repository",
1212 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
1213 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
1214 },
1215 branches: {
1216 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
1217 },
1218 commits: {
1219 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
1220 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
1221 },
1222 files: {
052c2e6Claude1223 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
1224 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
1225 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
1226 },
1227 git: {
1228 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
1229 },
1230 statuses: {
1231 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude1232 },
1233 issues: {
1234 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
1235 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
1236 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
1237 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
1238 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
1239 },
1240 pullRequests: {
1241 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
1242 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
1243 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude1244 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude1245 },
1246 stars: {
1247 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
1248 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
1249 },
1250 labels: {
1251 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
1252 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
1253 },
1254 search: {
1255 "GET /api/v2/search/repos": "Search repositories",
1256 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
1257 },
1258 topics: {
1259 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
1260 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
1261 },
1262 webhooks: {
1263 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
1264 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
1265 },
1266 activity: {
1267 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
1268 },
1269 },
1270 authentication: {
1271 method: "Bearer token",
1272 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]1273 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude1274 },
1275 rateLimit: {
1276 api: "100 requests/minute",
1277 search: "30 requests/minute",
1278 auth: "10 requests/minute",
1279 },
1280 });
1281});
1282
1283export default apiv2;