Commit4366c70unknown_key
feat(api-v2): actions/workflows dispatch + runs + logs + cancel — addendum group 3
2 files changed+1062−04366c701305902a79393b55e4418c1cd7cb87fbd
2 changed files+1062−0
Addedsrc/__tests__/api-v2-actions.test.ts+493−0View fileUnifiedSplit
@@ -0,0 +1,493 @@
1/**
2 * API v2 — Actions / Workflows REST surface (addendum group 3).
3 *
4 * Covers the five GitHub-compatible endpoints added in `src/routes/api-v2.ts`:
5 *
6 * POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches
7 * GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs
8 * GET /api/v2/repos/:owner/:repo/actions/runs/:run_id
9 * GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs
10 * POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel
11 *
12 * Auth/validation checks run without a database; the deeper end-to-end
13 * flows (dispatch → list → cancel → logs zip) are DB-gated via the standard
14 * HAS_DB pattern so the suite stays green on machines without Postgres.
15 */
16
17import { describe, it, expect, beforeAll, afterAll } from "bun:test";
18import { join } from "path";
19import { rm, mkdir } from "fs/promises";
20import { randomBytes } from "crypto";
21import app from "../app";
22import { clearRateLimitStore } from "../middleware/rate-limit";
23import { initBareRepo, getRepoPath } from "../git/repository";
24
25const HAS_DB = Boolean(process.env.DATABASE_URL);
26const TEST_REPOS = join(
27 import.meta.dir,
28 "../../.test-repos-api-v2-actions-" + Date.now()
29);
30
31beforeAll(async () => {
32 process.env.GIT_REPOS_PATH = TEST_REPOS;
33 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
34 clearRateLimitStore();
35 await rm(TEST_REPOS, { recursive: true, force: true });
36 await mkdir(TEST_REPOS, { recursive: true });
37});
38
39afterAll(async () => {
40 await rm(TEST_REPOS, { recursive: true, force: true });
41});
42
43// ---------------------------------------------------------------------------
44// Helpers
45// ---------------------------------------------------------------------------
46
47function apiUrl(path: string): string {
48 return `/api/v2${path}`;
49}
50
51function jsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
52 return { "Content-Type": "application/json", ...extra };
53}
54
55async function run(cmd: string[], cwd: string) {
56 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
57 await new Response(proc.stdout).text();
58 await proc.exited;
59}
60
61async function seedBareRepoWithCommit(owner: string, name: string) {
62 await initBareRepo(owner, name);
63 const bare = getRepoPath(owner, name);
64 const work = join(TEST_REPOS, "_work_" + randomBytes(4).toString("hex"));
65 await mkdir(work, { recursive: true });
66 await run(["git", "clone", bare, work], TEST_REPOS);
67 await run(["git", "config", "user.email", "test@gluecron.com"], work);
68 await run(["git", "config", "user.name", "Test User"], work);
69 await run(["git", "checkout", "-B", "main"], work);
70 await Bun.write(join(work, "README.md"), "# hi\n");
71 await run(["git", "add", "-A"], work);
72 await run(["git", "commit", "-m", "seed"], work);
73 await run(["git", "push", "-u", "origin", "main"], work);
74 await rm(work, { recursive: true, force: true });
75}
76
77// SHA-256 hex for the API-token loader. Matches src/middleware/api-auth.ts.
78async function sha256Hex(s: string): Promise<string> {
79 const hasher = new Bun.CryptoHasher("sha256");
80 hasher.update(s);
81 return hasher.digest("hex");
82}
83
84// ---------------------------------------------------------------------------
85// 1. Auth/validation — no DB required
86// ---------------------------------------------------------------------------
87
88describe("API v2 actions — auth + validation (no DB)", () => {
89 it("POST /actions/workflows/:f/dispatches without auth returns 401", async () => {
90 const res = await app.request(
91 apiUrl("/repos/nobody/nothing/actions/workflows/ci.yml/dispatches"),
92 {
93 method: "POST",
94 headers: jsonHeaders(),
95 body: JSON.stringify({ ref: "main" }),
96 }
97 );
98 expect(res.status).toBe(401);
99 });
100
101 it("POST /actions/runs/:id/cancel without auth returns 401", async () => {
102 const res = await app.request(
103 apiUrl(
104 "/repos/nobody/nothing/actions/runs/00000000-0000-0000-0000-000000000000/cancel"
105 ),
106 { method: "POST", headers: jsonHeaders() }
107 );
108 expect(res.status).toBe(401);
109 });
110
111 it("GET /actions/workflows/:f/runs on missing repo returns 404 or 500", async () => {
112 const res = await app.request(
113 apiUrl("/repos/nobody/nothing/actions/workflows/ci.yml/runs")
114 );
115 expect([404, 500]).toContain(res.status);
116 });
117
118 it("GET /actions/runs/:id on missing repo returns 404 or 500", async () => {
119 const res = await app.request(
120 apiUrl(
121 "/repos/nobody/nothing/actions/runs/00000000-0000-0000-0000-000000000000"
122 )
123 );
124 expect([404, 500]).toContain(res.status);
125 });
126
127 it("GET /actions/runs/:id/logs on missing repo returns 404 or 500", async () => {
128 const res = await app.request(
129 apiUrl(
130 "/repos/nobody/nothing/actions/runs/00000000-0000-0000-0000-000000000000/logs"
131 )
132 );
133 expect([404, 500]).toContain(res.status);
134 });
135});
136
137// ---------------------------------------------------------------------------
138// 2. End-to-end with a real DB + seeded bare repo
139// ---------------------------------------------------------------------------
140
141describe.skipIf(!HAS_DB)("API v2 actions — DB-backed flows", () => {
142 it("dispatch → list → get → cancel → logs.zip", async () => {
143 const { db } = await import("../db");
144 const {
145 users,
146 repositories,
147 apiTokens,
148 workflows,
149 workflowRuns,
150 workflowJobs,
151 } = await import("../db/schema");
152 const { eq } = await import("drizzle-orm");
153
154 const stamp = randomBytes(4).toString("hex");
155 const username = `actuser-${stamp}`;
156 const reponame = `actrepo-${stamp}`;
157
158 // ── user + bare repo ──
159 const [u] = await db
160 .insert(users)
161 .values({
162 username,
163 email: `${username}@test.local`,
164 passwordHash: "x",
165 })
166 .returning();
167 expect(u).toBeDefined();
168 if (!u) return;
169
170 await seedBareRepoWithCommit(username, reponame);
171
172 const [r] = await db
173 .insert(repositories)
174 .values({
175 name: reponame,
176 ownerId: u.id,
177 diskPath: getRepoPath(username, reponame),
178 defaultBranch: "main",
179 })
180 .returning();
181 expect(r).toBeDefined();
182 if (!r) return;
183
184 // ── workflow row (parsed.on includes workflow_dispatch as a string) ──
185 const parsed = {
186 name: "CI",
187 on: ["push", "workflow_dispatch"],
188 jobs: [{ name: "build", runsOn: "default", steps: [{ name: "x", run: "echo hi" }] }],
189 };
190 const [wf] = await db
191 .insert(workflows)
192 .values({
193 repositoryId: r.id,
194 name: "CI",
195 path: ".gluecron/workflows/ci.yml",
196 yaml: "name: CI\non: [push, workflow_dispatch]\njobs:\n build:\n steps:\n - run: echo hi\n",
197 parsed: JSON.stringify(parsed),
198 onEvents: JSON.stringify(parsed.on),
199 })
200 .returning();
201 expect(wf).toBeDefined();
202 if (!wf) return;
203
204 // ── PAT with `repo` scope ──
205 const tokenPlain = "glc_" + randomBytes(32).toString("hex");
206 const tokenHash = await sha256Hex(tokenPlain);
207 await db.insert(apiTokens).values({
208 userId: u.id,
209 name: `test-${stamp}`,
210 tokenHash,
211 tokenPrefix: tokenPlain.slice(0, 12),
212 scopes: "repo",
213 });
214 const bearer = { Authorization: `Bearer ${tokenPlain}` };
215
216 // ── 1. dispatch the workflow ──
217 const disp = await app.request(
218 apiUrl(
219 `/repos/${username}/${reponame}/actions/workflows/ci.yml/dispatches`
220 ),
221 {
222 method: "POST",
223 headers: jsonHeaders(bearer),
224 body: JSON.stringify({ ref: "main" }),
225 }
226 );
227 expect(disp.status).toBe(204);
228
229 // The run should now exist; pull it back via the list endpoint.
230 const list = await app.request(
231 apiUrl(
232 `/repos/${username}/${reponame}/actions/workflows/ci.yml/runs`
233 ),
234 { headers: bearer }
235 );
236 expect(list.status).toBe(200);
237 const listBody = (await list.json()) as any;
238 expect(listBody.total_count).toBeGreaterThanOrEqual(1);
239 expect(Array.isArray(listBody.workflow_runs)).toBe(true);
240 const runEntry = listBody.workflow_runs[0];
241 expect(runEntry.id).toBeDefined();
242 expect(runEntry.head_branch).toBe("main");
243 expect(runEntry.event).toBe("workflow_dispatch");
244 expect(runEntry.html_url).toContain(
245 `/${username}/${reponame}/actions/runs/${runEntry.id}`
246 );
247
248 // ── 2. fetch the run by id ──
249 const single = await app.request(
250 apiUrl(`/repos/${username}/${reponame}/actions/runs/${runEntry.id}`),
251 { headers: bearer }
252 );
253 expect(single.status).toBe(200);
254 const singleBody = (await single.json()) as any;
255 expect(singleBody.id).toBe(runEntry.id);
256 expect(singleBody.name).toBe("CI");
257
258 // Cross-repo isolation: a different (seeded) repo should not see this run.
259 const otherRepoName = `other-${stamp}`;
260 await seedBareRepoWithCommit(username, otherRepoName);
261 const [r2] = await db
262 .insert(repositories)
263 .values({
264 name: otherRepoName,
265 ownerId: u.id,
266 diskPath: getRepoPath(username, otherRepoName),
267 defaultBranch: "main",
268 })
269 .returning();
270 expect(r2).toBeDefined();
271 const cross = await app.request(
272 apiUrl(`/repos/${username}/${otherRepoName}/actions/runs/${runEntry.id}`),
273 { headers: bearer }
274 );
275 expect(cross.status).toBe(404);
276
277 // ── 3. attach a job with logs, then download the zip ──
278 await db.insert(workflowJobs).values({
279 runId: runEntry.id,
280 name: "build",
281 jobOrder: 0,
282 runsOn: "default",
283 status: "success",
284 conclusion: "success",
285 steps: JSON.stringify([{ name: "x", exitCode: 0 }]),
286 logs: "hello from build job\n",
287 });
288 const logs = await app.request(
289 apiUrl(
290 `/repos/${username}/${reponame}/actions/runs/${runEntry.id}/logs`
291 ),
292 { headers: bearer }
293 );
294 expect(logs.status).toBe(200);
295 expect(logs.headers.get("content-type")).toContain("application/zip");
296 const bytes = new Uint8Array(await logs.arrayBuffer());
297 // PKZIP local file header magic — `PK\x03\x04`.
298 expect(bytes[0]).toBe(0x50);
299 expect(bytes[1]).toBe(0x4b);
300 expect(bytes[2]).toBe(0x03);
301 expect(bytes[3]).toBe(0x04);
302
303 // ── 4. cancel a queued run ──
304 const cancel = await app.request(
305 apiUrl(
306 `/repos/${username}/${reponame}/actions/runs/${runEntry.id}/cancel`
307 ),
308 { method: "POST", headers: jsonHeaders(bearer) }
309 );
310 expect(cancel.status).toBe(202);
311
312 const [after] = await db
313 .select()
314 .from(workflowRuns)
315 .where(eq(workflowRuns.id, runEntry.id))
316 .limit(1);
317 expect(after?.status).toBe("cancelled");
318 expect(after?.conclusion).toBe("cancelled");
319 expect(after?.finishedAt).not.toBeNull();
320
321 // Re-cancelling a terminal run is a 409 Conflict.
322 const reCancel = await app.request(
323 apiUrl(
324 `/repos/${username}/${reponame}/actions/runs/${runEntry.id}/cancel`
325 ),
326 { method: "POST", headers: jsonHeaders(bearer) }
327 );
328 expect(reCancel.status).toBe(409);
329 });
330
331 it("dispatch on a workflow without workflow_dispatch returns 422", async () => {
332 const { db } = await import("../db");
333 const { users, repositories, apiTokens, workflows } = await import(
334 "../db/schema"
335 );
336
337 const stamp = randomBytes(4).toString("hex");
338 const username = `actuser-422-${stamp}`;
339 const reponame = `actrepo-422-${stamp}`;
340
341 const [u] = await db
342 .insert(users)
343 .values({
344 username,
345 email: `${username}@test.local`,
346 passwordHash: "x",
347 })
348 .returning();
349 if (!u) return;
350 await seedBareRepoWithCommit(username, reponame);
351 const [r] = await db
352 .insert(repositories)
353 .values({
354 name: reponame,
355 ownerId: u.id,
356 diskPath: getRepoPath(username, reponame),
357 defaultBranch: "main",
358 })
359 .returning();
360 if (!r) return;
361
362 // Only `push` — no workflow_dispatch.
363 const parsed = {
364 name: "push-only",
365 on: ["push"],
366 jobs: [{ name: "build", runsOn: "default", steps: [{ name: "x", run: "echo hi" }] }],
367 };
368 await db.insert(workflows).values({
369 repositoryId: r.id,
370 name: "push-only",
371 path: ".gluecron/workflows/push.yml",
372 yaml: "name: push-only\non: push\njobs:\n build:\n steps:\n - run: echo hi\n",
373 parsed: JSON.stringify(parsed),
374 onEvents: JSON.stringify(parsed.on),
375 });
376
377 const tokenPlain = "glc_" + randomBytes(32).toString("hex");
378 const tokenHash = await sha256Hex(tokenPlain);
379 await db.insert(apiTokens).values({
380 userId: u.id,
381 name: `test-${stamp}`,
382 tokenHash,
383 tokenPrefix: tokenPlain.slice(0, 12),
384 scopes: "repo",
385 });
386
387 const res = await app.request(
388 apiUrl(
389 `/repos/${username}/${reponame}/actions/workflows/push.yml/dispatches`
390 ),
391 {
392 method: "POST",
393 headers: jsonHeaders({ Authorization: `Bearer ${tokenPlain}` }),
394 body: JSON.stringify({ ref: "main" }),
395 }
396 );
397 expect(res.status).toBe(422);
398 });
399
400 it("dispatch with a missing required input returns 422 with details", async () => {
401 const { db } = await import("../db");
402 const { users, repositories, apiTokens, workflows } = await import(
403 "../db/schema"
404 );
405
406 const stamp = randomBytes(4).toString("hex");
407 const username = `actuser-inp-${stamp}`;
408 const reponame = `actrepo-inp-${stamp}`;
409
410 const [u] = await db
411 .insert(users)
412 .values({
413 username,
414 email: `${username}@test.local`,
415 passwordHash: "x",
416 })
417 .returning();
418 if (!u) return;
419 await seedBareRepoWithCommit(username, reponame);
420 const [r] = await db
421 .insert(repositories)
422 .values({
423 name: reponame,
424 ownerId: u.id,
425 diskPath: getRepoPath(username, reponame),
426 defaultBranch: "main",
427 })
428 .returning();
429 if (!r) return;
430
431 // Mapping-shaped `on` with workflow_dispatch.inputs — the only place an
432 // inputs schema can live.
433 const parsed = {
434 name: "needs-input",
435 on: {
436 workflow_dispatch: {
437 inputs: {
438 target: { type: "string", required: true, description: "required" },
439 level: { type: "string", required: false, default: "info" },
440 },
441 },
442 },
443 jobs: [{ name: "build", runsOn: "default", steps: [{ name: "x", run: "echo" }] }],
444 };
445 await db.insert(workflows).values({
446 repositoryId: r.id,
447 name: "needs-input",
448 path: ".gluecron/workflows/dispatch.yml",
449 yaml: "name: needs-input\non:\n workflow_dispatch:\n inputs:\n target:\n required: true\njobs:\n build:\n steps:\n - run: echo\n",
450 parsed: JSON.stringify(parsed),
451 onEvents: JSON.stringify(["workflow_dispatch"]),
452 });
453
454 const tokenPlain = "glc_" + randomBytes(32).toString("hex");
455 const tokenHash = await sha256Hex(tokenPlain);
456 await db.insert(apiTokens).values({
457 userId: u.id,
458 name: `test-${stamp}`,
459 tokenHash,
460 tokenPrefix: tokenPlain.slice(0, 12),
461 scopes: "repo",
462 });
463
464 const bad = await app.request(
465 apiUrl(
466 `/repos/${username}/${reponame}/actions/workflows/dispatch.yml/dispatches`
467 ),
468 {
469 method: "POST",
470 headers: jsonHeaders({ Authorization: `Bearer ${tokenPlain}` }),
471 body: JSON.stringify({ ref: "main", inputs: {} }),
472 }
473 );
474 expect(bad.status).toBe(422);
475 const badBody = (await bad.json()) as any;
476 expect(Array.isArray(badBody.details)).toBe(true);
477 expect(badBody.details.join("\n")).toMatch(/target/);
478
479 // Supplying the required input clears the gate (the run enqueue itself
480 // is what we're verifying, not the worker — so 204 is the contract).
481 const good = await app.request(
482 apiUrl(
483 `/repos/${username}/${reponame}/actions/workflows/dispatch.yml/dispatches`
484 ),
485 {
486 method: "POST",
487 headers: jsonHeaders({ Authorization: `Bearer ${tokenPlain}` }),
488 body: JSON.stringify({ ref: "main", inputs: { target: "prod" } }),
489 }
490 );
491 expect(good.status).toBe(204);
492 });
493});
Modifiedsrc/routes/api-v2.ts+569−0View fileUnifiedSplit
@@ -1788,6 +1788,563 @@ apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin")
17881788 return c.json(result[0], 201);
17891789});
17901790
1791// ─── Actions / Workflows ────────────────────────────────────────────────────
1792//
1793// GitHub-Actions-compatible REST surface (subset).
1794//
1795// POST /repos/:owner/:repo/actions/workflows/:filename/dispatches
1796// GET /repos/:owner/:repo/actions/workflows/:filename/runs
1797// GET /repos/:owner/:repo/actions/runs/:run_id
1798// GET /repos/:owner/:repo/actions/runs/:run_id/logs (.zip)
1799// POST /repos/:owner/:repo/actions/runs/:run_id/cancel
1800//
1801// Shapes follow GitHub REST v3 — snake_case fields, HTML URLs back to the
1802// gluecron run page, identical status-code semantics (204 for dispatch, 202
1803// for cancel, 409 for already-terminal, 422 for bad inputs).
1804
1805type ParsedOn =
1806 | string
1807 | string[]
1808 | Record<string, unknown>
1809 | null
1810 | undefined;
1811
1812type DispatchInputSpec = {
1813 type?: string;
1814 required?: boolean;
1815 default?: unknown;
1816 options?: unknown[];
1817 description?: string;
1818};
1819
1820/**
1821 * Pull the workflow_dispatch slice out of whatever shape `parsed.on` happens
1822 * to be. The v1 parser normalises `on` to a `string[]`, but the extended
1823 * parser may store an object — and YAML in the wild can be either form. We
1824 * accept all three: scalar string, array of event names, mapping keyed by
1825 * event name.
1826 */
1827function extractDispatchSpec(rawOn: ParsedOn): {
1828 enabled: boolean;
1829 inputs: Record<string, DispatchInputSpec> | null;
1830} {
1831 if (rawOn == null) return { enabled: false, inputs: null };
1832 if (typeof rawOn === "string") {
1833 return { enabled: rawOn === "workflow_dispatch", inputs: null };
1834 }
1835 if (Array.isArray(rawOn)) {
1836 return { enabled: rawOn.includes("workflow_dispatch"), inputs: null };
1837 }
1838 if (typeof rawOn === "object") {
1839 const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"];
1840 if (slot === undefined) return { enabled: false, inputs: null };
1841 // `workflow_dispatch:` with no children is a valid trigger declaration.
1842 if (slot == null || typeof slot !== "object") {
1843 return { enabled: true, inputs: null };
1844 }
1845 const inputs = (slot as Record<string, unknown>).inputs;
1846 if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) {
1847 return { enabled: true, inputs: null };
1848 }
1849 return {
1850 enabled: true,
1851 inputs: inputs as Record<string, DispatchInputSpec>,
1852 };
1853 }
1854 return { enabled: false, inputs: null };
1855}
1856
1857function validateDispatchInputs(
1858 schema: Record<string, DispatchInputSpec> | null,
1859 provided: Record<string, unknown> | undefined
1860): { ok: true } | { ok: false; details: string[] } {
1861 if (!schema) return { ok: true };
1862 const details: string[] = [];
1863 const supplied = provided ?? {};
1864 for (const [name, spec] of Object.entries(schema)) {
1865 if (!spec || typeof spec !== "object") continue;
1866 const present =
1867 Object.prototype.hasOwnProperty.call(supplied, name) &&
1868 supplied[name] !== undefined &&
1869 supplied[name] !== null;
1870 if (spec.required && !present) {
1871 // No default → required input is missing.
1872 if (spec.default === undefined) {
1873 details.push(`Missing required input: ${name}`);
1874 }
1875 }
1876 }
1877 if (details.length) return { ok: false, details };
1878 return { ok: true };
1879}
1880
1881/**
1882 * Look up a workflow row by repository + the basename of its `path` column.
1883 * Stored path is `.gluecron/workflows/<filename>`; we match the trailing
1884 * segment so callers don't need to know our on-disk layout.
1885 */
1886async function findWorkflowByFilename(
1887 repositoryId: string,
1888 filename: string
1889): Promise<typeof workflows.$inferSelect | null> {
1890 const rows = await db
1891 .select()
1892 .from(workflows)
1893 .where(eq(workflows.repositoryId, repositoryId));
1894 for (const row of rows) {
1895 const idx = row.path.lastIndexOf("/");
1896 const base = idx >= 0 ? row.path.slice(idx + 1) : row.path;
1897 if (base === filename) return row;
1898 }
1899 return null;
1900}
1901
1902function runHtmlUrl(owner: string, repo: string, runId: string): string {
1903 return `https://gluecron.com/${owner}/${repo}/actions/runs/${runId}`;
1904}
1905
1906function toIso(d: Date | string | null | undefined): string | null {
1907 if (!d) return null;
1908 return d instanceof Date ? d.toISOString() : new Date(d).toISOString();
1909}
1910
1911function serializeRun(
1912 run: typeof workflowRuns.$inferSelect,
1913 workflowName: string,
1914 owner: string,
1915 repo: string
1916): Record<string, unknown> {
1917 // GitHub returns `head_branch` as the short branch name (no refs/heads/).
1918 let head_branch: string | null = null;
1919 if (run.ref) {
1920 head_branch = run.ref.startsWith("refs/heads/")
1921 ? run.ref.slice("refs/heads/".length)
1922 : run.ref;
1923 }
1924 return {
1925 id: run.id,
1926 name: workflowName,
1927 head_branch,
1928 head_sha: run.commitSha,
1929 status: run.status,
1930 conclusion: run.conclusion,
1931 event: run.event,
1932 created_at: toIso(run.queuedAt),
1933 updated_at:
1934 toIso(run.finishedAt) ?? toIso(run.startedAt) ?? toIso(run.queuedAt),
1935 run_started_at: toIso(run.startedAt),
1936 html_url: runHtmlUrl(owner, repo, run.id),
1937 };
1938}
1939
1940// ─── 1. POST /actions/workflows/:filename/dispatches ────────────────────────
1941
1942apiv2.post(
1943 "/repos/:owner/:repo/actions/workflows/:filename/dispatches",
1944 requireApiAuth,
1945 requireScope("repo"),
1946 async (c) => {
1947 const { owner, repo, filename } = c.req.param();
1948 const user = c.get("user")!;
1949
1950 let body: { ref?: unknown; inputs?: unknown } = {};
1951 try {
1952 body = await c.req.json();
1953 } catch {
1954 body = {};
1955 }
1956
1957 const resolved = await resolveRepo(owner, repo);
1958 if (!resolved) return c.json({ error: "Not Found" }, 404);
1959
1960 const repoRow = resolved.repo as any;
1961 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
1962 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
1963
1964 // Parse the stored workflow JSON to look at its triggers + input schema.
1965 let parsedObj: Record<string, unknown> = {};
1966 try {
1967 const v = JSON.parse(workflowRow.parsed);
1968 if (v && typeof v === "object" && !Array.isArray(v)) {
1969 parsedObj = v as Record<string, unknown>;
1970 }
1971 } catch {
1972 // Treat unparseable parsed-blob as no triggers — falls through to 422.
1973 }
1974
1975 const spec = extractDispatchSpec(parsedObj.on as ParsedOn);
1976 if (!spec.enabled) {
1977 return c.json(
1978 { error: "Workflow does not have a 'workflow_dispatch' trigger." },
1979 422
1980 );
1981 }
1982
1983 const providedInputs =
1984 body.inputs && typeof body.inputs === "object" && !Array.isArray(body.inputs)
1985 ? (body.inputs as Record<string, unknown>)
1986 : undefined;
1987 const inputCheck = validateDispatchInputs(spec.inputs, providedInputs);
1988 if (!inputCheck.ok) {
1989 return c.json(
1990 { error: "Invalid workflow inputs", details: inputCheck.details },
1991 422
1992 );
1993 }
1994
1995 // Resolve the ref → commit SHA. Default to repo default_branch.
1996 const refIn =
1997 typeof body.ref === "string" && body.ref.trim().length > 0
1998 ? body.ref.trim()
1999 : repoRow.defaultBranch || "main";
2000 const commitSha = await resolveRef(owner, repo, refIn);
2001 if (!commitSha) {
2002 return c.json({ error: `Ref not found: ${refIn}` }, 422);
2003 }
2004
2005 const runId = await enqueueRun({
2006 workflowId: workflowRow.id,
2007 repositoryId: repoRow.id,
2008 event: "workflow_dispatch",
2009 ref: refIn,
2010 commitSha,
2011 triggeredBy: user.id,
2012 });
2013 if (!runId) {
2014 return c.json({ error: "Failed to enqueue run" }, 500);
2015 }
2016
2017 // GitHub returns 204 No Content with no body on success.
2018 return c.body(null, 204);
2019 }
2020);
2021
2022// ─── 2. GET /actions/workflows/:filename/runs ───────────────────────────────
2023
2024apiv2.get(
2025 "/repos/:owner/:repo/actions/workflows/:filename/runs",
2026 async (c) => {
2027 const { owner, repo, filename } = c.req.param();
2028 const resolved = await resolveRepo(owner, repo);
2029 if (!resolved) return c.json({ error: "Not Found" }, 404);
2030
2031 const repoRow = resolved.repo as any;
2032 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
2033 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
2034
2035 const perPage = Math.min(
2036 100,
2037 Math.max(1, parseInt(c.req.query("per_page") || "30", 10) || 30)
2038 );
2039 const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
2040 const offset = (page - 1) * perPage;
2041 const branch = c.req.query("branch");
2042 const headSha = c.req.query("head_sha");
2043
2044 const conditions = [eq(workflowRuns.workflowId, workflowRow.id)];
2045 if (branch) {
2046 // Accept either short branch name or fully-qualified refs/heads/...
2047 const refValue = branch.startsWith("refs/")
2048 ? branch
2049 : `refs/heads/${branch}`;
2050 conditions.push(eq(workflowRuns.ref, refValue));
2051 }
2052 if (headSha) conditions.push(eq(workflowRuns.commitSha, headSha));
2053
2054 const where = conditions.length === 1 ? conditions[0] : and(...conditions);
2055
2056 const [{ n }] = await db
2057 .select({ n: sql<number>`count(*)::int` })
2058 .from(workflowRuns)
2059 .where(where);
2060 const total_count = Number(n) || 0;
2061
2062 const rows = await db
2063 .select()
2064 .from(workflowRuns)
2065 .where(where)
2066 .orderBy(desc(workflowRuns.queuedAt))
2067 .limit(perPage)
2068 .offset(offset);
2069
2070 return c.json({
2071 total_count,
2072 workflow_runs: rows.map((r) =>
2073 serializeRun(r, workflowRow.name, owner, repo)
2074 ),
2075 });
2076 }
2077);
2078
2079// ─── 3. GET /actions/runs/:run_id ───────────────────────────────────────────
2080
2081apiv2.get("/repos/:owner/:repo/actions/runs/:run_id", async (c) => {
2082 const { owner, repo, run_id } = c.req.param();
2083 const resolved = await resolveRepo(owner, repo);
2084 if (!resolved) return c.json({ error: "Not Found" }, 404);
2085
2086 const repoRow = resolved.repo as any;
2087 const [run] = await db
2088 .select()
2089 .from(workflowRuns)
2090 .where(eq(workflowRuns.id, run_id))
2091 .limit(1);
2092 if (!run) return c.json({ error: "Not Found" }, 404);
2093 // Don't leak runs across repos.
2094 if (run.repositoryId !== repoRow.id) {
2095 return c.json({ error: "Not Found" }, 404);
2096 }
2097
2098 const [wf] = await db
2099 .select()
2100 .from(workflows)
2101 .where(eq(workflows.id, run.workflowId))
2102 .limit(1);
2103 const workflowName = wf?.name ?? "";
2104
2105 return c.json(serializeRun(run, workflowName, owner, repo));
2106});
2107
2108// ─── 4. GET /actions/runs/:run_id/logs — ZIP of per-job logs ────────────────
2109//
2110// Reuses the same in-process zip writer pattern as `connect-claude.tsx` —
2111// PKZIP 2.0, no zip64, deflateRawSync with STORED fallback when compression
2112// would inflate. The handler is self-contained so the dxt downloader stays
2113// the canonical reference.
2114
2115function crc32(buf: Uint8Array): number {
2116 let c = 0xffffffff;
2117 for (let i = 0; i < buf.length; i++) {
2118 c ^= buf[i]!;
2119 for (let k = 0; k < 8; k++) {
2120 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
2121 }
2122 }
2123 return (c ^ 0xffffffff) >>> 0;
2124}
2125
2126type ZipEntry = { name: string; data: Uint8Array };
2127
2128function buildZip(entries: ZipEntry[]): Uint8Array {
2129 const localParts: Uint8Array[] = [];
2130 const centralParts: Uint8Array[] = [];
2131 let offset = 0;
2132
2133 for (const entry of entries) {
2134 const nameBytes = new TextEncoder().encode(entry.name);
2135 const crc = crc32(entry.data);
2136 const uncompressedSize = entry.data.length;
2137
2138 let method = 8;
2139 let compressed: Uint8Array;
2140 try {
2141 const out = deflateRawSync(entry.data);
2142 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
2143 if (compressed.length >= uncompressedSize) {
2144 method = 0;
2145 compressed = entry.data;
2146 }
2147 } catch {
2148 method = 0;
2149 compressed = entry.data;
2150 }
2151 const compressedSize = compressed.length;
2152
2153 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
2154 const lv = new DataView(local.buffer);
2155 lv.setUint32(0, 0x04034b50, true);
2156 lv.setUint16(4, 20, true);
2157 lv.setUint16(6, 0, true);
2158 lv.setUint16(8, method, true);
2159 lv.setUint16(10, 0, true);
2160 lv.setUint16(12, 0, true);
2161 lv.setUint32(14, crc, true);
2162 lv.setUint32(18, compressedSize, true);
2163 lv.setUint32(22, uncompressedSize, true);
2164 lv.setUint16(26, nameBytes.length, true);
2165 lv.setUint16(28, 0, true);
2166 local.set(nameBytes, 30);
2167 local.set(compressed, 30 + nameBytes.length);
2168 localParts.push(local);
2169
2170 const central = new Uint8Array(46 + nameBytes.length);
2171 const cv = new DataView(central.buffer);
2172 cv.setUint32(0, 0x02014b50, true);
2173 cv.setUint16(4, 20, true);
2174 cv.setUint16(6, 20, true);
2175 cv.setUint16(8, 0, true);
2176 cv.setUint16(10, method, true);
2177 cv.setUint16(12, 0, true);
2178 cv.setUint16(14, 0, true);
2179 cv.setUint32(16, crc, true);
2180 cv.setUint32(20, compressedSize, true);
2181 cv.setUint32(24, uncompressedSize, true);
2182 cv.setUint16(28, nameBytes.length, true);
2183 cv.setUint16(30, 0, true);
2184 cv.setUint16(32, 0, true);
2185 cv.setUint16(34, 0, true);
2186 cv.setUint16(36, 0, true);
2187 cv.setUint32(38, 0, true);
2188 cv.setUint32(42, offset, true);
2189 central.set(nameBytes, 46);
2190 centralParts.push(central);
2191
2192 offset += local.length;
2193 }
2194
2195 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
2196 const centralOffset = offset;
2197
2198 const end = new Uint8Array(22);
2199 const ev = new DataView(end.buffer);
2200 ev.setUint32(0, 0x06054b50, true);
2201 ev.setUint16(4, 0, true);
2202 ev.setUint16(6, 0, true);
2203 ev.setUint16(8, entries.length, true);
2204 ev.setUint16(10, entries.length, true);
2205 ev.setUint32(12, centralSize, true);
2206 ev.setUint32(16, centralOffset, true);
2207 ev.setUint16(20, 0, true);
2208
2209 const total =
2210 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
2211 const out = new Uint8Array(total);
2212 let pos = 0;
2213 for (const p of localParts) {
2214 out.set(p, pos);
2215 pos += p.length;
2216 }
2217 for (const p of centralParts) {
2218 out.set(p, pos);
2219 pos += p.length;
2220 }
2221 out.set(end, pos);
2222 return out;
2223}
2224
2225apiv2.get("/repos/:owner/:repo/actions/runs/:run_id/logs", async (c) => {
2226 const { owner, repo, run_id } = c.req.param();
2227 const resolved = await resolveRepo(owner, repo);
2228 if (!resolved) return c.json({ error: "Not Found" }, 404);
2229
2230 const repoRow = resolved.repo as any;
2231 const [run] = await db
2232 .select()
2233 .from(workflowRuns)
2234 .where(eq(workflowRuns.id, run_id))
2235 .limit(1);
2236 if (!run || run.repositoryId !== repoRow.id) {
2237 return c.json({ error: "Not Found" }, 404);
2238 }
2239
2240 const jobs = await db
2241 .select()
2242 .from(workflowJobs)
2243 .where(eq(workflowJobs.runId, run.id))
2244 .orderBy(asc(workflowJobs.jobOrder));
2245
2246 // 404 when there's truly nothing to package up — matches GitHub's behaviour
2247 // for runs that never produced logs.
2248 const usable = jobs.filter(
2249 (j) => typeof j.logs === "string" && j.logs.length > 0
2250 );
2251 if (usable.length === 0) {
2252 return c.json({ error: "Not Found" }, 404);
2253 }
2254
2255 // Make filenames safe + unique. Collisions get a numeric suffix.
2256 const seen = new Set<string>();
2257 const entries: ZipEntry[] = [];
2258 for (const job of usable) {
2259 let base = (job.name || "job").replace(/[^a-zA-Z0-9_.-]+/g, "_");
2260 if (!base) base = "job";
2261 let filename = `${base}.log`;
2262 let n = 1;
2263 while (seen.has(filename)) {
2264 filename = `${base}-${++n}.log`;
2265 }
2266 seen.add(filename);
2267 entries.push({
2268 name: filename,
2269 data: new TextEncoder().encode(job.logs),
2270 });
2271 }
2272
2273 const zip = buildZip(entries);
2274 // Wrap the bytes in a Blob so Response's BodyInit type is happy across
2275 // both Bun's `globalThis.Response` and Hono's. Uint8Array works at
2276 // runtime but trips strict TS — cast to BlobPart resolves the
2277 // ArrayBufferLike/ArrayBuffer mismatch on `.buffer`.
2278 return new Response(new Blob([zip as BlobPart], { type: "application/zip" }), {
2279 status: 200,
2280 headers: {
2281 "Content-Disposition": `attachment; filename="run-${run.id}-logs.zip"`,
2282 "Content-Length": String(zip.length),
2283 },
2284 });
2285});
2286
2287// ─── 5. POST /actions/runs/:run_id/cancel ───────────────────────────────────
2288
2289apiv2.post(
2290 "/repos/:owner/:repo/actions/runs/:run_id/cancel",
2291 requireApiAuth,
2292 requireScope("repo"),
2293 async (c) => {
2294 const { owner, repo, run_id } = c.req.param();
2295 const resolved = await resolveRepo(owner, repo);
2296 if (!resolved) return c.json({ error: "Not Found" }, 404);
2297
2298 const repoRow = resolved.repo as any;
2299 const [run] = await db
2300 .select()
2301 .from(workflowRuns)
2302 .where(eq(workflowRuns.id, run_id))
2303 .limit(1);
2304 if (!run || run.repositoryId !== repoRow.id) {
2305 return c.json({ error: "Not Found" }, 404);
2306 }
2307
2308 // Already-terminal states are a 409 Conflict per GitHub semantics. We
2309 // only allow queued → cancelled and running → cancelled transitions.
2310 if (run.status !== "queued" && run.status !== "running") {
2311 return c.json(
2312 {
2313 error:
2314 "Cannot cancel workflow run in its current state",
2315 status: run.status,
2316 },
2317 409
2318 );
2319 }
2320
2321 const now = new Date();
2322 await db
2323 .update(workflowRuns)
2324 .set({
2325 status: "cancelled",
2326 conclusion: "cancelled",
2327 finishedAt: now,
2328 })
2329 .where(
2330 and(
2331 eq(workflowRuns.id, run.id),
2332 eq(workflowRuns.repositoryId, repoRow.id)
2333 )
2334 );
2335 await db
2336 .update(workflowJobs)
2337 .set({
2338 status: "cancelled",
2339 conclusion: "cancelled",
2340 finishedAt: now,
2341 })
2342 .where(eq(workflowJobs.runId, run.id));
2343
2344 return c.json({}, 202);
2345 }
2346);
2347
17912348// ─── API Info ───────────────────────────────────────────────────────────────
17922349
17932350apiv2.get("/", (c) => {
@@ -1875,6 +2432,18 @@ apiv2.get("/", (c) => {
18752432 activity: {
18762433 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
18772434 },
2435 actions: {
2436 "POST /api/v2/repos/:owner/:repo/actions/workflows/:filename/dispatches":
2437 "Dispatch a workflow run (204 No Content)",
2438 "GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs":
2439 "List runs of a workflow (paginated: per_page, page; filters: branch, head_sha)",
2440 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id":
2441 "Get a single workflow run",
2442 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs":
2443 "Download per-job logs as a .zip archive",
2444 "POST /api/v2/repos/:owner/:repo/actions/runs/:run_id/cancel":
2445 "Cancel a queued or running workflow run (202 Accepted)",
2446 },
18782447 },
18792448 authentication: {
18802449 method: "Bearer token",
18812450