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