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.tsBlame2820 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";
da50b83Claude10import { join } from "path";
45e31d0Claude11import { eq, and, desc, asc, sql, like, or } from "drizzle-orm";
da50b83Claude12import { deflateRawSync } from "node:zlib";
45e31d0Claude13import { db } from "../db";
14import {
15 users,
16 repositories,
17 issues,
18 issueComments,
19 pullRequests,
20 prComments,
21 stars,
22 labels,
23 issueLabels,
24 activityFeed,
25 webhooks,
26 repoTopics,
da50b83Claude27 workflows,
28 workflowRuns,
29 workflowJobs,
45e31d0Claude30} from "../db/schema";
da50b83Claude31import { enqueueRun } from "../lib/workflow-runner";
45e31d0Claude32import {
33 listBranches,
34 getDefaultBranch,
052c2e6Claude35 getDefaultBranchFresh,
45e31d0Claude36 getTree,
052c2e6Claude37 getTreeRecursive,
45e31d0Claude38 getBlob,
39 getCommit,
40 listCommits,
41 getDiff,
42 searchCode,
43 repoExists,
44 initBareRepo,
45 resolveRef,
052c2e6Claude46 catBlobBytes,
47 refExists,
48 objectExists,
49 updateRef,
da50b83Claude50 writeBlob,
51 getBlobShaAtPath,
52 getRepoPath,
052c2e6Claude53 createOrUpdateFileOnBranch,
45e31d0Claude54} from "../git/repository";
da50b83Claude55import { config } from "../lib/config";
45e31d0Claude56import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
57import type { ApiAuthEnv } from "../middleware/api-auth";
e75eddcClaude58import {
59 agentAuth,
60 enforceAgentBranchNamespace,
61} from "../middleware/agent-auth";
62import type { AgentAuthEnv } from "../middleware/agent-auth";
45e31d0Claude63import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
052c2e6Claude64import { postCommitStatusHandler } from "./commit-statuses";
46d6165Claude65import { apiTokens } from "../db/schema";
66import { audit } from "../lib/notify";
67import {
68 computeAiSavingsForUser,
69 computeLifetimeAiSavingsForUser,
70} from "../lib/ai-hours-saved";
9018b1fClaude71import {
72 generateCommitMessage,
73 DIFF_BYTE_CAP,
74} from "../lib/ai-commit-message";
45e31d0Claude75
e75eddcClaude76const apiv2 = new Hono<ApiAuthEnv & AgentAuthEnv>().basePath("/api/v2");
45e31d0Claude77
e75eddcClaude78// Apply auth and rate limiting to all v2 routes.
79//
80// Agent-multiplayer v1: `agentAuth` runs BEFORE `apiAuth` so that an
81// `agt_` Bearer token is detected and stashed at `c.get("agent")`
82// without consuming the token in apiAuth (which only recognises PAT
83// + session). Non-agent tokens fall straight through. The
84// branch-namespace guard runs on PATCH /git/refs/heads/* only.
45e31d0Claude85apiv2.use("*", apiRateLimit);
e75eddcClaude86apiv2.use("*", agentAuth);
45e31d0Claude87apiv2.use("*", apiAuth);
e75eddcClaude88apiv2.use(
89 "/repos/:owner/:repo/git/refs/heads/:branch",
90 enforceAgentBranchNamespace
91);
45e31d0Claude92
93// ─── Helper ─────────────────────────────────────────────────────────────────
94
95async function resolveRepo(ownerName: string, repoName: string) {
96 const [owner] = await db
97 .select()
98 .from(users)
99 .where(eq(users.username, ownerName))
100 .limit(1);
101 if (!owner) return null;
102
103 const [repo] = await db
104 .select()
105 .from(repositories)
106 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
107 .limit(1);
108 if (!repo) return null;
109
110 return { owner, repo };
111}
112
46d6165Claude113// ─── Auth (install-token) ───────────────────────────────────────────────────
114//
115// POST /api/v2/auth/install-token
116//
117// Mint a new personal access token from a one-command install script
118// (`scripts/install.sh`). Session-cookie auth ONLY — Bearer tokens are
119// explicitly rejected so existing PATs cannot escalate into a fan-out of new
120// PATs. The plaintext token value is returned exactly once and never persisted.
121//
122// Audit-logged as `auth.install_token.created`.
123
124function generateInstallToken(): string {
125 const bytes = crypto.getRandomValues(new Uint8Array(32));
126 return (
127 "glc_" +
128 Array.from(bytes)
129 .map((b) => b.toString(16).padStart(2, "0"))
130 .join("")
131 );
132}
133
134async function hashInstallToken(token: string): Promise<string> {
135 const data = new TextEncoder().encode(token);
136 const hash = await crypto.subtle.digest("SHA-256", data);
137 return Array.from(new Uint8Array(hash))
138 .map((b) => b.toString(16).padStart(2, "0"))
139 .join("");
140}
141
142apiv2.post("/auth/install-token", async (c) => {
143 // Reject Bearer-token callers outright. The whole point of this endpoint
144 // is preventing token escalation, so we check the raw header rather than
145 // relying on whatever the middleware decided.
146 const authHeader = c.req.header("Authorization");
147 if (authHeader && authHeader.toLowerCase().startsWith("bearer ")) {
148 return c.json(
149 {
150 error: "Session authentication required",
151 hint: "This endpoint refuses Bearer tokens — sign in with a session cookie.",
152 },
153 401
154 );
155 }
156
157 const user = c.get("user");
158 const authMethod = c.get("authMethod");
159 if (!user || authMethod !== "session") {
160 return c.json(
161 {
162 error: "Session authentication required",
163 hint: "Sign in via /login first; install-token mints PATs only over the session cookie.",
164 },
165 401
166 );
167 }
168
169 let body: { name?: unknown; scope?: unknown } = {};
170 try {
171 body = await c.req.json();
172 } catch {
173 // Empty / unparseable body is allowed — we fall back to defaults.
174 body = {};
175 }
176
177 const shortStamp = Math.floor(Date.now() / 1000)
178 .toString(36)
179 .slice(-6);
180 const name =
181 typeof body.name === "string" && body.name.trim().length > 0
182 ? body.name.trim().slice(0, 80)
183 : `gluecron-install-${shortStamp}`;
184
185 const requested = typeof body.scope === "string" ? body.scope : "admin";
186 const scope = requested === "repo" ? "repo" : "admin";
187 // Mirror existing PAT semantics: a comma-separated list. `admin` implies
188 // everything; `repo` keeps it narrow.
189 const scopes = scope === "admin" ? "admin,repo,user" : "repo";
190
191 const token = generateInstallToken();
192 const tokenHash = await hashInstallToken(token);
193 const tokenPrefix = token.slice(0, 12);
194
195 const [row] = await db
196 .insert(apiTokens)
197 .values({
198 userId: user.id,
199 name,
200 tokenHash,
201 tokenPrefix,
202 scopes,
203 })
204 .returning();
205
206 await audit({
207 userId: user.id,
208 action: "auth.install_token.created",
209 targetType: "api_token",
210 targetId: row?.id,
211 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
212 userAgent: c.req.header("user-agent") || undefined,
213 metadata: { name, scope, prefix: tokenPrefix },
214 });
215
216 return c.json(
217 {
218 token,
219 id: row?.id,
220 name,
221 scope,
222 scopes,
223 expiresAt: row?.expiresAt ?? null,
224 },
225 201
226 );
227});
228
45e31d0Claude229// ─── Users ──────────────────────────────────────────────────────────────────
230
231apiv2.get("/user", requireApiAuth, (c) => {
232 const user = c.get("user")!;
233 return c.json({
234 id: user.id,
235 username: user.username,
236 email: user.email,
237 displayName: user.displayName,
238 bio: user.bio,
239 avatarUrl: user.avatarUrl,
240 createdAt: user.createdAt,
241 });
242});
243
244apiv2.get("/users/:username", async (c) => {
245 const { username } = c.req.param();
246 const [user] = await db
247 .select({
248 id: users.id,
249 username: users.username,
250 displayName: users.displayName,
251 bio: users.bio,
252 avatarUrl: users.avatarUrl,
253 createdAt: users.createdAt,
254 })
255 .from(users)
256 .where(eq(users.username, username))
257 .limit(1);
258 if (!user) return c.json({ error: "User not found" }, 404);
259 return c.json(user);
260});
261
46d6165Claude262/**
263 * Block L9 — AI hours-saved counter, exposed for the dashboard widget,
264 * VS Code extension, and CLI. Returns the same numbers the web UI shows.
265 * No special scope — any authenticated token can read its own counter.
266 */
267apiv2.get("/me/ai-savings", requireApiAuth, async (c) => {
268 const user = c.get("user")!;
269 const [window, lifetime] = await Promise.all([
270 computeAiSavingsForUser(user.id, { windowHours: 168 }),
271 computeLifetimeAiSavingsForUser(user.id),
272 ]);
273 return c.json({
274 window: {
275 hours: window.windowHours,
276 hoursSaved: window.hoursSaved,
277 breakdown: window.breakdown,
278 },
279 lifetime: {
280 hoursSaved: lifetime.hoursSaved,
281 breakdown: lifetime.breakdown,
282 sinceCreatedAt: lifetime.sinceCreatedAt.toISOString(),
283 },
284 });
285});
286
9018b1fClaude287// ─── AI: commit-message ─────────────────────────────────────────────────────
288//
289// POST /api/v2/ai/commit-message
290//
291// Body: { diff: string, style?: "conventional" | "plain" }
292// Returns: { subject, body }
293//
294// Powers the `gluecron commit` CLI + the `prepare-commit-msg` git hook.
295// Rate-limited to 60 req/min per token (this runs once per developer
296// commit; we don't want a runaway `git rebase -i exec` to burn $$$).
297// Falls back to a heuristic when ANTHROPIC_API_KEY is missing, so the
298// route never 5xx's on a misconfigured deploy — the CLI can always show
299// the developer something.
300
301interface AiCommitBucket {
302 count: number;
303 resetAt: number;
304}
305const _aiCommitBuckets = new Map<string, AiCommitBucket>();
306const AI_COMMIT_LIMIT = 60; // requests
307const AI_COMMIT_WINDOW_MS = 60_000; // per minute, per token
308
309function aiCommitRateLimit(c: {
310 req: { header: (k: string) => string | undefined };
311 get: (k: string) => unknown;
312}): { ok: true } | { ok: false; retryAfter: number } {
313 // Key on the raw bearer token where possible (so two CLIs sharing an
314 // IP each get their own bucket); fall back to user id for session
315 // callers (web UI) and to "anon" as a last resort.
316 const authHeader = c.req.header("Authorization") || "";
317 let key: string;
318 if (authHeader.toLowerCase().startsWith("bearer ")) {
319 // Hash the token so we don't keep the plaintext in memory forever.
320 const tok = authHeader.slice(7).trim();
321 const hasher = new Bun.CryptoHasher("sha256");
322 hasher.update(tok);
323 key = "tok:" + hasher.digest("hex").slice(0, 32);
324 } else {
325 const user = c.get("user") as { id?: string } | null | undefined;
326 key = user?.id ? `usr:${user.id}` : "anon";
327 }
328 const now = Date.now();
329 let bucket = _aiCommitBuckets.get(key);
330 if (!bucket || bucket.resetAt < now) {
331 bucket = { count: 0, resetAt: now + AI_COMMIT_WINDOW_MS };
332 _aiCommitBuckets.set(key, bucket);
333 }
334 bucket.count++;
335 if (bucket.count > AI_COMMIT_LIMIT) {
336 return {
337 ok: false,
338 retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
339 };
340 }
341 return { ok: true };
342}
343
344apiv2.post(
345 "/ai/commit-message",
346 requireApiAuth,
347 requireScope("repo"),
348 async (c) => {
349 // In test env we keep the bucket disabled (matches the global pattern
350 // in src/middleware/rate-limit.ts — see the isTestEnv branch there).
351 const isTestEnv =
352 process.env.NODE_ENV !== "production" &&
353 (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test");
354 if (!isTestEnv) {
355 const gate = aiCommitRateLimit(c);
356 if (!gate.ok) {
357 c.header("Retry-After", String(gate.retryAfter));
358 return c.json(
359 {
360 error: "Rate limit exceeded — 60 commit-message requests per minute per token.",
361 retryAfter: gate.retryAfter,
362 },
363 429
364 );
365 }
366 }
367
368 let body: { diff?: unknown; style?: unknown } = {};
369 try {
370 body = await c.req.json();
371 } catch {
372 return c.json({ error: "Invalid JSON body" }, 400);
373 }
374 const diff = typeof body.diff === "string" ? body.diff : "";
375 if (!diff.trim()) {
376 return c.json({ error: "diff is required" }, 400);
377 }
378 // Hard cap so we never blow memory even before the lib truncates.
379 // Library re-truncates as a defence-in-depth measure.
380 const capped = diff.length > DIFF_BYTE_CAP * 4 ? diff.slice(0, DIFF_BYTE_CAP * 4) : diff;
381
382 const style =
383 body.style === "plain" || body.style === "conventional"
384 ? body.style
385 : "conventional";
386
387 const result = await generateCommitMessage(capped, { style });
388 return c.json(result);
389 }
390);
391
45e31d0Claude392apiv2.patch("/user", requireApiAuth, requireScope("user"), async (c) => {
393 const user = c.get("user")!;
394 const body = await c.req.json<{
395 displayName?: string;
396 bio?: string;
397 avatarUrl?: string;
398 }>();
399
400 const updates: Record<string, any> = { updatedAt: new Date() };
401 if (body.displayName !== undefined) updates.displayName = body.displayName;
402 if (body.bio !== undefined) updates.bio = body.bio;
403 if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
404
405 await db.update(users).set(updates).where(eq(users.id, user.id));
406 return c.json({ ok: true });
407});
408
409// ─── Repositories ───────────────────────────────────────────────────────────
410
411apiv2.get("/users/:username/repos", async (c) => {
412 const { username } = c.req.param();
413 const sort = c.req.query("sort") || "updated";
414 const [owner] = await db.select().from(users).where(eq(users.username, username)).limit(1);
415 if (!owner) return c.json({ error: "User not found" }, 404);
416
417 const currentUser = c.get("user");
418 const orderBy = sort === "stars" ? desc(repositories.starCount) :
419 sort === "name" ? asc(repositories.name) :
420 desc(repositories.updatedAt);
421
422 let repoList = await db
423 .select()
424 .from(repositories)
425 .where(eq(repositories.ownerId, owner.id))
426 .orderBy(orderBy);
427
428 // Only show private repos to owner
429 if (!currentUser || currentUser.id !== owner.id) {
430 repoList = repoList.filter((r: any) => !r.isPrivate);
431 }
432
433 return c.json(repoList);
434});
435
436apiv2.post("/repos", requireApiAuth, requireScope("repo"), async (c) => {
437 const user = c.get("user")!;
438 const body = await c.req.json<{
439 name: string;
440 description?: string;
441 isPrivate?: boolean;
442 }>();
443
444 if (!body.name || !/^[a-zA-Z0-9._-]+$/.test(body.name)) {
445 return c.json({ error: "Invalid repository name" }, 400);
446 }
447
c63b860Claude448 // P4 — plan-quota gate. 402 Payment Required is the canonical HTTP
449 // signal that the client should branch on (e.g. show an upgrade CTA).
450 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
451 const gate = await checkRepoCreateAllowed(user.id);
452 if (!gate.ok) {
453 return c.json({ error: gate.reason, upgrade_url: gate.upgradeUrl }, 402);
454 }
455
45e31d0Claude456 if (await repoExists(user.username, body.name)) {
457 return c.json({ error: "Repository already exists" }, 409);
458 }
459
460 const diskPath = await initBareRepo(user.username, body.name);
461 const result = await db
462 .insert(repositories)
463 .values({
464 name: body.name,
465 ownerId: user.id,
466 description: body.description || null,
467 isPrivate: body.isPrivate || false,
468 diskPath,
469 })
470 .returning();
471
472 return c.json(result[0], 201);
473});
474
475apiv2.get("/repos/:owner/:repo", async (c) => {
476 const { owner, repo } = c.req.param();
477 const resolved = await resolveRepo(owner, repo);
478 if (!resolved) return c.json({ error: "Not found" }, 404);
479
480 const currentUser = c.get("user");
481 if ((resolved.repo as any).isPrivate && (!currentUser || currentUser.id !== resolved.owner.id)) {
482 return c.json({ error: "Not found" }, 404);
483 }
484
052c2e6Claude485 // Cache-free fresh read of HEAD's ref — needed by GateTest.
486 const defaultBranch = await getDefaultBranchFresh(owner, repo);
487
488 return c.json({
489 ...(resolved.repo as any),
490 defaultBranch,
491 owner: {
492 id: (resolved.owner as any).id,
493 login: (resolved.owner as any).username,
494 },
495 });
45e31d0Claude496});
497
498apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
499 const { owner, repo } = c.req.param();
500 const resolved = await resolveRepo(owner, repo);
501 if (!resolved) return c.json({ error: "Not found" }, 404);
502
503 const user = c.get("user")!;
504 if (user.id !== resolved.owner.id) {
505 return c.json({ error: "Permission denied" }, 403);
506 }
507
508 const body = await c.req.json<{
509 description?: string;
510 isPrivate?: boolean;
511 defaultBranch?: string;
512 }>();
513
514 const updates: Record<string, any> = { updatedAt: new Date() };
515 if (body.description !== undefined) updates.description = body.description;
516 if (body.isPrivate !== undefined) updates.isPrivate = body.isPrivate;
517 if (body.defaultBranch !== undefined) updates.defaultBranch = body.defaultBranch;
518
519 await db.update(repositories).set(updates).where(eq(repositories.id, (resolved.repo as any).id));
520 return c.json({ ok: true });
521});
522
523apiv2.delete("/repos/:owner/:repo", requireApiAuth, requireScope("admin"), async (c) => {
524 const { owner, repo } = c.req.param();
525 const resolved = await resolveRepo(owner, repo);
526 if (!resolved) return c.json({ error: "Not found" }, 404);
527
528 const user = c.get("user")!;
529 if (user.id !== resolved.owner.id) {
530 return c.json({ error: "Permission denied" }, 403);
531 }
532
533 await db.delete(repositories).where(eq(repositories.id, (resolved.repo as any).id));
534 return c.json({ ok: true });
535});
536
537// ─── Branches ───────────────────────────────────────────────────────────────
538
539apiv2.get("/repos/:owner/:repo/branches", async (c) => {
540 const { owner, repo } = c.req.param();
541 if (!(await repoExists(owner, repo))) {
542 return c.json({ error: "Not found" }, 404);
543 }
544
545 const branches = await listBranches(owner, repo);
546 const defaultBranch = await getDefaultBranch(owner, repo);
547
548 return c.json(
549 branches.map((name) => ({
550 name,
551 isDefault: name === defaultBranch,
552 }))
553 );
554});
555
556// ─── Commits ────────────────────────────────────────────────────────────────
557
558apiv2.get("/repos/:owner/:repo/commits", async (c) => {
559 const { owner, repo } = c.req.param();
560 const ref = c.req.query("ref") || c.req.query("sha") || "HEAD";
561 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
562 const offset = parseInt(c.req.query("offset") || "0");
563
564 if (!(await repoExists(owner, repo))) {
565 return c.json({ error: "Not found" }, 404);
566 }
567
568 const commits = await listCommits(owner, repo, ref, limit, offset);
569 return c.json(commits);
570});
571
572apiv2.get("/repos/:owner/:repo/commits/:sha", async (c) => {
573 const { owner, repo, sha } = c.req.param();
574 if (!(await repoExists(owner, repo))) {
575 return c.json({ error: "Not found" }, 404);
576 }
577
578 const commit = await getCommit(owner, repo, sha);
579 if (!commit) return c.json({ error: "Commit not found" }, 404);
580
581 const { files } = await getDiff(owner, repo, sha);
582 return c.json({ ...commit, files });
583});
584
585// ─── Tree & Files ───────────────────────────────────────────────────────────
586
587apiv2.get("/repos/:owner/:repo/tree/:ref", async (c) => {
588 const { owner, repo } = c.req.param();
589 const ref = c.req.param("ref");
590 const path = c.req.query("path") || "";
052c2e6Claude591 const recursive = c.req.query("recursive");
45e31d0Claude592
593 if (!(await repoExists(owner, repo))) {
594 return c.json({ error: "Not found" }, 404);
595 }
596
052c2e6Claude597 if (recursive === "1" || recursive === "true") {
598 const result = await getTreeRecursive(owner, repo, ref, 50_000);
599 if (!result) return c.json({ error: "Ref not found" }, 404);
600 return c.json(result);
601 }
602
45e31d0Claude603 const tree = await getTree(owner, repo, ref, path);
604 return c.json(tree);
605});
606
052c2e6Claude607const CONTENTS_MAX_BYTES = 10 * 1024 * 1024;
608
45e31d0Claude609apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
610 const { owner, repo } = c.req.param();
611 const filePath = c.req.param("path");
612 const ref = c.req.query("ref") || "HEAD";
052c2e6Claude613 const encoding = c.req.query("encoding") || "utf8";
45e31d0Claude614
615 if (!(await repoExists(owner, repo))) {
616 return c.json({ error: "Not found" }, 404);
617 }
618
052c2e6Claude619 if (encoding === "base64") {
620 const got = await catBlobBytes(owner, repo, ref, filePath);
621 if (!got) return c.json({ error: "File not found" }, 404);
622 if (got.size > CONTENTS_MAX_BYTES) {
623 return c.json(
624 { error: `File too large (${got.size} bytes, max ${CONTENTS_MAX_BYTES})` },
625 413
626 );
627 }
628 const content = Buffer.from(got.bytes).toString("base64");
629 return c.json({
630 path: filePath,
631 size: got.size,
632 sha: got.sha,
633 encoding: "base64",
634 content,
635 });
636 }
637
45e31d0Claude638 const blob = await getBlob(owner, repo, ref, filePath);
639 if (!blob) return c.json({ error: "File not found" }, 404);
052c2e6Claude640 if (blob.size > CONTENTS_MAX_BYTES) {
641 return c.json(
642 { error: `File too large (${blob.size} bytes, max ${CONTENTS_MAX_BYTES})` },
643 413
644 );
645 }
45e31d0Claude646
647 return c.json({
648 path: filePath,
649 size: blob.size,
650 isBinary: blob.isBinary,
651 content: blob.isBinary ? null : blob.content,
052c2e6Claude652 encoding: blob.isBinary ? null : "utf8",
45e31d0Claude653 });
654});
655
a686079Claude656// ─── Semantic search ────────────────────────────────────────────────────────
657//
658// Continuous semantic index — see src/lib/semantic-index.ts. Every push
659// embeds the changed files into pgvector. This endpoint ranks them by
660// cosine similarity to the query.
661//
662// Public repos: anonymous read is allowed.
663// Private repos: requireApiAuth + requireScope("repo") (plus the same
664// owner-only check the rest of /repos/:owner/:repo enforces).
665//
666// Returns `[]` (not 5xx) when pgvector is missing or the index is empty,
667// so callers can treat absence as "no hits" rather than a hard error.
668
669apiv2.get("/repos/:owner/:repo/semantic-search", async (c) => {
670 const { owner, repo } = c.req.param();
671 const q = (c.req.query("q") || "").trim();
672 const limit = Math.max(1, Math.min(parseInt(c.req.query("limit") || "20"), 100));
673
674 const resolved = await resolveRepo(owner, repo);
675 if (!resolved) return c.json({ error: "Not found" }, 404);
676
677 // Private-repo gate: requireApiAuth + requireScope("repo") + owner match.
678 // We can't compose Hono middleware conditionally per-request, so we
679 // inline the same checks the apiv2 middleware would have applied.
680 if ((resolved.repo as any).isPrivate) {
681 const user = c.get("user");
682 if (!user) {
683 return c.json(
684 { error: "Authentication required", hint: "Use Authorization: Bearer <token> header" },
685 401
686 );
687 }
688 const scopes = (c.get("tokenScopes") as string[] | undefined) || [];
689 // tokenScopes is [] for unauthenticated; ["repo","user","admin"] for
690 // session-cookie auth (web UI); whatever scopes the PAT carries
691 // otherwise. The "admin" wildcard skips the scope check.
692 if (
693 scopes.length > 0 &&
694 !scopes.includes("repo") &&
695 !scopes.includes("admin")
696 ) {
697 return c.json({ error: "Insufficient scope. Required: repo" }, 403);
698 }
699 if (user.id !== resolved.owner.id) {
700 return c.json({ error: "Not found" }, 404);
701 }
702 }
703
704 if (!q) return c.json([]);
705
706 const { searchSemantic, semanticIndexProvider } = await import(
707 "../lib/semantic-index"
708 );
709 const hits = await searchSemantic({
710 repositoryId: (resolved.repo as any).id,
711 query: q,
712 limit,
713 });
714
715 // Shape matches the task spec: { file_path, snippet, score, blob_sha }.
716 const payload = hits.map((h) => ({
717 file_path: h.filePath,
718 snippet: h.snippet,
719 score: h.score,
720 blob_sha: h.blobSha,
721 }));
722
723 // Surface the provider so clients can detect graceful-degrade mode.
724 c.header("X-Gluecron-Semantic-Provider", semanticIndexProvider());
725 return c.json(payload);
726});
727
38d31d3Claude728// ─── Repo chat — streaming SSE ──────────────────────────────────────────────
729//
730// POST /api/v2/repos/:owner/:repo/chat/messages
731//
732// Body (application/json): { chat_id?: string, message: string }
733//
734// Behaviour:
735// - If no `chat_id`, creates a new chat row scoped to the caller.
736// - Streams assistant text deltas via SSE (`event: token`, data is the
737// raw delta). When the stream closes, a final `event: done` carries
738// `{ chat_id, message_id, citations, token_cost }`.
739// - On any internal failure we emit `event: error` and end the stream
740// cleanly so the client can render an inline message.
741//
742// Auth: requireApiAuth + requireScope("repo") so PATs need the right
743// scope, and session-cookie auth (web UI) works out of the box.
744
745apiv2.post(
746 "/repos/:owner/:repo/chat/messages",
747 requireApiAuth,
748 requireScope("repo"),
749 async (c) => {
750 const { owner, repo } = c.req.param();
751 const user = c.get("user")!;
752 const resolved = await resolveRepo(owner, repo);
753 if (!resolved) return c.json({ error: "Not found" }, 404);
754
755 // Private-repo: only the owner (or admin scope) may chat.
756 if ((resolved.repo as any).isPrivate && user.id !== resolved.owner.id) {
757 const scopes = (c.get("tokenScopes") as string[] | undefined) || [];
758 if (!scopes.includes("admin")) {
759 return c.json({ error: "Not found" }, 404);
760 }
761 }
762
763 let body: { chat_id?: string; message?: string };
764 try {
765 body = (await c.req.json()) as { chat_id?: string; message?: string };
766 } catch {
767 return c.json({ error: "Invalid JSON body" }, 400);
768 }
769
770 const userMessage = String(body?.message || "").trim();
771 if (!userMessage) {
772 return c.json({ error: "message is required" }, 400);
773 }
774
775 const {
776 appendUserMessage,
777 createChat,
778 getChatForUser,
779 streamAssistantReply,
780 } = await import("../lib/repo-chat");
781
782 let chatId = String(body?.chat_id || "").trim();
783 const repoId = (resolved.repo as any).id as string;
784
785 if (!chatId) {
786 const created = await createChat({
787 repositoryId: repoId,
788 ownerUserId: user.id,
789 title: userMessage.slice(0, 80),
790 });
791 if (!created) {
792 return c.json({ error: "Failed to create chat" }, 500);
793 }
794 chatId = created.id;
795 } else {
796 const existing = await getChatForUser(chatId, user.id);
797 if (!existing || existing.repositoryId !== repoId) {
798 return c.json({ error: "Not found" }, 404);
799 }
800 }
801
802 await appendUserMessage(chatId, userMessage);
803
804 const encoder = new TextEncoder();
805 const stream = new ReadableStream<Uint8Array>({
806 async start(controller) {
807 let closed = false;
808 const safeEnqueue = (chunk: string) => {
809 if (closed) return;
810 try {
811 controller.enqueue(encoder.encode(chunk));
812 } catch {
813 closed = true;
814 }
815 };
816 const sseEvent = (event: string, data: string) => {
817 let payload = `event: ${event}\n`;
818 for (const line of data.split("\n")) {
819 payload += `data: ${line}\n`;
820 }
821 payload += "\n";
822 safeEnqueue(payload);
823 };
824
825 // Initial comment flushes proxy headers.
826 safeEnqueue(": open\n\n");
827
828 try {
829 const stored = await streamAssistantReply({
830 chatId,
831 repoId,
832 userMessage,
833 onChunk: (chunk) => {
834 sseEvent("token", chunk);
835 },
836 });
837
838 sseEvent(
839 "done",
840 JSON.stringify({
841 chat_id: chatId,
842 message_id: stored?.id || null,
843 citations: stored?.citations ?? [],
844 token_cost: stored?.tokenCost ?? 0,
845 })
846 );
847 } catch (err) {
848 const msg = err instanceof Error ? err.message : "unknown error";
849 sseEvent("error", JSON.stringify({ error: msg }));
850 } finally {
851 closed = true;
852 try {
853 controller.close();
854 } catch {
855 /* already closed */
856 }
857 }
858 },
859 });
860
861 return new Response(stream, {
862 status: 200,
863 headers: {
864 "Content-Type": "text/event-stream; charset=utf-8",
865 "Cache-Control": "no-cache, no-transform",
866 Connection: "keep-alive",
867 "X-Accel-Buffering": "no",
868 },
869 });
870 }
871);
872
45e31d0Claude873// ─── Issues ─────────────────────────────────────────────────────────────────
874
875apiv2.get("/repos/:owner/:repo/issues", async (c) => {
876 const { owner, repo } = c.req.param();
877 const state = c.req.query("state") || "open";
878 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
879
880 const resolved = await resolveRepo(owner, repo);
881 if (!resolved) return c.json({ error: "Not found" }, 404);
882
883 const issueList = await db
884 .select({
885 issue: issues,
886 author: { username: users.username, id: users.id },
887 })
888 .from(issues)
889 .innerJoin(users, eq(issues.authorId, users.id))
890 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
891 .orderBy(desc(issues.createdAt))
892 .limit(limit);
893
894 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
895});
896
897apiv2.post("/repos/:owner/:repo/issues", requireApiAuth, requireScope("repo"), async (c) => {
898 const { owner, repo } = c.req.param();
899 const user = c.get("user")!;
900 const body = await c.req.json<{ title: string; body?: string }>();
901
902 if (!body.title?.trim()) {
903 return c.json({ error: "Title is required" }, 400);
904 }
905
906 const resolved = await resolveRepo(owner, repo);
907 if (!resolved) return c.json({ error: "Not found" }, 404);
908
909 const result = await db
910 .insert(issues)
911 .values({
912 repositoryId: (resolved.repo as any).id,
913 authorId: user.id,
914 title: body.title.trim(),
915 body: body.body?.trim() || null,
916 })
917 .returning();
918
919 return c.json(result[0], 201);
920});
921
922apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
923 const { owner, repo } = c.req.param();
924 const num = parseInt(c.req.param("number"), 10);
925
926 const resolved = await resolveRepo(owner, repo);
927 if (!resolved) return c.json({ error: "Not found" }, 404);
928
929 const [issue] = await db
930 .select()
931 .from(issues)
932 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
933 .limit(1);
934
935 if (!issue) return c.json({ error: "Issue not found" }, 404);
936
937 const comments = await db
938 .select({
939 comment: issueComments,
940 author: { username: users.username },
941 })
942 .from(issueComments)
943 .innerJoin(users, eq(issueComments.authorId, users.id))
944 .where(eq(issueComments.issueId, issue.id))
945 .orderBy(asc(issueComments.createdAt));
946
947 return c.json({
948 ...issue,
949 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
950 });
951});
952
953apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
954 const { owner, repo } = c.req.param();
955 const num = parseInt(c.req.param("number"), 10);
956 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
957
958 const resolved = await resolveRepo(owner, repo);
959 if (!resolved) return c.json({ error: "Not found" }, 404);
960
961 const updates: Record<string, any> = { updatedAt: new Date() };
962 if (body.title !== undefined) updates.title = body.title;
963 if (body.body !== undefined) updates.body = body.body;
964 if (body.state === "closed") {
965 updates.state = "closed";
966 updates.closedAt = new Date();
967 } else if (body.state === "open") {
968 updates.state = "open";
969 updates.closedAt = null;
970 }
971
972 await db
973 .update(issues)
974 .set(updates)
975 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
976
977 return c.json({ ok: true });
978});
979
980apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
981 const { owner, repo } = c.req.param();
982 const num = parseInt(c.req.param("number"), 10);
983 const user = c.get("user")!;
984 const body = await c.req.json<{ body: string }>();
985
986 if (!body.body?.trim()) {
987 return c.json({ error: "Comment body is required" }, 400);
988 }
989
990 const resolved = await resolveRepo(owner, repo);
991 if (!resolved) return c.json({ error: "Not found" }, 404);
992
993 const [issue] = await db
994 .select()
995 .from(issues)
996 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
997 .limit(1);
998
999 if (!issue) return c.json({ error: "Issue not found" }, 404);
1000
1001 const result = await db
1002 .insert(issueComments)
1003 .values({
1004 issueId: issue.id,
1005 authorId: user.id,
1006 body: body.body.trim(),
1007 })
1008 .returning();
1009
1010 return c.json(result[0], 201);
1011});
1012
1013// ─── Pull Requests ──────────────────────────────────────────────────────────
1014
1015apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
1016 const { owner, repo } = c.req.param();
1017 const state = c.req.query("state") || "open";
8c09fb9Claude1018 // Match the issue-list pagination contract: default 30, max 100,
1019 // 0-indexed offset for cursor-style scrolling. Bounded so a buggy
1020 // client can't accidentally pull the whole table.
1021 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit")) || 30));
1022 const offset = Math.max(0, Number(c.req.query("offset")) || 0);
45e31d0Claude1023
1024 const resolved = await resolveRepo(owner, repo);
1025 if (!resolved) return c.json({ error: "Not found" }, 404);
1026
1027 const prList = await db
1028 .select({
1029 pr: pullRequests,
1030 author: { username: users.username },
1031 })
1032 .from(pullRequests)
1033 .innerJoin(users, eq(pullRequests.authorId, users.id))
1034 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
8c09fb9Claude1035 .orderBy(desc(pullRequests.createdAt))
1036 .limit(limit)
1037 .offset(offset);
45e31d0Claude1038
1039 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
1040});
1041
1042apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
1043 const { owner, repo } = c.req.param();
1044 const user = c.get("user")!;
1045 const body = await c.req.json<{
1046 title: string;
1047 body?: string;
1048 baseBranch: string;
1049 headBranch: string;
1050 }>();
1051
1052 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
1053 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
1054 }
1055
1056 const resolved = await resolveRepo(owner, repo);
1057 if (!resolved) return c.json({ error: "Not found" }, 404);
1058
1059 const result = await db
1060 .insert(pullRequests)
1061 .values({
1062 repositoryId: (resolved.repo as any).id,
1063 authorId: user.id,
1064 title: body.title.trim(),
1065 body: body.body?.trim() || null,
1066 baseBranch: body.baseBranch,
1067 headBranch: body.headBranch,
1068 })
1069 .returning();
1070
1071 return c.json(result[0], 201);
1072});
1073
1074apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
1075 const { owner, repo } = c.req.param();
1076 const num = parseInt(c.req.param("number"), 10);
1077
1078 const resolved = await resolveRepo(owner, repo);
1079 if (!resolved) return c.json({ error: "Not found" }, 404);
1080
1081 const [pr] = await db
1082 .select()
1083 .from(pullRequests)
1084 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
1085 .limit(1);
1086
1087 if (!pr) return c.json({ error: "PR not found" }, 404);
1088
1089 const comments = await db
1090 .select({
1091 comment: prComments,
1092 author: { username: users.username },
1093 })
1094 .from(prComments)
1095 .innerJoin(users, eq(prComments.authorId, users.id))
1096 .where(eq(prComments.pullRequestId, pr.id))
1097 .orderBy(asc(prComments.createdAt));
1098
1099 return c.json({
1100 ...pr,
1101 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
1102 });
1103});
1104
052c2e6Claude1105// ─── PR Comments (GateTest integration) ────────────────────────────────────
1106
1107apiv2.post(
1108 "/repos/:owner/:repo/pulls/:number/comments",
1109 requireApiAuth,
1110 requireScope("repo"),
1111 async (c) => {
1112 const { owner, repo } = c.req.param();
1113 const num = parseInt(c.req.param("number"), 10);
1114 const user = c.get("user")!;
1115
1116 let body: { body?: string } = {};
1117 try {
1118 body = await c.req.json();
1119 } catch {
1120 body = {};
1121 }
1122 if (!body.body?.trim()) {
1123 return c.json({ error: "Comment body is required" }, 400);
1124 }
1125
1126 const resolved = await resolveRepo(owner, repo);
1127 if (!resolved) return c.json({ error: "Not found" }, 404);
1128 if (user.id !== (resolved.owner as any).id) {
1129 return c.json({ error: "Forbidden" }, 403);
1130 }
1131
1132 const [pr] = await db
1133 .select()
1134 .from(pullRequests)
1135 .where(
1136 and(
1137 eq(pullRequests.repositoryId, (resolved.repo as any).id),
1138 eq(pullRequests.number, num)
1139 )
1140 )
1141 .limit(1);
1142 if (!pr) return c.json({ error: "PR not found" }, 404);
1143
1144 const [comment] = await db
1145 .insert(prComments)
1146 .values({
1147 pullRequestId: pr.id,
1148 authorId: user.id,
1149 body: body.body.trim(),
1150 })
1151 .returning();
1152
1153 return c.json({ ok: true, comment }, 201);
1154 }
1155);
1156
1157// ─── Git refs — create branch / tag from sha ────────────────────────────────
1158
1159apiv2.post(
1160 "/repos/:owner/:repo/git/refs",
1161 requireApiAuth,
1162 requireScope("repo"),
1163 async (c) => {
1164 const { owner, repo } = c.req.param();
1165 const user = c.get("user")!;
1166
1167 let body: { ref?: string; sha?: string } = {};
1168 try {
1169 body = await c.req.json();
1170 } catch {
1171 body = {};
1172 }
1173 const ref = body.ref?.trim();
1174 const sha = body.sha?.trim();
1175
1176 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
1177 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
1178 }
1179 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
1180 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
1181 }
1182
1183 const resolved = await resolveRepo(owner, repo);
1184 if (!resolved) return c.json({ error: "Not found" }, 404);
1185 if (user.id !== (resolved.owner as any).id) {
1186 return c.json({ error: "Forbidden" }, 403);
1187 }
1188
1189 // Verify sha reachable.
1190 if (!(await objectExists(owner, repo, sha))) {
1191 return c.json({ error: "sha not found in repository" }, 400);
1192 }
1193
1194 // Conflict check: if ref already exists, the existing sha must match.
1195 if (await refExists(owner, repo, ref)) {
1196 const existing = await resolveRef(owner, repo, ref);
1197 if (existing !== sha) {
1198 return c.json({ error: "ref already exists", existing }, 409);
1199 }
1200 }
1201
1202 const ok = await updateRef(owner, repo, ref, sha);
1203 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
1204
1205 return c.json({ ok: true, ref, sha }, 201);
1206 }
1207);
1208
1209// ─── Contents PUT — create/update a file via git plumbing ────────────────────
1210
1211apiv2.put(
1212 "/repos/:owner/:repo/contents/:path{.+$}",
1213 requireApiAuth,
1214 requireScope("repo"),
1215 async (c) => {
1216 const { owner, repo } = c.req.param();
1217 const filePath = c.req.param("path");
1218 const user = c.get("user")!;
1219
1220 let body: {
1221 message?: string;
1222 content?: string;
1223 branch?: string;
1224 sha?: string | null;
1225 } = {};
1226 try {
1227 body = await c.req.json();
1228 } catch {
1229 body = {};
1230 }
1231
1232 const message = body.message?.trim();
1233 const branch = body.branch?.trim();
1234 const base64 = body.content;
1235
1236 if (!message) return c.json({ error: "message is required" }, 400);
1237 if (!branch) return c.json({ error: "branch is required" }, 400);
1238 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
1239
1240 let bytes: Uint8Array;
1241 try {
1242 bytes = new Uint8Array(Buffer.from(base64, "base64"));
1243 } catch {
1244 return c.json({ error: "content is not valid base64" }, 400);
1245 }
1246 if (bytes.length > CONTENTS_MAX_BYTES) {
1247 return c.json({ error: "File too large" }, 413);
1248 }
1249
1250 const resolved = await resolveRepo(owner, repo);
1251 if (!resolved) return c.json({ error: "Not found" }, 404);
1252 if (user.id !== (resolved.owner as any).id) {
1253 return c.json({ error: "Forbidden" }, 403);
1254 }
1255
1256 const result = await createOrUpdateFileOnBranch({
1257 owner,
1258 name: repo,
1259 branch,
1260 filePath,
1261 bytes,
1262 message,
1263 authorName: (user as any).displayName || user.username,
1264 authorEmail: user.email,
1265 expectBlobSha: body.sha ?? null,
1266 });
1267
1268 if ("error" in result) {
1269 if (result.error === "sha-mismatch") {
1270 return c.json({ error: "sha does not match current blob at path" }, 409);
1271 }
1272 return c.json({ error: "Failed to write file" }, 500);
1273 }
1274
1275 return c.json(
1276 {
1277 ok: true,
1278 commit: { sha: result.commitSha, message },
1279 content: { path: filePath, sha: result.blobSha },
1280 },
1281 201
1282 );
1283 }
1284);
1285
da50b83Claude1286// ─── Helper: shell out to git in a bare repo ─────────────────────────────────
1287//
1288// Mirrors the pattern in src/git/repository.ts. Kept inline here so the
1289// plumbing endpoints below don't have to leak through the helper module.
1290
1291async function runGit(
1292 cmd: string[],
1293 opts: { cwd: string; env?: Record<string, string>; stdin?: Uint8Array }
1294): Promise<{ stdout: string; stderr: string; exitCode: number }> {
1295 const proc = Bun.spawn(cmd, {
1296 cwd: opts.cwd,
1297 env: { ...process.env, ...opts.env },
1298 stdin: opts.stdin ? "pipe" : "ignore",
1299 stdout: "pipe",
1300 stderr: "pipe",
1301 });
1302 if (opts.stdin && proc.stdin) {
1303 proc.stdin.write(opts.stdin);
1304 proc.stdin.end();
1305 }
1306 const [stdout, stderr] = await Promise.all([
1307 new Response(proc.stdout).text(),
1308 new Response(proc.stderr).text(),
1309 ]);
1310 const exitCode = await proc.exited;
1311 return { stdout, stderr, exitCode };
1312}
1313
1314function htmlUrlForCommit(owner: string, repo: string, sha: string): string {
1315 return `${config.appBaseUrl}/${owner}/${repo}/commit/${sha}`;
1316}
1317
1318// ─── Contents DELETE — remove a file via git plumbing ────────────────────────
1319//
1320// Body: { message, sha, branch? }
1321// - `sha` is the current blob sha at `:path`; mismatch → 409 (optimistic
1322// concurrency, matches GitHub's `DELETE /repos/.../contents/...` semantics).
1323// - `branch` defaults to the repo's default branch.
1324
1325apiv2.delete(
1326 "/repos/:owner/:repo/contents/:path{.+$}",
1327 requireApiAuth,
1328 requireScope("repo"),
1329 async (c) => {
1330 const { owner, repo } = c.req.param();
1331 const filePath = c.req.param("path");
1332 const user = c.get("user")!;
1333
1334 let body: { message?: string; sha?: string; branch?: string } = {};
1335 try {
1336 body = await c.req.json();
1337 } catch {
1338 body = {};
1339 }
1340
1341 const message = body.message?.trim();
1342 const expectSha = body.sha?.trim();
1343 if (!message) return c.json({ error: "message is required" }, 400);
1344 if (!expectSha || !/^[0-9a-f]{40}$/.test(expectSha)) {
1345 return c.json({ error: "sha is required (40-hex)" }, 400);
1346 }
1347
1348 const resolved = await resolveRepo(owner, repo);
1349 if (!resolved) return c.json({ error: "Not found" }, 404);
1350 if (user.id !== (resolved.owner as any).id) {
1351 return c.json({ error: "Forbidden" }, 403);
1352 }
1353
1354 const branch =
1355 body.branch?.trim() ||
1356 (await getDefaultBranchFresh(owner, repo)) ||
1357 "main";
1358 const fullRef = `refs/heads/${branch}`;
1359 const repoDir = getRepoPath(owner, repo);
1360
1361 // Resolve current parent + existing blob sha at that path.
1362 const parentSha = await resolveRef(owner, repo, fullRef);
1363 if (!parentSha) return c.json({ error: "Branch not found" }, 404);
1364
1365 const existingBlobSha = await getBlobShaAtPath(owner, repo, branch, filePath);
1366 if (!existingBlobSha) return c.json({ error: "File not found" }, 404);
1367 if (existingBlobSha !== expectSha) {
1368 return c.json({ error: "sha does not match current blob at path" }, 409);
1369 }
1370
1371 const tmpIndex = join(
1372 repoDir,
1373 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1374 );
296ee49Claude1375 // `update-index --remove` checks `is_inside_work_tree()`, so a bare
1376 // repo needs a transient stand-in. Empty directory is sufficient —
1377 // git only consults it for safety checks, never writes blobs through it.
1378 const tmpWorkTree = join(
1379 repoDir,
1380 `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1381 );
1382 const { mkdir } = await import("fs/promises");
1383 await mkdir(tmpWorkTree, { recursive: true });
1384
da50b83Claude1385 const authorName = (user as any).displayName || user.username;
1386 const authorEmail = user.email;
1387 const env = {
1388 GIT_INDEX_FILE: tmpIndex,
296ee49Claude1389 GIT_DIR: repoDir,
1390 GIT_WORK_TREE: tmpWorkTree,
da50b83Claude1391 GIT_AUTHOR_NAME: authorName,
1392 GIT_AUTHOR_EMAIL: authorEmail,
1393 GIT_COMMITTER_NAME: authorName,
1394 GIT_COMMITTER_EMAIL: authorEmail,
1395 };
1396
1397 const cleanup = async () => {
1398 try {
296ee49Claude1399 const { unlink, rm } = await import("fs/promises");
1400 await unlink(tmpIndex).catch(() => {});
1401 await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {});
da50b83Claude1402 } catch {
1403 /* ignore */
1404 }
1405 };
1406
1407 try {
1408 const rt = await runGit(["git", "read-tree", parentSha], {
1409 cwd: repoDir,
1410 env,
1411 });
1412 if (rt.exitCode !== 0) {
1413 await cleanup();
1414 return c.json({ error: "Failed to read base tree" }, 500);
1415 }
1416
1417 const ui = await runGit(
1418 ["git", "update-index", "--remove", filePath],
1419 { cwd: repoDir, env }
1420 );
1421 if (ui.exitCode !== 0) {
1422 await cleanup();
1423 return c.json({ error: "Failed to remove path from index" }, 500);
1424 }
1425
1426 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1427 const newTreeSha = wt.stdout.trim();
1428 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(newTreeSha)) {
1429 await cleanup();
1430 return c.json({ error: "Failed to write tree" }, 500);
1431 }
1432
1433 const ct = await runGit(
1434 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1435 { cwd: repoDir, env }
1436 );
1437 const commitSha = ct.stdout.trim();
1438 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1439 await cleanup();
1440 return c.json({ error: "Failed to create commit" }, 500);
1441 }
1442
1443 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha);
1444 if (!ok) {
1445 await cleanup();
1446 return c.json({ error: "Failed to update ref" }, 500);
1447 }
1448
1449 await cleanup();
1450 return c.json({
1451 commit: {
1452 sha: commitSha,
1453 message,
1454 html_url: htmlUrlForCommit(owner, repo, commitSha),
1455 author: { name: authorName, email: authorEmail },
1456 },
1457 });
1458 } catch {
1459 await cleanup();
1460 return c.json({ error: "Failed to delete file" }, 500);
1461 }
1462 }
1463);
1464
1465// ─── Git plumbing: refs / commits / blobs / trees ────────────────────────────
1466
1467// GET /repos/:owner/:repo/git/refs/heads/:branch
1468apiv2.get(
1469 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1470 async (c) => {
1471 const { owner, repo } = c.req.param();
1472 const branch = c.req.param("branch");
1473 if (!(await repoExists(owner, repo))) {
1474 return c.json({ error: "Not found" }, 404);
1475 }
1476 const fullRef = `refs/heads/${branch}`;
1477 const sha = await resolveRef(owner, repo, fullRef);
1478 if (!sha) return c.json({ error: "Reference not found" }, 404);
1479 return c.json({
1480 ref: fullRef,
1481 object: { sha, type: "commit" },
1482 });
1483 }
1484);
1485
1486// GET /repos/:owner/:repo/git/commits/:sha
1487apiv2.get("/repos/:owner/:repo/git/commits/:sha", async (c) => {
1488 const { owner, repo, sha } = c.req.param();
1489 if (!(await repoExists(owner, repo))) {
1490 return c.json({ error: "Not found" }, 404);
1491 }
1492 const commit = await getCommit(owner, repo, sha);
1493 if (!commit) return c.json({ error: "Commit not found" }, 404);
1494
1495 // Resolve tree sha for the commit (cat-file <sha>^{tree}).
1496 const repoDir = getRepoPath(owner, repo);
1497 const { stdout: treeOut } = await runGit(
1498 ["git", "rev-parse", `${commit.sha}^{tree}`],
1499 { cwd: repoDir }
1500 );
1501 const treeSha = treeOut.trim();
1502
1503 return c.json({
1504 sha: commit.sha,
1505 tree: { sha: treeSha },
1506 parents: commit.parentShas.map((p) => ({ sha: p })),
1507 message: commit.message,
1508 author: {
1509 name: commit.author,
1510 email: commit.authorEmail,
1511 date: commit.date,
1512 },
1513 });
1514});
1515
1516// POST /repos/:owner/:repo/git/blobs
1517apiv2.post(
1518 "/repos/:owner/:repo/git/blobs",
1519 requireApiAuth,
1520 requireScope("repo"),
1521 async (c) => {
1522 const { owner, repo } = c.req.param();
1523 const user = c.get("user")!;
1524
1525 let body: { content?: string; encoding?: string } = {};
1526 try {
1527 body = await c.req.json();
1528 } catch {
1529 body = {};
1530 }
1531 const content = body.content;
1532 const encoding = (body.encoding || "utf-8").toLowerCase();
1533 if (typeof content !== "string") {
1534 return c.json({ error: "content is required" }, 400);
1535 }
1536 if (encoding !== "utf-8" && encoding !== "utf8" && encoding !== "base64") {
1537 return c.json({ error: "encoding must be 'utf-8' or 'base64'" }, 400);
1538 }
1539
1540 const resolved = await resolveRepo(owner, repo);
1541 if (!resolved) return c.json({ error: "Not found" }, 404);
1542 if (user.id !== (resolved.owner as any).id) {
1543 return c.json({ error: "Forbidden" }, 403);
1544 }
1545
1546 let bytes: Uint8Array;
1547 try {
1548 if (encoding === "base64") {
1549 bytes = new Uint8Array(Buffer.from(content, "base64"));
1550 } else {
1551 bytes = new TextEncoder().encode(content);
1552 }
1553 } catch {
1554 return c.json({ error: "Failed to decode content" }, 400);
1555 }
1556
1557 const sha = await writeBlob(owner, repo, bytes);
1558 if (!sha) return c.json({ error: "Failed to write blob" }, 500);
1559
1560 return c.json(
1561 {
1562 sha,
1563 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/blobs/${sha}`,
1564 size: bytes.length,
1565 },
1566 201
1567 );
1568 }
1569);
1570
1571// POST /repos/:owner/:repo/git/trees
1572//
1573// Body: { base_tree?: <tree_sha>, tree: [{ path, mode, type: "blob", sha: <blob_sha> | null }] }
1574// `sha: null` removes the entry from base_tree.
1575apiv2.post(
1576 "/repos/:owner/:repo/git/trees",
1577 requireApiAuth,
1578 requireScope("repo"),
1579 async (c) => {
1580 const { owner, repo } = c.req.param();
1581 const user = c.get("user")!;
1582
1583 let body: {
1584 base_tree?: string;
1585 tree?: Array<{
1586 path?: string;
1587 mode?: string;
1588 type?: string;
1589 sha?: string | null;
1590 }>;
1591 } = {};
1592 try {
1593 body = await c.req.json();
1594 } catch {
1595 body = {};
1596 }
1597
1598 if (!Array.isArray(body.tree)) {
1599 return c.json({ error: "tree array is required" }, 400);
1600 }
1601 for (const entry of body.tree) {
1602 if (!entry.path || typeof entry.path !== "string") {
1603 return c.json({ error: "each tree entry needs a path" }, 400);
1604 }
1605 if (!entry.mode || typeof entry.mode !== "string") {
1606 return c.json({ error: "each tree entry needs a mode" }, 400);
1607 }
1608 if (entry.sha !== null && typeof entry.sha !== "string") {
1609 return c.json(
1610 { error: "each tree entry needs sha (40-hex) or null to delete" },
1611 400
1612 );
1613 }
1614 if (typeof entry.sha === "string" && !/^[0-9a-f]{40}$/.test(entry.sha)) {
1615 return c.json({ error: "sha must be 40-hex" }, 400);
1616 }
1617 }
1618 if (body.base_tree && !/^[0-9a-f]{40}$/.test(body.base_tree)) {
1619 return c.json({ error: "base_tree must be 40-hex" }, 400);
1620 }
1621
1622 const resolved = await resolveRepo(owner, repo);
1623 if (!resolved) return c.json({ error: "Not found" }, 404);
1624 if (user.id !== (resolved.owner as any).id) {
1625 return c.json({ error: "Forbidden" }, 403);
1626 }
1627
1628 const repoDir = getRepoPath(owner, repo);
1629 const tmpIndex = join(
1630 repoDir,
1631 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1632 );
1633 const env = { GIT_INDEX_FILE: tmpIndex };
1634
1635 const cleanup = async () => {
1636 try {
1637 const { unlink } = await import("fs/promises");
1638 await unlink(tmpIndex);
1639 } catch {
1640 /* ignore */
1641 }
1642 };
1643
1644 try {
1645 if (body.base_tree) {
1646 const rt = await runGit(["git", "read-tree", body.base_tree], {
1647 cwd: repoDir,
1648 env,
1649 });
1650 if (rt.exitCode !== 0) {
1651 await cleanup();
1652 return c.json({ error: "base_tree not found" }, 404);
1653 }
1654 }
1655
1656 for (const entry of body.tree) {
1657 if (entry.sha === null) {
1658 const r = await runGit(
1659 ["git", "update-index", "--remove", entry.path!],
1660 { cwd: repoDir, env }
1661 );
1662 if (r.exitCode !== 0) {
1663 await cleanup();
1664 return c.json(
1665 { error: `Failed to remove ${entry.path}` },
1666 422
1667 );
1668 }
1669 } else {
1670 const r = await runGit(
1671 [
1672 "git",
1673 "update-index",
1674 "--add",
1675 "--cacheinfo",
1676 `${entry.mode},${entry.sha},${entry.path}`,
1677 ],
1678 { cwd: repoDir, env }
1679 );
1680 if (r.exitCode !== 0) {
1681 await cleanup();
1682 return c.json(
1683 { error: `Failed to add ${entry.path}` },
1684 422
1685 );
1686 }
1687 }
1688 }
1689
1690 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1691 const treeSha = wt.stdout.trim();
1692 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(treeSha)) {
1693 await cleanup();
1694 return c.json({ error: "Failed to write tree" }, 500);
1695 }
1696
1697 // List entries in the new tree (one level deep, matching GitHub's
1698 // POST /git/trees response shape).
1699 const ls = await runGit(["git", "ls-tree", treeSha], { cwd: repoDir });
1700 const entries: Array<{
1701 path: string;
1702 mode: string;
1703 type: string;
1704 sha: string;
1705 }> = [];
1706 for (const line of ls.stdout.split("\n").filter(Boolean)) {
1707 const m = line.match(
1708 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\t(.+)$/
1709 );
1710 if (m) {
1711 entries.push({
1712 mode: m[1],
1713 type: m[2],
1714 sha: m[3],
1715 path: m[4],
1716 });
1717 }
1718 }
1719
1720 await cleanup();
1721 return c.json(
1722 {
1723 sha: treeSha,
1724 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/trees/${treeSha}`,
1725 tree: entries,
1726 },
1727 201
1728 );
1729 } catch {
1730 await cleanup();
1731 return c.json({ error: "Failed to write tree" }, 500);
1732 }
1733 }
1734);
1735
1736// POST /repos/:owner/:repo/git/commits
1737//
1738// Body: { message, tree, parents: [<sha>] }
1739apiv2.post(
1740 "/repos/:owner/:repo/git/commits",
1741 requireApiAuth,
1742 requireScope("repo"),
1743 async (c) => {
1744 const { owner, repo } = c.req.param();
1745 const user = c.get("user")!;
1746
1747 let body: { message?: string; tree?: string; parents?: string[] } = {};
1748 try {
1749 body = await c.req.json();
1750 } catch {
1751 body = {};
1752 }
1753
1754 const message = body.message?.trim();
1755 const tree = body.tree?.trim();
1756 const parents = Array.isArray(body.parents) ? body.parents : [];
1757
1758 if (!message) return c.json({ error: "message is required" }, 400);
1759 if (!tree || !/^[0-9a-f]{40}$/.test(tree)) {
1760 return c.json({ error: "tree must be 40-hex" }, 400);
1761 }
1762 for (const p of parents) {
1763 if (typeof p !== "string" || !/^[0-9a-f]{40}$/.test(p)) {
1764 return c.json({ error: "each parent must be 40-hex" }, 400);
1765 }
1766 }
1767
1768 const resolved = await resolveRepo(owner, repo);
1769 if (!resolved) return c.json({ error: "Not found" }, 404);
1770 if (user.id !== (resolved.owner as any).id) {
1771 return c.json({ error: "Forbidden" }, 403);
1772 }
1773
1774 // Verify tree object exists.
1775 if (!(await objectExists(owner, repo, tree))) {
1776 return c.json({ error: "tree not found in repository" }, 422);
1777 }
1778 for (const p of parents) {
1779 if (!(await objectExists(owner, repo, p))) {
1780 return c.json({ error: `parent ${p} not found in repository` }, 422);
1781 }
1782 }
1783
1784 const repoDir = getRepoPath(owner, repo);
1785 const authorName = (user as any).displayName || user.username;
1786 const authorEmail = user.email;
1787 const env = {
1788 GIT_AUTHOR_NAME: authorName,
1789 GIT_AUTHOR_EMAIL: authorEmail,
1790 GIT_COMMITTER_NAME: authorName,
1791 GIT_COMMITTER_EMAIL: authorEmail,
1792 };
1793
1794 const args = ["git", "commit-tree", tree];
1795 for (const p of parents) {
1796 args.push("-p", p);
1797 }
1798 args.push("-m", message);
1799
1800 const ct = await runGit(args, { cwd: repoDir, env });
1801 const commitSha = ct.stdout.trim();
1802 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1803 return c.json({ error: "Failed to create commit" }, 500);
1804 }
1805
1806 // Re-read the new commit's recorded date so the response carries the
1807 // exact ISO timestamp git wrote into the object.
1808 const recorded = await getCommit(owner, repo, commitSha);
1809 const date = recorded?.date ?? new Date().toISOString();
1810
1811 return c.json(
1812 {
1813 sha: commitSha,
1814 tree: { sha: tree },
1815 message,
1816 parents: parents.map((p) => ({ sha: p })),
1817 author: { name: authorName, email: authorEmail, date },
1818 html_url: htmlUrlForCommit(owner, repo, commitSha),
1819 },
1820 201
1821 );
1822 }
1823);
1824
1825// PATCH /repos/:owner/:repo/git/refs/heads/:branch
1826//
1827// Body: { sha: <new_commit>, force?: false }
1828apiv2.patch(
1829 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1830 requireApiAuth,
1831 requireScope("repo"),
1832 async (c) => {
1833 const { owner, repo } = c.req.param();
1834 const branch = c.req.param("branch");
1835 const user = c.get("user")!;
1836
1837 let body: { sha?: string; force?: boolean } = {};
1838 try {
1839 body = await c.req.json();
1840 } catch {
1841 body = {};
1842 }
1843
1844 const newSha = body.sha?.trim();
1845 const force = body.force === true;
1846 if (!newSha || !/^[0-9a-f]{40}$/.test(newSha)) {
1847 return c.json({ error: "sha must be 40-hex" }, 400);
1848 }
1849
1850 const resolved = await resolveRepo(owner, repo);
1851 if (!resolved) return c.json({ error: "Not found" }, 404);
1852 if (user.id !== (resolved.owner as any).id) {
1853 return c.json({ error: "Forbidden" }, 403);
1854 }
1855
1856 const fullRef = `refs/heads/${branch}`;
1857 const currentSha = await resolveRef(owner, repo, fullRef);
1858 if (!currentSha) return c.json({ error: "Reference not found" }, 404);
1859
1860 if (!(await objectExists(owner, repo, newSha))) {
1861 return c.json({ error: "sha not found in repository" }, 422);
1862 }
1863
1864 if (!force) {
1865 // Fast-forward check: currentSha must be an ancestor of newSha.
1866 const repoDir = getRepoPath(owner, repo);
1867 const ff = await runGit(
1868 ["git", "merge-base", "--is-ancestor", currentSha, newSha],
1869 { cwd: repoDir }
1870 );
1871 if (ff.exitCode !== 0) {
1872 return c.json(
1873 { error: "Update is not a fast-forward" },
1874 422
1875 );
1876 }
1877 }
1878
1879 const ok = force
1880 ? await updateRef(owner, repo, fullRef, newSha)
1881 : await updateRef(owner, repo, fullRef, newSha, currentSha);
1882 if (!ok) return c.json({ error: "Failed to update ref" }, 500);
1883
1884 return c.json({
1885 ref: fullRef,
1886 object: { sha: newSha, type: "commit" },
1887 });
1888 }
1889);
1890
052c2e6Claude1891// ─── v2 alias for commit-status POST ─────────────────────────────────────────
1892// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
1893apiv2.post(
1894 "/repos/:owner/:repo/statuses/:sha",
1895 requireApiAuth,
1896 requireScope("repo"),
1897 postCommitStatusHandler
1898);
1899
45e31d0Claude1900// ─── Stars ──────────────────────────────────────────────────────────────────
1901
1902apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
1903 const { owner, repo } = c.req.param();
1904 const user = c.get("user")!;
1905
1906 const resolved = await resolveRepo(owner, repo);
1907 if (!resolved) return c.json({ error: "Not found" }, 404);
1908
1909 const repoId = (resolved.repo as any).id;
1910 const [existing] = await db
1911 .select()
1912 .from(stars)
1913 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
1914 .limit(1);
1915
1916 if (!existing) {
1917 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
1918 await db
1919 .update(repositories)
1920 .set({ starCount: sql`${repositories.starCount} + 1` })
1921 .where(eq(repositories.id, repoId));
1922 }
1923
1924 return c.json({ starred: true });
1925});
1926
1927apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
1928 const { owner, repo } = c.req.param();
1929 const user = c.get("user")!;
1930
1931 const resolved = await resolveRepo(owner, repo);
1932 if (!resolved) return c.json({ error: "Not found" }, 404);
1933
1934 const repoId = (resolved.repo as any).id;
1935 const [existing] = await db
1936 .select()
1937 .from(stars)
1938 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
1939 .limit(1);
1940
1941 if (existing) {
1942 await db.delete(stars).where(eq(stars.id, existing.id));
1943 await db
1944 .update(repositories)
1945 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
1946 .where(eq(repositories.id, repoId));
1947 }
1948
1949 return c.json({ starred: false });
1950});
1951
1952// ─── Labels ─────────────────────────────────────────────────────────────────
1953
1954apiv2.get("/repos/:owner/:repo/labels", async (c) => {
1955 const { owner, repo } = c.req.param();
1956 const resolved = await resolveRepo(owner, repo);
1957 if (!resolved) return c.json({ error: "Not found" }, 404);
1958
1959 const labelList = await db
1960 .select()
1961 .from(labels)
1962 .where(eq(labels.repositoryId, (resolved.repo as any).id))
1963 .orderBy(asc(labels.name));
1964
1965 return c.json(labelList);
1966});
1967
1968apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
1969 const { owner, repo } = c.req.param();
1970 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
1971
1972 if (!body.name?.trim()) {
1973 return c.json({ error: "Label name is required" }, 400);
1974 }
1975
1976 const resolved = await resolveRepo(owner, repo);
1977 if (!resolved) return c.json({ error: "Not found" }, 404);
1978
1979 const result = await db
1980 .insert(labels)
1981 .values({
1982 repositoryId: (resolved.repo as any).id,
1983 name: body.name.trim(),
1984 color: body.color || "#8b949e",
1985 description: body.description || null,
1986 })
1987 .returning();
1988
1989 return c.json(result[0], 201);
1990});
1991
1992// ─── Search ─────────────────────────────────────────────────────────────────
1993
1994apiv2.get("/search/repos", searchRateLimit, async (c) => {
1995 const q = c.req.query("q") || "";
1996 const sort = c.req.query("sort") || "stars";
1997 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1998
1999 if (!q.trim()) {
2000 return c.json({ error: "Query parameter 'q' is required" }, 400);
2001 }
2002
2003 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
2004 sort === "name" ? asc(repositories.name) :
2005 desc(repositories.starCount);
2006
2007 const results = await db
2008 .select({
2009 repo: repositories,
2010 owner: { username: users.username },
2011 })
2012 .from(repositories)
2013 .innerJoin(users, eq(repositories.ownerId, users.id))
2014 .where(
2015 and(
2016 eq(repositories.isPrivate, false),
2017 or(
2018 like(repositories.name, `%${q}%`),
2019 like(repositories.description, `%${q}%`)
2020 )
2021 )
2022 )
2023 .orderBy(orderBy)
2024 .limit(limit);
2025
2026 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
2027});
2028
2029apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
2030 const { owner, repo } = c.req.param();
2031 const q = c.req.query("q") || "";
2032
2033 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
2034 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
2035
2036 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
2037 const results = await searchCode(owner, repo, defaultBranch, q.trim());
2038 return c.json(results);
2039});
2040
2041// ─── Activity Feed ──────────────────────────────────────────────────────────
2042
2043apiv2.get("/repos/:owner/:repo/activity", async (c) => {
2044 const { owner, repo } = c.req.param();
2045 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
2046
2047 const resolved = await resolveRepo(owner, repo);
2048 if (!resolved) return c.json({ error: "Not found" }, 404);
2049
2050 const activity = await db
2051 .select()
2052 .from(activityFeed)
2053 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
2054 .orderBy(desc(activityFeed.createdAt))
2055 .limit(limit);
2056
2057 return c.json(activity);
2058});
2059
2060// ─── Topics ─────────────────────────────────────────────────────────────────
2061
2062apiv2.get("/repos/:owner/:repo/topics", async (c) => {
2063 const { owner, repo } = c.req.param();
2064 const resolved = await resolveRepo(owner, repo);
2065 if (!resolved) return c.json({ error: "Not found" }, 404);
2066
2067 const topicsList = await db
2068 .select()
2069 .from(repoTopics)
2070 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
2071
2072 return c.json(topicsList.map((t: any) => t.topic));
2073});
2074
2075apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
2076 const { owner, repo } = c.req.param();
2077 const user = c.get("user")!;
2078 const body = await c.req.json<{ topics: string[] }>();
2079
2080 const resolved = await resolveRepo(owner, repo);
2081 if (!resolved) return c.json({ error: "Not found" }, 404);
2082 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2083
2084 const repoId = (resolved.repo as any).id;
2085
2086 // Clear existing topics and insert new ones
2087 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
2088 if (body.topics && body.topics.length > 0) {
2089 await db.insert(repoTopics).values(
2090 body.topics.slice(0, 20).map((topic: string) => ({
2091 repositoryId: repoId,
2092 topic: topic.toLowerCase().trim(),
2093 }))
2094 );
2095 }
2096
2097 return c.json({ ok: true });
2098});
2099
2100// ─── Webhooks ───────────────────────────────────────────────────────────────
2101
2102apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
2103 const { owner, repo } = c.req.param();
2104 const user = c.get("user")!;
2105
2106 const resolved = await resolveRepo(owner, repo);
2107 if (!resolved) return c.json({ error: "Not found" }, 404);
2108 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2109
2110 const hookList = await db
2111 .select()
2112 .from(webhooks)
2113 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
2114
2115 return c.json(hookList);
2116});
2117
2118apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
2119 const { owner, repo } = c.req.param();
2120 const user = c.get("user")!;
2121 const body = await c.req.json<{
2122 url: string;
2123 secret?: string;
2124 events?: string;
2125 }>();
2126
2127 if (!body.url) return c.json({ error: "URL is required" }, 400);
2128
2129 const resolved = await resolveRepo(owner, repo);
2130 if (!resolved) return c.json({ error: "Not found" }, 404);
2131 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2132
2133 const result = await db
2134 .insert(webhooks)
2135 .values({
2136 repositoryId: (resolved.repo as any).id,
2137 url: body.url,
2138 secret: body.secret || null,
2139 events: body.events || "push",
2140 })
2141 .returning();
2142
2143 return c.json(result[0], 201);
2144});
2145
4366c70Claude2146// ─── Actions / Workflows ────────────────────────────────────────────────────
2147//
2148// GitHub-Actions-compatible REST surface (subset).
2149//
2150// POST /repos/:owner/:repo/actions/workflows/:filename/dispatches
2151// GET /repos/:owner/:repo/actions/workflows/:filename/runs
2152// GET /repos/:owner/:repo/actions/runs/:run_id
2153// GET /repos/:owner/:repo/actions/runs/:run_id/logs (.zip)
2154// POST /repos/:owner/:repo/actions/runs/:run_id/cancel
2155//
2156// Shapes follow GitHub REST v3 — snake_case fields, HTML URLs back to the
2157// gluecron run page, identical status-code semantics (204 for dispatch, 202
2158// for cancel, 409 for already-terminal, 422 for bad inputs).
2159
2160type ParsedOn =
2161 | string
2162 | string[]
2163 | Record<string, unknown>
2164 | null
2165 | undefined;
2166
2167type DispatchInputSpec = {
2168 type?: string;
2169 required?: boolean;
2170 default?: unknown;
2171 options?: unknown[];
2172 description?: string;
2173};
2174
2175/**
2176 * Pull the workflow_dispatch slice out of whatever shape `parsed.on` happens
2177 * to be. The v1 parser normalises `on` to a `string[]`, but the extended
2178 * parser may store an object — and YAML in the wild can be either form. We
2179 * accept all three: scalar string, array of event names, mapping keyed by
2180 * event name.
2181 */
2182function extractDispatchSpec(rawOn: ParsedOn): {
2183 enabled: boolean;
2184 inputs: Record<string, DispatchInputSpec> | null;
2185} {
2186 if (rawOn == null) return { enabled: false, inputs: null };
2187 if (typeof rawOn === "string") {
2188 return { enabled: rawOn === "workflow_dispatch", inputs: null };
2189 }
2190 if (Array.isArray(rawOn)) {
2191 return { enabled: rawOn.includes("workflow_dispatch"), inputs: null };
2192 }
2193 if (typeof rawOn === "object") {
2194 const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"];
2195 if (slot === undefined) return { enabled: false, inputs: null };
2196 // `workflow_dispatch:` with no children is a valid trigger declaration.
2197 if (slot == null || typeof slot !== "object") {
2198 return { enabled: true, inputs: null };
2199 }
2200 const inputs = (slot as Record<string, unknown>).inputs;
2201 if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
2202 return { enabled: true, inputs: null };
2203 }
2204 return {
2205 enabled: true,
2206 inputs: inputs as Record<string, DispatchInputSpec>,
2207 };
2208 }
2209 return { enabled: false, inputs: null };
2210}
2211
2212function validateDispatchInputs(
2213 schema: Record<string, DispatchInputSpec> | null,
2214 provided: Record<string, unknown> | undefined
2215): { ok: true } | { ok: false; details: string[] } {
2216 if (!schema) return { ok: true };
2217 const details: string[] = [];
2218 const supplied = provided ?? {};
2219 for (const [name, spec] of Object.entries(schema)) {
2220 if (!spec || typeof spec !== "object") continue;
2221 const present =
2222 Object.prototype.hasOwnProperty.call(supplied, name) &&
2223 supplied[name] !== undefined &&
2224 supplied[name] !== null;
2225 if (spec.required && !present) {
2226 // No default → required input is missing.
2227 if (spec.default === undefined) {
2228 details.push(`Missing required input: ${name}`);
2229 }
2230 }
2231 }
2232 if (details.length) return { ok: false, details };
2233 return { ok: true };
2234}
2235
2236/**
2237 * Look up a workflow row by repository + the basename of its `path` column.
2238 * Stored path is `.gluecron/workflows/<filename>`; we match the trailing
2239 * segment so callers don't need to know our on-disk layout.
2240 */
2241async function findWorkflowByFilename(
2242 repositoryId: string,
2243 filename: string
2244): Promise<typeof workflows.$inferSelect | null> {
2245 const rows = await db
2246 .select()
2247 .from(workflows)
2248 .where(eq(workflows.repositoryId, repositoryId));
2249 for (const row of rows) {
2250 const idx = row.path.lastIndexOf("/");
2251 const base = idx >= 0 ? row.path.slice(idx + 1) : row.path;
2252 if (base === filename) return row;
2253 }
2254 return null;
2255}
2256
2257function runHtmlUrl(owner: string, repo: string, runId: string): string {
2258 return `https://gluecron.com/${owner}/${repo}/actions/runs/${runId}`;
2259}
2260
2261function toIso(d: Date | string | null | undefined): string | null {
2262 if (!d) return null;
2263 return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
2264}
2265
2266function serializeRun(
2267 run: typeof workflowRuns.$inferSelect,
2268 workflowName: string,
2269 owner: string,
2270 repo: string
2271): Record<string, unknown> {
2272 // GitHub returns `head_branch` as the short branch name (no refs/heads/).
2273 let head_branch: string | null = null;
2274 if (run.ref) {
2275 head_branch = run.ref.startsWith("refs/heads/")
2276 ? run.ref.slice("refs/heads/".length)
2277 : run.ref;
2278 }
2279 return {
2280 id: run.id,
2281 name: workflowName,
2282 head_branch,
2283 head_sha: run.commitSha,
2284 status: run.status,
2285 conclusion: run.conclusion,
2286 event: run.event,
2287 created_at: toIso(run.queuedAt),
2288 updated_at:
2289 toIso(run.finishedAt) ?? toIso(run.startedAt) ?? toIso(run.queuedAt),
2290 run_started_at: toIso(run.startedAt),
2291 html_url: runHtmlUrl(owner, repo, run.id),
2292 };
2293}
2294
2295// ─── 1. POST /actions/workflows/:filename/dispatches ────────────────────────
2296
2297apiv2.post(
2298 "/repos/:owner/:repo/actions/workflows/:filename/dispatches",
2299 requireApiAuth,
2300 requireScope("repo"),
2301 async (c) => {
2302 const { owner, repo, filename } = c.req.param();
2303 const user = c.get("user")!;
2304
2305 let body: { ref?: unknown; inputs?: unknown } = {};
2306 try {
2307 body = await c.req.json();
2308 } catch {
2309 body = {};
2310 }
2311
2312 const resolved = await resolveRepo(owner, repo);
2313 if (!resolved) return c.json({ error: "Not Found" }, 404);
2314
2315 const repoRow = resolved.repo as any;
2316 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2317 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2318
2319 // Parse the stored workflow JSON to look at its triggers + input schema.
2320 let parsedObj: Record<string, unknown> = {};
2321 try {
2322 const v = JSON.parse(workflowRow.parsed);
2323 if (v && typeof v === "object" && !Array.isArray(v)) {
2324 parsedObj = v as Record<string, unknown>;
2325 }
2326 } catch {
2327 // Treat unparseable parsed-blob as no triggers — falls through to 422.
2328 }
2329
2330 const spec = extractDispatchSpec(parsedObj.on as ParsedOn);
2331 if (!spec.enabled) {
2332 return c.json(
2333 { error: "Workflow does not have a 'workflow_dispatch' trigger." },
2334 422
2335 );
2336 }
2337
2338 const providedInputs =
2339 body.inputs && typeof body.inputs === "object" && !Array.isArray(body.inputs)
2340 ? (body.inputs as Record<string, unknown>)
2341 : undefined;
2342 const inputCheck = validateDispatchInputs(spec.inputs, providedInputs);
2343 if (!inputCheck.ok) {
2344 return c.json(
2345 { error: "Invalid workflow inputs", details: inputCheck.details },
2346 422
2347 );
2348 }
2349
2350 // Resolve the ref → commit SHA. Default to repo default_branch.
2351 const refIn =
2352 typeof body.ref === "string" && body.ref.trim().length > 0
2353 ? body.ref.trim()
2354 : repoRow.defaultBranch || "main";
2355 const commitSha = await resolveRef(owner, repo, refIn);
2356 if (!commitSha) {
2357 return c.json({ error: `Ref not found: ${refIn}` }, 422);
2358 }
2359
2360 const runId = await enqueueRun({
2361 workflowId: workflowRow.id,
2362 repositoryId: repoRow.id,
2363 event: "workflow_dispatch",
2364 ref: refIn,
2365 commitSha,
2366 triggeredBy: user.id,
2367 });
2368 if (!runId) {
2369 return c.json({ error: "Failed to enqueue run" }, 500);
2370 }
2371
2372 // GitHub returns 204 No Content with no body on success.
2373 return c.body(null, 204);
2374 }
2375);
2376
2377// ─── 2. GET /actions/workflows/:filename/runs ───────────────────────────────
2378
2379apiv2.get(
2380 "/repos/:owner/:repo/actions/workflows/:filename/runs",
2381 async (c) => {
2382 const { owner, repo, filename } = c.req.param();
2383 const resolved = await resolveRepo(owner, repo);
2384 if (!resolved) return c.json({ error: "Not Found" }, 404);
2385
2386 const repoRow = resolved.repo as any;
2387 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2388 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2389
2390 const perPage = Math.min(
2391 100,
2392 Math.max(1, parseInt(c.req.query("per_page") || "30", 10) || 30)
2393 );
2394 const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
2395 const offset = (page - 1) * perPage;
2396 const branch = c.req.query("branch");
2397 const headSha = c.req.query("head_sha");
2398
2399 const conditions = [eq(workflowRuns.workflowId, workflowRow.id)];
2400 if (branch) {
2401 // Accept either short branch name or fully-qualified refs/heads/...
2402 const refValue = branch.startsWith("refs/")
2403 ? branch
2404 : `refs/heads/${branch}`;
2405 conditions.push(eq(workflowRuns.ref, refValue));
2406 }
2407 if (headSha) conditions.push(eq(workflowRuns.commitSha, headSha));
2408
2409 const where = conditions.length === 1 ? conditions[0] : and(...conditions);
2410
2411 const [{ n }] = await db
2412 .select({ n: sql<number>`count(*)::int` })
2413 .from(workflowRuns)
2414 .where(where);
2415 const total_count = Number(n) || 0;
2416
2417 const rows = await db
2418 .select()
2419 .from(workflowRuns)
2420 .where(where)
2421 .orderBy(desc(workflowRuns.queuedAt))
2422 .limit(perPage)
2423 .offset(offset);
2424
2425 return c.json({
2426 total_count,
2427 workflow_runs: rows.map((r) =>
2428 serializeRun(r, workflowRow.name, owner, repo)
2429 ),
2430 });
2431 }
2432);
2433
2434// ─── 3. GET /actions/runs/:run_id ───────────────────────────────────────────
2435
2436apiv2.get("/repos/:owner/:repo/actions/runs/:run_id", async (c) => {
2437 const { owner, repo, run_id } = c.req.param();
2438 const resolved = await resolveRepo(owner, repo);
2439 if (!resolved) return c.json({ error: "Not Found" }, 404);
2440
2441 const repoRow = resolved.repo as any;
2442 const [run] = await db
2443 .select()
2444 .from(workflowRuns)
2445 .where(eq(workflowRuns.id, run_id))
2446 .limit(1);
2447 if (!run) return c.json({ error: "Not Found" }, 404);
2448 // Don't leak runs across repos.
2449 if (run.repositoryId !== repoRow.id) {
2450 return c.json({ error: "Not Found" }, 404);
2451 }
2452
2453 const [wf] = await db
2454 .select()
2455 .from(workflows)
2456 .where(eq(workflows.id, run.workflowId))
2457 .limit(1);
2458 const workflowName = wf?.name ?? "";
2459
2460 return c.json(serializeRun(run, workflowName, owner, repo));
2461});
2462
2463// ─── 4. GET /actions/runs/:run_id/logs — ZIP of per-job logs ────────────────
2464//
2465// Reuses the same in-process zip writer pattern as `connect-claude.tsx` —
2466// PKZIP 2.0, no zip64, deflateRawSync with STORED fallback when compression
2467// would inflate. The handler is self-contained so the dxt downloader stays
2468// the canonical reference.
2469
2470function crc32(buf: Uint8Array): number {
2471 let c = 0xffffffff;
2472 for (let i = 0; i < buf.length; i++) {
2473 c ^= buf[i]!;
2474 for (let k = 0; k < 8; k++) {
2475 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
2476 }
2477 }
2478 return (c ^ 0xffffffff) >>> 0;
2479}
2480
2481type ZipEntry = { name: string; data: Uint8Array };
2482
2483function buildZip(entries: ZipEntry[]): Uint8Array {
2484 const localParts: Uint8Array[] = [];
2485 const centralParts: Uint8Array[] = [];
2486 let offset = 0;
2487
2488 for (const entry of entries) {
2489 const nameBytes = new TextEncoder().encode(entry.name);
2490 const crc = crc32(entry.data);
2491 const uncompressedSize = entry.data.length;
2492
2493 let method = 8;
2494 let compressed: Uint8Array;
2495 try {
2496 const out = deflateRawSync(entry.data);
2497 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
2498 if (compressed.length >= uncompressedSize) {
2499 method = 0;
2500 compressed = entry.data;
2501 }
2502 } catch {
2503 method = 0;
2504 compressed = entry.data;
2505 }
2506 const compressedSize = compressed.length;
2507
2508 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
2509 const lv = new DataView(local.buffer);
2510 lv.setUint32(0, 0x04034b50, true);
2511 lv.setUint16(4, 20, true);
2512 lv.setUint16(6, 0, true);
2513 lv.setUint16(8, method, true);
2514 lv.setUint16(10, 0, true);
2515 lv.setUint16(12, 0, true);
2516 lv.setUint32(14, crc, true);
2517 lv.setUint32(18, compressedSize, true);
2518 lv.setUint32(22, uncompressedSize, true);
2519 lv.setUint16(26, nameBytes.length, true);
2520 lv.setUint16(28, 0, true);
2521 local.set(nameBytes, 30);
2522 local.set(compressed, 30 + nameBytes.length);
2523 localParts.push(local);
2524
2525 const central = new Uint8Array(46 + nameBytes.length);
2526 const cv = new DataView(central.buffer);
2527 cv.setUint32(0, 0x02014b50, true);
2528 cv.setUint16(4, 20, true);
2529 cv.setUint16(6, 20, true);
2530 cv.setUint16(8, 0, true);
2531 cv.setUint16(10, method, true);
2532 cv.setUint16(12, 0, true);
2533 cv.setUint16(14, 0, true);
2534 cv.setUint32(16, crc, true);
2535 cv.setUint32(20, compressedSize, true);
2536 cv.setUint32(24, uncompressedSize, true);
2537 cv.setUint16(28, nameBytes.length, true);
2538 cv.setUint16(30, 0, true);
2539 cv.setUint16(32, 0, true);
2540 cv.setUint16(34, 0, true);
2541 cv.setUint16(36, 0, true);
2542 cv.setUint32(38, 0, true);
2543 cv.setUint32(42, offset, true);
2544 central.set(nameBytes, 46);
2545 centralParts.push(central);
2546
2547 offset += local.length;
2548 }
2549
2550 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
2551 const centralOffset = offset;
2552
2553 const end = new Uint8Array(22);
2554 const ev = new DataView(end.buffer);
2555 ev.setUint32(0, 0x06054b50, true);
2556 ev.setUint16(4, 0, true);
2557 ev.setUint16(6, 0, true);
2558 ev.setUint16(8, entries.length, true);
2559 ev.setUint16(10, entries.length, true);
2560 ev.setUint32(12, centralSize, true);
2561 ev.setUint32(16, centralOffset, true);
2562 ev.setUint16(20, 0, true);
2563
2564 const total =
2565 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
2566 const out = new Uint8Array(total);
2567 let pos = 0;
2568 for (const p of localParts) {
2569 out.set(p, pos);
2570 pos += p.length;
2571 }
2572 for (const p of centralParts) {
2573 out.set(p, pos);
2574 pos += p.length;
2575 }
2576 out.set(end, pos);
2577 return out;
2578}
2579
2580apiv2.get("/repos/:owner/:repo/actions/runs/:run_id/logs", async (c) => {
2581 const { owner, repo, run_id } = c.req.param();
2582 const resolved = await resolveRepo(owner, repo);
2583 if (!resolved) return c.json({ error: "Not Found" }, 404);
2584
2585 const repoRow = resolved.repo as any;
2586 const [run] = await db
2587 .select()
2588 .from(workflowRuns)
2589 .where(eq(workflowRuns.id, run_id))
2590 .limit(1);
2591 if (!run || run.repositoryId !== repoRow.id) {
2592 return c.json({ error: "Not Found" }, 404);
2593 }
2594
2595 const jobs = await db
2596 .select()
2597 .from(workflowJobs)
2598 .where(eq(workflowJobs.runId, run.id))
2599 .orderBy(asc(workflowJobs.jobOrder));
2600
2601 // 404 when there's truly nothing to package up — matches GitHub's behaviour
2602 // for runs that never produced logs.
2603 const usable = jobs.filter(
2604 (j) => typeof j.logs === "string" && j.logs.length > 0
2605 );
2606 if (usable.length === 0) {
2607 return c.json({ error: "Not Found" }, 404);
2608 }
2609
2610 // Make filenames safe + unique. Collisions get a numeric suffix.
2611 const seen = new Set<string>();
2612 const entries: ZipEntry[] = [];
2613 for (const job of usable) {
2614 let base = (job.name || "job").replace(/[^a-zA-Z0-9_.-]+/g, "_");
2615 if (!base) base = "job";
2616 let filename = `${base}.log`;
2617 let n = 1;
2618 while (seen.has(filename)) {
2619 filename = `${base}-${++n}.log`;
2620 }
2621 seen.add(filename);
2622 entries.push({
2623 name: filename,
2624 data: new TextEncoder().encode(job.logs),
2625 });
2626 }
2627
2628 const zip = buildZip(entries);
2629 // Wrap the bytes in a Blob so Response's BodyInit type is happy across
2630 // both Bun's `globalThis.Response` and Hono's. Uint8Array works at
2631 // runtime but trips strict TS — cast to BlobPart resolves the
2632 // ArrayBufferLike/ArrayBuffer mismatch on `.buffer`.
2633 return new Response(new Blob([zip as BlobPart], { type: "application/zip" }), {
2634 status: 200,
2635 headers: {
2636 "Content-Disposition": `attachment; filename="run-${run.id}-logs.zip"`,
2637 "Content-Length": String(zip.length),
2638 },
2639 });
2640});
2641
2642// ─── 5. POST /actions/runs/:run_id/cancel ───────────────────────────────────
2643
2644apiv2.post(
2645 "/repos/:owner/:repo/actions/runs/:run_id/cancel",
2646 requireApiAuth,
2647 requireScope("repo"),
2648 async (c) => {
2649 const { owner, repo, run_id } = c.req.param();
2650 const resolved = await resolveRepo(owner, repo);
2651 if (!resolved) return c.json({ error: "Not Found" }, 404);
2652
2653 const repoRow = resolved.repo as any;
2654 const [run] = await db
2655 .select()
2656 .from(workflowRuns)
2657 .where(eq(workflowRuns.id, run_id))
2658 .limit(1);
2659 if (!run || run.repositoryId !== repoRow.id) {
2660 return c.json({ error: "Not Found" }, 404);
2661 }
2662
2663 // Already-terminal states are a 409 Conflict per GitHub semantics. We
2664 // only allow queued → cancelled and running → cancelled transitions.
2665 if (run.status !== "queued" && run.status !== "running") {
2666 return c.json(
2667 {
2668 error:
2669 "Cannot cancel workflow run in its current state",
2670 status: run.status,
2671 },
2672 409
2673 );
2674 }
2675
2676 const now = new Date();
2677 await db
2678 .update(workflowRuns)
2679 .set({
2680 status: "cancelled",
2681 conclusion: "cancelled",
2682 finishedAt: now,
2683 })
2684 .where(
2685 and(
2686 eq(workflowRuns.id, run.id),
2687 eq(workflowRuns.repositoryId, repoRow.id)
2688 )
2689 );
2690 await db
2691 .update(workflowJobs)
2692 .set({
2693 status: "cancelled",
2694 conclusion: "cancelled",
2695 finishedAt: now,
2696 })
2697 .where(eq(workflowJobs.runId, run.id));
2698
2699 return c.json({}, 202);
2700 }
2701);
2702
45e31d0Claude2703// ─── API Info ───────────────────────────────────────────────────────────────
2704
2705apiv2.get("/", (c) => {
2706 return c.json({
2707 name: "gluecron API",
2708 version: "2.0",
2709 documentation: "/api/docs",
2710 endpoints: {
46d6165Claude2711 auth: {
2712 "POST /api/v2/auth/install-token":
2713 "Mint a PAT for one-command install (session-cookie auth only)",
2714 },
45e31d0Claude2715 users: {
2716 "GET /api/v2/user": "Get authenticated user",
2717 "GET /api/v2/users/:username": "Get user by username",
2718 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude2719 "GET /api/v2/me/ai-savings":
2720 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude2721 },
2722 repositories: {
2723 "GET /api/v2/users/:username/repos": "List user repositories",
2724 "POST /api/v2/repos": "Create repository",
2725 "GET /api/v2/repos/:owner/:repo": "Get repository",
2726 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
2727 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
2728 },
2729 branches: {
2730 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
2731 },
2732 commits: {
2733 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
2734 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
2735 },
2736 files: {
052c2e6Claude2737 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
2738 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
2739 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
da50b83Claude2740 "DELETE /api/v2/repos/:owner/:repo/contents/:path": "Delete a file on a branch",
052c2e6Claude2741 },
2742 git: {
2743 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
da50b83Claude2744 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Get a branch ref",
2745 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Move a branch ref (fast-forward by default)",
2746 "GET /api/v2/repos/:owner/:repo/git/commits/:sha": "Get a raw git commit object",
2747 "POST /api/v2/repos/:owner/:repo/git/commits": "Create a commit from a tree + parents",
2748 "POST /api/v2/repos/:owner/:repo/git/blobs": "Write a blob from utf-8 or base64 content",
2749 "POST /api/v2/repos/:owner/:repo/git/trees": "Build a tree from entries (optionally based on base_tree)",
052c2e6Claude2750 },
2751 statuses: {
2752 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude2753 },
2754 issues: {
2755 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
2756 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
2757 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
2758 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
2759 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
2760 },
2761 pullRequests: {
2762 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
2763 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
2764 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude2765 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude2766 },
2767 stars: {
2768 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
2769 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
2770 },
2771 labels: {
2772 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
2773 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
2774 },
2775 search: {
2776 "GET /api/v2/search/repos": "Search repositories",
2777 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
2778 },
2779 topics: {
2780 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
2781 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
2782 },
2783 webhooks: {
2784 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
2785 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
2786 },
2787 activity: {
2788 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
2789 },
9018b1fClaude2790 ai: {
2791 "POST /api/v2/ai/commit-message":
2792 "Generate a Conventional Commits message from a staged diff (60/min/token)",
2793 },
4366c70Claude2794 actions: {
2795 "POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches":
2796 "Dispatch a workflow run (204 No Content)",
2797 "GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs":
2798 "List runs of a workflow (paginated: per_page, page; filters: branch, head_sha)",
2799 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id":
2800 "Get a single workflow run",
2801 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs":
2802 "Download per-job logs as a .zip archive",
2803 "POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel":
2804 "Cancel a queued or running workflow run (202 Accepted)",
2805 },
45e31d0Claude2806 },
2807 authentication: {
2808 method: "Bearer token",
2809 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]2810 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude2811 },
2812 rateLimit: {
2813 api: "100 requests/minute",
2814 search: "30 requests/minute",
2815 auth: "10 requests/minute",
2816 },
2817 });
2818});
2819
2820export default apiv2;