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.tsBlame2707 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
38d31d3Claude619// ─── Repo chat — streaming SSE ──────────────────────────────────────────────
620//
621// POST /api/v2/repos/:owner/:repo/chat/messages
622//
623// Body (application/json): { chat_id?: string, message: string }
624//
625// Behaviour:
626// - If no `chat_id`, creates a new chat row scoped to the caller.
627// - Streams assistant text deltas via SSE (`event: token`, data is the
628// raw delta). When the stream closes, a final `event: done` carries
629// `{ chat_id, message_id, citations, token_cost }`.
630// - On any internal failure we emit `event: error` and end the stream
631// cleanly so the client can render an inline message.
632//
633// Auth: requireApiAuth + requireScope("repo") so PATs need the right
634// scope, and session-cookie auth (web UI) works out of the box.
635
636apiv2.post(
637 "/repos/:owner/:repo/chat/messages",
638 requireApiAuth,
639 requireScope("repo"),
640 async (c) => {
641 const { owner, repo } = c.req.param();
642 const user = c.get("user")!;
643 const resolved = await resolveRepo(owner, repo);
644 if (!resolved) return c.json({ error: "Not found" }, 404);
645
646 // Private-repo: only the owner (or admin scope) may chat.
647 if ((resolved.repo as any).isPrivate && user.id !== resolved.owner.id) {
648 const scopes = (c.get("tokenScopes") as string[] | undefined) || [];
649 if (!scopes.includes("admin")) {
650 return c.json({ error: "Not found" }, 404);
651 }
652 }
653
654 let body: { chat_id?: string; message?: string };
655 try {
656 body = (await c.req.json()) as { chat_id?: string; message?: string };
657 } catch {
658 return c.json({ error: "Invalid JSON body" }, 400);
659 }
660
661 const userMessage = String(body?.message || "").trim();
662 if (!userMessage) {
663 return c.json({ error: "message is required" }, 400);
664 }
665
666 const {
667 appendUserMessage,
668 createChat,
669 getChatForUser,
670 streamAssistantReply,
671 } = await import("../lib/repo-chat");
672
673 let chatId = String(body?.chat_id || "").trim();
674 const repoId = (resolved.repo as any).id as string;
675
676 if (!chatId) {
677 const created = await createChat({
678 repositoryId: repoId,
679 ownerUserId: user.id,
680 title: userMessage.slice(0, 80),
681 });
682 if (!created) {
683 return c.json({ error: "Failed to create chat" }, 500);
684 }
685 chatId = created.id;
686 } else {
687 const existing = await getChatForUser(chatId, user.id);
688 if (!existing || existing.repositoryId !== repoId) {
689 return c.json({ error: "Not found" }, 404);
690 }
691 }
692
693 await appendUserMessage(chatId, userMessage);
694
695 const encoder = new TextEncoder();
696 const stream = new ReadableStream<Uint8Array>({
697 async start(controller) {
698 let closed = false;
699 const safeEnqueue = (chunk: string) => {
700 if (closed) return;
701 try {
702 controller.enqueue(encoder.encode(chunk));
703 } catch {
704 closed = true;
705 }
706 };
707 const sseEvent = (event: string, data: string) => {
708 let payload = `event: ${event}\n`;
709 for (const line of data.split("\n")) {
710 payload += `data: ${line}\n`;
711 }
712 payload += "\n";
713 safeEnqueue(payload);
714 };
715
716 // Initial comment flushes proxy headers.
717 safeEnqueue(": open\n\n");
718
719 try {
720 const stored = await streamAssistantReply({
721 chatId,
722 repoId,
723 userMessage,
724 onChunk: (chunk) => {
725 sseEvent("token", chunk);
726 },
727 });
728
729 sseEvent(
730 "done",
731 JSON.stringify({
732 chat_id: chatId,
733 message_id: stored?.id || null,
734 citations: stored?.citations ?? [],
735 token_cost: stored?.tokenCost ?? 0,
736 })
737 );
738 } catch (err) {
739 const msg = err instanceof Error ? err.message : "unknown error";
740 sseEvent("error", JSON.stringify({ error: msg }));
741 } finally {
742 closed = true;
743 try {
744 controller.close();
745 } catch {
746 /* already closed */
747 }
748 }
749 },
750 });
751
752 return new Response(stream, {
753 status: 200,
754 headers: {
755 "Content-Type": "text/event-stream; charset=utf-8",
756 "Cache-Control": "no-cache, no-transform",
757 Connection: "keep-alive",
758 "X-Accel-Buffering": "no",
759 },
760 });
761 }
762);
763
45e31d0Claude764// ─── Issues ─────────────────────────────────────────────────────────────────
765
766apiv2.get("/repos/:owner/:repo/issues", async (c) => {
767 const { owner, repo } = c.req.param();
768 const state = c.req.query("state") || "open";
769 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
770
771 const resolved = await resolveRepo(owner, repo);
772 if (!resolved) return c.json({ error: "Not found" }, 404);
773
774 const issueList = await db
775 .select({
776 issue: issues,
777 author: { username: users.username, id: users.id },
778 })
779 .from(issues)
780 .innerJoin(users, eq(issues.authorId, users.id))
781 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
782 .orderBy(desc(issues.createdAt))
783 .limit(limit);
784
785 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
786});
787
788apiv2.post("/repos/:owner/:repo/issues", 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<{ title: string; body?: string }>();
792
793 if (!body.title?.trim()) {
794 return c.json({ error: "Title is required" }, 400);
795 }
796
797 const resolved = await resolveRepo(owner, repo);
798 if (!resolved) return c.json({ error: "Not found" }, 404);
799
800 const result = await db
801 .insert(issues)
802 .values({
803 repositoryId: (resolved.repo as any).id,
804 authorId: user.id,
805 title: body.title.trim(),
806 body: body.body?.trim() || null,
807 })
808 .returning();
809
810 return c.json(result[0], 201);
811});
812
813apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
814 const { owner, repo } = c.req.param();
815 const num = parseInt(c.req.param("number"), 10);
816
817 const resolved = await resolveRepo(owner, repo);
818 if (!resolved) return c.json({ error: "Not found" }, 404);
819
820 const [issue] = await db
821 .select()
822 .from(issues)
823 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
824 .limit(1);
825
826 if (!issue) return c.json({ error: "Issue not found" }, 404);
827
828 const comments = await db
829 .select({
830 comment: issueComments,
831 author: { username: users.username },
832 })
833 .from(issueComments)
834 .innerJoin(users, eq(issueComments.authorId, users.id))
835 .where(eq(issueComments.issueId, issue.id))
836 .orderBy(asc(issueComments.createdAt));
837
838 return c.json({
839 ...issue,
840 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
841 });
842});
843
844apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
845 const { owner, repo } = c.req.param();
846 const num = parseInt(c.req.param("number"), 10);
847 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
848
849 const resolved = await resolveRepo(owner, repo);
850 if (!resolved) return c.json({ error: "Not found" }, 404);
851
852 const updates: Record<string, any> = { updatedAt: new Date() };
853 if (body.title !== undefined) updates.title = body.title;
854 if (body.body !== undefined) updates.body = body.body;
855 if (body.state === "closed") {
856 updates.state = "closed";
857 updates.closedAt = new Date();
858 } else if (body.state === "open") {
859 updates.state = "open";
860 updates.closedAt = null;
861 }
862
863 await db
864 .update(issues)
865 .set(updates)
866 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
867
868 return c.json({ ok: true });
869});
870
871apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
872 const { owner, repo } = c.req.param();
873 const num = parseInt(c.req.param("number"), 10);
874 const user = c.get("user")!;
875 const body = await c.req.json<{ body: string }>();
876
877 if (!body.body?.trim()) {
878 return c.json({ error: "Comment body is required" }, 400);
879 }
880
881 const resolved = await resolveRepo(owner, repo);
882 if (!resolved) return c.json({ error: "Not found" }, 404);
883
884 const [issue] = await db
885 .select()
886 .from(issues)
887 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
888 .limit(1);
889
890 if (!issue) return c.json({ error: "Issue not found" }, 404);
891
892 const result = await db
893 .insert(issueComments)
894 .values({
895 issueId: issue.id,
896 authorId: user.id,
897 body: body.body.trim(),
898 })
899 .returning();
900
901 return c.json(result[0], 201);
902});
903
904// ─── Pull Requests ──────────────────────────────────────────────────────────
905
906apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
907 const { owner, repo } = c.req.param();
908 const state = c.req.query("state") || "open";
8c09fb9Claude909 // Match the issue-list pagination contract: default 30, max 100,
910 // 0-indexed offset for cursor-style scrolling. Bounded so a buggy
911 // client can't accidentally pull the whole table.
912 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit")) || 30));
913 const offset = Math.max(0, Number(c.req.query("offset")) || 0);
45e31d0Claude914
915 const resolved = await resolveRepo(owner, repo);
916 if (!resolved) return c.json({ error: "Not found" }, 404);
917
918 const prList = await db
919 .select({
920 pr: pullRequests,
921 author: { username: users.username },
922 })
923 .from(pullRequests)
924 .innerJoin(users, eq(pullRequests.authorId, users.id))
925 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
8c09fb9Claude926 .orderBy(desc(pullRequests.createdAt))
927 .limit(limit)
928 .offset(offset);
45e31d0Claude929
930 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
931});
932
933apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
934 const { owner, repo } = c.req.param();
935 const user = c.get("user")!;
936 const body = await c.req.json<{
937 title: string;
938 body?: string;
939 baseBranch: string;
940 headBranch: string;
941 }>();
942
943 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
944 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
945 }
946
947 const resolved = await resolveRepo(owner, repo);
948 if (!resolved) return c.json({ error: "Not found" }, 404);
949
950 const result = await db
951 .insert(pullRequests)
952 .values({
953 repositoryId: (resolved.repo as any).id,
954 authorId: user.id,
955 title: body.title.trim(),
956 body: body.body?.trim() || null,
957 baseBranch: body.baseBranch,
958 headBranch: body.headBranch,
959 })
960 .returning();
961
962 return c.json(result[0], 201);
963});
964
965apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
966 const { owner, repo } = c.req.param();
967 const num = parseInt(c.req.param("number"), 10);
968
969 const resolved = await resolveRepo(owner, repo);
970 if (!resolved) return c.json({ error: "Not found" }, 404);
971
972 const [pr] = await db
973 .select()
974 .from(pullRequests)
975 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
976 .limit(1);
977
978 if (!pr) return c.json({ error: "PR not found" }, 404);
979
980 const comments = await db
981 .select({
982 comment: prComments,
983 author: { username: users.username },
984 })
985 .from(prComments)
986 .innerJoin(users, eq(prComments.authorId, users.id))
987 .where(eq(prComments.pullRequestId, pr.id))
988 .orderBy(asc(prComments.createdAt));
989
990 return c.json({
991 ...pr,
992 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
993 });
994});
995
052c2e6Claude996// ─── PR Comments (GateTest integration) ────────────────────────────────────
997
998apiv2.post(
999 "/repos/:owner/:repo/pulls/:number/comments",
1000 requireApiAuth,
1001 requireScope("repo"),
1002 async (c) => {
1003 const { owner, repo } = c.req.param();
1004 const num = parseInt(c.req.param("number"), 10);
1005 const user = c.get("user")!;
1006
1007 let body: { body?: string } = {};
1008 try {
1009 body = await c.req.json();
1010 } catch {
1011 body = {};
1012 }
1013 if (!body.body?.trim()) {
1014 return c.json({ error: "Comment body is required" }, 400);
1015 }
1016
1017 const resolved = await resolveRepo(owner, repo);
1018 if (!resolved) return c.json({ error: "Not found" }, 404);
1019 if (user.id !== (resolved.owner as any).id) {
1020 return c.json({ error: "Forbidden" }, 403);
1021 }
1022
1023 const [pr] = await db
1024 .select()
1025 .from(pullRequests)
1026 .where(
1027 and(
1028 eq(pullRequests.repositoryId, (resolved.repo as any).id),
1029 eq(pullRequests.number, num)
1030 )
1031 )
1032 .limit(1);
1033 if (!pr) return c.json({ error: "PR not found" }, 404);
1034
1035 const [comment] = await db
1036 .insert(prComments)
1037 .values({
1038 pullRequestId: pr.id,
1039 authorId: user.id,
1040 body: body.body.trim(),
1041 })
1042 .returning();
1043
1044 return c.json({ ok: true, comment }, 201);
1045 }
1046);
1047
1048// ─── Git refs — create branch / tag from sha ────────────────────────────────
1049
1050apiv2.post(
1051 "/repos/:owner/:repo/git/refs",
1052 requireApiAuth,
1053 requireScope("repo"),
1054 async (c) => {
1055 const { owner, repo } = c.req.param();
1056 const user = c.get("user")!;
1057
1058 let body: { ref?: string; sha?: string } = {};
1059 try {
1060 body = await c.req.json();
1061 } catch {
1062 body = {};
1063 }
1064 const ref = body.ref?.trim();
1065 const sha = body.sha?.trim();
1066
1067 if (!ref || !/^refs\/(heads|tags)\/.+/.test(ref)) {
1068 return c.json({ error: "ref must be of the form refs/heads/... or refs/tags/..." }, 400);
1069 }
1070 if (!sha || !/^[0-9a-f]{40}$/.test(sha)) {
1071 return c.json({ error: "sha must be a 40-char lowercase hex string" }, 400);
1072 }
1073
1074 const resolved = await resolveRepo(owner, repo);
1075 if (!resolved) return c.json({ error: "Not found" }, 404);
1076 if (user.id !== (resolved.owner as any).id) {
1077 return c.json({ error: "Forbidden" }, 403);
1078 }
1079
1080 // Verify sha reachable.
1081 if (!(await objectExists(owner, repo, sha))) {
1082 return c.json({ error: "sha not found in repository" }, 400);
1083 }
1084
1085 // Conflict check: if ref already exists, the existing sha must match.
1086 if (await refExists(owner, repo, ref)) {
1087 const existing = await resolveRef(owner, repo, ref);
1088 if (existing !== sha) {
1089 return c.json({ error: "ref already exists", existing }, 409);
1090 }
1091 }
1092
1093 const ok = await updateRef(owner, repo, ref, sha);
1094 if (!ok) return c.json({ error: "Failed to create ref" }, 500);
1095
1096 return c.json({ ok: true, ref, sha }, 201);
1097 }
1098);
1099
1100// ─── Contents PUT — create/update a file via git plumbing ────────────────────
1101
1102apiv2.put(
1103 "/repos/:owner/:repo/contents/:path{.+$}",
1104 requireApiAuth,
1105 requireScope("repo"),
1106 async (c) => {
1107 const { owner, repo } = c.req.param();
1108 const filePath = c.req.param("path");
1109 const user = c.get("user")!;
1110
1111 let body: {
1112 message?: string;
1113 content?: string;
1114 branch?: string;
1115 sha?: string | null;
1116 } = {};
1117 try {
1118 body = await c.req.json();
1119 } catch {
1120 body = {};
1121 }
1122
1123 const message = body.message?.trim();
1124 const branch = body.branch?.trim();
1125 const base64 = body.content;
1126
1127 if (!message) return c.json({ error: "message is required" }, 400);
1128 if (!branch) return c.json({ error: "branch is required" }, 400);
1129 if (typeof base64 !== "string") return c.json({ error: "content (base64) is required" }, 400);
1130
1131 let bytes: Uint8Array;
1132 try {
1133 bytes = new Uint8Array(Buffer.from(base64, "base64"));
1134 } catch {
1135 return c.json({ error: "content is not valid base64" }, 400);
1136 }
1137 if (bytes.length > CONTENTS_MAX_BYTES) {
1138 return c.json({ error: "File too large" }, 413);
1139 }
1140
1141 const resolved = await resolveRepo(owner, repo);
1142 if (!resolved) return c.json({ error: "Not found" }, 404);
1143 if (user.id !== (resolved.owner as any).id) {
1144 return c.json({ error: "Forbidden" }, 403);
1145 }
1146
1147 const result = await createOrUpdateFileOnBranch({
1148 owner,
1149 name: repo,
1150 branch,
1151 filePath,
1152 bytes,
1153 message,
1154 authorName: (user as any).displayName || user.username,
1155 authorEmail: user.email,
1156 expectBlobSha: body.sha ?? null,
1157 });
1158
1159 if ("error" in result) {
1160 if (result.error === "sha-mismatch") {
1161 return c.json({ error: "sha does not match current blob at path" }, 409);
1162 }
1163 return c.json({ error: "Failed to write file" }, 500);
1164 }
1165
1166 return c.json(
1167 {
1168 ok: true,
1169 commit: { sha: result.commitSha, message },
1170 content: { path: filePath, sha: result.blobSha },
1171 },
1172 201
1173 );
1174 }
1175);
1176
da50b83Claude1177// ─── Helper: shell out to git in a bare repo ─────────────────────────────────
1178//
1179// Mirrors the pattern in src/git/repository.ts. Kept inline here so the
1180// plumbing endpoints below don't have to leak through the helper module.
1181
1182async function runGit(
1183 cmd: string[],
1184 opts: { cwd: string; env?: Record<string, string>; stdin?: Uint8Array }
1185): Promise<{ stdout: string; stderr: string; exitCode: number }> {
1186 const proc = Bun.spawn(cmd, {
1187 cwd: opts.cwd,
1188 env: { ...process.env, ...opts.env },
1189 stdin: opts.stdin ? "pipe" : "ignore",
1190 stdout: "pipe",
1191 stderr: "pipe",
1192 });
1193 if (opts.stdin && proc.stdin) {
1194 proc.stdin.write(opts.stdin);
1195 proc.stdin.end();
1196 }
1197 const [stdout, stderr] = await Promise.all([
1198 new Response(proc.stdout).text(),
1199 new Response(proc.stderr).text(),
1200 ]);
1201 const exitCode = await proc.exited;
1202 return { stdout, stderr, exitCode };
1203}
1204
1205function htmlUrlForCommit(owner: string, repo: string, sha: string): string {
1206 return `${config.appBaseUrl}/${owner}/${repo}/commit/${sha}`;
1207}
1208
1209// ─── Contents DELETE — remove a file via git plumbing ────────────────────────
1210//
1211// Body: { message, sha, branch? }
1212// - `sha` is the current blob sha at `:path`; mismatch → 409 (optimistic
1213// concurrency, matches GitHub's `DELETE /repos/.../contents/...` semantics).
1214// - `branch` defaults to the repo's default branch.
1215
1216apiv2.delete(
1217 "/repos/:owner/:repo/contents/:path{.+$}",
1218 requireApiAuth,
1219 requireScope("repo"),
1220 async (c) => {
1221 const { owner, repo } = c.req.param();
1222 const filePath = c.req.param("path");
1223 const user = c.get("user")!;
1224
1225 let body: { message?: string; sha?: string; branch?: string } = {};
1226 try {
1227 body = await c.req.json();
1228 } catch {
1229 body = {};
1230 }
1231
1232 const message = body.message?.trim();
1233 const expectSha = body.sha?.trim();
1234 if (!message) return c.json({ error: "message is required" }, 400);
1235 if (!expectSha || !/^[0-9a-f]{40}$/.test(expectSha)) {
1236 return c.json({ error: "sha is required (40-hex)" }, 400);
1237 }
1238
1239 const resolved = await resolveRepo(owner, repo);
1240 if (!resolved) return c.json({ error: "Not found" }, 404);
1241 if (user.id !== (resolved.owner as any).id) {
1242 return c.json({ error: "Forbidden" }, 403);
1243 }
1244
1245 const branch =
1246 body.branch?.trim() ||
1247 (await getDefaultBranchFresh(owner, repo)) ||
1248 "main";
1249 const fullRef = `refs/heads/${branch}`;
1250 const repoDir = getRepoPath(owner, repo);
1251
1252 // Resolve current parent + existing blob sha at that path.
1253 const parentSha = await resolveRef(owner, repo, fullRef);
1254 if (!parentSha) return c.json({ error: "Branch not found" }, 404);
1255
1256 const existingBlobSha = await getBlobShaAtPath(owner, repo, branch, filePath);
1257 if (!existingBlobSha) return c.json({ error: "File not found" }, 404);
1258 if (existingBlobSha !== expectSha) {
1259 return c.json({ error: "sha does not match current blob at path" }, 409);
1260 }
1261
1262 const tmpIndex = join(
1263 repoDir,
1264 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1265 );
296ee49Claude1266 // `update-index --remove` checks `is_inside_work_tree()`, so a bare
1267 // repo needs a transient stand-in. Empty directory is sufficient —
1268 // git only consults it for safety checks, never writes blobs through it.
1269 const tmpWorkTree = join(
1270 repoDir,
1271 `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1272 );
1273 const { mkdir } = await import("fs/promises");
1274 await mkdir(tmpWorkTree, { recursive: true });
1275
da50b83Claude1276 const authorName = (user as any).displayName || user.username;
1277 const authorEmail = user.email;
1278 const env = {
1279 GIT_INDEX_FILE: tmpIndex,
296ee49Claude1280 GIT_DIR: repoDir,
1281 GIT_WORK_TREE: tmpWorkTree,
da50b83Claude1282 GIT_AUTHOR_NAME: authorName,
1283 GIT_AUTHOR_EMAIL: authorEmail,
1284 GIT_COMMITTER_NAME: authorName,
1285 GIT_COMMITTER_EMAIL: authorEmail,
1286 };
1287
1288 const cleanup = async () => {
1289 try {
296ee49Claude1290 const { unlink, rm } = await import("fs/promises");
1291 await unlink(tmpIndex).catch(() => {});
1292 await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {});
da50b83Claude1293 } catch {
1294 /* ignore */
1295 }
1296 };
1297
1298 try {
1299 const rt = await runGit(["git", "read-tree", parentSha], {
1300 cwd: repoDir,
1301 env,
1302 });
1303 if (rt.exitCode !== 0) {
1304 await cleanup();
1305 return c.json({ error: "Failed to read base tree" }, 500);
1306 }
1307
1308 const ui = await runGit(
1309 ["git", "update-index", "--remove", filePath],
1310 { cwd: repoDir, env }
1311 );
1312 if (ui.exitCode !== 0) {
1313 await cleanup();
1314 return c.json({ error: "Failed to remove path from index" }, 500);
1315 }
1316
1317 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1318 const newTreeSha = wt.stdout.trim();
1319 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(newTreeSha)) {
1320 await cleanup();
1321 return c.json({ error: "Failed to write tree" }, 500);
1322 }
1323
1324 const ct = await runGit(
1325 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
1326 { cwd: repoDir, env }
1327 );
1328 const commitSha = ct.stdout.trim();
1329 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1330 await cleanup();
1331 return c.json({ error: "Failed to create commit" }, 500);
1332 }
1333
1334 const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha);
1335 if (!ok) {
1336 await cleanup();
1337 return c.json({ error: "Failed to update ref" }, 500);
1338 }
1339
1340 await cleanup();
1341 return c.json({
1342 commit: {
1343 sha: commitSha,
1344 message,
1345 html_url: htmlUrlForCommit(owner, repo, commitSha),
1346 author: { name: authorName, email: authorEmail },
1347 },
1348 });
1349 } catch {
1350 await cleanup();
1351 return c.json({ error: "Failed to delete file" }, 500);
1352 }
1353 }
1354);
1355
1356// ─── Git plumbing: refs / commits / blobs / trees ────────────────────────────
1357
1358// GET /repos/:owner/:repo/git/refs/heads/:branch
1359apiv2.get(
1360 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1361 async (c) => {
1362 const { owner, repo } = c.req.param();
1363 const branch = c.req.param("branch");
1364 if (!(await repoExists(owner, repo))) {
1365 return c.json({ error: "Not found" }, 404);
1366 }
1367 const fullRef = `refs/heads/${branch}`;
1368 const sha = await resolveRef(owner, repo, fullRef);
1369 if (!sha) return c.json({ error: "Reference not found" }, 404);
1370 return c.json({
1371 ref: fullRef,
1372 object: { sha, type: "commit" },
1373 });
1374 }
1375);
1376
1377// GET /repos/:owner/:repo/git/commits/:sha
1378apiv2.get("/repos/:owner/:repo/git/commits/:sha", async (c) => {
1379 const { owner, repo, sha } = c.req.param();
1380 if (!(await repoExists(owner, repo))) {
1381 return c.json({ error: "Not found" }, 404);
1382 }
1383 const commit = await getCommit(owner, repo, sha);
1384 if (!commit) return c.json({ error: "Commit not found" }, 404);
1385
1386 // Resolve tree sha for the commit (cat-file <sha>^{tree}).
1387 const repoDir = getRepoPath(owner, repo);
1388 const { stdout: treeOut } = await runGit(
1389 ["git", "rev-parse", `${commit.sha}^{tree}`],
1390 { cwd: repoDir }
1391 );
1392 const treeSha = treeOut.trim();
1393
1394 return c.json({
1395 sha: commit.sha,
1396 tree: { sha: treeSha },
1397 parents: commit.parentShas.map((p) => ({ sha: p })),
1398 message: commit.message,
1399 author: {
1400 name: commit.author,
1401 email: commit.authorEmail,
1402 date: commit.date,
1403 },
1404 });
1405});
1406
1407// POST /repos/:owner/:repo/git/blobs
1408apiv2.post(
1409 "/repos/:owner/:repo/git/blobs",
1410 requireApiAuth,
1411 requireScope("repo"),
1412 async (c) => {
1413 const { owner, repo } = c.req.param();
1414 const user = c.get("user")!;
1415
1416 let body: { content?: string; encoding?: string } = {};
1417 try {
1418 body = await c.req.json();
1419 } catch {
1420 body = {};
1421 }
1422 const content = body.content;
1423 const encoding = (body.encoding || "utf-8").toLowerCase();
1424 if (typeof content !== "string") {
1425 return c.json({ error: "content is required" }, 400);
1426 }
1427 if (encoding !== "utf-8" && encoding !== "utf8" && encoding !== "base64") {
1428 return c.json({ error: "encoding must be 'utf-8' or 'base64'" }, 400);
1429 }
1430
1431 const resolved = await resolveRepo(owner, repo);
1432 if (!resolved) return c.json({ error: "Not found" }, 404);
1433 if (user.id !== (resolved.owner as any).id) {
1434 return c.json({ error: "Forbidden" }, 403);
1435 }
1436
1437 let bytes: Uint8Array;
1438 try {
1439 if (encoding === "base64") {
1440 bytes = new Uint8Array(Buffer.from(content, "base64"));
1441 } else {
1442 bytes = new TextEncoder().encode(content);
1443 }
1444 } catch {
1445 return c.json({ error: "Failed to decode content" }, 400);
1446 }
1447
1448 const sha = await writeBlob(owner, repo, bytes);
1449 if (!sha) return c.json({ error: "Failed to write blob" }, 500);
1450
1451 return c.json(
1452 {
1453 sha,
1454 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/blobs/${sha}`,
1455 size: bytes.length,
1456 },
1457 201
1458 );
1459 }
1460);
1461
1462// POST /repos/:owner/:repo/git/trees
1463//
1464// Body: { base_tree?: <tree_sha>, tree: [{ path, mode, type: "blob", sha: <blob_sha> | null }] }
1465// `sha: null` removes the entry from base_tree.
1466apiv2.post(
1467 "/repos/:owner/:repo/git/trees",
1468 requireApiAuth,
1469 requireScope("repo"),
1470 async (c) => {
1471 const { owner, repo } = c.req.param();
1472 const user = c.get("user")!;
1473
1474 let body: {
1475 base_tree?: string;
1476 tree?: Array<{
1477 path?: string;
1478 mode?: string;
1479 type?: string;
1480 sha?: string | null;
1481 }>;
1482 } = {};
1483 try {
1484 body = await c.req.json();
1485 } catch {
1486 body = {};
1487 }
1488
1489 if (!Array.isArray(body.tree)) {
1490 return c.json({ error: "tree array is required" }, 400);
1491 }
1492 for (const entry of body.tree) {
1493 if (!entry.path || typeof entry.path !== "string") {
1494 return c.json({ error: "each tree entry needs a path" }, 400);
1495 }
1496 if (!entry.mode || typeof entry.mode !== "string") {
1497 return c.json({ error: "each tree entry needs a mode" }, 400);
1498 }
1499 if (entry.sha !== null && typeof entry.sha !== "string") {
1500 return c.json(
1501 { error: "each tree entry needs sha (40-hex) or null to delete" },
1502 400
1503 );
1504 }
1505 if (typeof entry.sha === "string" && !/^[0-9a-f]{40}$/.test(entry.sha)) {
1506 return c.json({ error: "sha must be 40-hex" }, 400);
1507 }
1508 }
1509 if (body.base_tree && !/^[0-9a-f]{40}$/.test(body.base_tree)) {
1510 return c.json({ error: "base_tree must be 40-hex" }, 400);
1511 }
1512
1513 const resolved = await resolveRepo(owner, repo);
1514 if (!resolved) return c.json({ error: "Not found" }, 404);
1515 if (user.id !== (resolved.owner as any).id) {
1516 return c.json({ error: "Forbidden" }, 403);
1517 }
1518
1519 const repoDir = getRepoPath(owner, repo);
1520 const tmpIndex = join(
1521 repoDir,
1522 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1523 );
1524 const env = { GIT_INDEX_FILE: tmpIndex };
1525
1526 const cleanup = async () => {
1527 try {
1528 const { unlink } = await import("fs/promises");
1529 await unlink(tmpIndex);
1530 } catch {
1531 /* ignore */
1532 }
1533 };
1534
1535 try {
1536 if (body.base_tree) {
1537 const rt = await runGit(["git", "read-tree", body.base_tree], {
1538 cwd: repoDir,
1539 env,
1540 });
1541 if (rt.exitCode !== 0) {
1542 await cleanup();
1543 return c.json({ error: "base_tree not found" }, 404);
1544 }
1545 }
1546
1547 for (const entry of body.tree) {
1548 if (entry.sha === null) {
1549 const r = await runGit(
1550 ["git", "update-index", "--remove", entry.path!],
1551 { cwd: repoDir, env }
1552 );
1553 if (r.exitCode !== 0) {
1554 await cleanup();
1555 return c.json(
1556 { error: `Failed to remove ${entry.path}` },
1557 422
1558 );
1559 }
1560 } else {
1561 const r = await runGit(
1562 [
1563 "git",
1564 "update-index",
1565 "--add",
1566 "--cacheinfo",
1567 `${entry.mode},${entry.sha},${entry.path}`,
1568 ],
1569 { cwd: repoDir, env }
1570 );
1571 if (r.exitCode !== 0) {
1572 await cleanup();
1573 return c.json(
1574 { error: `Failed to add ${entry.path}` },
1575 422
1576 );
1577 }
1578 }
1579 }
1580
1581 const wt = await runGit(["git", "write-tree"], { cwd: repoDir, env });
1582 const treeSha = wt.stdout.trim();
1583 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(treeSha)) {
1584 await cleanup();
1585 return c.json({ error: "Failed to write tree" }, 500);
1586 }
1587
1588 // List entries in the new tree (one level deep, matching GitHub's
1589 // POST /git/trees response shape).
1590 const ls = await runGit(["git", "ls-tree", treeSha], { cwd: repoDir });
1591 const entries: Array<{
1592 path: string;
1593 mode: string;
1594 type: string;
1595 sha: string;
1596 }> = [];
1597 for (const line of ls.stdout.split("\n").filter(Boolean)) {
1598 const m = line.match(
1599 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\t(.+)$/
1600 );
1601 if (m) {
1602 entries.push({
1603 mode: m[1],
1604 type: m[2],
1605 sha: m[3],
1606 path: m[4],
1607 });
1608 }
1609 }
1610
1611 await cleanup();
1612 return c.json(
1613 {
1614 sha: treeSha,
1615 url: `${config.appBaseUrl}/api/v2/repos/${owner}/${repo}/git/trees/${treeSha}`,
1616 tree: entries,
1617 },
1618 201
1619 );
1620 } catch {
1621 await cleanup();
1622 return c.json({ error: "Failed to write tree" }, 500);
1623 }
1624 }
1625);
1626
1627// POST /repos/:owner/:repo/git/commits
1628//
1629// Body: { message, tree, parents: [<sha>] }
1630apiv2.post(
1631 "/repos/:owner/:repo/git/commits",
1632 requireApiAuth,
1633 requireScope("repo"),
1634 async (c) => {
1635 const { owner, repo } = c.req.param();
1636 const user = c.get("user")!;
1637
1638 let body: { message?: string; tree?: string; parents?: string[] } = {};
1639 try {
1640 body = await c.req.json();
1641 } catch {
1642 body = {};
1643 }
1644
1645 const message = body.message?.trim();
1646 const tree = body.tree?.trim();
1647 const parents = Array.isArray(body.parents) ? body.parents : [];
1648
1649 if (!message) return c.json({ error: "message is required" }, 400);
1650 if (!tree || !/^[0-9a-f]{40}$/.test(tree)) {
1651 return c.json({ error: "tree must be 40-hex" }, 400);
1652 }
1653 for (const p of parents) {
1654 if (typeof p !== "string" || !/^[0-9a-f]{40}$/.test(p)) {
1655 return c.json({ error: "each parent must be 40-hex" }, 400);
1656 }
1657 }
1658
1659 const resolved = await resolveRepo(owner, repo);
1660 if (!resolved) return c.json({ error: "Not found" }, 404);
1661 if (user.id !== (resolved.owner as any).id) {
1662 return c.json({ error: "Forbidden" }, 403);
1663 }
1664
1665 // Verify tree object exists.
1666 if (!(await objectExists(owner, repo, tree))) {
1667 return c.json({ error: "tree not found in repository" }, 422);
1668 }
1669 for (const p of parents) {
1670 if (!(await objectExists(owner, repo, p))) {
1671 return c.json({ error: `parent ${p} not found in repository` }, 422);
1672 }
1673 }
1674
1675 const repoDir = getRepoPath(owner, repo);
1676 const authorName = (user as any).displayName || user.username;
1677 const authorEmail = user.email;
1678 const env = {
1679 GIT_AUTHOR_NAME: authorName,
1680 GIT_AUTHOR_EMAIL: authorEmail,
1681 GIT_COMMITTER_NAME: authorName,
1682 GIT_COMMITTER_EMAIL: authorEmail,
1683 };
1684
1685 const args = ["git", "commit-tree", tree];
1686 for (const p of parents) {
1687 args.push("-p", p);
1688 }
1689 args.push("-m", message);
1690
1691 const ct = await runGit(args, { cwd: repoDir, env });
1692 const commitSha = ct.stdout.trim();
1693 if (ct.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) {
1694 return c.json({ error: "Failed to create commit" }, 500);
1695 }
1696
1697 // Re-read the new commit's recorded date so the response carries the
1698 // exact ISO timestamp git wrote into the object.
1699 const recorded = await getCommit(owner, repo, commitSha);
1700 const date = recorded?.date ?? new Date().toISOString();
1701
1702 return c.json(
1703 {
1704 sha: commitSha,
1705 tree: { sha: tree },
1706 message,
1707 parents: parents.map((p) => ({ sha: p })),
1708 author: { name: authorName, email: authorEmail, date },
1709 html_url: htmlUrlForCommit(owner, repo, commitSha),
1710 },
1711 201
1712 );
1713 }
1714);
1715
1716// PATCH /repos/:owner/:repo/git/refs/heads/:branch
1717//
1718// Body: { sha: <new_commit>, force?: false }
1719apiv2.patch(
1720 "/repos/:owner/:repo/git/refs/heads/:branch{.+$}",
1721 requireApiAuth,
1722 requireScope("repo"),
1723 async (c) => {
1724 const { owner, repo } = c.req.param();
1725 const branch = c.req.param("branch");
1726 const user = c.get("user")!;
1727
1728 let body: { sha?: string; force?: boolean } = {};
1729 try {
1730 body = await c.req.json();
1731 } catch {
1732 body = {};
1733 }
1734
1735 const newSha = body.sha?.trim();
1736 const force = body.force === true;
1737 if (!newSha || !/^[0-9a-f]{40}$/.test(newSha)) {
1738 return c.json({ error: "sha must be 40-hex" }, 400);
1739 }
1740
1741 const resolved = await resolveRepo(owner, repo);
1742 if (!resolved) return c.json({ error: "Not found" }, 404);
1743 if (user.id !== (resolved.owner as any).id) {
1744 return c.json({ error: "Forbidden" }, 403);
1745 }
1746
1747 const fullRef = `refs/heads/${branch}`;
1748 const currentSha = await resolveRef(owner, repo, fullRef);
1749 if (!currentSha) return c.json({ error: "Reference not found" }, 404);
1750
1751 if (!(await objectExists(owner, repo, newSha))) {
1752 return c.json({ error: "sha not found in repository" }, 422);
1753 }
1754
1755 if (!force) {
1756 // Fast-forward check: currentSha must be an ancestor of newSha.
1757 const repoDir = getRepoPath(owner, repo);
1758 const ff = await runGit(
1759 ["git", "merge-base", "--is-ancestor", currentSha, newSha],
1760 { cwd: repoDir }
1761 );
1762 if (ff.exitCode !== 0) {
1763 return c.json(
1764 { error: "Update is not a fast-forward" },
1765 422
1766 );
1767 }
1768 }
1769
1770 const ok = force
1771 ? await updateRef(owner, repo, fullRef, newSha)
1772 : await updateRef(owner, repo, fullRef, newSha, currentSha);
1773 if (!ok) return c.json({ error: "Failed to update ref" }, 500);
1774
1775 return c.json({
1776 ref: fullRef,
1777 object: { sha: newSha, type: "commit" },
1778 });
1779 }
1780);
1781
052c2e6Claude1782// ─── v2 alias for commit-status POST ─────────────────────────────────────────
1783// Reuses the handler from src/routes/commit-statuses.ts (v1 mount).
1784apiv2.post(
1785 "/repos/:owner/:repo/statuses/:sha",
1786 requireApiAuth,
1787 requireScope("repo"),
1788 postCommitStatusHandler
1789);
1790
45e31d0Claude1791// ─── Stars ──────────────────────────────────────────────────────────────────
1792
1793apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
1794 const { owner, repo } = c.req.param();
1795 const user = c.get("user")!;
1796
1797 const resolved = await resolveRepo(owner, repo);
1798 if (!resolved) return c.json({ error: "Not found" }, 404);
1799
1800 const repoId = (resolved.repo as any).id;
1801 const [existing] = await db
1802 .select()
1803 .from(stars)
1804 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
1805 .limit(1);
1806
1807 if (!existing) {
1808 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
1809 await db
1810 .update(repositories)
1811 .set({ starCount: sql`${repositories.starCount} + 1` })
1812 .where(eq(repositories.id, repoId));
1813 }
1814
1815 return c.json({ starred: true });
1816});
1817
1818apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
1819 const { owner, repo } = c.req.param();
1820 const user = c.get("user")!;
1821
1822 const resolved = await resolveRepo(owner, repo);
1823 if (!resolved) return c.json({ error: "Not found" }, 404);
1824
1825 const repoId = (resolved.repo as any).id;
1826 const [existing] = await db
1827 .select()
1828 .from(stars)
1829 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
1830 .limit(1);
1831
1832 if (existing) {
1833 await db.delete(stars).where(eq(stars.id, existing.id));
1834 await db
1835 .update(repositories)
1836 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
1837 .where(eq(repositories.id, repoId));
1838 }
1839
1840 return c.json({ starred: false });
1841});
1842
1843// ─── Labels ─────────────────────────────────────────────────────────────────
1844
1845apiv2.get("/repos/:owner/:repo/labels", async (c) => {
1846 const { owner, repo } = c.req.param();
1847 const resolved = await resolveRepo(owner, repo);
1848 if (!resolved) return c.json({ error: "Not found" }, 404);
1849
1850 const labelList = await db
1851 .select()
1852 .from(labels)
1853 .where(eq(labels.repositoryId, (resolved.repo as any).id))
1854 .orderBy(asc(labels.name));
1855
1856 return c.json(labelList);
1857});
1858
1859apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
1860 const { owner, repo } = c.req.param();
1861 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
1862
1863 if (!body.name?.trim()) {
1864 return c.json({ error: "Label name is required" }, 400);
1865 }
1866
1867 const resolved = await resolveRepo(owner, repo);
1868 if (!resolved) return c.json({ error: "Not found" }, 404);
1869
1870 const result = await db
1871 .insert(labels)
1872 .values({
1873 repositoryId: (resolved.repo as any).id,
1874 name: body.name.trim(),
1875 color: body.color || "#8b949e",
1876 description: body.description || null,
1877 })
1878 .returning();
1879
1880 return c.json(result[0], 201);
1881});
1882
1883// ─── Search ─────────────────────────────────────────────────────────────────
1884
1885apiv2.get("/search/repos", searchRateLimit, async (c) => {
1886 const q = c.req.query("q") || "";
1887 const sort = c.req.query("sort") || "stars";
1888 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1889
1890 if (!q.trim()) {
1891 return c.json({ error: "Query parameter 'q' is required" }, 400);
1892 }
1893
1894 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
1895 sort === "name" ? asc(repositories.name) :
1896 desc(repositories.starCount);
1897
1898 const results = await db
1899 .select({
1900 repo: repositories,
1901 owner: { username: users.username },
1902 })
1903 .from(repositories)
1904 .innerJoin(users, eq(repositories.ownerId, users.id))
1905 .where(
1906 and(
1907 eq(repositories.isPrivate, false),
1908 or(
1909 like(repositories.name, `%${q}%`),
1910 like(repositories.description, `%${q}%`)
1911 )
1912 )
1913 )
1914 .orderBy(orderBy)
1915 .limit(limit);
1916
1917 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
1918});
1919
1920apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
1921 const { owner, repo } = c.req.param();
1922 const q = c.req.query("q") || "";
1923
1924 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
1925 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
1926
1927 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1928 const results = await searchCode(owner, repo, defaultBranch, q.trim());
1929 return c.json(results);
1930});
1931
1932// ─── Activity Feed ──────────────────────────────────────────────────────────
1933
1934apiv2.get("/repos/:owner/:repo/activity", async (c) => {
1935 const { owner, repo } = c.req.param();
1936 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
1937
1938 const resolved = await resolveRepo(owner, repo);
1939 if (!resolved) return c.json({ error: "Not found" }, 404);
1940
1941 const activity = await db
1942 .select()
1943 .from(activityFeed)
1944 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
1945 .orderBy(desc(activityFeed.createdAt))
1946 .limit(limit);
1947
1948 return c.json(activity);
1949});
1950
1951// ─── Topics ─────────────────────────────────────────────────────────────────
1952
1953apiv2.get("/repos/:owner/:repo/topics", async (c) => {
1954 const { owner, repo } = c.req.param();
1955 const resolved = await resolveRepo(owner, repo);
1956 if (!resolved) return c.json({ error: "Not found" }, 404);
1957
1958 const topicsList = await db
1959 .select()
1960 .from(repoTopics)
1961 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
1962
1963 return c.json(topicsList.map((t: any) => t.topic));
1964});
1965
1966apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
1967 const { owner, repo } = c.req.param();
1968 const user = c.get("user")!;
1969 const body = await c.req.json<{ topics: string[] }>();
1970
1971 const resolved = await resolveRepo(owner, repo);
1972 if (!resolved) return c.json({ error: "Not found" }, 404);
1973 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
1974
1975 const repoId = (resolved.repo as any).id;
1976
1977 // Clear existing topics and insert new ones
1978 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
1979 if (body.topics && body.topics.length > 0) {
1980 await db.insert(repoTopics).values(
1981 body.topics.slice(0, 20).map((topic: string) => ({
1982 repositoryId: repoId,
1983 topic: topic.toLowerCase().trim(),
1984 }))
1985 );
1986 }
1987
1988 return c.json({ ok: true });
1989});
1990
1991// ─── Webhooks ───────────────────────────────────────────────────────────────
1992
1993apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
1994 const { owner, repo } = c.req.param();
1995 const user = c.get("user")!;
1996
1997 const resolved = await resolveRepo(owner, repo);
1998 if (!resolved) return c.json({ error: "Not found" }, 404);
1999 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2000
2001 const hookList = await db
2002 .select()
2003 .from(webhooks)
2004 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
2005
2006 return c.json(hookList);
2007});
2008
2009apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
2010 const { owner, repo } = c.req.param();
2011 const user = c.get("user")!;
2012 const body = await c.req.json<{
2013 url: string;
2014 secret?: string;
2015 events?: string;
2016 }>();
2017
2018 if (!body.url) return c.json({ error: "URL is required" }, 400);
2019
2020 const resolved = await resolveRepo(owner, repo);
2021 if (!resolved) return c.json({ error: "Not found" }, 404);
2022 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
2023
2024 const result = await db
2025 .insert(webhooks)
2026 .values({
2027 repositoryId: (resolved.repo as any).id,
2028 url: body.url,
2029 secret: body.secret || null,
2030 events: body.events || "push",
2031 })
2032 .returning();
2033
2034 return c.json(result[0], 201);
2035});
2036
4366c70Claude2037// ─── Actions / Workflows ────────────────────────────────────────────────────
2038//
2039// GitHub-Actions-compatible REST surface (subset).
2040//
2041// POST /repos/:owner/:repo/actions/workflows/:filename/dispatches
2042// GET /repos/:owner/:repo/actions/workflows/:filename/runs
2043// GET /repos/:owner/:repo/actions/runs/:run_id
2044// GET /repos/:owner/:repo/actions/runs/:run_id/logs (.zip)
2045// POST /repos/:owner/:repo/actions/runs/:run_id/cancel
2046//
2047// Shapes follow GitHub REST v3 — snake_case fields, HTML URLs back to the
2048// gluecron run page, identical status-code semantics (204 for dispatch, 202
2049// for cancel, 409 for already-terminal, 422 for bad inputs).
2050
2051type ParsedOn =
2052 | string
2053 | string[]
2054 | Record<string, unknown>
2055 | null
2056 | undefined;
2057
2058type DispatchInputSpec = {
2059 type?: string;
2060 required?: boolean;
2061 default?: unknown;
2062 options?: unknown[];
2063 description?: string;
2064};
2065
2066/**
2067 * Pull the workflow_dispatch slice out of whatever shape `parsed.on` happens
2068 * to be. The v1 parser normalises `on` to a `string[]`, but the extended
2069 * parser may store an object — and YAML in the wild can be either form. We
2070 * accept all three: scalar string, array of event names, mapping keyed by
2071 * event name.
2072 */
2073function extractDispatchSpec(rawOn: ParsedOn): {
2074 enabled: boolean;
2075 inputs: Record<string, DispatchInputSpec> | null;
2076} {
2077 if (rawOn == null) return { enabled: false, inputs: null };
2078 if (typeof rawOn === "string") {
2079 return { enabled: rawOn === "workflow_dispatch", inputs: null };
2080 }
2081 if (Array.isArray(rawOn)) {
2082 return { enabled: rawOn.includes("workflow_dispatch"), inputs: null };
2083 }
2084 if (typeof rawOn === "object") {
2085 const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"];
2086 if (slot === undefined) return { enabled: false, inputs: null };
2087 // `workflow_dispatch:` with no children is a valid trigger declaration.
2088 if (slot == null || typeof slot !== "object") {
2089 return { enabled: true, inputs: null };
2090 }
2091 const inputs = (slot as Record<string, unknown>).inputs;
2092 if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
2093 return { enabled: true, inputs: null };
2094 }
2095 return {
2096 enabled: true,
2097 inputs: inputs as Record<string, DispatchInputSpec>,
2098 };
2099 }
2100 return { enabled: false, inputs: null };
2101}
2102
2103function validateDispatchInputs(
2104 schema: Record<string, DispatchInputSpec> | null,
2105 provided: Record<string, unknown> | undefined
2106): { ok: true } | { ok: false; details: string[] } {
2107 if (!schema) return { ok: true };
2108 const details: string[] = [];
2109 const supplied = provided ?? {};
2110 for (const [name, spec] of Object.entries(schema)) {
2111 if (!spec || typeof spec !== "object") continue;
2112 const present =
2113 Object.prototype.hasOwnProperty.call(supplied, name) &&
2114 supplied[name] !== undefined &&
2115 supplied[name] !== null;
2116 if (spec.required && !present) {
2117 // No default → required input is missing.
2118 if (spec.default === undefined) {
2119 details.push(`Missing required input: ${name}`);
2120 }
2121 }
2122 }
2123 if (details.length) return { ok: false, details };
2124 return { ok: true };
2125}
2126
2127/**
2128 * Look up a workflow row by repository + the basename of its `path` column.
2129 * Stored path is `.gluecron/workflows/<filename>`; we match the trailing
2130 * segment so callers don't need to know our on-disk layout.
2131 */
2132async function findWorkflowByFilename(
2133 repositoryId: string,
2134 filename: string
2135): Promise<typeof workflows.$inferSelect | null> {
2136 const rows = await db
2137 .select()
2138 .from(workflows)
2139 .where(eq(workflows.repositoryId, repositoryId));
2140 for (const row of rows) {
2141 const idx = row.path.lastIndexOf("/");
2142 const base = idx >= 0 ? row.path.slice(idx + 1) : row.path;
2143 if (base === filename) return row;
2144 }
2145 return null;
2146}
2147
2148function runHtmlUrl(owner: string, repo: string, runId: string): string {
2149 return `https://gluecron.com/${owner}/${repo}/actions/runs/${runId}`;
2150}
2151
2152function toIso(d: Date | string | null | undefined): string | null {
2153 if (!d) return null;
2154 return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
2155}
2156
2157function serializeRun(
2158 run: typeof workflowRuns.$inferSelect,
2159 workflowName: string,
2160 owner: string,
2161 repo: string
2162): Record<string, unknown> {
2163 // GitHub returns `head_branch` as the short branch name (no refs/heads/).
2164 let head_branch: string | null = null;
2165 if (run.ref) {
2166 head_branch = run.ref.startsWith("refs/heads/")
2167 ? run.ref.slice("refs/heads/".length)
2168 : run.ref;
2169 }
2170 return {
2171 id: run.id,
2172 name: workflowName,
2173 head_branch,
2174 head_sha: run.commitSha,
2175 status: run.status,
2176 conclusion: run.conclusion,
2177 event: run.event,
2178 created_at: toIso(run.queuedAt),
2179 updated_at:
2180 toIso(run.finishedAt) ?? toIso(run.startedAt) ?? toIso(run.queuedAt),
2181 run_started_at: toIso(run.startedAt),
2182 html_url: runHtmlUrl(owner, repo, run.id),
2183 };
2184}
2185
2186// ─── 1. POST /actions/workflows/:filename/dispatches ────────────────────────
2187
2188apiv2.post(
2189 "/repos/:owner/:repo/actions/workflows/:filename/dispatches",
2190 requireApiAuth,
2191 requireScope("repo"),
2192 async (c) => {
2193 const { owner, repo, filename } = c.req.param();
2194 const user = c.get("user")!;
2195
2196 let body: { ref?: unknown; inputs?: unknown } = {};
2197 try {
2198 body = await c.req.json();
2199 } catch {
2200 body = {};
2201 }
2202
2203 const resolved = await resolveRepo(owner, repo);
2204 if (!resolved) return c.json({ error: "Not Found" }, 404);
2205
2206 const repoRow = resolved.repo as any;
2207 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2208 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2209
2210 // Parse the stored workflow JSON to look at its triggers + input schema.
2211 let parsedObj: Record<string, unknown> = {};
2212 try {
2213 const v = JSON.parse(workflowRow.parsed);
2214 if (v && typeof v === "object" && !Array.isArray(v)) {
2215 parsedObj = v as Record<string, unknown>;
2216 }
2217 } catch {
2218 // Treat unparseable parsed-blob as no triggers — falls through to 422.
2219 }
2220
2221 const spec = extractDispatchSpec(parsedObj.on as ParsedOn);
2222 if (!spec.enabled) {
2223 return c.json(
2224 { error: "Workflow does not have a 'workflow_dispatch' trigger." },
2225 422
2226 );
2227 }
2228
2229 const providedInputs =
2230 body.inputs && typeof body.inputs === "object" && !Array.isArray(body.inputs)
2231 ? (body.inputs as Record<string, unknown>)
2232 : undefined;
2233 const inputCheck = validateDispatchInputs(spec.inputs, providedInputs);
2234 if (!inputCheck.ok) {
2235 return c.json(
2236 { error: "Invalid workflow inputs", details: inputCheck.details },
2237 422
2238 );
2239 }
2240
2241 // Resolve the ref → commit SHA. Default to repo default_branch.
2242 const refIn =
2243 typeof body.ref === "string" && body.ref.trim().length > 0
2244 ? body.ref.trim()
2245 : repoRow.defaultBranch || "main";
2246 const commitSha = await resolveRef(owner, repo, refIn);
2247 if (!commitSha) {
2248 return c.json({ error: `Ref not found: ${refIn}` }, 422);
2249 }
2250
2251 const runId = await enqueueRun({
2252 workflowId: workflowRow.id,
2253 repositoryId: repoRow.id,
2254 event: "workflow_dispatch",
2255 ref: refIn,
2256 commitSha,
2257 triggeredBy: user.id,
2258 });
2259 if (!runId) {
2260 return c.json({ error: "Failed to enqueue run" }, 500);
2261 }
2262
2263 // GitHub returns 204 No Content with no body on success.
2264 return c.body(null, 204);
2265 }
2266);
2267
2268// ─── 2. GET /actions/workflows/:filename/runs ───────────────────────────────
2269
2270apiv2.get(
2271 "/repos/:owner/:repo/actions/workflows/:filename/runs",
2272 async (c) => {
2273 const { owner, repo, filename } = c.req.param();
2274 const resolved = await resolveRepo(owner, repo);
2275 if (!resolved) return c.json({ error: "Not Found" }, 404);
2276
2277 const repoRow = resolved.repo as any;
2278 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2279 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2280
2281 const perPage = Math.min(
2282 100,
2283 Math.max(1, parseInt(c.req.query("per_page") || "30", 10) || 30)
2284 );
2285 const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
2286 const offset = (page - 1) * perPage;
2287 const branch = c.req.query("branch");
2288 const headSha = c.req.query("head_sha");
2289
2290 const conditions = [eq(workflowRuns.workflowId, workflowRow.id)];
2291 if (branch) {
2292 // Accept either short branch name or fully-qualified refs/heads/...
2293 const refValue = branch.startsWith("refs/")
2294 ? branch
2295 : `refs/heads/${branch}`;
2296 conditions.push(eq(workflowRuns.ref, refValue));
2297 }
2298 if (headSha) conditions.push(eq(workflowRuns.commitSha, headSha));
2299
2300 const where = conditions.length === 1 ? conditions[0] : and(...conditions);
2301
2302 const [{ n }] = await db
2303 .select({ n: sql<number>`count(*)::int` })
2304 .from(workflowRuns)
2305 .where(where);
2306 const total_count = Number(n) || 0;
2307
2308 const rows = await db
2309 .select()
2310 .from(workflowRuns)
2311 .where(where)
2312 .orderBy(desc(workflowRuns.queuedAt))
2313 .limit(perPage)
2314 .offset(offset);
2315
2316 return c.json({
2317 total_count,
2318 workflow_runs: rows.map((r) =>
2319 serializeRun(r, workflowRow.name, owner, repo)
2320 ),
2321 });
2322 }
2323);
2324
2325// ─── 3. GET /actions/runs/:run_id ───────────────────────────────────────────
2326
2327apiv2.get("/repos/:owner/:repo/actions/runs/:run_id", async (c) => {
2328 const { owner, repo, run_id } = c.req.param();
2329 const resolved = await resolveRepo(owner, repo);
2330 if (!resolved) return c.json({ error: "Not Found" }, 404);
2331
2332 const repoRow = resolved.repo as any;
2333 const [run] = await db
2334 .select()
2335 .from(workflowRuns)
2336 .where(eq(workflowRuns.id, run_id))
2337 .limit(1);
2338 if (!run) return c.json({ error: "Not Found" }, 404);
2339 // Don't leak runs across repos.
2340 if (run.repositoryId !== repoRow.id) {
2341 return c.json({ error: "Not Found" }, 404);
2342 }
2343
2344 const [wf] = await db
2345 .select()
2346 .from(workflows)
2347 .where(eq(workflows.id, run.workflowId))
2348 .limit(1);
2349 const workflowName = wf?.name ?? "";
2350
2351 return c.json(serializeRun(run, workflowName, owner, repo));
2352});
2353
2354// ─── 4. GET /actions/runs/:run_id/logs — ZIP of per-job logs ────────────────
2355//
2356// Reuses the same in-process zip writer pattern as `connect-claude.tsx` —
2357// PKZIP 2.0, no zip64, deflateRawSync with STORED fallback when compression
2358// would inflate. The handler is self-contained so the dxt downloader stays
2359// the canonical reference.
2360
2361function crc32(buf: Uint8Array): number {
2362 let c = 0xffffffff;
2363 for (let i = 0; i < buf.length; i++) {
2364 c ^= buf[i]!;
2365 for (let k = 0; k < 8; k++) {
2366 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
2367 }
2368 }
2369 return (c ^ 0xffffffff) >>> 0;
2370}
2371
2372type ZipEntry = { name: string; data: Uint8Array };
2373
2374function buildZip(entries: ZipEntry[]): Uint8Array {
2375 const localParts: Uint8Array[] = [];
2376 const centralParts: Uint8Array[] = [];
2377 let offset = 0;
2378
2379 for (const entry of entries) {
2380 const nameBytes = new TextEncoder().encode(entry.name);
2381 const crc = crc32(entry.data);
2382 const uncompressedSize = entry.data.length;
2383
2384 let method = 8;
2385 let compressed: Uint8Array;
2386 try {
2387 const out = deflateRawSync(entry.data);
2388 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
2389 if (compressed.length >= uncompressedSize) {
2390 method = 0;
2391 compressed = entry.data;
2392 }
2393 } catch {
2394 method = 0;
2395 compressed = entry.data;
2396 }
2397 const compressedSize = compressed.length;
2398
2399 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
2400 const lv = new DataView(local.buffer);
2401 lv.setUint32(0, 0x04034b50, true);
2402 lv.setUint16(4, 20, true);
2403 lv.setUint16(6, 0, true);
2404 lv.setUint16(8, method, true);
2405 lv.setUint16(10, 0, true);
2406 lv.setUint16(12, 0, true);
2407 lv.setUint32(14, crc, true);
2408 lv.setUint32(18, compressedSize, true);
2409 lv.setUint32(22, uncompressedSize, true);
2410 lv.setUint16(26, nameBytes.length, true);
2411 lv.setUint16(28, 0, true);
2412 local.set(nameBytes, 30);
2413 local.set(compressed, 30 + nameBytes.length);
2414 localParts.push(local);
2415
2416 const central = new Uint8Array(46 + nameBytes.length);
2417 const cv = new DataView(central.buffer);
2418 cv.setUint32(0, 0x02014b50, true);
2419 cv.setUint16(4, 20, true);
2420 cv.setUint16(6, 20, true);
2421 cv.setUint16(8, 0, true);
2422 cv.setUint16(10, method, true);
2423 cv.setUint16(12, 0, true);
2424 cv.setUint16(14, 0, true);
2425 cv.setUint32(16, crc, true);
2426 cv.setUint32(20, compressedSize, true);
2427 cv.setUint32(24, uncompressedSize, true);
2428 cv.setUint16(28, nameBytes.length, true);
2429 cv.setUint16(30, 0, true);
2430 cv.setUint16(32, 0, true);
2431 cv.setUint16(34, 0, true);
2432 cv.setUint16(36, 0, true);
2433 cv.setUint32(38, 0, true);
2434 cv.setUint32(42, offset, true);
2435 central.set(nameBytes, 46);
2436 centralParts.push(central);
2437
2438 offset += local.length;
2439 }
2440
2441 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
2442 const centralOffset = offset;
2443
2444 const end = new Uint8Array(22);
2445 const ev = new DataView(end.buffer);
2446 ev.setUint32(0, 0x06054b50, true);
2447 ev.setUint16(4, 0, true);
2448 ev.setUint16(6, 0, true);
2449 ev.setUint16(8, entries.length, true);
2450 ev.setUint16(10, entries.length, true);
2451 ev.setUint32(12, centralSize, true);
2452 ev.setUint32(16, centralOffset, true);
2453 ev.setUint16(20, 0, true);
2454
2455 const total =
2456 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
2457 const out = new Uint8Array(total);
2458 let pos = 0;
2459 for (const p of localParts) {
2460 out.set(p, pos);
2461 pos += p.length;
2462 }
2463 for (const p of centralParts) {
2464 out.set(p, pos);
2465 pos += p.length;
2466 }
2467 out.set(end, pos);
2468 return out;
2469}
2470
2471apiv2.get("/repos/:owner/:repo/actions/runs/:run_id/logs", async (c) => {
2472 const { owner, repo, run_id } = c.req.param();
2473 const resolved = await resolveRepo(owner, repo);
2474 if (!resolved) return c.json({ error: "Not Found" }, 404);
2475
2476 const repoRow = resolved.repo as any;
2477 const [run] = await db
2478 .select()
2479 .from(workflowRuns)
2480 .where(eq(workflowRuns.id, run_id))
2481 .limit(1);
2482 if (!run || run.repositoryId !== repoRow.id) {
2483 return c.json({ error: "Not Found" }, 404);
2484 }
2485
2486 const jobs = await db
2487 .select()
2488 .from(workflowJobs)
2489 .where(eq(workflowJobs.runId, run.id))
2490 .orderBy(asc(workflowJobs.jobOrder));
2491
2492 // 404 when there's truly nothing to package up — matches GitHub's behaviour
2493 // for runs that never produced logs.
2494 const usable = jobs.filter(
2495 (j) => typeof j.logs === "string" && j.logs.length > 0
2496 );
2497 if (usable.length === 0) {
2498 return c.json({ error: "Not Found" }, 404);
2499 }
2500
2501 // Make filenames safe + unique. Collisions get a numeric suffix.
2502 const seen = new Set<string>();
2503 const entries: ZipEntry[] = [];
2504 for (const job of usable) {
2505 let base = (job.name || "job").replace(/[^a-zA-Z0-9_.-]+/g, "_");
2506 if (!base) base = "job";
2507 let filename = `${base}.log`;
2508 let n = 1;
2509 while (seen.has(filename)) {
2510 filename = `${base}-${++n}.log`;
2511 }
2512 seen.add(filename);
2513 entries.push({
2514 name: filename,
2515 data: new TextEncoder().encode(job.logs),
2516 });
2517 }
2518
2519 const zip = buildZip(entries);
2520 // Wrap the bytes in a Blob so Response's BodyInit type is happy across
2521 // both Bun's `globalThis.Response` and Hono's. Uint8Array works at
2522 // runtime but trips strict TS — cast to BlobPart resolves the
2523 // ArrayBufferLike/ArrayBuffer mismatch on `.buffer`.
2524 return new Response(new Blob([zip as BlobPart], { type: "application/zip" }), {
2525 status: 200,
2526 headers: {
2527 "Content-Disposition": `attachment; filename="run-${run.id}-logs.zip"`,
2528 "Content-Length": String(zip.length),
2529 },
2530 });
2531});
2532
2533// ─── 5. POST /actions/runs/:run_id/cancel ───────────────────────────────────
2534
2535apiv2.post(
2536 "/repos/:owner/:repo/actions/runs/:run_id/cancel",
2537 requireApiAuth,
2538 requireScope("repo"),
2539 async (c) => {
2540 const { owner, repo, run_id } = c.req.param();
2541 const resolved = await resolveRepo(owner, repo);
2542 if (!resolved) return c.json({ error: "Not Found" }, 404);
2543
2544 const repoRow = resolved.repo as any;
2545 const [run] = await db
2546 .select()
2547 .from(workflowRuns)
2548 .where(eq(workflowRuns.id, run_id))
2549 .limit(1);
2550 if (!run || run.repositoryId !== repoRow.id) {
2551 return c.json({ error: "Not Found" }, 404);
2552 }
2553
2554 // Already-terminal states are a 409 Conflict per GitHub semantics. We
2555 // only allow queued → cancelled and running → cancelled transitions.
2556 if (run.status !== "queued" && run.status !== "running") {
2557 return c.json(
2558 {
2559 error:
2560 "Cannot cancel workflow run in its current state",
2561 status: run.status,
2562 },
2563 409
2564 );
2565 }
2566
2567 const now = new Date();
2568 await db
2569 .update(workflowRuns)
2570 .set({
2571 status: "cancelled",
2572 conclusion: "cancelled",
2573 finishedAt: now,
2574 })
2575 .where(
2576 and(
2577 eq(workflowRuns.id, run.id),
2578 eq(workflowRuns.repositoryId, repoRow.id)
2579 )
2580 );
2581 await db
2582 .update(workflowJobs)
2583 .set({
2584 status: "cancelled",
2585 conclusion: "cancelled",
2586 finishedAt: now,
2587 })
2588 .where(eq(workflowJobs.runId, run.id));
2589
2590 return c.json({}, 202);
2591 }
2592);
2593
45e31d0Claude2594// ─── API Info ───────────────────────────────────────────────────────────────
2595
2596apiv2.get("/", (c) => {
2597 return c.json({
2598 name: "gluecron API",
2599 version: "2.0",
2600 documentation: "/api/docs",
2601 endpoints: {
46d6165Claude2602 auth: {
2603 "POST /api/v2/auth/install-token":
2604 "Mint a PAT for one-command install (session-cookie auth only)",
2605 },
45e31d0Claude2606 users: {
2607 "GET /api/v2/user": "Get authenticated user",
2608 "GET /api/v2/users/:username": "Get user by username",
2609 "PATCH /api/v2/user": "Update authenticated user profile",
46d6165Claude2610 "GET /api/v2/me/ai-savings":
2611 "AI hours-saved counter (window + lifetime, Block L9)",
45e31d0Claude2612 },
2613 repositories: {
2614 "GET /api/v2/users/:username/repos": "List user repositories",
2615 "POST /api/v2/repos": "Create repository",
2616 "GET /api/v2/repos/:owner/:repo": "Get repository",
2617 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
2618 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
2619 },
2620 branches: {
2621 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
2622 },
2623 commits: {
2624 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
2625 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
2626 },
2627 files: {
052c2e6Claude2628 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree (supports ?recursive=1)",
2629 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents (supports ?encoding=base64)",
2630 "PUT /api/v2/repos/:owner/:repo/contents/:path": "Create or update a file on a branch",
da50b83Claude2631 "DELETE /api/v2/repos/:owner/:repo/contents/:path": "Delete a file on a branch",
052c2e6Claude2632 },
2633 git: {
2634 "POST /api/v2/repos/:owner/:repo/git/refs": "Create a branch or tag pointing at a sha",
da50b83Claude2635 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Get a branch ref",
2636 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch": "Move a branch ref (fast-forward by default)",
2637 "GET /api/v2/repos/:owner/:repo/git/commits/:sha": "Get a raw git commit object",
2638 "POST /api/v2/repos/:owner/:repo/git/commits": "Create a commit from a tree + parents",
2639 "POST /api/v2/repos/:owner/:repo/git/blobs": "Write a blob from utf-8 or base64 content",
2640 "POST /api/v2/repos/:owner/:repo/git/trees": "Build a tree from entries (optionally based on base_tree)",
052c2e6Claude2641 },
2642 statuses: {
2643 "POST /api/v2/repos/:owner/:repo/statuses/:sha": "Post commit status (v2 alias)",
45e31d0Claude2644 },
2645 issues: {
2646 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
2647 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
2648 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
2649 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
2650 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
2651 },
2652 pullRequests: {
2653 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
2654 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
2655 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
052c2e6Claude2656 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
45e31d0Claude2657 },
2658 stars: {
2659 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
2660 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
2661 },
2662 labels: {
2663 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
2664 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
2665 },
2666 search: {
2667 "GET /api/v2/search/repos": "Search repositories",
2668 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
2669 },
2670 topics: {
2671 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
2672 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
2673 },
2674 webhooks: {
2675 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
2676 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
2677 },
2678 activity: {
2679 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
2680 },
4366c70Claude2681 actions: {
2682 "POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches":
2683 "Dispatch a workflow run (204 No Content)",
2684 "GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs":
2685 "List runs of a workflow (paginated: per_page, page; filters: branch, head_sha)",
2686 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id":
2687 "Get a single workflow run",
2688 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs":
2689 "Download per-job logs as a .zip archive",
2690 "POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel":
2691 "Cancel a queued or running workflow run (202 Accepted)",
2692 },
45e31d0Claude2693 },
2694 authentication: {
2695 method: "Bearer token",
2696 header: "Authorization: Bearer <your-token>",
ea52715copilot-swe-agent[bot]2697 createApiKey: "Visit /settings/tokens to create a personal access key",
45e31d0Claude2698 },
2699 rateLimit: {
2700 api: "100 requests/minute",
2701 search: "30 requests/minute",
2702 auth: "10 requests/minute",
2703 },
2704 });
2705});
2706
2707export default apiv2;