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