Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

579 lines · 25.7 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { parseWorkflow, type DispatchInput } from "@diffinery/workflow-yaml";
3import { raw } from "hono/html";
4import type { FC } from "hono/jsx";
5import { loadRepo } from "../http/common.js";
6import { HttpError } from "../lib/util.js";
7import type { JobInfo, RunInfo } from "../repo/actions.js";
8import { atLeast } from "../repo/types.js";
9import { ICONS } from "./assets.js";
10import { csrfToken } from "./csrf.js";
11import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js";
12
13type Loaded = Awaited<ReturnType<typeof loadRepo>>;
14type LogLine = { seq: number; t: string; step: number; text: string };
15/** Plain (non-RPC-wrapped) shape of a run, which keeps type checking shallow. */
16type RunWithJobs = RunInfo & { jobs: JobInfo[] };
17
18const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
19const MAX_LOG_LINES = 20000;
20
21const refLabel = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, "");
22
23function runDuration(r: Pick<RunInfo, "status" | "createdAt" | "updatedAt">): string {
24 return duration(r.createdAt, r.status === "completed" ? r.updatedAt : null);
25}
26
27function matrixLabel(m: Record<string, unknown>): string {
28 const entries = Object.entries(m ?? {});
29 if (!entries.length) return "";
30 return entries.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join(", ");
31}
32
33const RunRow: FC<{ base: string; r: RunInfo }> = ({ base, r }) => (
34 <li>
35 <Status status={r.status} conclusion={r.conclusion} />
36 <div class="grow">
37 <a class="title" href={`${base}/actions/runs/${r.id}`}>
38 {r.title || r.workflowName}
39 </a>
40 <div class="meta">
41 <b>{r.workflowName}</b> #{r.number}
42 {r.attempt > 1 ? ` (attempt ${r.attempt})` : ""}: {r.event.replace(/_/g, " ")}
43 {r.prNumber ? (
44 <>
45 {" "}
46 for <a href={`${base}/pull/${r.prNumber}`}>#{r.prNumber}</a>
47 </>
48 ) : null}{" "}
49 by {r.actor.login}
50 {r.actor.kind === "agent" ? <span class="badge agent" style="margin-left:4px">agent</span> : null}
51 </div>
52 </div>
53 <span class="badge mono nowrap" title={r.ref}>
54 {refLabel(r.ref)}
55 </span>
56 <div class="right small muted nowrap" style="min-width:110px">
57 <div>
58 <Time at={r.createdAt} />
59 </div>
60 <div>{runDuration(r)}</div>
61 </div>
62 </li>
63);
64
65/** Parse workflow_dispatch input definitions from the workflow file on the default branch. */
66async function dispatchInputs(l: Loaded, path: string): Promise<Record<string, DispatchInput> | null> {
67 try {
68 const blob = await l.stub.blob(l.repo.default_branch, path);
69 if (!blob || blob.binary) return null;
70 const { workflow } = parseWorkflow(new TextDecoder().decode(blob.content), path);
71 const d = workflow?.on.workflow_dispatch;
72 return d ? (d.inputs ?? {}) : null;
73 } catch {
74 return null;
75 }
76}
77
78function classify(text: string): { cls: string; text: string } {
79 const t = text.replace(ANSI, "");
80 if (t.startsWith("::group::")) return { cls: "grp", text: `▾ ${t.slice(9)}` };
81 if (t.startsWith("::endgroup::")) return { cls: "grp", text: "" };
82 if (/^(##\[error\]|::error)/.test(t)) return { cls: "err", text: t };
83 if (/^(##\[warning\]|::warning)/.test(t)) return { cls: "wrn", text: t };
84 return { cls: "", text: t };
85}
86
87async function allLogs(l: Loaded, jobId: string): Promise<{ lines: LogLine[]; complete: boolean; truncated: boolean }> {
88 const out: LogLine[] = [];
89 let after = -1;
90 let complete = false;
91 for (let i = 0; i < 5; i++) {
92 const page = await l.stub.jobLogs(jobId, after);
93 complete = page.complete;
94 out.push(...page.lines);
95 if (page.lines.length < 5000 || out.length >= MAX_LOG_LINES) break;
96 after = page.lines[page.lines.length - 1]!.seq;
97 }
98 return { lines: out.slice(0, MAX_LOG_LINES), complete, truncated: out.length >= MAX_LOG_LINES };
99}
100
101export function registerActions(web: WebApp): void {
102 web.get("/:owner/:repo/actions", async (c) => {
103 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
104 const { repo, caller, stub } = l;
105 const base = repoUrl(repo);
106 const workflow = c.req.query("workflow") ?? "";
107 const branch = c.req.query("branch") ?? "";
108 const status = c.req.query("status") ?? "";
109 const page = pageNum(c);
110 const per = 25;
111 const [runs, workflows, names] = await Promise.all([
112 stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }),
113 stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>),
114 refNames(stub),
115 ]);
116 const selected = workflow ? workflows.find((w) => w.path === workflow || w.path.endsWith(`/${workflow}`)) : undefined;
117 const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
118 const inputs = selected && canWrite && selected.triggers.includes("workflow_dispatch") ? await dispatchInputs(l, selected.path) : null;
119 const t = csrfToken(c);
120 const href = (p: Record<string, string | number | undefined>) => `${base}/actions${qs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, ...p })}`;
121 return render(
122 c,
123 { title: `Actions · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "actions" }, live: "runs" },
124 <div class="grid left">
125 <aside class="side">
126 <section>
127 <h3>Workflows</h3>
128 <div class="nav">
129 <a href={`${base}/actions`} class={!workflow ? "on" : ""}>
130 All workflows
131 </a>
132 {workflows.map((w) => (
133 <a href={`${base}/actions${qs({ workflow: w.path })}`} class={selected?.path === w.path ? "on" : ""} title={w.path}>
134 {w.errors.length ? <span class="bad">{raw(ICONS.failure)}</span> : null} {w.name}
135 </a>
136 ))}
137 </div>
138 {workflows.length === 0 ? (
139 <p class="muted small">
140 No workflows on <span class="mono">{repo.default_branch}</span>. Add a YAML file under <code>.github/workflows/</code>.
141 </p>
142 ) : null}
143 </section>
144 <section>
145 <h3>Help</h3>
146 <p class="small muted m0">Workflows use GitHub Actions syntax. Jobs run on Linux containers; secrets and variables are configured in repository settings.</p>
147 </section>
148 </aside>
149 <div>
150 <div class="row between mb">
151 <h1 class="m0">{selected ? selected.name : "All workflows"}</h1>
152 {selected ? <span class="mono muted small">{selected.path}</span> : null}
153 </div>
154 {selected?.errors.length ? (
155 <div class="failure">
156 <b>This workflow file has errors</b>
157 <pre>{selected.errors.join("\n")}</pre>
158 </div>
159 ) : null}
160 {!selected && workflows.some((w) => w.errors.length) ? (
161 <div class="failure">
162 <b>Some workflow files have errors</b>
163 <pre>{workflows.filter((w) => w.errors.length).map((w) => `${w.path}\n ${w.errors.join("\n ")}`).join("\n")}</pre>
164 </div>
165 ) : null}
166 {selected && inputs ? (
167 <details class="box">
168 <summary class="head" style="cursor:pointer">
169 {raw(ICONS.play)} <b>Run workflow</b>
170 <span class="muted small">This workflow has a workflow_dispatch trigger.</span>
171 </summary>
172 <Form action={`${base}/actions/dispatch`} t={t} class="body">
173 <input type="hidden" name="workflow" value={selected.path} />
174 <div class="field" style="max-width:320px">
175 <label>Use workflow from</label>
176 <select name="ref">
177 {names.branches.map((b) => (
178 <option value={b} selected={b === repo.default_branch}>
179 {b}
180 </option>
181 ))}
182 {names.tags.map((tg) => (
183 <option value={tg}>tag: {tg}</option>
184 ))}
185 </select>
186 </div>
187 {Object.entries(inputs).map(([name, d]) => (
188 <div class="field" style="max-width:520px">
189 {d.type === "boolean" ? (
190 <>
191 <input type="hidden" name={`in_${name}`} value="false" />
192 <label class="inline">
193 <input type="checkbox" name={`in_${name}`} value="true" checked={d.default === true || d.default === "true"} /> {d.description ?? name}
194 </label>
195 </>
196 ) : (
197 <>
198 <label>
199 {d.description ?? name} {d.required ? <span class="bad">*</span> : null}
200 </label>
201 {d.type === "choice" && d.options?.length ? (
202 <select name={`in_${name}`}>
203 {d.options.map((o) => (
204 <option value={o} selected={String(d.default ?? "") === o}>
205 {o}
206 </option>
207 ))}
208 </select>
209 ) : (
210 <input type={d.type === "number" ? "number" : "text"} name={`in_${name}`} value={d.default === undefined || d.default === null ? "" : String(d.default)} required={!!d.required} />
211 )}
212 </>
213 )}
214 {d.description && d.type !== "boolean" ? <div class="hint mono">{name}</div> : null}
215 </div>
216 ))}
217 <button class="btn green">{raw(ICONS.play)} Run workflow</button>
218 </Form>
219 </details>
220 ) : null}
221 <div class="box" data-fragment="runs">
222 <div class="head">
223 <b>
224 {runs.total} workflow run{runs.total === 1 ? "" : "s"}
225 </b>
226 <span class="grow" />
227 <form method="get" action={`${base}/actions`} class="row" style="gap:6px">
228 {workflow ? <input type="hidden" name="workflow" value={workflow} /> : null}
229 <select name="branch" style="width:auto;padding:3px 6px">
230 <option value="">All branches</option>
231 {names.branches.map((b) => (
232 <option value={b} selected={b === branch}>
233 {b}
234 </option>
235 ))}
236 </select>
237 <select name="status" style="width:auto;padding:3px 6px">
238 <option value="">Any status</option>
239 {["queued", "in_progress", "completed", "success", "failure", "cancelled"].map((s) => (
240 <option value={s} selected={s === status}>
241 {s.replace("_", " ")}
242 </option>
243 ))}
244 </select>
245 <button class="btn sm">Filter</button>
246 </form>
247 </div>
248 {runs.runs.length ? (
249 <ul class="list">
250 {runs.runs.map((r) => (
251 <RunRow base={base} r={r} />
252 ))}
253 </ul>
254 ) : (
255 <div class="empty">{workflow || branch || status ? "No runs match these filters." : "No workflow runs yet. Push a commit with a workflow file to start one."}</div>
256 )}
257 </div>
258 <Pager page={page} hasNext={page * per < runs.total} href={(p) => href({ page: p > 1 ? p : undefined })} />
259 </div>
260 </div>,
261 );
262 });
263
264 post(web, "/:owner/:repo/actions/dispatch", async (c, f) => {
265 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
266 if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
267 const base = repoUrl(l.repo);
268 const workflow = f.s("workflow");
269 const inputs: Record<string, unknown> = {};
270 for (const key of f.keys()) if (key.startsWith("in_")) inputs[key.slice(3)] = f.all(key).pop();
271 return act(c, `${base}/actions${qs({ workflow })}`, async () => {
272 const run = await l.stub.dispatchWorkflow(workflow, f.s("ref") || l.repo.default_branch, inputs, l.caller);
273 return `${base}/actions/runs/${run.id}`;
274 });
275 });
276
277 web.get("/:owner/:repo/actions/runs/:runId", async (c) => {
278 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
279 const { repo, caller, stub } = l;
280 const base = repoUrl(repo);
281 const runId = prm(c, "runId");
282 const run = (await stub.run(runId)) as unknown as RunWithJobs;
283 const artifacts = await stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>);
284 const groups = new Map<string, JobInfo[]>();
285 for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]);
286 const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
287 const t = csrfToken(c);
288 const behalf = run.actor.onBehalfOf;
289 return render(
290 c,
291 { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined },
292 <div data-fragment="run">
293 <div class="row between mb" style="align-items:flex-start">
294 <div class="grow">
295 <div class="muted small">
296 <a href={`${base}/actions${qs({ workflow: run.workflowPath })}`}>{run.workflowName}</a> · run #{run.number}
297 {run.attempt > 1 ? ` · attempt ${run.attempt}` : ""}
298 </div>
299 <h1 class="row" style="gap:8px;margin-top:4px">
300 <Status status={run.status} conclusion={run.conclusion} /> {run.title || run.workflowName}
301 </h1>
302 </div>
303 <div class="row">
304 {canWrite && run.status !== "completed" ? (
305 <Form action={`${base}/actions/runs/${run.id}/cancel`} t={t} class="inline" confirm="Cancel this run?">
306 <button class="btn danger">{raw(ICONS.cancelled)} Cancel run</button>
307 </Form>
308 ) : null}
309 {canWrite && run.status === "completed" ? (
310 <Form action={`${base}/actions/runs/${run.id}/rerun`} t={t} class="inline">
311 <button class="btn">{raw(ICONS.history)} Re-run all jobs</button>
312 </Form>
313 ) : null}
314 </div>
315 </div>
316 <div class="box">
317 <div class="body">
318 <dl class="kv">
319 <dt>Status</dt>
320 <dd>
321 <b>{run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace("_", " ")}</b>
322 {run.status === "completed" ? <span class="muted"> in {runDuration(run)}</span> : <span class="muted"> for {runDuration(run)}</span>}
323 </dd>
324 <dt>Triggered</dt>
325 <dd>
326 {run.event.replace(/_/g, " ")} by <Actor login={run.actor.login} kind={run.actor.kind} onBehalfOf={behalf} avatar={false} /> <Time at={run.createdAt} />
327 </dd>
328 <dt>Commit</dt>
329 <dd>
330 <a class="sha" href={`${base}/commit/${run.sha}`}>
331 {shortSha(run.sha)}
332 </a>{" "}
333 on{" "}
334 <a class="mono" href={`${base}/tree/${encPath(refLabel(run.ref))}`}>
335 {refLabel(run.ref)}
336 </a>
337 {run.prNumber ? (
338 <>
339 {" "}
340 · pull request <a href={`${base}/pull/${run.prNumber}`}>#{run.prNumber}</a>
341 </>
342 ) : null}
343 </dd>
344 <dt>Workflow file</dt>
345 <dd>
346 <a class="mono" href={`${base}/blob/${run.sha}/${encPath(run.workflowPath)}`}>
347 {run.workflowPath}
348 </a>
349 </dd>
350 </dl>
351 </div>
352 </div>
353 {[...groups.entries()].map(([key, jobs]) => (
354 <div class="box jobgroup">
355 <div class="head">
356 <b class="mono">{key}</b>
357 {jobs.length > 1 ? <span class="muted small">{jobs.length} jobs (matrix)</span> : null}
358 </div>
359 {jobs.map((j) => (
360 <a class="job" href={`${base}/actions/runs/${run.id}/job/${j.id}`}>
361 <Status status={j.status} conclusion={j.conclusion} />
362 <div class="grow">
363 <b>{j.name}</b>
364 {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null}
365 {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null}
366 </div>
367 <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status}</span>
368 </a>
369 ))}
370 </div>
371 ))}
372 {run.jobs.length === 0 ? <div class="blank mb">{run.status === "completed" ? "This run has no jobs." : "Waiting for jobs to be scheduled…"}</div> : null}
373 {artifacts.length ? (
374 <div class="box">
375 <div class="head">
376 <h3>{raw(ICONS.download)} Artifacts</h3>
377 </div>
378 <ul class="list">
379 {artifacts.map((a) => (
380 <li>
381 <div class="grow">
382 <a class="title" href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(a.name)}`}>
383 {a.name}
384 </a>
385 <div class="meta">
386 {fmtBytes(a.size)} · <Time at={a.createdAt} />
387 </div>
388 </div>
389 </li>
390 ))}
391 </ul>
392 </div>
393 ) : null}
394 </div>,
395 );
396 });
397
398 post(web, "/:owner/:repo/actions/runs/:runId/cancel", async (c) => {
399 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
400 const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`;
401 return act(c, back, async () => l.stub.cancelRun(prm(c, "runId"), l.caller), "Cancellation requested.");
402 });
403
404 post(web, "/:owner/:repo/actions/runs/:runId/rerun", async (c) => {
405 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
406 const base = repoUrl(l.repo);
407 return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => {
408 const run = await l.stub.rerun(prm(c, "runId"), l.caller);
409 return `${base}/actions/runs/${run.id}`;
410 });
411 });
412
413 web.get("/:owner/:repo/actions/runs/:runId/job/:jobId", async (c) => {
414 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
415 const { repo, caller, stub } = l;
416 const base = repoUrl(repo);
417 const runId = prm(c, "runId");
418 const jobId = prm(c, "jobId");
419 const run = (await stub.run(runId)) as unknown as RunWithJobs;
420 const job = (await stub.job(jobId)) as unknown as JobInfo;
421 if (job.runId !== run.id) throw new HttpError(404, "job not found");
422 const logs = await allLogs(l, jobId);
423 const byStep = new Map<number, LogLine[]>();
424 for (const line of logs.lines) byStep.set(line.step, [...(byStep.get(line.step) ?? []), line]);
425 const stepIdx = new Set<number>([...job.steps.map((s) => s.index), ...byStep.keys()]);
426 const sections = [...stepIdx].sort((a, b) => a - b);
427 const stepOf = new Map(job.steps.map((s) => [s.index, s]));
428 const lastSeq = logs.lines.length ? logs.lines[logs.lines.length - 1]!.seq : -1;
429 const live = job.status !== "completed";
430 const failedStep = job.failure?.step;
431 return render(
432 c,
433 { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id}` : undefined, liveMode: "log", lastSeq, wide: true },
434 <div class="grid left">
435 <aside class="side">
436 <section>
437 <a href={`${base}/actions/runs/${run.id}`} class="small">
438 ← {run.workflowName} #{run.number}
439 </a>
440 <h3 class="mt">Jobs</h3>
441 <div class="nav">
442 {run.jobs.map((j) => (
443 <a href={`${base}/actions/runs/${run.id}/job/${j.id}`} class={j.id === job.id ? "on" : ""} style="display:flex;gap:6px;align-items:center">
444 <Status status={j.status} conclusion={j.conclusion} /> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{j.name}</span>
445 </a>
446 ))}
447 </div>
448 </section>
449 </aside>
450 <div>
451 <div class="row between mb">
452 <h1 class="row m0" style="gap:8px">
453 <Status status={job.status} conclusion={job.conclusion} /> {job.name}
454 </h1>
455 <div class="row">
456 <span class="muted small">
457 {job.startedAt ? (
458 <>
459 {job.status === "completed" ? "took" : "running for"} {duration(job.startedAt, job.completedAt)}
460 </>
461 ) : (
462 job.status
463 )}
464 </span>
465 <a class="btn sm" href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>
466 {raw(ICONS.download)} Raw logs
467 </a>
468 </div>
469 </div>
470 {matrixLabel(job.matrix) ? <p class="muted small">Matrix: {matrixLabel(job.matrix)}</p> : null}
471 {job.failure ? (
472 <div class="failure">
473 <b>
474 {raw(ICONS.failure)} {job.conclusion === "cancelled" ? "Cancelled" : job.conclusion === "timed_out" ? "Timed out" : "Failed"}
475 {failedStep !== undefined && failedStep !== null ? (
476 <>
477 {" "}
478 at{" "}
479 <a href={`#step-${failedStep}`} class="bad">
480 {stepOf.get(failedStep)?.name ?? `step ${failedStep + 1}`}
481 </a>
482 </>
483 ) : null}
484 </b>
485 <pre>{job.failure.message}</pre>
486 </div>
487 ) : null}
488 {logs.truncated ? (
489 <div class="flash info">
490 Showing the first {MAX_LOG_LINES.toLocaleString("en-US")} lines. <a href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>Download the full log</a>.
491 </div>
492 ) : null}
493 <div class="log">
494 {sections.length === 0 ? <div style="padding:14px;color:#9aa3ad">{live ? "Waiting for the runner to start…" : "No log output."}</div> : null}
495 {sections.map((idx) => {
496 const s = stepOf.get(idx);
497 const lines = byStep.get(idx) ?? [];
498 const failed = s?.conclusion === "failure" || failedStep === idx;
499 const running = s?.status === "in_progress";
500 return (
501 <details data-step={String(idx)} id={`step-${idx}`} open={failed || running || (live && idx === sections[sections.length - 1])}>
502 <summary>
503 {s ? (
504 <Status status={s.status} conclusion={s.conclusion} />
505 ) : idx < 0 ? (
506 <Status conclusion={job.startedAt ? "success" : "queued"} />
507 ) : (
508 <Status conclusion="neutral" />
509 )}
510 {idx < 0 ? "Set up job" : (s?.name ?? `Step ${idx + 1}`)}
511 <span class="dur">{s?.startedAt ? duration(s.startedAt, s.completedAt) : ""}</span>
512 </summary>
513 <pre>
514 {lines.map((ln) => {
515 const k = classify(ln.text);
516 return <span class={k.cls || undefined}>{k.text}</span>;
517 })}
518 </pre>
519 </details>
520 );
521 })}
522 </div>
523 {job.summary ? (
524 <div class="box">
525 <div class="head">
526 <h3>Job summary</h3>
527 </div>
528 <div class="body">
529 <Markdown text={job.summary} />
530 </div>
531 </div>
532 ) : null}
533 {Object.keys(job.outputs ?? {}).length ? (
534 <div class="box">
535 <div class="head">
536 <h3>Outputs</h3>
537 </div>
538 <div class="body">
539 <dl class="kv">
540 {Object.entries(job.outputs).map(([k, v]) => (
541 <>
542 <dt class="mono">{k}</dt>
543 <dd class="mono" style="word-break:break-all">
544 {v}
545 </dd>
546 </>
547 ))}
548 </dl>
549 </div>
550 </div>
551 ) : null}
552 </div>
553 </div>,
554 );
555 });
556
557 web.get("/:owner/:repo/actions/runs/:runId/job/:jobId/logs", async (c) => {
558 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
559 const job = await l.stub.job(prm(c, "jobId"));
560 if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found");
561 const out: string[] = [];
562 let after = -1;
563 for (let i = 0; i < 40; i++) {
564 const page = await l.stub.jobLogs(job.id, after);
565 for (const ln of page.lines) out.push(`${ln.t} ${ln.text.replace(ANSI, "")}`);
566 if (page.lines.length < 5000) break;
567 after = page.lines[page.lines.length - 1]!.seq;
568 }
569 return new Response(`${out.join("\n")}\n`, {
570 headers: {
571 "content-type": "text/plain; charset=utf-8",
572 "x-content-type-options": "nosniff",
573 "content-security-policy": "sandbox",
574 "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(`${job.name}.log`)}`,
575 "cache-control": "private, no-store",
576 },
577 });
578 });
579}