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