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.tsBlame1115 lines · 1 contributor
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";
45e31d0Claude50
51const apiv2 = new Hono<ApiAuthEnv>().basePath("/api/v2");
52
53// Apply auth and rate limiting to all v2 routes
54apiv2.use("*", apiRateLimit);
55apiv2.use("*", apiAuth);
56
57// ─── Helper ─────────────────────────────────────────────────────────────────
58
59async function resolveRepo(ownerName: string, repoName: string) {
60 const [owner] = await db
61 .select()
62 .from(users)
63 .where(eq(users.username, ownerName))
64 .limit(1);
65 if (!owner) return null;
66
67 const [repo] = await db
68 .select()
69 .from(repositories)
70 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
71 .limit(1);
72 if (!repo) return null;
73
74 return { owner, repo };
75}
76
77// ─── Users ──────────────────────────────────────────────────────────────────
78
79apiv2.get("/user", requireApiAuth, (c) => {
80 const user = c.get("user")!;
81 return c.json({
82 id: user.id,
83 username: user.username,
84 email: user.email,
85 displayName: user.displayName,
86 bio: user.bio,
87 avatarUrl: user.avatarUrl,
88 createdAt: user.createdAt,
89 });
90});
91
92apiv2.get("/users/:username", async (c) => {
93 const { username } = c.req.param();
94 const [user] = await db
95 .select({
96 id: users.id,
97 username: users.username,
98 displayName: users.displayName,
99 bio: users.bio,
100 avatarUrl: users.avatarUrl,
101 createdAt: users.createdAt,
102 })
103 .from(users)
104 .where(eq(users.username, username))
105 .limit(1);
106 if (!user) return c.json({ error: "User not found" }, 404);
107 return c.json(user);
108});
109
110apiv2.patch("/user", requireApiAuth, requireScope("user"), async (c) => {
111 const user = c.get("user")!;
112 const body = await c.req.json<{
113 displayName?: string;
114 bio?: string;
115 avatarUrl?: string;
116 }>();
117
118 const updates: Record<string, any> = { updatedAt: new Date() };
119 if (body.displayName !== undefined) updates.displayName = body.displayName;
120 if (body.bio !== undefined) updates.bio = body.bio;
121 if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
122
123 await db.update(users).set(updates).where(eq(users.id, user.id));
124 return c.json({ ok: true });
125});
126
127// ─── Repositories ───────────────────────────────────────────────────────────
128
129apiv2.get("/users/:username/repos", async (c) => {
130 const { username } = c.req.param();
131 const sort = c.req.query("sort") || "updated";
132 const [owner] = await db.select().from(users).where(eq(users.username, username)).limit(1);
133 if (!owner) return c.json({ error: "User not found" }, 404);
134
135 const currentUser = c.get("user");
136 const orderBy = sort === "stars" ? desc(repositories.starCount) :
137 sort === "name" ? asc(repositories.name) :
138 desc(repositories.updatedAt);
139
140 let repoList = await db
141 .select()
142 .from(repositories)
143 .where(eq(repositories.ownerId, owner.id))
144 .orderBy(orderBy);
145
146 // Only show private repos to owner
147 if (!currentUser || currentUser.id !== owner.id) {
148 repoList = repoList.filter((r: any) => !r.isPrivate);
149 }
150
151 return c.json(repoList);
152});
153
154apiv2.post("/repos", requireApiAuth, requireScope("repo"), async (c) => {
155 const user = c.get("user")!;
156 const body = await c.req.json<{
157 name: string;
158 description?: string;
159 isPrivate?: boolean;
160 }>();
161
162 if (!body.name || !/^[a-zA-Z0-9._-]+$/.test(body.name)) {
163 return c.json({ error: "Invalid repository name" }, 400);
164 }
165
166 if (await repoExists(user.username, body.name)) {
167 return c.json({ error: "Repository already exists" }, 409);
168 }
169
170 const diskPath = await initBareRepo(user.username, body.name);
171 const result = await db
172 .insert(repositories)
173 .values({
174 name: body.name,
175 ownerId: user.id,
176 description: body.description || null,
177 isPrivate: body.isPrivate || false,
178 diskPath,
179 })
180 .returning();
181
182 return c.json(result[0], 201);
183});
184
185apiv2.get("/repos/:owner/:repo", async (c) => {
186 const { owner, repo } = c.req.param();
187 const resolved = await resolveRepo(owner, repo);
188 if (!resolved) return c.json({ error: "Not found" }, 404);
189
190 const currentUser = c.get("user");
191 if ((resolved.repo as any).isPrivate && (!currentUser || currentUser.id !== resolved.owner.id)) {
192 return c.json({ error: "Not found" }, 404);
193 }
194
052c2e6Claude195 // Cache-free fresh read of HEAD's ref — needed by GateTest.
196 const defaultBranch = await getDefaultBranchFresh(owner, repo);
197
198 return c.json({
199 ...(resolved.repo as any),
200 defaultBranch,
201 owner: {
202 id: (resolved.owner as any).id,
203 login: (resolved.owner as any).username,
204 },
205 });
45e31d0Claude206});
207
208apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
209 const { owner, repo } = c.req.param();
210 const resolved = await resolveRepo(owner, repo);
211 if (!resolved) return c.json({ error: "Not found" }, 404);
212
213 const user = c.get("user")!;
214 if (user.id !== resolved.owner.id) {
215 return c.json({ error: "Permission denied" }, 403);
216 }
217
218 const body = await c.req.json<{
219 description?: string;
220 isPrivate?: boolean;
221 defaultBranch?: string;
222 }>();
223
224 const updates: Record<string, any> = { updatedAt: new Date() };
225 if (body.description !== undefined) updates.description = body.description;
226 if (body.isPrivate !== undefined) updates.isPrivate = body.isPrivate;
227 if (body.defaultBranch !== undefined) updates.defaultBranch = body.defaultBranch;
228
229 await db.update(repositories).set(updates).where(eq(repositories.id, (resolved.repo as any).id));
230 return c.json({ ok: true });
231});
232
233apiv2.delete("/repos/:owner/:repo", requireApiAuth, requireScope("admin"), async (c) => {
234 const { owner, repo } = c.req.param();
235 const resolved = await resolveRepo(owner, repo);
236 if (!resolved) return c.json({ error: "Not found" }, 404);
237
238 const user = c.get("user")!;
239 if (user.id !== resolved.owner.id) {
240 return c.json({ error: "Permission denied" }, 403);
241 }
242
243 await db.delete(repositories).where(eq(repositories.id, (resolved.repo as any).id));
244 return c.json({ ok: true });
245});
246
247// ─── Branches ───────────────────────────────────────────────────────────────
248
249apiv2.get("/repos/:owner/:repo/branches", async (c) => {
250 const { owner, repo } = c.req.param();
251 if (!(await repoExists(owner, repo))) {
252 return c.json({ error: "Not found" }, 404);
253 }
254
255 const branches = await listBranches(owner, repo);
256 const defaultBranch = await getDefaultBranch(owner, repo);
257
258 return c.json(
259 branches.map((name) => ({
260 name,
261 isDefault: name === defaultBranch,
262 }))
263 );
264});
265
266// ─── Commits ────────────────────────────────────────────────────────────────
267
268apiv2.get("/repos/:owner/:repo/commits", async (c) => {
269 const { owner, repo } = c.req.param();
270 const ref = c.req.query("ref") || c.req.query("sha") || "HEAD";
271 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
272 const offset = parseInt(c.req.query("offset") || "0");
273
274 if (!(await repoExists(owner, repo))) {
275 return c.json({ error: "Not found" }, 404);
276 }
277
278 const commits = await listCommits(owner, repo, ref, limit, offset);
279 return c.json(commits);
280});
281
282apiv2.get("/repos/:owner/:repo/commits/:sha", async (c) => {
283 const { owner, repo, sha } = c.req.param();
284 if (!(await repoExists(owner, repo))) {
285 return c.json({ error: "Not found" }, 404);
286 }
287
288 const commit = await getCommit(owner, repo, sha);
289 if (!commit) return c.json({ error: "Commit not found" }, 404);
290
291 const { files } = await getDiff(owner, repo, sha);
292 return c.json({ ...commit, files });
293});
294
295// ─── Tree & Files ───────────────────────────────────────────────────────────
296
297apiv2.get("/repos/:owner/:repo/tree/:ref", async (c) => {
298 const { owner, repo } = c.req.param();
299 const ref = c.req.param("ref");
300 const path = c.req.query("path") || "";
052c2e6Claude301 const recursive = c.req.query("recursive");
45e31d0Claude302
303 if (!(await repoExists(owner, repo))) {
304 return c.json({ error: "Not found" }, 404);
305 }
306
052c2e6Claude307 if (recursive === "1" || recursive === "true") {
308 const result = await getTreeRecursive(owner, repo, ref, 50_000);
309 if (!result) return c.json({ error: "Ref not found" }, 404);
310 return c.json(result);
311 }
312
45e31d0Claude313 const tree = await getTree(owner, repo, ref, path);
314 return c.json(tree);
315});
316
052c2e6Claude317const CONTENTS_MAX_BYTES = 10 * 1024 * 1024;
318
45e31d0Claude319apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
320 const { owner, repo } = c.req.param();
321 const filePath = c.req.param("path");
322 const ref = c.req.query("ref") || "HEAD";
052c2e6Claude323 const encoding = c.req.query("encoding") || "utf8";
45e31d0Claude324
325 if (!(await repoExists(owner, repo))) {
326 return c.json({ error: "Not found" }, 404);
327 }
328
052c2e6Claude329 if (encoding === "base64") {
330 const got = await catBlobBytes(owner, repo, ref, filePath);
331 if (!got) return c.json({ error: "File not found" }, 404);
332 if (got.size > CONTENTS_MAX_BYTES) {
333 return c.json(
334 { error: `File too large (${got.size} bytes, max ${CONTENTS_MAX_BYTES})` },
335 413
336 );
337 }
338 const content = Buffer.from(got.bytes).toString("base64");
339 return c.json({
340 path: filePath,
341 size: got.size,
342 sha: got.sha,
343 encoding: "base64",
344 content,
345 });
346 }
347
45e31d0Claude348 const blob = await getBlob(owner, repo, ref, filePath);
349 if (!blob) return c.json({ error: "File not found" }, 404);
052c2e6Claude350 if (blob.size > CONTENTS_MAX_BYTES) {
351 return c.json(
352 { error: `File too large (${blob.size} bytes, max ${CONTENTS_MAX_BYTES})` },
353 413
354 );
355 }
45e31d0Claude356
357 return c.json({
358 path: filePath,
359 size: blob.size,
360 isBinary: blob.isBinary,
361 content: blob.isBinary ? null : blob.content,
052c2e6Claude362 encoding: blob.isBinary ? null : "utf8",
45e31d0Claude363 });
364});
365
366// ─── Issues ─────────────────────────────────────────────────────────────────
367
368apiv2.get("/repos/:owner/:repo/issues", async (c) => {
369 const { owner, repo } = c.req.param();
370 const state = c.req.query("state") || "open";
371 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
372
373 const resolved = await resolveRepo(owner, repo);
374 if (!resolved) return c.json({ error: "Not found" }, 404);
375
376 const issueList = await db
377 .select({
378 issue: issues,
379 author: { username: users.username, id: users.id },
380 })
381 .from(issues)
382 .innerJoin(users, eq(issues.authorId, users.id))
383 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
384 .orderBy(desc(issues.createdAt))
385 .limit(limit);
386
387 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
388});
389
390apiv2.post("/repos/:owner/:repo/issues", requireApiAuth, requireScope("repo"), async (c) => {
391 const { owner, repo } = c.req.param();
392 const user = c.get("user")!;
393 const body = await c.req.json<{ title: string; body?: string }>();
394
395 if (!body.title?.trim()) {
396 return c.json({ error: "Title is required" }, 400);
397 }
398
399 const resolved = await resolveRepo(owner, repo);
400 if (!resolved) return c.json({ error: "Not found" }, 404);
401
402 const result = await db
403 .insert(issues)
404 .values({
405 repositoryId: (resolved.repo as any).id,
406 authorId: user.id,
407 title: body.title.trim(),
408 body: body.body?.trim() || null,
409 })
410 .returning();
411
412 return c.json(result[0], 201);
413});
414
415apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
416 const { owner, repo } = c.req.param();
417 const num = parseInt(c.req.param("number"), 10);
418
419 const resolved = await resolveRepo(owner, repo);
420 if (!resolved) return c.json({ error: "Not found" }, 404);
421
422 const [issue] = await db
423 .select()
424 .from(issues)
425 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
426 .limit(1);
427
428 if (!issue) return c.json({ error: "Issue not found" }, 404);
429
430 const comments = await db
431 .select({
432 comment: issueComments,
433 author: { username: users.username },
434 })
435 .from(issueComments)
436 .innerJoin(users, eq(issueComments.authorId, users.id))
437 .where(eq(issueComments.issueId, issue.id))
438 .orderBy(asc(issueComments.createdAt));
439
440 return c.json({
441 ...issue,
442 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
443 });
444});
445
446apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
447 const { owner, repo } = c.req.param();
448 const num = parseInt(c.req.param("number"), 10);
449 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
450
451 const resolved = await resolveRepo(owner, repo);
452 if (!resolved) return c.json({ error: "Not found" }, 404);
453
454 const updates: Record<string, any> = { updatedAt: new Date() };
455 if (body.title !== undefined) updates.title = body.title;
456 if (body.body !== undefined) updates.body = body.body;
457 if (body.state === "closed") {
458 updates.state = "closed";
459 updates.closedAt = new Date();
460 } else if (body.state === "open") {
461 updates.state = "open";
462 updates.closedAt = null;
463 }
464
465 await db
466 .update(issues)
467 .set(updates)
468 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
469
470 return c.json({ ok: true });
471});
472
473apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
474 const { owner, repo } = c.req.param();
475 const num = parseInt(c.req.param("number"), 10);
476 const user = c.get("user")!;
477 const body = await c.req.json<{ body: string }>();
478
479 if (!body.body?.trim()) {
480 return c.json({ error: "Comment body is required" }, 400);
481 }
482
483 const resolved = await resolveRepo(owner, repo);
484 if (!resolved) return c.json({ error: "Not found" }, 404);
485
486 const [issue] = await db
487 .select()
488 .from(issues)
489 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
490 .limit(1);
491
492 if (!issue) return c.json({ error: "Issue not found" }, 404);
493
494 const result = await db
495 .insert(issueComments)
496 .values({
497 issueId: issue.id,
498 authorId: user.id,
499 body: body.body.trim(),
500 })
501 .returning();
502
503 return c.json(result[0], 201);
504});
505
506// ─── Pull Requests ──────────────────────────────────────────────────────────
507
508apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
509 const { owner, repo } = c.req.param();
510 const state = c.req.query("state") || "open";
511
512 const resolved = await resolveRepo(owner, repo);
513 if (!resolved) return c.json({ error: "Not found" }, 404);
514
515 const prList = await db
516 .select({
517 pr: pullRequests,
518 author: { username: users.username },
519 })
520 .from(pullRequests)
521 .innerJoin(users, eq(pullRequests.authorId, users.id))
522 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
523 .orderBy(desc(pullRequests.createdAt));
524
525 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
526});
527
528apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
529 const { owner, repo } = c.req.param();
530 const user = c.get("user")!;
531 const body = await c.req.json<{
532 title: string;
533 body?: string;
534 baseBranch: string;
535 headBranch: string;
536 }>();
537
538 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
539 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
540 }
541
542 const resolved = await resolveRepo(owner, repo);
543 if (!resolved) return c.json({ error: "Not found" }, 404);
544
545 const result = await db
546 .insert(pullRequests)
547 .values({
548 repositoryId: (resolved.repo as any).id,
549 authorId: user.id,
550 title: body.title.trim(),
551 body: body.body?.trim() || null,
552 baseBranch: body.baseBranch,
553 headBranch: body.headBranch,
554 })
555 .returning();
556
557 return c.json(result[0], 201);
558});
559
560apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
561 const { owner, repo } = c.req.param();
562 const num = parseInt(c.req.param("number"), 10);
563
564 const resolved = await resolveRepo(owner, repo);
565 if (!resolved) return c.json({ error: "Not found" }, 404);
566
567 const [pr] = await db
568 .select()
569 .from(pullRequests)
570 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
571 .limit(1);
572
573 if (!pr) return c.json({ error: "PR not found" }, 404);
574
575 const comments = await db
576 .select({
577 comment: prComments,
578 author: { username: users.username },
579 })
580 .from(prComments)
581 .innerJoin(users, eq(prComments.authorId, users.id))
582 .where(eq(prComments.pullRequestId, pr.id))
583 .orderBy(asc(prComments.createdAt));
584
585 return c.json({
586 ...pr,
587 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
588 });
589});
590
052c2e6Claude591// ─── PR Comments (GateTest integration) ────────────────────────────────────
592
593apiv2.post(
594 "/repos/:owner/:repo/pulls/:number/comments",
595 requireApiAuth,
596 requireScope("repo"),
597 async (c) => {
598 const { owner, repo } = c.req.param();
599 const num = parseInt(c.req.param("number"), 10);
600 const user = c.get("user")!;
601
602 let body: { body?: string } = {};
603 try {
604 body = await c.req.json();
605 } catch {
606 body = {};
607 }
608 if (!body.body?.trim()) {
609 return c.json({ error: "Comment body is required" }, 400);
610 }
611
612 const resolved = await resolveRepo(owner, repo);
613 if (!resolved) return c.json({ error: "Not found" }, 404);
614 if (user.id !== (resolved.owner as any).id) {
615 return c.json({ error: "Forbidden" }, 403);
616 }
617
618 const [pr] = await db
619 .select()
620 .from(pullRequests)
621 .where(
622 and(
623 eq(pullRequests.repositoryId, (resolved.repo as any).id),
624 eq(pullRequests.number, num)
625 )
626 )
627 .limit(1);
628 if (!pr) return c.json({ error: "PR not found" }, 404);
629
630 const [comment] = await db
631 .insert(prComments)
632 .values({
633 pullRequestId: pr.id,
634 authorId: user.id,
635 body: body.body.trim(),
636 })
637 .returning();
638
639 return c.json({ ok: true, comment }, 201);
640 }
641);
642
643// ─── Git refs — create branch / tag from sha ────────────────────────────────
644
645apiv2.post(
646 "/repos/:owner/:repo/git/refs",
647 requireApiAuth,
648 requireScope("repo"),
649 async (c) => {
650 const { owner, repo } = c.req.param();
651 const user = c.get("user")!;
652
653 let body: { ref?: string; sha?: string } = {};
654 try {
655 body = await c.req.json();
656 } catch {
657 body = {};
658 }
659 const ref = body.ref?.trim();
660 const sha = body.sha?.trim();
661
662 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
663 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
664 }
665 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
666 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
667 }
668
669 const resolved = await resolveRepo(owner, repo);
670 if (!resolved) return c.json({ error: "Not found" }, 404);
671 if (user.id !== (resolved.owner as any).id) {
672 return c.json({ error: "Forbidden" }, 403);
673 }
674
675 // Verify sha reachable.
676 if (!(await objectExists(owner, repo, sha))) {
677 return c.json({ error: "sha not found in repository" }, 400);
678 }
679
680 // Conflict check: if ref already exists, the existing sha must match.
681 if (await refExists(owner, repo, ref)) {
682 const existing = await resolveRef(owner, repo, ref);
683 if (existing !== sha) {
684 return c.json({ error: "ref already exists", existing }, 409);
685 }
686 }
687
688 const ok = await updateRef(owner, repo, ref, sha);
689 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
690
691 return c.json({ ok: true, ref, sha }, 201);
692 }
693);
694
695// ─── Contents PUT — create/update a file via git plumbing ────────────────────
696
697apiv2.put(
698 "/repos/:owner/:repo/contents/:path{.+$}",
699 requireApiAuth,
700 requireScope("repo"),
701 async (c) => {
702 const { owner, repo } = c.req.param();
703 const filePath = c.req.param("path");
704 const user = c.get("user")!;
705
706 let body: {
707 message?: string;
708 content?: string;
709 branch?: string;
710 sha?: string | null;
711 } = {};
712 try {
713 body = await c.req.json();
714 } catch {
715 body = {};
716 }
717
718 const message = body.message?.trim();
719 const branch = body.branch?.trim();
720 const base64 = body.content;
721
722 if (!message) return c.json({ error: "message is required" }, 400);
723 if (!branch) return c.json({ error: "branch is required" }, 400);
724 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
725
726 let bytes: Uint8Array;
727 try {
728 bytes = new Uint8Array(Buffer.from(base64, "base64"));
729 } catch {
730 return c.json({ error: "content is not valid base64" }, 400);
731 }
732 if (bytes.length > CONTENTS_MAX_BYTES) {
733 return c.json({ error: "File too large" }, 413);
734 }
735
736 const resolved = await resolveRepo(owner, repo);
737 if (!resolved) return c.json({ error: "Not found" }, 404);
738 if (user.id !== (resolved.owner as any).id) {
739 return c.json({ error: "Forbidden" }, 403);
740 }
741
742 const result = await createOrUpdateFileOnBranch({
743 owner,
744 name: repo,
745 branch,
746 filePath,
747 bytes,
748 message,
749 authorName: (user as any).displayName || user.username,
750 authorEmail: user.email,
751 expectBlobSha: body.sha ?? null,
752 });
753
754 if ("error" in result) {
755 if (result.error === "sha-mismatch") {
756 return c.json({ error: "sha does not match current blob at path" }, 409);
757 }
758 return c.json({ error: "Failed to write file" }, 500);
759 }
760
761 return c.json(
762 {
763 ok: true,
764 commit: { sha: result.commitSha, message },
765 content: { path: filePath, sha: result.blobSha },
766 },
767 201
768 );
769 }
770);
771
772// ─── v2 alias for commit-status POST ─────────────────────────────────────────
773// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
774apiv2.post(
775 "/repos/:owner/:repo/statuses/:sha",
776 requireApiAuth,
777 requireScope("repo"),
778 postCommitStatusHandler
779);
780
45e31d0Claude781// ─── Stars ──────────────────────────────────────────────────────────────────
782
783apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
784 const { owner, repo } = c.req.param();
785 const user = c.get("user")!;
786
787 const resolved = await resolveRepo(owner, repo);
788 if (!resolved) return c.json({ error: "Not found" }, 404);
789
790 const repoId = (resolved.repo as any).id;
791 const [existing] = await db
792 .select()
793 .from(stars)
794 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
795 .limit(1);
796
797 if (!existing) {
798 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
799 await db
800 .update(repositories)
801 .set({ starCount: sql`${repositories.starCount} + 1` })
802 .where(eq(repositories.id, repoId));
803 }
804
805 return c.json({ starred: true });
806});
807
808apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
809 const { owner, repo } = c.req.param();
810 const user = c.get("user")!;
811
812 const resolved = await resolveRepo(owner, repo);
813 if (!resolved) return c.json({ error: "Not found" }, 404);
814
815 const repoId = (resolved.repo as any).id;
816 const [existing] = await db
817 .select()
818 .from(stars)
819 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
820 .limit(1);
821
822 if (existing) {
823 await db.delete(stars).where(eq(stars.id, existing.id));
824 await db
825 .update(repositories)
826 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
827 .where(eq(repositories.id, repoId));
828 }
829
830 return c.json({ starred: false });
831});
832
833// ─── Labels ─────────────────────────────────────────────────────────────────
834
835apiv2.get("/repos/:owner/:repo/labels", async (c) => {
836 const { owner, repo } = c.req.param();
837 const resolved = await resolveRepo(owner, repo);
838 if (!resolved) return c.json({ error: "Not found" }, 404);
839
840 const labelList = await db
841 .select()
842 .from(labels)
843 .where(eq(labels.repositoryId, (resolved.repo as any).id))
844 .orderBy(asc(labels.name));
845
846 return c.json(labelList);
847});
848
849apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
850 const { owner, repo } = c.req.param();
851 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
852
853 if (!body.name?.trim()) {
854 return c.json({ error: "Label name is required" }, 400);
855 }
856
857 const resolved = await resolveRepo(owner, repo);
858 if (!resolved) return c.json({ error: "Not found" }, 404);
859
860 const result = await db
861 .insert(labels)
862 .values({
863 repositoryId: (resolved.repo as any).id,
864 name: body.name.trim(),
865 color: body.color || "#8b949e",
866 description: body.description || null,
867 })
868 .returning();
869
870 return c.json(result[0], 201);
871});
872
873// ─── Search ─────────────────────────────────────────────────────────────────
874
875apiv2.get("/search/repos", searchRateLimit, async (c) => {
876 const q = c.req.query("q") || "";
877 const sort = c.req.query("sort") || "stars";
878 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
879
880 if (!q.trim()) {
881 return c.json({ error: "Query parameter 'q' is required" }, 400);
882 }
883
884 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
885 sort === "name" ? asc(repositories.name) :
886 desc(repositories.starCount);
887
888 const results = await db
889 .select({
890 repo: repositories,
891 owner: { username: users.username },
892 })
893 .from(repositories)
894 .innerJoin(users, eq(repositories.ownerId, users.id))
895 .where(
896 and(
897 eq(repositories.isPrivate, false),
898 or(
899 like(repositories.name, `%${q}%`),
900 like(repositories.description, `%${q}%`)
901 )
902 )
903 )
904 .orderBy(orderBy)
905 .limit(limit);
906
907 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
908});
909
910apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
911 const { owner, repo } = c.req.param();
912 const q = c.req.query("q") || "";
913
914 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
915 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
916
917 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
918 const results = await searchCode(owner, repo, defaultBranch, q.trim());
919 return c.json(results);
920});
921
922// ─── Activity Feed ──────────────────────────────────────────────────────────
923
924apiv2.get("/repos/:owner/:repo/activity", async (c) => {
925 const { owner, repo } = c.req.param();
926 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
927
928 const resolved = await resolveRepo(owner, repo);
929 if (!resolved) return c.json({ error: "Not found" }, 404);
930
931 const activity = await db
932 .select()
933 .from(activityFeed)
934 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
935 .orderBy(desc(activityFeed.createdAt))
936 .limit(limit);
937
938 return c.json(activity);
939});
940
941// ─── Topics ─────────────────────────────────────────────────────────────────
942
943apiv2.get("/repos/:owner/:repo/topics", async (c) => {
944 const { owner, repo } = c.req.param();
945 const resolved = await resolveRepo(owner, repo);
946 if (!resolved) return c.json({ error: "Not found" }, 404);
947
948 const topicsList = await db
949 .select()
950 .from(repoTopics)
951 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
952
953 return c.json(topicsList.map((t: any) => t.topic));
954});
955
956apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
957 const { owner, repo } = c.req.param();
958 const user = c.get("user")!;
959 const body = await c.req.json<{ topics: string[] }>();
960
961 const resolved = await resolveRepo(owner, repo);
962 if (!resolved) return c.json({ error: "Not found" }, 404);
963 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
964
965 const repoId = (resolved.repo as any).id;
966
967 // Clear existing topics and insert new ones
968 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
969 if (body.topics && body.topics.length > 0) {
970 await db.insert(repoTopics).values(
971 body.topics.slice(0, 20).map((topic: string) => ({
972 repositoryId: repoId,
973 topic: topic.toLowerCase().trim(),
974 }))
975 );
976 }
977
978 return c.json({ ok: true });
979});
980
981// ─── Webhooks ───────────────────────────────────────────────────────────────
982
983apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
984 const { owner, repo } = c.req.param();
985 const user = c.get("user")!;
986
987 const resolved = await resolveRepo(owner, repo);
988 if (!resolved) return c.json({ error: "Not found" }, 404);
989 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
990
991 const hookList = await db
992 .select()
993 .from(webhooks)
994 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
995
996 return c.json(hookList);
997});
998
999apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
1000 const { owner, repo } = c.req.param();
1001 const user = c.get("user")!;
1002 const body = await c.req.json<{
1003 url: string;
1004 secret?: string;
1005 events?: string;
1006 }>();
1007
1008 if (!body.url) return c.json({ error: "URL is required" }, 400);
1009
1010 const resolved = await resolveRepo(owner, repo);
1011 if (!resolved) return c.json({ error: "Not found" }, 404);
1012 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1013
1014 const result = await db
1015 .insert(webhooks)
1016 .values({
1017 repositoryId: (resolved.repo as any).id,
1018 url: body.url,
1019 secret: body.secret || null,
1020 events: body.events || "push",
1021 })
1022 .returning();
1023
1024 return c.json(result[0], 201);
1025});
1026
1027// ─── API Info ───────────────────────────────────────────────────────────────
1028
1029apiv2.get("/", (c) => {
1030 return c.json({
1031 name: "gluecron API",
1032 version: "2.0",
1033 documentation: "/api/docs",
1034 endpoints: {
1035 users: {
1036 "GET /api/v2/user": "Get authenticated user",
1037 "GET /api/v2/users/:username": "Get user by username",
1038 "PATCH /api/v2/user": "Update authenticated user profile",
1039 },
1040 repositories: {
1041 "GET /api/v2/users/:username/repos": "List user repositories",
1042 "POST /api/v2/repos": "Create repository",
1043 "GET /api/v2/repos/:owner/:repo": "Get repository",
1044 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
1045 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
1046 },
1047 branches: {
1048 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
1049 },
1050 commits: {
1051 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
1052 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
1053 },
1054 files: {
052c2e6Claude1055 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
1056 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
1057 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
1058 },
1059 git: {
1060 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
1061 },
1062 statuses: {
1063 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude1064 },
1065 issues: {
1066 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
1067 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
1068 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
1069 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
1070 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
1071 },
1072 pullRequests: {
1073 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
1074 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
1075 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude1076 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude1077 },
1078 stars: {
1079 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
1080 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
1081 },
1082 labels: {
1083 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
1084 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
1085 },
1086 search: {
1087 "GET /api/v2/search/repos": "Search repositories",
1088 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
1089 },
1090 topics: {
1091 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
1092 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
1093 },
1094 webhooks: {
1095 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
1096 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
1097 },
1098 activity: {
1099 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
1100 },
1101 },
1102 authentication: {
1103 method: "Bearer token",
1104 header: "Authorization: Bearer <your-token>",
1105 createToken: "Visit /settings/tokens to create a personal access token",
1106 },
1107 rateLimit: {
1108 api: "100 requests/minute",
1109 search: "30 requests/minute",
1110 auth: "10 requests/minute",
1111 },
1112 });
1113});
1114
1115export default apiv2;