Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame1276 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";
666
667 const resolved = await resolveRepo(owner, repo);
668 if (!resolved) return c.json({ error: "Not found" }, 404);
669
670 const prList = await db
671 .select({
672 pr: pullRequests,
673 author: { username: users.username },
674 })
675 .from(pullRequests)
676 .innerJoin(users, eq(pullRequests.authorId, users.id))
677 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
678 .orderBy(desc(pullRequests.createdAt));
679
680 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
681});
682
683apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
684 const { owner, repo } = c.req.param();
685 const user = c.get("user")!;
686 const body = await c.req.json<{
687 title: string;
688 body?: string;
689 baseBranch: string;
690 headBranch: string;
691 }>();
692
693 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
694 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
695 }
696
697 const resolved = await resolveRepo(owner, repo);
698 if (!resolved) return c.json({ error: "Not found" }, 404);
699
700 const result = await db
701 .insert(pullRequests)
702 .values({
703 repositoryId: (resolved.repo as any).id,
704 authorId: user.id,
705 title: body.title.trim(),
706 body: body.body?.trim() || null,
707 baseBranch: body.baseBranch,
708 headBranch: body.headBranch,
709 })
710 .returning();
711
712 return c.json(result[0], 201);
713});
714
715apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
716 const { owner, repo } = c.req.param();
717 const num = parseInt(c.req.param("number"), 10);
718
719 const resolved = await resolveRepo(owner, repo);
720 if (!resolved) return c.json({ error: "Not found" }, 404);
721
722 const [pr] = await db
723 .select()
724 .from(pullRequests)
725 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
726 .limit(1);
727
728 if (!pr) return c.json({ error: "PR not found" }, 404);
729
730 const comments = await db
731 .select({
732 comment: prComments,
733 author: { username: users.username },
734 })
735 .from(prComments)
736 .innerJoin(users, eq(prComments.authorId, users.id))
737 .where(eq(prComments.pullRequestId, pr.id))
738 .orderBy(asc(prComments.createdAt));
739
740 return c.json({
741 ...pr,
742 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
743 });
744});
745
052c2e6Claude746// ─── PR Comments (GateTest integration) ────────────────────────────────────
747
748apiv2.post(
749 "/repos/:owner/:repo/pulls/:number/comments",
750 requireApiAuth,
751 requireScope("repo"),
752 async (c) => {
753 const { owner, repo } = c.req.param();
754 const num = parseInt(c.req.param("number"), 10);
755 const user = c.get("user")!;
756
757 let body: { body?: string } = {};
758 try {
759 body = await c.req.json();
760 } catch {
761 body = {};
762 }
763 if (!body.body?.trim()) {
764 return c.json({ error: "Comment body is required" }, 400);
765 }
766
767 const resolved = await resolveRepo(owner, repo);
768 if (!resolved) return c.json({ error: "Not found" }, 404);
769 if (user.id !== (resolved.owner as any).id) {
770 return c.json({ error: "Forbidden" }, 403);
771 }
772
773 const [pr] = await db
774 .select()
775 .from(pullRequests)
776 .where(
777 and(
778 eq(pullRequests.repositoryId, (resolved.repo as any).id),
779 eq(pullRequests.number, num)
780 )
781 )
782 .limit(1);
783 if (!pr) return c.json({ error: "PR not found" }, 404);
784
785 const [comment] = await db
786 .insert(prComments)
787 .values({
788 pullRequestId: pr.id,
789 authorId: user.id,
790 body: body.body.trim(),
791 })
792 .returning();
793
794 return c.json({ ok: true, comment }, 201);
795 }
796);
797
798// ─── Git refs — create branch / tag from sha ────────────────────────────────
799
800apiv2.post(
801 "/repos/:owner/:repo/git/refs",
802 requireApiAuth,
803 requireScope("repo"),
804 async (c) => {
805 const { owner, repo } = c.req.param();
806 const user = c.get("user")!;
807
808 let body: { ref?: string; sha?: string } = {};
809 try {
810 body = await c.req.json();
811 } catch {
812 body = {};
813 }
814 const ref = body.ref?.trim();
815 const sha = body.sha?.trim();
816
817 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
818 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
819 }
820 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
821 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
822 }
823
824 const resolved = await resolveRepo(owner, repo);
825 if (!resolved) return c.json({ error: "Not found" }, 404);
826 if (user.id !== (resolved.owner as any).id) {
827 return c.json({ error: "Forbidden" }, 403);
828 }
829
830 // Verify sha reachable.
831 if (!(await objectExists(owner, repo, sha))) {
832 return c.json({ error: "sha not found in repository" }, 400);
833 }
834
835 // Conflict check: if ref already exists, the existing sha must match.
836 if (await refExists(owner, repo, ref)) {
837 const existing = await resolveRef(owner, repo, ref);
838 if (existing !== sha) {
839 return c.json({ error: "ref already exists", existing }, 409);
840 }
841 }
842
843 const ok = await updateRef(owner, repo, ref, sha);
844 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
845
846 return c.json({ ok: true, ref, sha }, 201);
847 }
848);
849
850// ─── Contents PUT — create/update a file via git plumbing ────────────────────
851
852apiv2.put(
853 "/repos/:owner/:repo/contents/:path{.+$}",
854 requireApiAuth,
855 requireScope("repo"),
856 async (c) => {
857 const { owner, repo } = c.req.param();
858 const filePath = c.req.param("path");
859 const user = c.get("user")!;
860
861 let body: {
862 message?: string;
863 content?: string;
864 branch?: string;
865 sha?: string | null;
866 } = {};
867 try {
868 body = await c.req.json();
869 } catch {
870 body = {};
871 }
872
873 const message = body.message?.trim();
874 const branch = body.branch?.trim();
875 const base64 = body.content;
876
877 if (!message) return c.json({ error: "message is required" }, 400);
878 if (!branch) return c.json({ error: "branch is required" }, 400);
879 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
880
881 let bytes: Uint8Array;
882 try {
883 bytes = new Uint8Array(Buffer.from(base64, "base64"));
884 } catch {
885 return c.json({ error: "content is not valid base64" }, 400);
886 }
887 if (bytes.length > CONTENTS_MAX_BYTES) {
888 return c.json({ error: "File too large" }, 413);
889 }
890
891 const resolved = await resolveRepo(owner, repo);
892 if (!resolved) return c.json({ error: "Not found" }, 404);
893 if (user.id !== (resolved.owner as any).id) {
894 return c.json({ error: "Forbidden" }, 403);
895 }
896
897 const result = await createOrUpdateFileOnBranch({
898 owner,
899 name: repo,
900 branch,
901 filePath,
902 bytes,
903 message,
904 authorName: (user as any).displayName || user.username,
905 authorEmail: user.email,
906 expectBlobSha: body.sha ?? null,
907 });
908
909 if ("error" in result) {
910 if (result.error === "sha-mismatch") {
911 return c.json({ error: "sha does not match current blob at path" }, 409);
912 }
913 return c.json({ error: "Failed to write file" }, 500);
914 }
915
916 return c.json(
917 {
918 ok: true,
919 commit: { sha: result.commitSha, message },
920 content: { path: filePath, sha: result.blobSha },
921 },
922 201
923 );
924 }
925);
926
927// ─── v2 alias for commit-status POST ─────────────────────────────────────────
928// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
929apiv2.post(
930 "/repos/:owner/:repo/statuses/:sha",
931 requireApiAuth,
932 requireScope("repo"),
933 postCommitStatusHandler
934);
935
45e31d0Claude936// ─── Stars ──────────────────────────────────────────────────────────────────
937
938apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
939 const { owner, repo } = c.req.param();
940 const user = c.get("user")!;
941
942 const resolved = await resolveRepo(owner, repo);
943 if (!resolved) return c.json({ error: "Not found" }, 404);
944
945 const repoId = (resolved.repo as any).id;
946 const [existing] = await db
947 .select()
948 .from(stars)
949 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
950 .limit(1);
951
952 if (!existing) {
953 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
954 await db
955 .update(repositories)
956 .set({ starCount: sql`${repositories.starCount} + 1` })
957 .where(eq(repositories.id, repoId));
958 }
959
960 return c.json({ starred: true });
961});
962
963apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
964 const { owner, repo } = c.req.param();
965 const user = c.get("user")!;
966
967 const resolved = await resolveRepo(owner, repo);
968 if (!resolved) return c.json({ error: "Not found" }, 404);
969
970 const repoId = (resolved.repo as any).id;
971 const [existing] = await db
972 .select()
973 .from(stars)
974 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
975 .limit(1);
976
977 if (existing) {
978 await db.delete(stars).where(eq(stars.id, existing.id));
979 await db
980 .update(repositories)
981 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
982 .where(eq(repositories.id, repoId));
983 }
984
985 return c.json({ starred: false });
986});
987
988// ─── Labels ─────────────────────────────────────────────────────────────────
989
990apiv2.get("/repos/:owner/:repo/labels", async (c) => {
991 const { owner, repo } = c.req.param();
992 const resolved = await resolveRepo(owner, repo);
993 if (!resolved) return c.json({ error: "Not found" }, 404);
994
995 const labelList = await db
996 .select()
997 .from(labels)
998 .where(eq(labels.repositoryId, (resolved.repo as any).id))
999 .orderBy(asc(labels.name));
1000
1001 return c.json(labelList);
1002});
1003
1004apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
1005 const { owner, repo } = c.req.param();
1006 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
1007
1008 if (!body.name?.trim()) {
1009 return c.json({ error: "Label name is required" }, 400);
1010 }
1011
1012 const resolved = await resolveRepo(owner, repo);
1013 if (!resolved) return c.json({ error: "Not found" }, 404);
1014
1015 const result = await db
1016 .insert(labels)
1017 .values({
1018 repositoryId: (resolved.repo as any).id,
1019 name: body.name.trim(),
1020 color: body.color || "#8b949e",
1021 description: body.description || null,
1022 })
1023 .returning();
1024
1025 return c.json(result[0], 201);
1026});
1027
1028// ─── Search ─────────────────────────────────────────────────────────────────
1029
1030apiv2.get("/search/repos", searchRateLimit, async (c) => {
1031 const q = c.req.query("q") || "";
1032 const sort = c.req.query("sort") || "stars";
1033 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1034
1035 if (!q.trim()) {
1036 return c.json({ error: "Query parameter 'q' is required" }, 400);
1037 }
1038
1039 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
1040 sort === "name" ? asc(repositories.name) :
1041 desc(repositories.starCount);
1042
1043 const results = await db
1044 .select({
1045 repo: repositories,
1046 owner: { username: users.username },
1047 })
1048 .from(repositories)
1049 .innerJoin(users, eq(repositories.ownerId, users.id))
1050 .where(
1051 and(
1052 eq(repositories.isPrivate, false),
1053 or(
1054 like(repositories.name, `%${q}%`),
1055 like(repositories.description, `%${q}%`)
1056 )
1057 )
1058 )
1059 .orderBy(orderBy)
1060 .limit(limit);
1061
1062 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
1063});
1064
1065apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
1066 const { owner, repo } = c.req.param();
1067 const q = c.req.query("q") || "";
1068
1069 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
1070 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
1071
1072 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1073 const results = await searchCode(owner, repo, defaultBranch, q.trim());
1074 return c.json(results);
1075});
1076
1077// ─── Activity Feed ──────────────────────────────────────────────────────────
1078
1079apiv2.get("/repos/:owner/:repo/activity", async (c) => {
1080 const { owner, repo } = c.req.param();
1081 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1082
1083 const resolved = await resolveRepo(owner, repo);
1084 if (!resolved) return c.json({ error: "Not found" }, 404);
1085
1086 const activity = await db
1087 .select()
1088 .from(activityFeed)
1089 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
1090 .orderBy(desc(activityFeed.createdAt))
1091 .limit(limit);
1092
1093 return c.json(activity);
1094});
1095
1096// ─── Topics ─────────────────────────────────────────────────────────────────
1097
1098apiv2.get("/repos/:owner/:repo/topics", async (c) => {
1099 const { owner, repo } = c.req.param();
1100 const resolved = await resolveRepo(owner, repo);
1101 if (!resolved) return c.json({ error: "Not found" }, 404);
1102
1103 const topicsList = await db
1104 .select()
1105 .from(repoTopics)
1106 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
1107
1108 return c.json(topicsList.map((t: any) => t.topic));
1109});
1110
1111apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
1112 const { owner, repo } = c.req.param();
1113 const user = c.get("user")!;
1114 const body = await c.req.json<{ topics: string[] }>();
1115
1116 const resolved = await resolveRepo(owner, repo);
1117 if (!resolved) return c.json({ error: "Not found" }, 404);
1118 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1119
1120 const repoId = (resolved.repo as any).id;
1121
1122 // Clear existing topics and insert new ones
1123 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
1124 if (body.topics && body.topics.length > 0) {
1125 await db.insert(repoTopics).values(
1126 body.topics.slice(0, 20).map((topic: string) => ({
1127 repositoryId: repoId,
1128 topic: topic.toLowerCase().trim(),
1129 }))
1130 );
1131 }
1132
1133 return c.json({ ok: true });
1134});
1135
1136// ─── Webhooks ───────────────────────────────────────────────────────────────
1137
1138apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
1139 const { owner, repo } = c.req.param();
1140 const user = c.get("user")!;
1141
1142 const resolved = await resolveRepo(owner, repo);
1143 if (!resolved) return c.json({ error: "Not found" }, 404);
1144 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1145
1146 const hookList = await db
1147 .select()
1148 .from(webhooks)
1149 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
1150
1151 return c.json(hookList);
1152});
1153
1154apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
1155 const { owner, repo } = c.req.param();
1156 const user = c.get("user")!;
1157 const body = await c.req.json<{
1158 url: string;
1159 secret?: string;
1160 events?: string;
1161 }>();
1162
1163 if (!body.url) return c.json({ error: "URL is required" }, 400);
1164
1165 const resolved = await resolveRepo(owner, repo);
1166 if (!resolved) return c.json({ error: "Not found" }, 404);
1167 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1168
1169 const result = await db
1170 .insert(webhooks)
1171 .values({
1172 repositoryId: (resolved.repo as any).id,
1173 url: body.url,
1174 secret: body.secret || null,
1175 events: body.events || "push",
1176 })
1177 .returning();
1178
1179 return c.json(result[0], 201);
1180});
1181
1182// ─── API Info ───────────────────────────────────────────────────────────────
1183
1184apiv2.get("/", (c) => {
1185 return c.json({
1186 name: "gluecron API",
1187 version: "2.0",
1188 documentation: "/api/docs",
1189 endpoints: {
46d6165Claude1190 auth: {
1191 "POST /api/v2/auth/install-token":
1192 "Mint a PAT for one-command install (session-cookie auth only)",
1193 },
45e31d0Claude1194 users: {
1195 "GET /api/v2/user": "Get authenticated user",
1196 "GET /api/v2/users/:username": "Get user by username",
1197 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude1198 "GET /api/v2/me/ai-savings":
1199 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude1200 },
1201 repositories: {
1202 "GET /api/v2/users/:username/repos": "List user repositories",
1203 "POST /api/v2/repos": "Create repository",
1204 "GET /api/v2/repos/:owner/:repo": "Get repository",
1205 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
1206 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
1207 },
1208 branches: {
1209 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
1210 },
1211 commits: {
1212 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
1213 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
1214 },
1215 files: {
052c2e6Claude1216 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
1217 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
1218 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
1219 },
1220 git: {
1221 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
1222 },
1223 statuses: {
1224 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude1225 },
1226 issues: {
1227 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
1228 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
1229 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
1230 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
1231 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
1232 },
1233 pullRequests: {
1234 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
1235 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
1236 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude1237 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude1238 },
1239 stars: {
1240 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
1241 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
1242 },
1243 labels: {
1244 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
1245 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
1246 },
1247 search: {
1248 "GET /api/v2/search/repos": "Search repositories",
1249 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
1250 },
1251 topics: {
1252 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
1253 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
1254 },
1255 webhooks: {
1256 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
1257 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
1258 },
1259 activity: {
1260 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
1261 },
1262 },
1263 authentication: {
1264 method: "Bearer token",
1265 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]1266 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude1267 },
1268 rateLimit: {
1269 api: "100 requests/minute",
1270 search: "30 requests/minute",
1271 auth: "10 requests/minute",
1272 },
1273 });
1274});
1275
1276export default apiv2;