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.tsBlame864 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,
29 getTree,
30 getBlob,
31 getCommit,
32 listCommits,
33 getDiff,
34 searchCode,
35 repoExists,
36 initBareRepo,
37 resolveRef,
38} from "../git/repository";
39import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
40import type { ApiAuthEnv } from "../middleware/api-auth";
41import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
42
43const apiv2 = new Hono<ApiAuthEnv>().basePath("/api/v2");
44
45// Apply auth and rate limiting to all v2 routes
46apiv2.use("*", apiRateLimit);
47apiv2.use("*", apiAuth);
48
49// ─── Helper ─────────────────────────────────────────────────────────────────
50
51async function resolveRepo(ownerName: string, repoName: string) {
52 const [owner] = await db
53 .select()
54 .from(users)
55 .where(eq(users.username, ownerName))
56 .limit(1);
57 if (!owner) return null;
58
59 const [repo] = await db
60 .select()
61 .from(repositories)
62 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
63 .limit(1);
64 if (!repo) return null;
65
66 return { owner, repo };
67}
68
69// ─── Users ──────────────────────────────────────────────────────────────────
70
71apiv2.get("/user", requireApiAuth, (c) => {
72 const user = c.get("user")!;
73 return c.json({
74 id: user.id,
75 username: user.username,
76 email: user.email,
77 displayName: user.displayName,
78 bio: user.bio,
79 avatarUrl: user.avatarUrl,
80 createdAt: user.createdAt,
81 });
82});
83
84apiv2.get("/users/:username", async (c) => {
85 const { username } = c.req.param();
86 const [user] = await db
87 .select({
88 id: users.id,
89 username: users.username,
90 displayName: users.displayName,
91 bio: users.bio,
92 avatarUrl: users.avatarUrl,
93 createdAt: users.createdAt,
94 })
95 .from(users)
96 .where(eq(users.username, username))
97 .limit(1);
98 if (!user) return c.json({ error: "User not found" }, 404);
99 return c.json(user);
100});
101
102apiv2.patch("/user", requireApiAuth, requireScope("user"), async (c) => {
103 const user = c.get("user")!;
104 const body = await c.req.json<{
105 displayName?: string;
106 bio?: string;
107 avatarUrl?: string;
108 }>();
109
110 const updates: Record<string, any> = { updatedAt: new Date() };
111 if (body.displayName !== undefined) updates.displayName = body.displayName;
112 if (body.bio !== undefined) updates.bio = body.bio;
113 if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
114
115 await db.update(users).set(updates).where(eq(users.id, user.id));
116 return c.json({ ok: true });
117});
118
119// ─── Repositories ───────────────────────────────────────────────────────────
120
121apiv2.get("/users/:username/repos", async (c) => {
122 const { username } = c.req.param();
123 const sort = c.req.query("sort") || "updated";
124 const [owner] = await db.select().from(users).where(eq(users.username, username)).limit(1);
125 if (!owner) return c.json({ error: "User not found" }, 404);
126
127 const currentUser = c.get("user");
128 const orderBy = sort === "stars" ? desc(repositories.starCount) :
129 sort === "name" ? asc(repositories.name) :
130 desc(repositories.updatedAt);
131
132 let repoList = await db
133 .select()
134 .from(repositories)
135 .where(eq(repositories.ownerId, owner.id))
136 .orderBy(orderBy);
137
138 // Only show private repos to owner
139 if (!currentUser || currentUser.id !== owner.id) {
140 repoList = repoList.filter((r: any) => !r.isPrivate);
141 }
142
143 return c.json(repoList);
144});
145
146apiv2.post("/repos", requireApiAuth, requireScope("repo"), async (c) => {
147 const user = c.get("user")!;
148 const body = await c.req.json<{
149 name: string;
150 description?: string;
151 isPrivate?: boolean;
152 }>();
153
154 if (!body.name || !/^[a-zA-Z0-9._-]+$/.test(body.name)) {
155 return c.json({ error: "Invalid repository name" }, 400);
156 }
157
158 if (await repoExists(user.username, body.name)) {
159 return c.json({ error: "Repository already exists" }, 409);
160 }
161
162 const diskPath = await initBareRepo(user.username, body.name);
163 const result = await db
164 .insert(repositories)
165 .values({
166 name: body.name,
167 ownerId: user.id,
168 description: body.description || null,
169 isPrivate: body.isPrivate || false,
170 diskPath,
171 })
172 .returning();
173
174 return c.json(result[0], 201);
175});
176
177apiv2.get("/repos/:owner/:repo", async (c) => {
178 const { owner, repo } = c.req.param();
179 const resolved = await resolveRepo(owner, repo);
180 if (!resolved) return c.json({ error: "Not found" }, 404);
181
182 const currentUser = c.get("user");
183 if ((resolved.repo as any).isPrivate && (!currentUser || currentUser.id !== resolved.owner.id)) {
184 return c.json({ error: "Not found" }, 404);
185 }
186
187 return c.json(resolved.repo);
188});
189
190apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
191 const { owner, repo } = c.req.param();
192 const resolved = await resolveRepo(owner, repo);
193 if (!resolved) return c.json({ error: "Not found" }, 404);
194
195 const user = c.get("user")!;
196 if (user.id !== resolved.owner.id) {
197 return c.json({ error: "Permission denied" }, 403);
198 }
199
200 const body = await c.req.json<{
201 description?: string;
202 isPrivate?: boolean;
203 defaultBranch?: string;
204 }>();
205
206 const updates: Record<string, any> = { updatedAt: new Date() };
207 if (body.description !== undefined) updates.description = body.description;
208 if (body.isPrivate !== undefined) updates.isPrivate = body.isPrivate;
209 if (body.defaultBranch !== undefined) updates.defaultBranch = body.defaultBranch;
210
211 await db.update(repositories).set(updates).where(eq(repositories.id, (resolved.repo as any).id));
212 return c.json({ ok: true });
213});
214
215apiv2.delete("/repos/:owner/:repo", requireApiAuth, requireScope("admin"), async (c) => {
216 const { owner, repo } = c.req.param();
217 const resolved = await resolveRepo(owner, repo);
218 if (!resolved) return c.json({ error: "Not found" }, 404);
219
220 const user = c.get("user")!;
221 if (user.id !== resolved.owner.id) {
222 return c.json({ error: "Permission denied" }, 403);
223 }
224
225 await db.delete(repositories).where(eq(repositories.id, (resolved.repo as any).id));
226 return c.json({ ok: true });
227});
228
229// ─── Branches ───────────────────────────────────────────────────────────────
230
231apiv2.get("/repos/:owner/:repo/branches", async (c) => {
232 const { owner, repo } = c.req.param();
233 if (!(await repoExists(owner, repo))) {
234 return c.json({ error: "Not found" }, 404);
235 }
236
237 const branches = await listBranches(owner, repo);
238 const defaultBranch = await getDefaultBranch(owner, repo);
239
240 return c.json(
241 branches.map((name) => ({
242 name,
243 isDefault: name === defaultBranch,
244 }))
245 );
246});
247
248// ─── Commits ────────────────────────────────────────────────────────────────
249
250apiv2.get("/repos/:owner/:repo/commits", async (c) => {
251 const { owner, repo } = c.req.param();
252 const ref = c.req.query("ref") || c.req.query("sha") || "HEAD";
253 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
254 const offset = parseInt(c.req.query("offset") || "0");
255
256 if (!(await repoExists(owner, repo))) {
257 return c.json({ error: "Not found" }, 404);
258 }
259
260 const commits = await listCommits(owner, repo, ref, limit, offset);
261 return c.json(commits);
262});
263
264apiv2.get("/repos/:owner/:repo/commits/:sha", async (c) => {
265 const { owner, repo, sha } = c.req.param();
266 if (!(await repoExists(owner, repo))) {
267 return c.json({ error: "Not found" }, 404);
268 }
269
270 const commit = await getCommit(owner, repo, sha);
271 if (!commit) return c.json({ error: "Commit not found" }, 404);
272
273 const { files } = await getDiff(owner, repo, sha);
274 return c.json({ ...commit, files });
275});
276
277// ─── Tree & Files ───────────────────────────────────────────────────────────
278
279apiv2.get("/repos/:owner/:repo/tree/:ref", async (c) => {
280 const { owner, repo } = c.req.param();
281 const ref = c.req.param("ref");
282 const path = c.req.query("path") || "";
283
284 if (!(await repoExists(owner, repo))) {
285 return c.json({ error: "Not found" }, 404);
286 }
287
288 const tree = await getTree(owner, repo, ref, path);
289 return c.json(tree);
290});
291
292apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
293 const { owner, repo } = c.req.param();
294 const filePath = c.req.param("path");
295 const ref = c.req.query("ref") || "HEAD";
296
297 if (!(await repoExists(owner, repo))) {
298 return c.json({ error: "Not found" }, 404);
299 }
300
301 const blob = await getBlob(owner, repo, ref, filePath);
302 if (!blob) return c.json({ error: "File not found" }, 404);
303
304 return c.json({
305 path: filePath,
306 size: blob.size,
307 isBinary: blob.isBinary,
308 content: blob.isBinary ? null : blob.content,
309 encoding: blob.isBinary ? null : "utf-8",
310 });
311});
312
313// ─── Issues ─────────────────────────────────────────────────────────────────
314
315apiv2.get("/repos/:owner/:repo/issues", async (c) => {
316 const { owner, repo } = c.req.param();
317 const state = c.req.query("state") || "open";
318 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
319
320 const resolved = await resolveRepo(owner, repo);
321 if (!resolved) return c.json({ error: "Not found" }, 404);
322
323 const issueList = await db
324 .select({
325 issue: issues,
326 author: { username: users.username, id: users.id },
327 })
328 .from(issues)
329 .innerJoin(users, eq(issues.authorId, users.id))
330 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
331 .orderBy(desc(issues.createdAt))
332 .limit(limit);
333
334 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
335});
336
337apiv2.post("/repos/:owner/:repo/issues", requireApiAuth, requireScope("repo"), async (c) => {
338 const { owner, repo } = c.req.param();
339 const user = c.get("user")!;
340 const body = await c.req.json<{ title: string; body?: string }>();
341
342 if (!body.title?.trim()) {
343 return c.json({ error: "Title is required" }, 400);
344 }
345
346 const resolved = await resolveRepo(owner, repo);
347 if (!resolved) return c.json({ error: "Not found" }, 404);
348
349 const result = await db
350 .insert(issues)
351 .values({
352 repositoryId: (resolved.repo as any).id,
353 authorId: user.id,
354 title: body.title.trim(),
355 body: body.body?.trim() || null,
356 })
357 .returning();
358
359 return c.json(result[0], 201);
360});
361
362apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
363 const { owner, repo } = c.req.param();
364 const num = parseInt(c.req.param("number"), 10);
365
366 const resolved = await resolveRepo(owner, repo);
367 if (!resolved) return c.json({ error: "Not found" }, 404);
368
369 const [issue] = await db
370 .select()
371 .from(issues)
372 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
373 .limit(1);
374
375 if (!issue) return c.json({ error: "Issue not found" }, 404);
376
377 const comments = await db
378 .select({
379 comment: issueComments,
380 author: { username: users.username },
381 })
382 .from(issueComments)
383 .innerJoin(users, eq(issueComments.authorId, users.id))
384 .where(eq(issueComments.issueId, issue.id))
385 .orderBy(asc(issueComments.createdAt));
386
387 return c.json({
388 ...issue,
389 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
390 });
391});
392
393apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
394 const { owner, repo } = c.req.param();
395 const num = parseInt(c.req.param("number"), 10);
396 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
397
398 const resolved = await resolveRepo(owner, repo);
399 if (!resolved) return c.json({ error: "Not found" }, 404);
400
401 const updates: Record<string, any> = { updatedAt: new Date() };
402 if (body.title !== undefined) updates.title = body.title;
403 if (body.body !== undefined) updates.body = body.body;
404 if (body.state === "closed") {
405 updates.state = "closed";
406 updates.closedAt = new Date();
407 } else if (body.state === "open") {
408 updates.state = "open";
409 updates.closedAt = null;
410 }
411
412 await db
413 .update(issues)
414 .set(updates)
415 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
416
417 return c.json({ ok: true });
418});
419
420apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
421 const { owner, repo } = c.req.param();
422 const num = parseInt(c.req.param("number"), 10);
423 const user = c.get("user")!;
424 const body = await c.req.json<{ body: string }>();
425
426 if (!body.body?.trim()) {
427 return c.json({ error: "Comment body is required" }, 400);
428 }
429
430 const resolved = await resolveRepo(owner, repo);
431 if (!resolved) return c.json({ error: "Not found" }, 404);
432
433 const [issue] = await db
434 .select()
435 .from(issues)
436 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
437 .limit(1);
438
439 if (!issue) return c.json({ error: "Issue not found" }, 404);
440
441 const result = await db
442 .insert(issueComments)
443 .values({
444 issueId: issue.id,
445 authorId: user.id,
446 body: body.body.trim(),
447 })
448 .returning();
449
450 return c.json(result[0], 201);
451});
452
453// ─── Pull Requests ──────────────────────────────────────────────────────────
454
455apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
456 const { owner, repo } = c.req.param();
457 const state = c.req.query("state") || "open";
458
459 const resolved = await resolveRepo(owner, repo);
460 if (!resolved) return c.json({ error: "Not found" }, 404);
461
462 const prList = await db
463 .select({
464 pr: pullRequests,
465 author: { username: users.username },
466 })
467 .from(pullRequests)
468 .innerJoin(users, eq(pullRequests.authorId, users.id))
469 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
470 .orderBy(desc(pullRequests.createdAt));
471
472 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
473});
474
475apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
476 const { owner, repo } = c.req.param();
477 const user = c.get("user")!;
478 const body = await c.req.json<{
479 title: string;
480 body?: string;
481 baseBranch: string;
482 headBranch: string;
483 }>();
484
485 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
486 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
487 }
488
489 const resolved = await resolveRepo(owner, repo);
490 if (!resolved) return c.json({ error: "Not found" }, 404);
491
492 const result = await db
493 .insert(pullRequests)
494 .values({
495 repositoryId: (resolved.repo as any).id,
496 authorId: user.id,
497 title: body.title.trim(),
498 body: body.body?.trim() || null,
499 baseBranch: body.baseBranch,
500 headBranch: body.headBranch,
501 })
502 .returning();
503
504 return c.json(result[0], 201);
505});
506
507apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
508 const { owner, repo } = c.req.param();
509 const num = parseInt(c.req.param("number"), 10);
510
511 const resolved = await resolveRepo(owner, repo);
512 if (!resolved) return c.json({ error: "Not found" }, 404);
513
514 const [pr] = await db
515 .select()
516 .from(pullRequests)
517 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
518 .limit(1);
519
520 if (!pr) return c.json({ error: "PR not found" }, 404);
521
522 const comments = await db
523 .select({
524 comment: prComments,
525 author: { username: users.username },
526 })
527 .from(prComments)
528 .innerJoin(users, eq(prComments.authorId, users.id))
529 .where(eq(prComments.pullRequestId, pr.id))
530 .orderBy(asc(prComments.createdAt));
531
532 return c.json({
533 ...pr,
534 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
535 });
536});
537
538// ─── Stars ──────────────────────────────────────────────────────────────────
539
540apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
541 const { owner, repo } = c.req.param();
542 const user = c.get("user")!;
543
544 const resolved = await resolveRepo(owner, repo);
545 if (!resolved) return c.json({ error: "Not found" }, 404);
546
547 const repoId = (resolved.repo as any).id;
548 const [existing] = await db
549 .select()
550 .from(stars)
551 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
552 .limit(1);
553
554 if (!existing) {
555 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
556 await db
557 .update(repositories)
558 .set({ starCount: sql`${repositories.starCount} + 1` })
559 .where(eq(repositories.id, repoId));
560 }
561
562 return c.json({ starred: true });
563});
564
565apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
566 const { owner, repo } = c.req.param();
567 const user = c.get("user")!;
568
569 const resolved = await resolveRepo(owner, repo);
570 if (!resolved) return c.json({ error: "Not found" }, 404);
571
572 const repoId = (resolved.repo as any).id;
573 const [existing] = await db
574 .select()
575 .from(stars)
576 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
577 .limit(1);
578
579 if (existing) {
580 await db.delete(stars).where(eq(stars.id, existing.id));
581 await db
582 .update(repositories)
583 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
584 .where(eq(repositories.id, repoId));
585 }
586
587 return c.json({ starred: false });
588});
589
590// ─── Labels ─────────────────────────────────────────────────────────────────
591
592apiv2.get("/repos/:owner/:repo/labels", async (c) => {
593 const { owner, repo } = c.req.param();
594 const resolved = await resolveRepo(owner, repo);
595 if (!resolved) return c.json({ error: "Not found" }, 404);
596
597 const labelList = await db
598 .select()
599 .from(labels)
600 .where(eq(labels.repositoryId, (resolved.repo as any).id))
601 .orderBy(asc(labels.name));
602
603 return c.json(labelList);
604});
605
606apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
607 const { owner, repo } = c.req.param();
608 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
609
610 if (!body.name?.trim()) {
611 return c.json({ error: "Label name is required" }, 400);
612 }
613
614 const resolved = await resolveRepo(owner, repo);
615 if (!resolved) return c.json({ error: "Not found" }, 404);
616
617 const result = await db
618 .insert(labels)
619 .values({
620 repositoryId: (resolved.repo as any).id,
621 name: body.name.trim(),
622 color: body.color || "#8b949e",
623 description: body.description || null,
624 })
625 .returning();
626
627 return c.json(result[0], 201);
628});
629
630// ─── Search ─────────────────────────────────────────────────────────────────
631
632apiv2.get("/search/repos", searchRateLimit, async (c) => {
633 const q = c.req.query("q") || "";
634 const sort = c.req.query("sort") || "stars";
635 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
636
637 if (!q.trim()) {
638 return c.json({ error: "Query parameter 'q' is required" }, 400);
639 }
640
641 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
642 sort === "name" ? asc(repositories.name) :
643 desc(repositories.starCount);
644
645 const results = await db
646 .select({
647 repo: repositories,
648 owner: { username: users.username },
649 })
650 .from(repositories)
651 .innerJoin(users, eq(repositories.ownerId, users.id))
652 .where(
653 and(
654 eq(repositories.isPrivate, false),
655 or(
656 like(repositories.name, `%${q}%`),
657 like(repositories.description, `%${q}%`)
658 )
659 )
660 )
661 .orderBy(orderBy)
662 .limit(limit);
663
664 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
665});
666
667apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
668 const { owner, repo } = c.req.param();
669 const q = c.req.query("q") || "";
670
671 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
672 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
673
674 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
675 const results = await searchCode(owner, repo, defaultBranch, q.trim());
676 return c.json(results);
677});
678
679// ─── Activity Feed ──────────────────────────────────────────────────────────
680
681apiv2.get("/repos/:owner/:repo/activity", async (c) => {
682 const { owner, repo } = c.req.param();
683 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
684
685 const resolved = await resolveRepo(owner, repo);
686 if (!resolved) return c.json({ error: "Not found" }, 404);
687
688 const activity = await db
689 .select()
690 .from(activityFeed)
691 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
692 .orderBy(desc(activityFeed.createdAt))
693 .limit(limit);
694
695 return c.json(activity);
696});
697
698// ─── Topics ─────────────────────────────────────────────────────────────────
699
700apiv2.get("/repos/:owner/:repo/topics", async (c) => {
701 const { owner, repo } = c.req.param();
702 const resolved = await resolveRepo(owner, repo);
703 if (!resolved) return c.json({ error: "Not found" }, 404);
704
705 const topicsList = await db
706 .select()
707 .from(repoTopics)
708 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
709
710 return c.json(topicsList.map((t: any) => t.topic));
711});
712
713apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
714 const { owner, repo } = c.req.param();
715 const user = c.get("user")!;
716 const body = await c.req.json<{ topics: string[] }>();
717
718 const resolved = await resolveRepo(owner, repo);
719 if (!resolved) return c.json({ error: "Not found" }, 404);
720 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
721
722 const repoId = (resolved.repo as any).id;
723
724 // Clear existing topics and insert new ones
725 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
726 if (body.topics && body.topics.length > 0) {
727 await db.insert(repoTopics).values(
728 body.topics.slice(0, 20).map((topic: string) => ({
729 repositoryId: repoId,
730 topic: topic.toLowerCase().trim(),
731 }))
732 );
733 }
734
735 return c.json({ ok: true });
736});
737
738// ─── Webhooks ───────────────────────────────────────────────────────────────
739
740apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
741 const { owner, repo } = c.req.param();
742 const user = c.get("user")!;
743
744 const resolved = await resolveRepo(owner, repo);
745 if (!resolved) return c.json({ error: "Not found" }, 404);
746 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
747
748 const hookList = await db
749 .select()
750 .from(webhooks)
751 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
752
753 return c.json(hookList);
754});
755
756apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
757 const { owner, repo } = c.req.param();
758 const user = c.get("user")!;
759 const body = await c.req.json<{
760 url: string;
761 secret?: string;
762 events?: string;
763 }>();
764
765 if (!body.url) return c.json({ error: "URL is required" }, 400);
766
767 const resolved = await resolveRepo(owner, repo);
768 if (!resolved) return c.json({ error: "Not found" }, 404);
769 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
770
771 const result = await db
772 .insert(webhooks)
773 .values({
774 repositoryId: (resolved.repo as any).id,
775 url: body.url,
776 secret: body.secret || null,
777 events: body.events || "push",
778 })
779 .returning();
780
781 return c.json(result[0], 201);
782});
783
784// ─── API Info ───────────────────────────────────────────────────────────────
785
786apiv2.get("/", (c) => {
787 return c.json({
788 name: "gluecron API",
789 version: "2.0",
790 documentation: "/api/docs",
791 endpoints: {
792 users: {
793 "GET /api/v2/user": "Get authenticated user",
794 "GET /api/v2/users/:username": "Get user by username",
795 "PATCH /api/v2/user": "Update authenticated user profile",
796 },
797 repositories: {
798 "GET /api/v2/users/:username/repos": "List user repositories",
799 "POST /api/v2/repos": "Create repository",
800 "GET /api/v2/repos/:owner/:repo": "Get repository",
801 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
802 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
803 },
804 branches: {
805 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
806 },
807 commits: {
808 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
809 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
810 },
811 files: {
812 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree",
813 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents",
814 },
815 issues: {
816 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
817 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
818 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
819 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
820 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
821 },
822 pullRequests: {
823 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
824 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
825 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
826 },
827 stars: {
828 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
829 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
830 },
831 labels: {
832 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
833 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
834 },
835 search: {
836 "GET /api/v2/search/repos": "Search repositories",
837 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
838 },
839 topics: {
840 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
841 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
842 },
843 webhooks: {
844 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
845 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
846 },
847 activity: {
848 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
849 },
850 },
851 authentication: {
852 method: "Bearer token",
853 header: "Authorization: Bearer <your-token>",
854 createToken: "Visit /settings/tokens to create a personal access token",
855 },
856 rateLimit: {
857 api: "100 requests/minute",
858 search: "30 requests/minute",
859 auth: "10 requests/minute",
860 },
861 });
862});
863
864export default apiv2;