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