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.tsBlame3018 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
cb5a796Claude994 // Only return approved comments through the public API surface —
995 // pending/rejected/spam should never leak via JSON. Moderation
996 // queue lives at the HTML `/comments/pending` page (owner only).
45e31d0Claude997 const comments = await db
998 .select({
999 comment: issueComments,
1000 author: { username: users.username },
1001 })
1002 .from(issueComments)
1003 .innerJoin(users, eq(issueComments.authorId, users.id))
cb5a796Claude1004 .where(
1005 and(
1006 eq(issueComments.issueId, issue.id),
1007 eq(issueComments.moderationStatus, "approved")
1008 )
1009 )
45e31d0Claude1010 .orderBy(asc(issueComments.createdAt));
1011
1012 return c.json({
1013 ...issue,
1014 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
1015 });
1016});
1017
1018apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
1019 const { owner, repo } = c.req.param();
1020 const num = parseInt(c.req.param("number"), 10);
1021 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
1022
1023 const resolved = await resolveRepo(owner, repo);
1024 if (!resolved) return c.json({ error: "Not found" }, 404);
1025
1026 const updates: Record<string, any> = { updatedAt: new Date() };
1027 if (body.title !== undefined) updates.title = body.title;
1028 if (body.body !== undefined) updates.body = body.body;
1029 if (body.state === "closed") {
1030 updates.state = "closed";
1031 updates.closedAt = new Date();
1032 } else if (body.state === "open") {
1033 updates.state = "open";
1034 updates.closedAt = null;
1035 }
1036
1037 await db
1038 .update(issues)
1039 .set(updates)
1040 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
1041
1042 return c.json({ ok: true });
1043});
1044
1045apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
1046 const { owner, repo } = c.req.param();
1047 const num = parseInt(c.req.param("number"), 10);
1048 const user = c.get("user")!;
1049 const body = await c.req.json<{ body: string }>();
1050
1051 if (!body.body?.trim()) {
1052 return c.json({ error: "Comment body is required" }, 400);
1053 }
1054
1055 const resolved = await resolveRepo(owner, repo);
1056 if (!resolved) return c.json({ error: "Not found" }, 404);
1057
1058 const [issue] = await db
1059 .select()
1060 .from(issues)
1061 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
1062 .limit(1);
1063
1064 if (!issue) return c.json({ error: "Issue not found" }, 404);
1065
1066 const result = await db
1067 .insert(issueComments)
1068 .values({
1069 issueId: issue.id,
1070 authorId: user.id,
1071 body: body.body.trim(),
1072 })
1073 .returning();
1074
1075 return c.json(result[0], 201);
1076});
1077
1078// ─── Pull Requests ──────────────────────────────────────────────────────────
1079
1080apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
1081 const { owner, repo } = c.req.param();
1082 const state = c.req.query("state") || "open";
8c09fb9Claude1083 // Match the issue-list pagination contract: default 30, max 100,
1084 // 0-indexed offset for cursor-style scrolling. Bounded so a buggy
1085 // client can't accidentally pull the whole table.
1086 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit")) || 30));
1087 const offset = Math.max(0, Number(c.req.query("offset")) || 0);
45e31d0Claude1088
1089 const resolved = await resolveRepo(owner, repo);
1090 if (!resolved) return c.json({ error: "Not found" }, 404);
1091
1092 const prList = await db
1093 .select({
1094 pr: pullRequests,
1095 author: { username: users.username },
1096 })
1097 .from(pullRequests)
1098 .innerJoin(users, eq(pullRequests.authorId, users.id))
1099 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
8c09fb9Claude1100 .orderBy(desc(pullRequests.createdAt))
1101 .limit(limit)
1102 .offset(offset);
45e31d0Claude1103
1104 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
1105});
1106
1107apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
1108 const { owner, repo } = c.req.param();
1109 const user = c.get("user")!;
1110 const body = await c.req.json<{
1111 title: string;
1112 body?: string;
1113 baseBranch: string;
1114 headBranch: string;
1115 }>();
1116
1117 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
1118 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
1119 }
1120
1121 const resolved = await resolveRepo(owner, repo);
1122 if (!resolved) return c.json({ error: "Not found" }, 404);
1123
1124 const result = await db
1125 .insert(pullRequests)
1126 .values({
1127 repositoryId: (resolved.repo as any).id,
1128 authorId: user.id,
1129 title: body.title.trim(),
1130 body: body.body?.trim() || null,
1131 baseBranch: body.baseBranch,
1132 headBranch: body.headBranch,
1133 })
1134 .returning();
1135
1136 return c.json(result[0], 201);
1137});
1138
1139apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
1140 const { owner, repo } = c.req.param();
1141 const num = parseInt(c.req.param("number"), 10);
1142
1143 const resolved = await resolveRepo(owner, repo);
1144 if (!resolved) return c.json({ error: "Not found" }, 404);
1145
1146 const [pr] = await db
1147 .select()
1148 .from(pullRequests)
1149 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
1150 .limit(1);
1151
1152 if (!pr) return c.json({ error: "PR not found" }, 404);
1153
cb5a796Claude1154 // Only return approved comments through the public API surface.
45e31d0Claude1155 const comments = await db
1156 .select({
1157 comment: prComments,
1158 author: { username: users.username },
1159 })
1160 .from(prComments)
1161 .innerJoin(users, eq(prComments.authorId, users.id))
cb5a796Claude1162 .where(
1163 and(
1164 eq(prComments.pullRequestId, pr.id),
1165 eq(prComments.moderationStatus, "approved")
1166 )
1167 )
45e31d0Claude1168 .orderBy(asc(prComments.createdAt));
1169
1170 return c.json({
1171 ...pr,
1172 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
1173 });
1174});
1175
4bbacbeClaude1176// ─── Branch previews (migration 0062) ──────────────────────────────────────
1177//
1178// GET /api/v2/repos/:owner/:repo/previews
1179// → list every active preview row, newest first.
1180//
1181// POST /api/v2/repos/:owner/:repo/previews/:branch/refresh
1182// → force a fresh build for the given branch. The branch must already
1183// exist and not be the default. The current HEAD of the branch is
1184// used as the commit_sha.
1185
1186apiv2.get("/repos/:owner/:repo/previews", async (c) => {
1187 const { owner, repo } = c.req.param();
1188 const resolved = await resolveRepo(owner, repo);
1189 if (!resolved) return c.json({ error: "Not found" }, 404);
1190 const rows = await listPreviewsForRepo((resolved.repo as { id: string }).id);
1191 return c.json({
1192 previews: rows.map((p) => ({
1193 id: p.id,
1194 branchName: p.branchName,
1195 commitSha: p.commitSha,
1196 previewUrl: p.previewUrl,
1197 status: p.status,
1198 buildStartedAt: p.buildStartedAt,
1199 buildCompletedAt: p.buildCompletedAt,
1200 expiresAt: p.expiresAt,
1201 errorMessage: p.errorMessage,
1202 })),
1203 });
1204});
1205
1206apiv2.post(
1207 "/repos/:owner/:repo/previews/:branch/refresh",
1208 requireApiAuth,
1209 requireScope("repo"),
1210 async (c) => {
1211 const { owner, repo } = c.req.param();
1212 const branch = c.req.param("branch");
1213 if (!branch) return c.json({ error: "branch is required" }, 400);
1214
1215 const resolved = await resolveRepo(owner, repo);
1216 if (!resolved) return c.json({ error: "Not found" }, 404);
1217
1218 // Refuse to "preview" the default branch — by design previews exist
1219 // only for non-default branches.
1220 if ((resolved.repo as { defaultBranch: string }).defaultBranch === branch) {
1221 return c.json(
1222 {
1223 error: "Default branch has no preview — previews are for non-default branches",
1224 },
1225 400
1226 );
1227 }
1228
1229 // Resolve the branch to its current SHA so the new row points at HEAD.
1230 let sha: string | null = null;
1231 try {
1232 sha = await resolveRef(owner, repo, branch);
1233 } catch {
1234 sha = null;
1235 }
1236 if (!sha) return c.json({ error: "Branch not found" }, 404);
1237
1238 const row = await enqueuePreviewBuild({
1239 repositoryId: (resolved.repo as { id: string }).id,
1240 ownerName: owner,
1241 repoName: repo,
1242 branchName: branch,
1243 commitSha: sha,
1244 });
1245 if (!row) {
1246 return c.json({ error: "Could not enqueue preview build" }, 503);
1247 }
1248 return c.json({
1249 id: row.id,
1250 branchName: row.branchName,
1251 commitSha: row.commitSha,
1252 previewUrl: row.previewUrl,
1253 status: row.status,
1254 expiresAt: row.expiresAt,
1255 }, 202);
1256 }
1257);
1258
052c2e6Claude1259// ─── PR Comments (GateTest integration) ────────────────────────────────────
1260
1261apiv2.post(
1262 "/repos/:owner/:repo/pulls/:number/comments",
1263 requireApiAuth,
1264 requireScope("repo"),
1265 async (c) => {
1266 const { owner, repo } = c.req.param();
1267 const num = parseInt(c.req.param("number"), 10);
1268 const user = c.get("user")!;
1269
1270 let body: { body?: string } = {};
1271 try {
1272 body = await c.req.json();
1273 } catch {
1274 body = {};
1275 }
1276 if (!body.body?.trim()) {
1277 return c.json({ error: "Comment body is required" }, 400);
1278 }
1279
1280 const resolved = await resolveRepo(owner, repo);
1281 if (!resolved) return c.json({ error: "Not found" }, 404);
1282 if (user.id !== (resolved.owner as any).id) {
1283 return c.json({ error: "Forbidden" }, 403);
1284 }
1285
1286 const [pr] = await db
1287 .select()
1288 .from(pullRequests)
1289 .where(
1290 and(
1291 eq(pullRequests.repositoryId, (resolved.repo as any).id),
1292 eq(pullRequests.number, num)
1293 )
1294 )
1295 .limit(1);
1296 if (!pr) return c.json({ error: "PR not found" }, 404);
1297
1298 const [comment] = await db
1299 .insert(prComments)
1300 .values({
1301 pullRequestId: pr.id,
1302 authorId: user.id,
1303 body: body.body.trim(),
1304 })
1305 .returning();
1306
1307 return c.json({ ok: true, comment }, 201);
1308 }
1309);
1310
1311// ─── Git refs — create branch / tag from sha ────────────────────────────────
1312
1313apiv2.post(
1314 "/repos/:owner/:repo/git/refs",
1315 requireApiAuth,
1316 requireScope("repo"),
1317 async (c) => {
1318 const { owner, repo } = c.req.param();
1319 const user = c.get("user")!;
1320
1321 let body: { ref?: string; sha?: string } = {};
1322 try {
1323 body = await c.req.json();
1324 } catch {
1325 body = {};
1326 }
1327 const ref = body.ref?.trim();
1328 const sha = body.sha?.trim();
1329
1330 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
1331 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
1332 }
1333 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
1334 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
1335 }
1336
1337 const resolved = await resolveRepo(owner, repo);
1338 if (!resolved) return c.json({ error: "Not found" }, 404);
1339 if (user.id !== (resolved.owner as any).id) {
1340 return c.json({ error: "Forbidden" }, 403);
1341 }
1342
1343 // Verify sha reachable.
1344 if (!(await objectExists(owner, repo, sha))) {
1345 return c.json({ error: "sha not found in repository" }, 400);
1346 }
1347
1348 // Conflict check: if ref already exists, the existing sha must match.
1349 if (await refExists(owner, repo, ref)) {
1350 const existing = await resolveRef(owner, repo, ref);
1351 if (existing !== sha) {
1352 return c.json({ error: "ref already exists", existing }, 409);
1353 }
1354 }
1355
1356 const ok = await updateRef(owner, repo, ref, sha);
1357 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
1358
1359 return c.json({ ok: true, ref, sha }, 201);
1360 }
1361);
1362
1363// ─── Contents PUT — create/update a file via git plumbing ────────────────────
1364
1365apiv2.put(
1366 "/repos/:owner/:repo/contents/:path{.+$}",
1367 requireApiAuth,
1368 requireScope("repo"),
1369 async (c) => {
1370 const { owner, repo } = c.req.param();
1371 const filePath = c.req.param("path");
1372 const user = c.get("user")!;
1373
1374 let body: {
1375 message?: string;
1376 content?: string;
1377 branch?: string;
1378 sha?: string | null;
1379 } = {};
1380 try {
1381 body = await c.req.json();
1382 } catch {
1383 body = {};
1384 }
1385
1386 const message = body.message?.trim();
1387 const branch = body.branch?.trim();
1388 const base64 = body.content;
1389
1390 if (!message) return c.json({ error: "message is required" }, 400);
1391 if (!branch) return c.json({ error: "branch is required" }, 400);
1392 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
1393
1394 let bytes: Uint8Array;
1395 try {
1396 bytes = new Uint8Array(Buffer.from(base64, "base64"));
1397 } catch {
1398 return c.json({ error: "content is not valid base64" }, 400);
1399 }
1400 if (bytes.length > CONTENTS_MAX_BYTES) {
1401 return c.json({ error: "File too large" }, 413);
1402 }
1403
1404 const resolved = await resolveRepo(owner, repo);
1405 if (!resolved) return c.json({ error: "Not found" }, 404);
1406 if (user.id !== (resolved.owner as any).id) {
1407 return c.json({ error: "Forbidden" }, 403);
1408 }
1409
1410 const result = await createOrUpdateFileOnBranch({
1411 owner,
1412 name: repo,
1413 branch,
1414 filePath,
1415 bytes,
1416 message,
1417 authorName: (user as any).displayName || user.username,
1418 authorEmail: user.email,
1419 expectBlobSha: body.sha ?? null,
1420 });
1421
1422 if ("error" in result) {
1423 if (result.error === "sha-mismatch") {
1424 return c.json({ error: "sha does not match current blob at path" }, 409);
1425 }
1426 return c.json({ error: "Failed to write file" }, 500);
1427 }
1428
1429 return c.json(
1430 {
1431 ok: true,
1432 commit: { sha: result.commitSha, message },
1433 content: { path: filePath, sha: result.blobSha },
1434 },
1435 201
1436 );
1437 }
1438);
1439
da50b83Claude1440// ─── Helper: shell out to git in a bare repo ─────────────────────────────────
1441//
1442// Mirrors the pattern in src/git/repository.ts. Kept inline here so the
1443// plumbing endpoints below don't have to leak through the helper module.
1444
1445async function runGit(
1446 cmd: string[],
1447 opts: { cwd: string; env?: Record<string, string>; stdin?: Uint8Array }
1448): Promise<{ stdout: string; stderr: string; exitCode: number }> {
1449 const proc = Bun.spawn(cmd, {
1450 cwd: opts.cwd,
1451 env: { ...process.env, ...opts.env },
1452 stdin: opts.stdin ? "pipe" : "ignore",
1453 stdout: "pipe",
1454 stderr: "pipe",
1455 });
1456 if (opts.stdin && proc.stdin) {
1457 proc.stdin.write(opts.stdin);
1458 proc.stdin.end();
1459 }
1460 const [stdout, stderr] = await Promise.all([
1461 new Response(proc.stdout).text(),
1462 new Response(proc.stderr).text(),
1463 ]);
1464 const exitCode = await proc.exited;
1465 return { stdout, stderr, exitCode };
1466}
1467
1468function htmlUrlForCommit(owner: string, repo: string, sha: string): string {
1469 return `${config.appBaseUrl}/${owner}/${repo}/commit/${sha}`;
1470}
1471
1472// ─── Contents DELETE — remove a file via git plumbing ────────────────────────
1473//
1474// Body: { message, sha, branch? }
1475// - `sha` is the current blob sha at `:path`; mismatch → 409 (optimistic
1476// concurrency, matches GitHub's `DELETE /repos/.../contents/...` semantics).
1477// - `branch` defaults to the repo's default branch.
1478
1479apiv2.delete(
1480 "/repos/:owner/:repo/contents/:path{.+$}",
1481 requireApiAuth,
1482 requireScope("repo"),
1483 async (c) => {
1484 const { owner, repo } = c.req.param();
1485 const filePath = c.req.param("path");
1486 const user = c.get("user")!;
1487
1488 let body: { message?: string; sha?: string; branch?: string } = {};
1489 try {
1490 body = await c.req.json();
1491 } catch {
1492 body = {};
1493 }
1494
1495 const message = body.message?.trim();
1496 const expectSha = body.sha?.trim();
1497 if (!message) return c.json({ error: "message is required" }, 400);
1498 if (!expectSha || !/^[0-9a-f]{40}$/.test(expectSha)) {
1499 return c.json({ error: "sha is required (40-hex)" }, 400);
1500 }
1501
1502 const resolved = await resolveRepo(owner, repo);
1503 if (!resolved) return c.json({ error: "Not found" }, 404);
1504 if (user.id !== (resolved.owner as any).id) {
1505 return c.json({ error: "Forbidden" }, 403);
1506 }
1507
1508 const branch =
1509 body.branch?.trim() ||
1510 (await getDefaultBranchFresh(owner, repo)) ||
1511 "main";
1512 const fullRef = `refs/heads/${branch}`;
1513 const repoDir = getRepoPath(owner, repo);
1514
1515 // Resolve current parent + existing blob sha at that path.
1516 const parentSha = await resolveRef(owner, repo, fullRef);
1517 if (!parentSha) return c.json({ error: "Branch not found" }, 404);
1518
1519 const existingBlobSha = await getBlobShaAtPath(owner, repo, branch, filePath);
1520 if (!existingBlobSha) return c.json({ error: "File not found" }, 404);
1521 if (existingBlobSha !== expectSha) {
1522 return c.json({ error: "sha does not match current blob at path" }, 409);
1523 }
1524
1525 const tmpIndex = join(
1526 repoDir,
1527 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1528 );
296ee49Claude1529 // `update-index --remove` checks `is_inside_work_tree()`, so a bare
1530 // repo needs a transient stand-in. Empty directory is sufficient —
1531 // git only consults it for safety checks, never writes blobs through it.
1532 const tmpWorkTree = join(
1533 repoDir,
1534 `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1535 );
1536 const { mkdir } = await import("fs/promises");
1537 await mkdir(tmpWorkTree, { recursive: true });
1538
da50b83Claude1539 const authorName = (user as any).displayName || user.username;
1540 const authorEmail = user.email;
1541 const env = {
1542 GIT_INDEX_FILE: tmpIndex,
296ee49Claude1543 GIT_DIR: repoDir,
1544 GIT_WORK_TREE: tmpWorkTree,
da50b83Claude1545 GIT_AUTHOR_NAME: authorName,
1546 GIT_AUTHOR_EMAIL: authorEmail,
1547 GIT_COMMITTER_NAME: authorName,
1548 GIT_COMMITTER_EMAIL: authorEmail,
1549 };
1550
1551 const cleanup = async () => {
1552 try {
296ee49Claude1553 const { unlink, rm } = await import("fs/promises");
1554 await unlink(tmpIndex).catch(() => {});
1555 await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {});
da50b83Claude1556 } catch {
1557 /* ignore */
1558 }
1559 };
1560
1561 try {
1562 const rt = await runGit(["git", "read-tree", parentSha], {
1563 cwd: repoDir,
1564 env,
1565 });
1566 if (rt.exitCode !== 0) {
1567 await cleanup();
1568 return c.json({ error: "Failed to read base tree" }, 500);
1569 }
1570
1571 const ui = await runGit(
1572 ["git", "update-index", "--remove", filePath],
1573 { cwd: repoDir, env }
1574 );
1575 if (ui.exitCode !== 0) {
1576 await cleanup();
1577 return c.json({ error: "Failed to remove path from index" }, 500);
1578 }
1579
1580 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1581 const newTreeSha = wt.stdout.trim();
1582 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(newTreeSha)) {
1583 await cleanup();
1584 return c.json({ error: "Failed to write tree" }, 500);
1585 }
1586
1587 const ct = await runGit(
1588 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1589 { cwd: repoDir, env }
1590 );
1591 const commitSha = ct.stdout.trim();
1592 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1593 await cleanup();
1594 return c.json({ error: "Failed to create commit" }, 500);
1595 }
1596
1597 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha);
1598 if (!ok) {
1599 await cleanup();
1600 return c.json({ error: "Failed to update ref" }, 500);
1601 }
1602
1603 await cleanup();
1604 return c.json({
1605 commit: {
1606 sha: commitSha,
1607 message,
1608 html_url: htmlUrlForCommit(owner, repo, commitSha),
1609 author: { name: authorName, email: authorEmail },
1610 },
1611 });
1612 } catch {
1613 await cleanup();
1614 return c.json({ error: "Failed to delete file" }, 500);
1615 }
1616 }
1617);
1618
1619// ─── Git plumbing: refs / commits / blobs / trees ────────────────────────────
1620
1621// GET /repos/:owner/:repo/git/refs/heads/:branch
1622apiv2.get(
1623 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1624 async (c) => {
1625 const { owner, repo } = c.req.param();
1626 const branch = c.req.param("branch");
1627 if (!(await repoExists(owner, repo))) {
1628 return c.json({ error: "Not found" }, 404);
1629 }
1630 const fullRef = `refs/heads/${branch}`;
1631 const sha = await resolveRef(owner, repo, fullRef);
1632 if (!sha) return c.json({ error: "Reference not found" }, 404);
1633 return c.json({
1634 ref: fullRef,
1635 object: { sha, type: "commit" },
1636 });
1637 }
1638);
1639
1640// GET /repos/:owner/:repo/git/commits/:sha
1641apiv2.get("/repos/:owner/:repo/git/commits/:sha", async (c) => {
1642 const { owner, repo, sha } = c.req.param();
1643 if (!(await repoExists(owner, repo))) {
1644 return c.json({ error: "Not found" }, 404);
1645 }
1646 const commit = await getCommit(owner, repo, sha);
1647 if (!commit) return c.json({ error: "Commit not found" }, 404);
1648
1649 // Resolve tree sha for the commit (cat-file <sha>^{tree}).
1650 const repoDir = getRepoPath(owner, repo);
1651 const { stdout: treeOut } = await runGit(
1652 ["git", "rev-parse", `${commit.sha}^{tree}`],
1653 { cwd: repoDir }
1654 );
1655 const treeSha = treeOut.trim();
1656
1657 return c.json({
1658 sha: commit.sha,
1659 tree: { sha: treeSha },
1660 parents: commit.parentShas.map((p) => ({ sha: p })),
1661 message: commit.message,
1662 author: {
1663 name: commit.author,
1664 email: commit.authorEmail,
1665 date: commit.date,
1666 },
1667 });
1668});
1669
1670// POST /repos/:owner/:repo/git/blobs
1671apiv2.post(
1672 "/repos/:owner/:repo/git/blobs",
1673 requireApiAuth,
1674 requireScope("repo"),
1675 async (c) => {
1676 const { owner, repo } = c.req.param();
1677 const user = c.get("user")!;
1678
1679 let body: { content?: string; encoding?: string } = {};
1680 try {
1681 body = await c.req.json();
1682 } catch {
1683 body = {};
1684 }
1685 const content = body.content;
1686 const encoding = (body.encoding || "utf-8").toLowerCase();
1687 if (typeof content !== "string") {
1688 return c.json({ error: "content is required" }, 400);
1689 }
1690 if (encoding !== "utf-8" && encoding !== "utf8" && encoding !== "base64") {
1691 return c.json({ error: "encoding must be 'utf-8' or 'base64'" }, 400);
1692 }
1693
1694 const resolved = await resolveRepo(owner, repo);
1695 if (!resolved) return c.json({ error: "Not found" }, 404);
1696 if (user.id !== (resolved.owner as any).id) {
1697 return c.json({ error: "Forbidden" }, 403);
1698 }
1699
1700 let bytes: Uint8Array;
1701 try {
1702 if (encoding === "base64") {
1703 bytes = new Uint8Array(Buffer.from(content, "base64"));
1704 } else {
1705 bytes = new TextEncoder().encode(content);
1706 }
1707 } catch {
1708 return c.json({ error: "Failed to decode content" }, 400);
1709 }
1710
1711 const sha = await writeBlob(owner, repo, bytes);
1712 if (!sha) return c.json({ error: "Failed to write blob" }, 500);
1713
1714 return c.json(
1715 {
1716 sha,
1717 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/blobs/${sha}`,
1718 size: bytes.length,
1719 },
1720 201
1721 );
1722 }
1723);
1724
1725// POST /repos/:owner/:repo/git/trees
1726//
1727// Body: { base_tree?: <tree_sha>, tree: [{ path, mode, type: "blob", sha: <blob_sha> | null }] }
1728// `sha: null` removes the entry from base_tree.
1729apiv2.post(
1730 "/repos/:owner/:repo/git/trees",
1731 requireApiAuth,
1732 requireScope("repo"),
1733 async (c) => {
1734 const { owner, repo } = c.req.param();
1735 const user = c.get("user")!;
1736
1737 let body: {
1738 base_tree?: string;
1739 tree?: Array<{
1740 path?: string;
1741 mode?: string;
1742 type?: string;
1743 sha?: string | null;
1744 }>;
1745 } = {};
1746 try {
1747 body = await c.req.json();
1748 } catch {
1749 body = {};
1750 }
1751
1752 if (!Array.isArray(body.tree)) {
1753 return c.json({ error: "tree array is required" }, 400);
1754 }
1755 for (const entry of body.tree) {
1756 if (!entry.path || typeof entry.path !== "string") {
1757 return c.json({ error: "each tree entry needs a path" }, 400);
1758 }
1759 if (!entry.mode || typeof entry.mode !== "string") {
1760 return c.json({ error: "each tree entry needs a mode" }, 400);
1761 }
1762 if (entry.sha !== null && typeof entry.sha !== "string") {
1763 return c.json(
1764 { error: "each tree entry needs sha (40-hex) or null to delete" },
1765 400
1766 );
1767 }
1768 if (typeof entry.sha === "string" && !/^[0-9a-f]{40}$/.test(entry.sha)) {
1769 return c.json({ error: "sha must be 40-hex" }, 400);
1770 }
1771 }
1772 if (body.base_tree && !/^[0-9a-f]{40}$/.test(body.base_tree)) {
1773 return c.json({ error: "base_tree must be 40-hex" }, 400);
1774 }
1775
1776 const resolved = await resolveRepo(owner, repo);
1777 if (!resolved) return c.json({ error: "Not found" }, 404);
1778 if (user.id !== (resolved.owner as any).id) {
1779 return c.json({ error: "Forbidden" }, 403);
1780 }
1781
1782 const repoDir = getRepoPath(owner, repo);
1783 const tmpIndex = join(
1784 repoDir,
1785 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1786 );
1787 const env = { GIT_INDEX_FILE: tmpIndex };
1788
1789 const cleanup = async () => {
1790 try {
1791 const { unlink } = await import("fs/promises");
1792 await unlink(tmpIndex);
1793 } catch {
1794 /* ignore */
1795 }
1796 };
1797
1798 try {
1799 if (body.base_tree) {
1800 const rt = await runGit(["git", "read-tree", body.base_tree], {
1801 cwd: repoDir,
1802 env,
1803 });
1804 if (rt.exitCode !== 0) {
1805 await cleanup();
1806 return c.json({ error: "base_tree not found" }, 404);
1807 }
1808 }
1809
1810 for (const entry of body.tree) {
1811 if (entry.sha === null) {
1812 const r = await runGit(
1813 ["git", "update-index", "--remove", entry.path!],
1814 { cwd: repoDir, env }
1815 );
1816 if (r.exitCode !== 0) {
1817 await cleanup();
1818 return c.json(
1819 { error: `Failed to remove ${entry.path}` },
1820 422
1821 );
1822 }
1823 } else {
1824 const r = await runGit(
1825 [
1826 "git",
1827 "update-index",
1828 "--add",
1829 "--cacheinfo",
1830 `${entry.mode},${entry.sha},${entry.path}`,
1831 ],
1832 { cwd: repoDir, env }
1833 );
1834 if (r.exitCode !== 0) {
1835 await cleanup();
1836 return c.json(
1837 { error: `Failed to add ${entry.path}` },
1838 422
1839 );
1840 }
1841 }
1842 }
1843
1844 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1845 const treeSha = wt.stdout.trim();
1846 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(treeSha)) {
1847 await cleanup();
1848 return c.json({ error: "Failed to write tree" }, 500);
1849 }
1850
1851 // List entries in the new tree (one level deep, matching GitHub's
1852 // POST /git/trees response shape).
1853 const ls = await runGit(["git", "ls-tree", treeSha], { cwd: repoDir });
1854 const entries: Array<{
1855 path: string;
1856 mode: string;
1857 type: string;
1858 sha: string;
1859 }> = [];
1860 for (const line of ls.stdout.split("\n").filter(Boolean)) {
1861 const m = line.match(
1862 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\t(.+)$/
1863 );
1864 if (m) {
1865 entries.push({
1866 mode: m[1],
1867 type: m[2],
1868 sha: m[3],
1869 path: m[4],
1870 });
1871 }
1872 }
1873
1874 await cleanup();
1875 return c.json(
1876 {
1877 sha: treeSha,
1878 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/trees/${treeSha}`,
1879 tree: entries,
1880 },
1881 201
1882 );
1883 } catch {
1884 await cleanup();
1885 return c.json({ error: "Failed to write tree" }, 500);
1886 }
1887 }
1888);
1889
1890// POST /repos/:owner/:repo/git/commits
1891//
1892// Body: { message, tree, parents: [<sha>] }
1893apiv2.post(
1894 "/repos/:owner/:repo/git/commits",
1895 requireApiAuth,
1896 requireScope("repo"),
1897 async (c) => {
1898 const { owner, repo } = c.req.param();
1899 const user = c.get("user")!;
1900
1901 let body: { message?: string; tree?: string; parents?: string[] } = {};
1902 try {
1903 body = await c.req.json();
1904 } catch {
1905 body = {};
1906 }
1907
1908 const message = body.message?.trim();
1909 const tree = body.tree?.trim();
1910 const parents = Array.isArray(body.parents) ? body.parents : [];
1911
1912 if (!message) return c.json({ error: "message is required" }, 400);
1913 if (!tree || !/^[0-9a-f]{40}$/.test(tree)) {
1914 return c.json({ error: "tree must be 40-hex" }, 400);
1915 }
1916 for (const p of parents) {
1917 if (typeof p !== "string" || !/^[0-9a-f]{40}$/.test(p)) {
1918 return c.json({ error: "each parent must be 40-hex" }, 400);
1919 }
1920 }
1921
1922 const resolved = await resolveRepo(owner, repo);
1923 if (!resolved) return c.json({ error: "Not found" }, 404);
1924 if (user.id !== (resolved.owner as any).id) {
1925 return c.json({ error: "Forbidden" }, 403);
1926 }
1927
1928 // Verify tree object exists.
1929 if (!(await objectExists(owner, repo, tree))) {
1930 return c.json({ error: "tree not found in repository" }, 422);
1931 }
1932 for (const p of parents) {
1933 if (!(await objectExists(owner, repo, p))) {
1934 return c.json({ error: `parent ${p} not found in repository` }, 422);
1935 }
1936 }
1937
1938 const repoDir = getRepoPath(owner, repo);
1939 const authorName = (user as any).displayName || user.username;
1940 const authorEmail = user.email;
1941 const env = {
1942 GIT_AUTHOR_NAME: authorName,
1943 GIT_AUTHOR_EMAIL: authorEmail,
1944 GIT_COMMITTER_NAME: authorName,
1945 GIT_COMMITTER_EMAIL: authorEmail,
1946 };
1947
1948 const args = ["git", "commit-tree", tree];
1949 for (const p of parents) {
1950 args.push("-p", p);
1951 }
1952 args.push("-m", message);
1953
1954 const ct = await runGit(args, { cwd: repoDir, env });
1955 const commitSha = ct.stdout.trim();
1956 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1957 return c.json({ error: "Failed to create commit" }, 500);
1958 }
1959
1960 // Re-read the new commit's recorded date so the response carries the
1961 // exact ISO timestamp git wrote into the object.
1962 const recorded = await getCommit(owner, repo, commitSha);
1963 const date = recorded?.date ?? new Date().toISOString();
1964
1965 return c.json(
1966 {
1967 sha: commitSha,
1968 tree: { sha: tree },
1969 message,
1970 parents: parents.map((p) => ({ sha: p })),
1971 author: { name: authorName, email: authorEmail, date },
1972 html_url: htmlUrlForCommit(owner, repo, commitSha),
1973 },
1974 201
1975 );
1976 }
1977);
1978
1979// PATCH /repos/:owner/:repo/git/refs/heads/:branch
1980//
1981// Body: { sha: <new_commit>, force?: false }
1982apiv2.patch(
1983 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1984 requireApiAuth,
1985 requireScope("repo"),
1986 async (c) => {
1987 const { owner, repo } = c.req.param();
1988 const branch = c.req.param("branch");
1989 const user = c.get("user")!;
1990
1991 let body: { sha?: string; force?: boolean } = {};
1992 try {
1993 body = await c.req.json();
1994 } catch {
1995 body = {};
1996 }
1997
1998 const newSha = body.sha?.trim();
1999 const force = body.force === true;
2000 if (!newSha || !/^[0-9a-f]{40}$/.test(newSha)) {
2001 return c.json({ error: "sha must be 40-hex" }, 400);
2002 }
2003
2004 const resolved = await resolveRepo(owner, repo);
2005 if (!resolved) return c.json({ error: "Not found" }, 404);
2006 if (user.id !== (resolved.owner as any).id) {
2007 return c.json({ error: "Forbidden" }, 403);
2008 }
2009
2010 const fullRef = `refs/heads/${branch}`;
2011 const currentSha = await resolveRef(owner, repo, fullRef);
2012 if (!currentSha) return c.json({ error: "Reference not found" }, 404);
2013
2014 if (!(await objectExists(owner, repo, newSha))) {
2015 return c.json({ error: "sha not found in repository" }, 422);
2016 }
2017
2018 if (!force) {
2019 // Fast-forward check: currentSha must be an ancestor of newSha.
2020 const repoDir = getRepoPath(owner, repo);
2021 const ff = await runGit(
2022 ["git", "merge-base", "--is-ancestor", currentSha, newSha],
2023 { cwd: repoDir }
2024 );
2025 if (ff.exitCode !== 0) {
2026 return c.json(
2027 { error: "Update is not a fast-forward" },
2028 422
2029 );
2030 }
2031 }
2032
2033 const ok = force
2034 ? await updateRef(owner, repo, fullRef, newSha)
2035 : await updateRef(owner, repo, fullRef, newSha, currentSha);
2036 if (!ok) return c.json({ error: "Failed to update ref" }, 500);
2037
2038 return c.json({
2039 ref: fullRef,
2040 object: { sha: newSha, type: "commit" },
2041 });
2042 }
2043);
2044
052c2e6Claude2045// ─── v2 alias for commit-status POST ─────────────────────────────────────────
2046// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
2047apiv2.post(
2048 "/repos/:owner/:repo/statuses/:sha",
2049 requireApiAuth,
2050 requireScope("repo"),
2051 postCommitStatusHandler
2052);
2053
45e31d0Claude2054// ─── Stars ──────────────────────────────────────────────────────────────────
2055
2056apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
2057 const { owner, repo } = c.req.param();
2058 const user = c.get("user")!;
2059
2060 const resolved = await resolveRepo(owner, repo);
2061 if (!resolved) return c.json({ error: "Not found" }, 404);
2062
2063 const repoId = (resolved.repo as any).id;
2064 const [existing] = await db
2065 .select()
2066 .from(stars)
2067 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
2068 .limit(1);
2069
2070 if (!existing) {
2071 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
2072 await db
2073 .update(repositories)
2074 .set({ starCount: sql`${repositories.starCount} + 1` })
2075 .where(eq(repositories.id, repoId));
2076 }
2077
2078 return c.json({ starred: true });
2079});
2080
2081apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
2082 const { owner, repo } = c.req.param();
2083 const user = c.get("user")!;
2084
2085 const resolved = await resolveRepo(owner, repo);
2086 if (!resolved) return c.json({ error: "Not found" }, 404);
2087
2088 const repoId = (resolved.repo as any).id;
2089 const [existing] = await db
2090 .select()
2091 .from(stars)
2092 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
2093 .limit(1);
2094
2095 if (existing) {
2096 await db.delete(stars).where(eq(stars.id, existing.id));
2097 await db
2098 .update(repositories)
2099 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
2100 .where(eq(repositories.id, repoId));
2101 }
2102
2103 return c.json({ starred: false });
2104});
2105
2106// ─── Labels ─────────────────────────────────────────────────────────────────
2107
2108apiv2.get("/repos/:owner/:repo/labels", async (c) => {
2109 const { owner, repo } = c.req.param();
2110 const resolved = await resolveRepo(owner, repo);
2111 if (!resolved) return c.json({ error: "Not found" }, 404);
2112
2113 const labelList = await db
2114 .select()
2115 .from(labels)
2116 .where(eq(labels.repositoryId, (resolved.repo as any).id))
2117 .orderBy(asc(labels.name));
2118
2119 return c.json(labelList);
2120});
2121
2122apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
2123 const { owner, repo } = c.req.param();
2124 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
2125
2126 if (!body.name?.trim()) {
2127 return c.json({ error: "Label name is required" }, 400);
2128 }
2129
2130 const resolved = await resolveRepo(owner, repo);
2131 if (!resolved) return c.json({ error: "Not found" }, 404);
2132
2133 const result = await db
2134 .insert(labels)
2135 .values({
2136 repositoryId: (resolved.repo as any).id,
2137 name: body.name.trim(),
2138 color: body.color || "#8b949e",
2139 description: body.description || null,
2140 })
2141 .returning();
2142
2143 return c.json(result[0], 201);
2144});
2145
2146// ─── Search ─────────────────────────────────────────────────────────────────
2147
2148apiv2.get("/search/repos", searchRateLimit, async (c) => {
2149 const q = c.req.query("q") || "";
2150 const sort = c.req.query("sort") || "stars";
2151 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
2152
2153 if (!q.trim()) {
2154 return c.json({ error: "Query parameter 'q' is required" }, 400);
2155 }
2156
2157 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
2158 sort === "name" ? asc(repositories.name) :
2159 desc(repositories.starCount);
2160
2161 const results = await db
2162 .select({
2163 repo: repositories,
2164 owner: { username: users.username },
2165 })
2166 .from(repositories)
2167 .innerJoin(users, eq(repositories.ownerId, users.id))
2168 .where(
2169 and(
2170 eq(repositories.isPrivate, false),
2171 or(
2172 like(repositories.name, `%${q}%`),
2173 like(repositories.description, `%${q}%`)
2174 )
2175 )
2176 )
2177 .orderBy(orderBy)
2178 .limit(limit);
2179
2180 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
2181});
2182
2183apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
2184 const { owner, repo } = c.req.param();
2185 const q = c.req.query("q") || "";
2186
2187 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
2188 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
2189
2190 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
2191 const results = await searchCode(owner, repo, defaultBranch, q.trim());
2192 return c.json(results);
2193});
2194
2195// ─── Activity Feed ──────────────────────────────────────────────────────────
2196
2197apiv2.get("/repos/:owner/:repo/activity", async (c) => {
2198 const { owner, repo } = c.req.param();
2199 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
2200
2201 const resolved = await resolveRepo(owner, repo);
2202 if (!resolved) return c.json({ error: "Not found" }, 404);
2203
2204 const activity = await db
2205 .select()
2206 .from(activityFeed)
2207 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
2208 .orderBy(desc(activityFeed.createdAt))
2209 .limit(limit);
2210
2211 return c.json(activity);
2212});
2213
2214// ─── Topics ─────────────────────────────────────────────────────────────────
2215
2216apiv2.get("/repos/:owner/:repo/topics", async (c) => {
2217 const { owner, repo } = c.req.param();
2218 const resolved = await resolveRepo(owner, repo);
2219 if (!resolved) return c.json({ error: "Not found" }, 404);
2220
2221 const topicsList = await db
2222 .select()
2223 .from(repoTopics)
2224 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
2225
2226 return c.json(topicsList.map((t: any) => t.topic));
2227});
2228
2229apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
2230 const { owner, repo } = c.req.param();
2231 const user = c.get("user")!;
2232 const body = await c.req.json<{ topics: string[] }>();
2233
2234 const resolved = await resolveRepo(owner, repo);
2235 if (!resolved) return c.json({ error: "Not found" }, 404);
2236 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2237
2238 const repoId = (resolved.repo as any).id;
2239
2240 // Clear existing topics and insert new ones
2241 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
2242 if (body.topics && body.topics.length > 0) {
2243 await db.insert(repoTopics).values(
2244 body.topics.slice(0, 20).map((topic: string) => ({
2245 repositoryId: repoId,
2246 topic: topic.toLowerCase().trim(),
2247 }))
2248 );
2249 }
2250
2251 return c.json({ ok: true });
2252});
2253
2254// ─── Webhooks ───────────────────────────────────────────────────────────────
2255
2256apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
2257 const { owner, repo } = c.req.param();
2258 const user = c.get("user")!;
2259
2260 const resolved = await resolveRepo(owner, repo);
2261 if (!resolved) return c.json({ error: "Not found" }, 404);
2262 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2263
2264 const hookList = await db
2265 .select()
2266 .from(webhooks)
2267 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
2268
2269 return c.json(hookList);
2270});
2271
2272apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
2273 const { owner, repo } = c.req.param();
2274 const user = c.get("user")!;
2275 const body = await c.req.json<{
2276 url: string;
2277 secret?: string;
2278 events?: string;
2279 }>();
2280
2281 if (!body.url) return c.json({ error: "URL is required" }, 400);
2282
2283 const resolved = await resolveRepo(owner, repo);
2284 if (!resolved) return c.json({ error: "Not found" }, 404);
2285 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2286
2287 const result = await db
2288 .insert(webhooks)
2289 .values({
2290 repositoryId: (resolved.repo as any).id,
2291 url: body.url,
2292 secret: body.secret || null,
2293 events: body.events || "push",
2294 })
2295 .returning();
2296
2297 return c.json(result[0], 201);
2298});
2299
4366c70Claude2300// ─── Actions / Workflows ────────────────────────────────────────────────────
2301//
2302// GitHub-Actions-compatible REST surface (subset).
2303//
2304// POST /repos/:owner/:repo/actions/workflows/:filename/dispatches
2305// GET /repos/:owner/:repo/actions/workflows/:filename/runs
2306// GET /repos/:owner/:repo/actions/runs/:run_id
2307// GET /repos/:owner/:repo/actions/runs/:run_id/logs (.zip)
2308// POST /repos/:owner/:repo/actions/runs/:run_id/cancel
2309//
2310// Shapes follow GitHub REST v3 — snake_case fields, HTML URLs back to the
2311// gluecron run page, identical status-code semantics (204 for dispatch, 202
2312// for cancel, 409 for already-terminal, 422 for bad inputs).
2313
2314type ParsedOn =
2315 | string
2316 | string[]
2317 | Record<string, unknown>
2318 | null
2319 | undefined;
2320
2321type DispatchInputSpec = {
2322 type?: string;
2323 required?: boolean;
2324 default?: unknown;
2325 options?: unknown[];
2326 description?: string;
2327};
2328
2329/**
2330 * Pull the workflow_dispatch slice out of whatever shape `parsed.on` happens
2331 * to be. The v1 parser normalises `on` to a `string[]`, but the extended
2332 * parser may store an object — and YAML in the wild can be either form. We
2333 * accept all three: scalar string, array of event names, mapping keyed by
2334 * event name.
2335 */
2336function extractDispatchSpec(rawOn: ParsedOn): {
2337 enabled: boolean;
2338 inputs: Record<string, DispatchInputSpec> | null;
2339} {
2340 if (rawOn == null) return { enabled: false, inputs: null };
2341 if (typeof rawOn === "string") {
2342 return { enabled: rawOn === "workflow_dispatch", inputs: null };
2343 }
2344 if (Array.isArray(rawOn)) {
2345 return { enabled: rawOn.includes("workflow_dispatch"), inputs: null };
2346 }
2347 if (typeof rawOn === "object") {
2348 const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"];
2349 if (slot === undefined) return { enabled: false, inputs: null };
2350 // `workflow_dispatch:` with no children is a valid trigger declaration.
2351 if (slot == null || typeof slot !== "object") {
2352 return { enabled: true, inputs: null };
2353 }
2354 const inputs = (slot as Record<string, unknown>).inputs;
2355 if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
2356 return { enabled: true, inputs: null };
2357 }
2358 return {
2359 enabled: true,
2360 inputs: inputs as Record<string, DispatchInputSpec>,
2361 };
2362 }
2363 return { enabled: false, inputs: null };
2364}
2365
2366function validateDispatchInputs(
2367 schema: Record<string, DispatchInputSpec> | null,
2368 provided: Record<string, unknown> | undefined
2369): { ok: true } | { ok: false; details: string[] } {
2370 if (!schema) return { ok: true };
2371 const details: string[] = [];
2372 const supplied = provided ?? {};
2373 for (const [name, spec] of Object.entries(schema)) {
2374 if (!spec || typeof spec !== "object") continue;
2375 const present =
2376 Object.prototype.hasOwnProperty.call(supplied, name) &&
2377 supplied[name] !== undefined &&
2378 supplied[name] !== null;
2379 if (spec.required && !present) {
2380 // No default → required input is missing.
2381 if (spec.default === undefined) {
2382 details.push(`Missing required input: ${name}`);
2383 }
2384 }
2385 }
2386 if (details.length) return { ok: false, details };
2387 return { ok: true };
2388}
2389
2390/**
2391 * Look up a workflow row by repository + the basename of its `path` column.
2392 * Stored path is `.gluecron/workflows/<filename>`; we match the trailing
2393 * segment so callers don't need to know our on-disk layout.
2394 */
2395async function findWorkflowByFilename(
2396 repositoryId: string,
2397 filename: string
2398): Promise<typeof workflows.$inferSelect | null> {
2399 const rows = await db
2400 .select()
2401 .from(workflows)
2402 .where(eq(workflows.repositoryId, repositoryId));
2403 for (const row of rows) {
2404 const idx = row.path.lastIndexOf("/");
2405 const base = idx >= 0 ? row.path.slice(idx + 1) : row.path;
2406 if (base === filename) return row;
2407 }
2408 return null;
2409}
2410
2411function runHtmlUrl(owner: string, repo: string, runId: string): string {
2412 return `https://gluecron.com/${owner}/${repo}/actions/runs/${runId}`;
2413}
2414
2415function toIso(d: Date | string | null | undefined): string | null {
2416 if (!d) return null;
2417 return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
2418}
2419
2420function serializeRun(
2421 run: typeof workflowRuns.$inferSelect,
2422 workflowName: string,
2423 owner: string,
2424 repo: string
2425): Record<string, unknown> {
2426 // GitHub returns `head_branch` as the short branch name (no refs/heads/).
2427 let head_branch: string | null = null;
2428 if (run.ref) {
2429 head_branch = run.ref.startsWith("refs/heads/")
2430 ? run.ref.slice("refs/heads/".length)
2431 : run.ref;
2432 }
2433 return {
2434 id: run.id,
2435 name: workflowName,
2436 head_branch,
2437 head_sha: run.commitSha,
2438 status: run.status,
2439 conclusion: run.conclusion,
2440 event: run.event,
2441 created_at: toIso(run.queuedAt),
2442 updated_at:
2443 toIso(run.finishedAt) ?? toIso(run.startedAt) ?? toIso(run.queuedAt),
2444 run_started_at: toIso(run.startedAt),
2445 html_url: runHtmlUrl(owner, repo, run.id),
2446 };
2447}
2448
2449// ─── 1. POST /actions/workflows/:filename/dispatches ────────────────────────
2450
2451apiv2.post(
2452 "/repos/:owner/:repo/actions/workflows/:filename/dispatches",
2453 requireApiAuth,
2454 requireScope("repo"),
2455 async (c) => {
2456 const { owner, repo, filename } = c.req.param();
2457 const user = c.get("user")!;
2458
2459 let body: { ref?: unknown; inputs?: unknown } = {};
2460 try {
2461 body = await c.req.json();
2462 } catch {
2463 body = {};
2464 }
2465
2466 const resolved = await resolveRepo(owner, repo);
2467 if (!resolved) return c.json({ error: "Not Found" }, 404);
2468
2469 const repoRow = resolved.repo as any;
2470 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2471 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2472
2473 // Parse the stored workflow JSON to look at its triggers + input schema.
2474 let parsedObj: Record<string, unknown> = {};
2475 try {
2476 const v = JSON.parse(workflowRow.parsed);
2477 if (v && typeof v === "object" && !Array.isArray(v)) {
2478 parsedObj = v as Record<string, unknown>;
2479 }
2480 } catch {
2481 // Treat unparseable parsed-blob as no triggers — falls through to 422.
2482 }
2483
2484 const spec = extractDispatchSpec(parsedObj.on as ParsedOn);
2485 if (!spec.enabled) {
2486 return c.json(
2487 { error: "Workflow does not have a 'workflow_dispatch' trigger." },
2488 422
2489 );
2490 }
2491
2492 const providedInputs =
2493 body.inputs && typeof body.inputs === "object" && !Array.isArray(body.inputs)
2494 ? (body.inputs as Record<string, unknown>)
2495 : undefined;
2496 const inputCheck = validateDispatchInputs(spec.inputs, providedInputs);
2497 if (!inputCheck.ok) {
2498 return c.json(
2499 { error: "Invalid workflow inputs", details: inputCheck.details },
2500 422
2501 );
2502 }
2503
2504 // Resolve the ref → commit SHA. Default to repo default_branch.
2505 const refIn =
2506 typeof body.ref === "string" && body.ref.trim().length > 0
2507 ? body.ref.trim()
2508 : repoRow.defaultBranch || "main";
2509 const commitSha = await resolveRef(owner, repo, refIn);
2510 if (!commitSha) {
2511 return c.json({ error: `Ref not found: ${refIn}` }, 422);
2512 }
2513
2514 const runId = await enqueueRun({
2515 workflowId: workflowRow.id,
2516 repositoryId: repoRow.id,
2517 event: "workflow_dispatch",
2518 ref: refIn,
2519 commitSha,
2520 triggeredBy: user.id,
2521 });
2522 if (!runId) {
2523 return c.json({ error: "Failed to enqueue run" }, 500);
2524 }
2525
2526 // GitHub returns 204 No Content with no body on success.
2527 return c.body(null, 204);
2528 }
2529);
2530
2531// ─── 2. GET /actions/workflows/:filename/runs ───────────────────────────────
2532
2533apiv2.get(
2534 "/repos/:owner/:repo/actions/workflows/:filename/runs",
2535 async (c) => {
2536 const { owner, repo, filename } = c.req.param();
2537 const resolved = await resolveRepo(owner, repo);
2538 if (!resolved) return c.json({ error: "Not Found" }, 404);
2539
2540 const repoRow = resolved.repo as any;
2541 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2542 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2543
2544 const perPage = Math.min(
2545 100,
2546 Math.max(1, parseInt(c.req.query("per_page") || "30", 10) || 30)
2547 );
2548 const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
2549 const offset = (page - 1) * perPage;
2550 const branch = c.req.query("branch");
2551 const headSha = c.req.query("head_sha");
2552
2553 const conditions = [eq(workflowRuns.workflowId, workflowRow.id)];
2554 if (branch) {
2555 // Accept either short branch name or fully-qualified refs/heads/...
2556 const refValue = branch.startsWith("refs/")
2557 ? branch
2558 : `refs/heads/${branch}`;
2559 conditions.push(eq(workflowRuns.ref, refValue));
2560 }
2561 if (headSha) conditions.push(eq(workflowRuns.commitSha, headSha));
2562
2563 const where = conditions.length === 1 ? conditions[0] : and(...conditions);
2564
2565 const [{ n }] = await db
2566 .select({ n: sql<number>`count(*)::int` })
2567 .from(workflowRuns)
2568 .where(where);
2569 const total_count = Number(n) || 0;
2570
2571 const rows = await db
2572 .select()
2573 .from(workflowRuns)
2574 .where(where)
2575 .orderBy(desc(workflowRuns.queuedAt))
2576 .limit(perPage)
2577 .offset(offset);
2578
2579 return c.json({
2580 total_count,
2581 workflow_runs: rows.map((r) =>
2582 serializeRun(r, workflowRow.name, owner, repo)
2583 ),
2584 });
2585 }
2586);
2587
2588// ─── 3. GET /actions/runs/:run_id ───────────────────────────────────────────
2589
2590apiv2.get("/repos/:owner/:repo/actions/runs/:run_id", async (c) => {
2591 const { owner, repo, run_id } = c.req.param();
2592 const resolved = await resolveRepo(owner, repo);
2593 if (!resolved) return c.json({ error: "Not Found" }, 404);
2594
2595 const repoRow = resolved.repo as any;
2596 const [run] = await db
2597 .select()
2598 .from(workflowRuns)
2599 .where(eq(workflowRuns.id, run_id))
2600 .limit(1);
2601 if (!run) return c.json({ error: "Not Found" }, 404);
2602 // Don't leak runs across repos.
2603 if (run.repositoryId !== repoRow.id) {
2604 return c.json({ error: "Not Found" }, 404);
2605 }
2606
2607 const [wf] = await db
2608 .select()
2609 .from(workflows)
2610 .where(eq(workflows.id, run.workflowId))
2611 .limit(1);
2612 const workflowName = wf?.name ?? "";
2613
2614 return c.json(serializeRun(run, workflowName, owner, repo));
2615});
2616
2617// ─── 4. GET /actions/runs/:run_id/logs — ZIP of per-job logs ────────────────
2618//
2619// Reuses the same in-process zip writer pattern as `connect-claude.tsx` —
2620// PKZIP 2.0, no zip64, deflateRawSync with STORED fallback when compression
2621// would inflate. The handler is self-contained so the dxt downloader stays
2622// the canonical reference.
2623
2624function crc32(buf: Uint8Array): number {
2625 let c = 0xffffffff;
2626 for (let i = 0; i < buf.length; i++) {
2627 c ^= buf[i]!;
2628 for (let k = 0; k < 8; k++) {
2629 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
2630 }
2631 }
2632 return (c ^ 0xffffffff) >>> 0;
2633}
2634
2635type ZipEntry = { name: string; data: Uint8Array };
2636
2637function buildZip(entries: ZipEntry[]): Uint8Array {
2638 const localParts: Uint8Array[] = [];
2639 const centralParts: Uint8Array[] = [];
2640 let offset = 0;
2641
2642 for (const entry of entries) {
2643 const nameBytes = new TextEncoder().encode(entry.name);
2644 const crc = crc32(entry.data);
2645 const uncompressedSize = entry.data.length;
2646
2647 let method = 8;
2648 let compressed: Uint8Array;
2649 try {
2650 const out = deflateRawSync(entry.data);
2651 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
2652 if (compressed.length >= uncompressedSize) {
2653 method = 0;
2654 compressed = entry.data;
2655 }
2656 } catch {
2657 method = 0;
2658 compressed = entry.data;
2659 }
2660 const compressedSize = compressed.length;
2661
2662 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
2663 const lv = new DataView(local.buffer);
2664 lv.setUint32(0, 0x04034b50, true);
2665 lv.setUint16(4, 20, true);
2666 lv.setUint16(6, 0, true);
2667 lv.setUint16(8, method, true);
2668 lv.setUint16(10, 0, true);
2669 lv.setUint16(12, 0, true);
2670 lv.setUint32(14, crc, true);
2671 lv.setUint32(18, compressedSize, true);
2672 lv.setUint32(22, uncompressedSize, true);
2673 lv.setUint16(26, nameBytes.length, true);
2674 lv.setUint16(28, 0, true);
2675 local.set(nameBytes, 30);
2676 local.set(compressed, 30 + nameBytes.length);
2677 localParts.push(local);
2678
2679 const central = new Uint8Array(46 + nameBytes.length);
2680 const cv = new DataView(central.buffer);
2681 cv.setUint32(0, 0x02014b50, true);
2682 cv.setUint16(4, 20, true);
2683 cv.setUint16(6, 20, true);
2684 cv.setUint16(8, 0, true);
2685 cv.setUint16(10, method, true);
2686 cv.setUint16(12, 0, true);
2687 cv.setUint16(14, 0, true);
2688 cv.setUint32(16, crc, true);
2689 cv.setUint32(20, compressedSize, true);
2690 cv.setUint32(24, uncompressedSize, true);
2691 cv.setUint16(28, nameBytes.length, true);
2692 cv.setUint16(30, 0, true);
2693 cv.setUint16(32, 0, true);
2694 cv.setUint16(34, 0, true);
2695 cv.setUint16(36, 0, true);
2696 cv.setUint32(38, 0, true);
2697 cv.setUint32(42, offset, true);
2698 central.set(nameBytes, 46);
2699 centralParts.push(central);
2700
2701 offset += local.length;
2702 }
2703
2704 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
2705 const centralOffset = offset;
2706
2707 const end = new Uint8Array(22);
2708 const ev = new DataView(end.buffer);
2709 ev.setUint32(0, 0x06054b50, true);
2710 ev.setUint16(4, 0, true);
2711 ev.setUint16(6, 0, true);
2712 ev.setUint16(8, entries.length, true);
2713 ev.setUint16(10, entries.length, true);
2714 ev.setUint32(12, centralSize, true);
2715 ev.setUint32(16, centralOffset, true);
2716 ev.setUint16(20, 0, true);
2717
2718 const total =
2719 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
2720 const out = new Uint8Array(total);
2721 let pos = 0;
2722 for (const p of localParts) {
2723 out.set(p, pos);
2724 pos += p.length;
2725 }
2726 for (const p of centralParts) {
2727 out.set(p, pos);
2728 pos += p.length;
2729 }
2730 out.set(end, pos);
2731 return out;
2732}
2733
2734apiv2.get("/repos/:owner/:repo/actions/runs/:run_id/logs", async (c) => {
2735 const { owner, repo, run_id } = c.req.param();
2736 const resolved = await resolveRepo(owner, repo);
2737 if (!resolved) return c.json({ error: "Not Found" }, 404);
2738
2739 const repoRow = resolved.repo as any;
2740 const [run] = await db
2741 .select()
2742 .from(workflowRuns)
2743 .where(eq(workflowRuns.id, run_id))
2744 .limit(1);
2745 if (!run || run.repositoryId !== repoRow.id) {
2746 return c.json({ error: "Not Found" }, 404);
2747 }
2748
2749 const jobs = await db
2750 .select()
2751 .from(workflowJobs)
2752 .where(eq(workflowJobs.runId, run.id))
2753 .orderBy(asc(workflowJobs.jobOrder));
2754
2755 // 404 when there's truly nothing to package up — matches GitHub's behaviour
2756 // for runs that never produced logs.
2757 const usable = jobs.filter(
2758 (j) => typeof j.logs === "string" && j.logs.length > 0
2759 );
2760 if (usable.length === 0) {
2761 return c.json({ error: "Not Found" }, 404);
2762 }
2763
2764 // Make filenames safe + unique. Collisions get a numeric suffix.
2765 const seen = new Set<string>();
2766 const entries: ZipEntry[] = [];
2767 for (const job of usable) {
2768 let base = (job.name || "job").replace(/[^a-zA-Z0-9_.-]+/g, "_");
2769 if (!base) base = "job";
2770 let filename = `${base}.log`;
2771 let n = 1;
2772 while (seen.has(filename)) {
2773 filename = `${base}-${++n}.log`;
2774 }
2775 seen.add(filename);
2776 entries.push({
2777 name: filename,
2778 data: new TextEncoder().encode(job.logs),
2779 });
2780 }
2781
2782 const zip = buildZip(entries);
2783 // Wrap the bytes in a Blob so Response's BodyInit type is happy across
2784 // both Bun's `globalThis.Response` and Hono's. Uint8Array works at
2785 // runtime but trips strict TS — cast to BlobPart resolves the
2786 // ArrayBufferLike/ArrayBuffer mismatch on `.buffer`.
2787 return new Response(new Blob([zip as BlobPart], { type: "application/zip" }), {
2788 status: 200,
2789 headers: {
2790 "Content-Disposition": `attachment; filename="run-${run.id}-logs.zip"`,
2791 "Content-Length": String(zip.length),
2792 },
2793 });
2794});
2795
2796// ─── 5. POST /actions/runs/:run_id/cancel ───────────────────────────────────
2797
2798apiv2.post(
2799 "/repos/:owner/:repo/actions/runs/:run_id/cancel",
2800 requireApiAuth,
2801 requireScope("repo"),
2802 async (c) => {
2803 const { owner, repo, run_id } = c.req.param();
2804 const resolved = await resolveRepo(owner, repo);
2805 if (!resolved) return c.json({ error: "Not Found" }, 404);
2806
2807 const repoRow = resolved.repo as any;
2808 const [run] = await db
2809 .select()
2810 .from(workflowRuns)
2811 .where(eq(workflowRuns.id, run_id))
2812 .limit(1);
2813 if (!run || run.repositoryId !== repoRow.id) {
2814 return c.json({ error: "Not Found" }, 404);
2815 }
2816
2817 // Already-terminal states are a 409 Conflict per GitHub semantics. We
2818 // only allow queued → cancelled and running → cancelled transitions.
2819 if (run.status !== "queued" && run.status !== "running") {
2820 return c.json(
2821 {
2822 error:
2823 "Cannot cancel workflow run in its current state",
2824 status: run.status,
2825 },
2826 409
2827 );
2828 }
2829
2830 const now = new Date();
2831 await db
2832 .update(workflowRuns)
2833 .set({
2834 status: "cancelled",
2835 conclusion: "cancelled",
2836 finishedAt: now,
2837 })
2838 .where(
2839 and(
2840 eq(workflowRuns.id, run.id),
2841 eq(workflowRuns.repositoryId, repoRow.id)
2842 )
2843 );
2844 await db
2845 .update(workflowJobs)
2846 .set({
2847 status: "cancelled",
2848 conclusion: "cancelled",
2849 finishedAt: now,
2850 })
2851 .where(eq(workflowJobs.runId, run.id));
2852
2853 return c.json({}, 202);
2854 }
2855);
2856
424eb72Claude2857// ─── Release notes (AI-generated) ───────────────────────────────────────────
2858//
2859// POST /api/v2/repos/:owner/:repo/releases/notes
2860// Body: { from_tag?: string | null, to_tag: string }
2861// Returns: { markdown, sections, headline, summary, prCount, aiUsed }
2862//
2863// Used by the release-form `Generate notes` button and external
2864// automation (e.g. CI scripts that auto-fill release bodies).
2865//
2866// Auth: read on the repo (lazy — public repos are open, private ones
2867// 404 from the resolver above). Generates notes; never persists them
2868// (caller decides). The autopilot watcher persists via a separate path.
2869
2870apiv2.post("/repos/:owner/:repo/releases/notes", async (c) => {
2871 const { owner, repo } = c.req.param();
2872 const resolved = await resolveRepo(owner, repo);
2873 if (!resolved) return c.json({ error: "Not Found" }, 404);
2874
2875 let body: { from_tag?: unknown; to_tag?: unknown } = {};
2876 try {
2877 body = await c.req.json();
2878 } catch {
2879 return c.json({ error: "Invalid JSON body" }, 400);
2880 }
2881 const toTag = typeof body.to_tag === "string" ? body.to_tag.trim() : "";
2882 if (!toTag) return c.json({ error: "to_tag is required" }, 400);
2883 const fromTag =
2884 typeof body.from_tag === "string" && body.from_tag.trim()
2885 ? body.from_tag.trim()
2886 : null;
2887
2888 const { generateReleaseNotes } = await import("../lib/ai-release-notes");
2889 const result = await generateReleaseNotes({
2890 repositoryId: (resolved.repo as any).id,
2891 fromTag,
2892 toTag,
2893 });
2894 return c.json(result);
2895});
2896
45e31d0Claude2897// ─── API Info ───────────────────────────────────────────────────────────────
2898
2899apiv2.get("/", (c) => {
2900 return c.json({
2901 name: "gluecron API",
2902 version: "2.0",
2903 documentation: "/api/docs",
2904 endpoints: {
46d6165Claude2905 auth: {
2906 "POST /api/v2/auth/install-token":
2907 "Mint a PAT for one-command install (session-cookie auth only)",
2908 },
45e31d0Claude2909 users: {
2910 "GET /api/v2/user": "Get authenticated user",
2911 "GET /api/v2/users/:username": "Get user by username",
2912 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude2913 "GET /api/v2/me/ai-savings":
2914 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude2915 },
2916 repositories: {
2917 "GET /api/v2/users/:username/repos": "List user repositories",
2918 "POST /api/v2/repos": "Create repository",
2919 "GET /api/v2/repos/:owner/:repo": "Get repository",
2920 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
2921 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
2922 },
2923 branches: {
2924 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
2925 },
2926 commits: {
2927 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
2928 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
2929 },
2930 files: {
052c2e6Claude2931 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
2932 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
2933 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
da50b83Claude2934 "DELETE /api/v2/repos/:owner/:repo/contents/:path": "Delete a file on a branch",
052c2e6Claude2935 },
2936 git: {
2937 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
da50b83Claude2938 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Get a branch ref",
2939 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Move a branch ref (fast-forward by default)",
2940 "GET /api/v2/repos/:owner/:repo/git/commits/:sha": "Get a raw git commit object",
2941 "POST /api/v2/repos/:owner/:repo/git/commits": "Create a commit from a tree + parents",
2942 "POST /api/v2/repos/:owner/:repo/git/blobs": "Write a blob from utf-8 or base64 content",
2943 "POST /api/v2/repos/:owner/:repo/git/trees": "Build a tree from entries (optionally based on base_tree)",
052c2e6Claude2944 },
2945 statuses: {
2946 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude2947 },
2948 issues: {
2949 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
2950 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
2951 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
2952 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
2953 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
2954 },
2955 pullRequests: {
2956 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
2957 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
2958 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude2959 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude2960 },
424eb72Claude2961 releases: {
2962 "POST /api/v2/repos/:owner/:repo/releases/notes":
2963 "Generate AI release notes for from_tag → to_tag (markdown + structured sections)",
2964 },
45e31d0Claude2965 stars: {
2966 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
2967 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
2968 },
2969 labels: {
2970 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
2971 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
2972 },
2973 search: {
2974 "GET /api/v2/search/repos": "Search repositories",
2975 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
2976 },
2977 topics: {
2978 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
2979 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
2980 },
2981 webhooks: {
2982 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
2983 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
2984 },
2985 activity: {
2986 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
2987 },
9018b1fClaude2988 ai: {
2989 "POST /api/v2/ai/commit-message":
2990 "Generate a Conventional Commits message from a staged diff (60/min/token)",
2991 },
4366c70Claude2992 actions: {
2993 "POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches":
2994 "Dispatch a workflow run (204 No Content)",
2995 "GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs":
2996 "List runs of a workflow (paginated: per_page, page; filters: branch, head_sha)",
2997 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id":
2998 "Get a single workflow run",
2999 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs":
3000 "Download per-job logs as a .zip archive",
3001 "POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel":
3002 "Cancel a queued or running workflow run (202 Accepted)",
3003 },
45e31d0Claude3004 },
3005 authentication: {
3006 method: "Bearer token",
3007 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]3008 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude3009 },
3010 rateLimit: {
3011 api: "100 requests/minute",
3012 search: "30 requests/minute",
3013 auth: "10 requests/minute",
3014 },
3015 });
3016});
3017
3018export default apiv2;