| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { parseWorkflow, type DispatchInput } from "@diffinery/workflow-yaml"; |
| 3 | import { raw } from "hono/html"; |
| 4 | import type { FC } from "hono/jsx"; |
| 5 | import { loadRepo } from "../http/common.js"; |
| 6 | import { HttpError } from "../lib/util.js"; |
| 7 | import type { JobInfo, PendingDeployment, RunInfo } from "../repo/actions.js"; |
| 8 | import type { DeploymentReview } from "../repo/deployments.js"; |
| 9 | import { reviewDeployments, viewerFor } from "../http/environment-reviews.js"; |
| 10 | import { atLeast } from "../repo/types.js"; |
| 11 | import { ICONS } from "./assets.js"; |
| 12 | import { KCheckbox, KSelect } from "./kumo.js"; |
| 13 | import { csrfToken } from "./csrf.js"; |
| 14 | import { ActionsBlockedBanner, actionsSwitches } from "./kill-switches.js"; |
| 15 | import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js"; |
| 16 | |
| 17 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 18 | type LogLine = { seq: number; t: string; step: number; text: string }; |
| 19 | /** Plain (non-RPC-wrapped) shape of a run, which keeps type checking shallow. */ |
| 20 | type RunWithJobs = RunInfo & { jobs: JobInfo[] }; |
| 21 | |
| 22 | const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; |
| 23 | const MAX_LOG_LINES = 20000; |
| 24 | /** Conclusions "Re-run failed jobs" keeps (repo/actions.ts KEPT_CONCLUSIONS); the button shows when a job ended any other way. */ |
| 25 | const KEPT_ON_RERUN = new Set(["success", "skipped", "neutral"]); |
| 26 | |
| 27 | const refLabel = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, ""); |
| 28 | |
| 29 | function runDuration(r: Pick<RunInfo, "status" | "createdAt" | "updatedAt">): string { |
| 30 | return duration(r.createdAt, r.status === "completed" ? r.updatedAt : null); |
| 31 | } |
| 32 | |
| 33 | function matrixLabel(m: Record<string, unknown>): string { |
| 34 | const entries = Object.entries(m ?? {}); |
| 35 | if (!entries.length) return ""; |
| 36 | return entries.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join(", "); |
| 37 | } |
| 38 | |
| 39 | const RunRow: FC<{ base: string; r: RunInfo }> = ({ base, r }) => ( |
| 40 | <li> |
| 41 | <Status status={r.status} conclusion={r.conclusion} /> |
| 42 | <div class="grow"> |
| 43 | <a class="title" href={`${base}/actions/runs/${r.id}`}> |
| 44 | {r.title || r.workflowName} |
| 45 | </a> |
| 46 | <div class="meta"> |
| 47 | <b>{r.workflowName}</b> #{r.number} |
| 48 | {r.attempt > 1 ? ` (attempt ${r.attempt})` : ""}: {r.event.replace(/_/g, " ")} |
| 49 | {r.prNumber ? ( |
| 50 | <> |
| 51 | {" "} |
| 52 | for <a href={`${base}/pull/${r.prNumber}`}>#{r.prNumber}</a> |
| 53 | </> |
| 54 | ) : null}{" "} |
| 55 | by {r.actor.login} |
| 56 | {r.actor.kind === "agent" ? <span class="badge agent" style="margin-left:4px">agent</span> : null} |
| 57 | </div> |
| 58 | </div> |
| 59 | <span class="badge mono nowrap" title={r.ref}> |
| 60 | {refLabel(r.ref)} |
| 61 | </span> |
| 62 | <div class="right small muted nowrap" style="min-width:110px"> |
| 63 | <div> |
| 64 | <Time at={r.createdAt} /> |
| 65 | </div> |
| 66 | <div>{runDuration(r)}</div> |
| 67 | </div> |
| 68 | </li> |
| 69 | ); |
| 70 | |
| 71 | /** Parse workflow_dispatch input definitions from the workflow file on the default branch. */ |
| 72 | async function dispatchInputs(l: Loaded, path: string): Promise<Record<string, DispatchInput> | null> { |
| 73 | try { |
| 74 | const blob = await l.stub.blob(l.repo.default_branch, path); |
| 75 | if (!blob || blob.binary) return null; |
| 76 | const { workflow } = parseWorkflow(new TextDecoder().decode(blob.content), path); |
| 77 | const d = workflow?.on.workflow_dispatch; |
| 78 | return d ? (d.inputs ?? {}) : null; |
| 79 | } catch { |
| 80 | return null; |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | function classify(text: string): { cls: string; text: string } { |
| 85 | const t = text.replace(ANSI, ""); |
| 86 | if (t.startsWith("::group::")) return { cls: "grp", text: `▾ ${t.slice(9)}` }; |
| 87 | if (t.startsWith("::endgroup::")) return { cls: "grp", text: "" }; |
| 88 | if (/^(##\[error\]|::error)/.test(t)) return { cls: "err", text: t }; |
| 89 | if (/^(##\[warning\]|::warning)/.test(t)) return { cls: "wrn", text: t }; |
| 90 | return { cls: "", text: t }; |
| 91 | } |
| 92 | |
| 93 | async function allLogs(l: Loaded, jobId: string): Promise<{ lines: LogLine[]; complete: boolean; truncated: boolean }> { |
| 94 | const out: LogLine[] = []; |
| 95 | let after = -1; |
| 96 | let complete = false; |
| 97 | for (let i = 0; i < 5; i++) { |
| 98 | const page = await l.stub.jobLogs(jobId, after); |
| 99 | complete = page.complete; |
| 100 | out.push(...page.lines); |
| 101 | if (page.lines.length < 5000 || out.length >= MAX_LOG_LINES) break; |
| 102 | after = page.lines[page.lines.length - 1]!.seq; |
| 103 | } |
| 104 | return { lines: out.slice(0, MAX_LOG_LINES), complete, truncated: out.length >= MAX_LOG_LINES }; |
| 105 | } |
| 106 | |
| 107 | /** Environments the run waits on: who may approve, any wait timer, and a review form for listed reviewers. */ |
| 108 | const ReviewDeployments: FC<{ base: string; runId: string; pending: PendingDeployment[]; reviews: DeploymentReview[]; t: string; canAct: boolean }> = ({ base, runId, pending, reviews, t, canAct }) => { |
| 109 | const mine = pending.filter((p) => p.currentUserCanApprove); |
| 110 | return ( |
| 111 | <div class="box" style="border-color:var(--amber)"> |
| 112 | <div class="head"> |
| 113 | <h3>Waiting for {pending.some((p) => p.needsReview) ? "review" : "a wait timer"}</h3> |
| 114 | </div> |
| 115 | <ul class="list"> |
| 116 | {pending.map((p) => { |
| 117 | const timerEnds = new Date(Date.parse(p.waitTimerStartedAt) + p.waitTimer * 60_000).toISOString(); |
| 118 | return ( |
| 119 | <li> |
| 120 | <div class="grow"> |
| 121 | <b>{p.environment.name}</b> |
| 122 | <div class="meta"> |
| 123 | {p.needsReview ? <>Needs an approval from {p.reviewers.map((r) => r.login).join(", ")}. </> : p.reviewers.length ? "Approved. " : null} |
| 124 | {p.waitTimer > 0 ? ( |
| 125 | <> |
| 126 | Wait timer of {p.waitTimer} minute{p.waitTimer === 1 ? "" : "s"}, ends <Time at={timerEnds} />.{" "} |
| 127 | </> |
| 128 | ) : null} |
| 129 | {p.selfBlocked ? "You started this run, and this environment doesn't let you approve it." : null} |
| 130 | </div> |
| 131 | </div> |
| 132 | </li> |
| 133 | ); |
| 134 | })} |
| 135 | </ul> |
| 136 | {canAct && mine.length ? ( |
| 137 | <Form action={`${base}/actions/runs/${runId}/review`} t={t} class="body" style="border-top:1px solid var(--border2)"> |
| 138 | <div class="field"> |
| 139 | <label>Environments to review</label> |
| 140 | {mine.map((p) => ( |
| 141 | <div> |
| 142 | <KCheckbox name="environment" value={String(p.environment.id)} checked label={p.environment.name} /> |
| 143 | </div> |
| 144 | ))} |
| 145 | </div> |
| 146 | <div class="field"> |
| 147 | <label>Comment</label> |
| 148 | <textarea name="comment" maxlength={4000} style="min-height:60px" placeholder="Optional"></textarea> |
| 149 | </div> |
| 150 | <div class="row"> |
| 151 | <button class="btn primary" name="state" value="approved"> |
| 152 | Approve and deploy |
| 153 | </button> |
| 154 | <button class="btn danger" name="state" value="rejected"> |
| 155 | Reject |
| 156 | </button> |
| 157 | </div> |
| 158 | </Form> |
| 159 | ) : null} |
| 160 | {reviews.length ? <ReviewHistory reviews={reviews} /> : null} |
| 161 | </div> |
| 162 | ); |
| 163 | }; |
| 164 | |
| 165 | const ReviewHistory: FC<{ reviews: DeploymentReview[] }> = ({ reviews }) => ( |
| 166 | <ul class="list"> |
| 167 | {reviews.map((r) => ( |
| 168 | <li> |
| 169 | <div class="grow small"> |
| 170 | <b>{r.user.login}</b> {r.state} <b>{r.environment}</b> <Time at={r.createdAt} /> |
| 171 | {r.comment ? <div class="muted">{r.comment}</div> : null} |
| 172 | </div> |
| 173 | </li> |
| 174 | ))} |
| 175 | </ul> |
| 176 | ); |
| 177 | |
| 178 | export function registerActions(web: WebApp): void { |
| 179 | web.get("/:owner/:repo/actions", async (c) => { |
| 180 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 181 | const { repo, caller, stub } = l; |
| 182 | const base = repoUrl(repo); |
| 183 | const workflow = c.req.query("workflow") ?? ""; |
| 184 | const branch = c.req.query("branch") ?? ""; |
| 185 | const status = c.req.query("status") ?? ""; |
| 186 | const page = pageNum(c); |
| 187 | const per = 25; |
| 188 | const switches = actionsSwitches(c.env, repo.id); |
| 189 | const [runs, workflows, names] = await Promise.all([ |
| 190 | stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }), |
| 191 | stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>), |
| 192 | refNames(stub), |
| 193 | ]); |
| 194 | const selected = workflow ? workflows.find((w) => w.path === workflow || w.path.endsWith(`/${workflow}`)) : undefined; |
| 195 | const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived; |
| 196 | const inputs = selected && canWrite && selected.triggers.includes("workflow_dispatch") ? await dispatchInputs(l, selected.path) : null; |
| 197 | const t = csrfToken(c); |
| 198 | const href = (p: Record<string, string | number | undefined>) => `${base}/actions${qs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, ...p })}`; |
| 199 | return render( |
| 200 | c, |
| 201 | { title: `Actions · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "actions" }, live: "runs" }, |
| 202 | <div class="grid left"> |
| 203 | <aside class="side"> |
| 204 | <section> |
| 205 | <h3>Workflows</h3> |
| 206 | <div class="nav"> |
| 207 | <a href={`${base}/actions`} class={!workflow ? "on" : ""}> |
| 208 | All workflows |
| 209 | </a> |
| 210 | {workflows.map((w) => ( |
| 211 | <a href={`${base}/actions${qs({ workflow: w.path })}`} class={selected?.path === w.path ? "on" : ""} title={w.path}> |
| 212 | {w.errors.length ? <span class="bad">{raw(ICONS.failure)}</span> : null} {w.name} |
| 213 | </a> |
| 214 | ))} |
| 215 | </div> |
| 216 | {workflows.length === 0 ? ( |
| 217 | <p class="muted small"> |
| 218 | No workflows on <span class="mono">{repo.default_branch}</span>. Add a YAML file under <code>.github/workflows/</code>. |
| 219 | </p> |
| 220 | ) : null} |
| 221 | </section> |
| 222 | <section> |
| 223 | <h3>Help</h3> |
| 224 | <p class="small muted m0">Workflows use GitHub Actions syntax. Jobs run on Linux containers; secrets and variables are configured in repository settings.</p> |
| 225 | </section> |
| 226 | </aside> |
| 227 | <div> |
| 228 | <ActionsBlockedBanner env={c.env} repoId={repo.id} switches={switches} /> |
| 229 | <div class="row between mb"> |
| 230 | <h1 class="m0">{selected ? selected.name : "All workflows"}</h1> |
| 231 | {selected ? <span class="mono muted small">{selected.path}</span> : null} |
| 232 | </div> |
| 233 | {selected?.errors.length ? ( |
| 234 | <div class="failure"> |
| 235 | <b>This workflow file has errors</b> |
| 236 | <pre>{selected.errors.join("\n")}</pre> |
| 237 | </div> |
| 238 | ) : null} |
| 239 | {!selected && workflows.some((w) => w.errors.length) ? ( |
| 240 | <div class="failure"> |
| 241 | <b>Some workflow files have errors</b> |
| 242 | <pre>{workflows.filter((w) => w.errors.length).map((w) => `${w.path}\n ${w.errors.join("\n ")}`).join("\n")}</pre> |
| 243 | </div> |
| 244 | ) : null} |
| 245 | {selected && inputs ? ( |
| 246 | <details class="box"> |
| 247 | <summary class="head" style="cursor:pointer"> |
| 248 | {raw(ICONS.play)} <b>Run workflow</b> |
| 249 | <span class="muted small">This workflow has a workflow_dispatch trigger.</span> |
| 250 | </summary> |
| 251 | <Form action={`${base}/actions/dispatch`} t={t} class="body"> |
| 252 | <input type="hidden" name="workflow" value={selected.path} /> |
| 253 | <div class="field" style="max-width:320px"> |
| 254 | <label>Use workflow from</label> |
| 255 | <KSelect |
| 256 | name="ref" |
| 257 | block |
| 258 | label="Use workflow from" |
| 259 | value={names.branches.includes(repo.default_branch) ? repo.default_branch : undefined} |
| 260 | options={[...names.branches.map((b) => ({ value: b, label: b })), ...names.tags.map((tg) => ({ value: tg, label: `tag: ${tg}` }))]} |
| 261 | /> |
| 262 | </div> |
| 263 | {Object.entries(inputs).map(([name, d]) => ( |
| 264 | <div class="field" style="max-width:520px"> |
| 265 | {d.type === "boolean" ? ( |
| 266 | <> |
| 267 | <input type="hidden" name={`in_${name}`} value="false" /> |
| 268 | <KCheckbox name={`in_${name}`} value="true" checked={d.default === true || d.default === "true"} label={d.description ?? name} /> |
| 269 | </> |
| 270 | ) : ( |
| 271 | <> |
| 272 | <label> |
| 273 | {d.description ?? name} {d.required ? <span class="bad">*</span> : null} |
| 274 | </label> |
| 275 | {d.type === "choice" && d.options?.length ? ( |
| 276 | <KSelect |
| 277 | name={`in_${name}`} |
| 278 | block |
| 279 | label={d.description ?? name} |
| 280 | value={d.options.includes(String(d.default ?? "")) ? String(d.default ?? "") : undefined} |
| 281 | options={d.options.map((o) => ({ value: o, label: o }))} |
| 282 | /> |
| 283 | ) : ( |
| 284 | <input type={d.type === "number" ? "number" : "text"} name={`in_${name}`} value={d.default === undefined || d.default === null ? "" : String(d.default)} required={!!d.required} /> |
| 285 | )} |
| 286 | </> |
| 287 | )} |
| 288 | {d.description && d.type !== "boolean" ? <div class="hint mono">{name}</div> : null} |
| 289 | </div> |
| 290 | ))} |
| 291 | <button class="btn green">{raw(ICONS.play)} Run workflow</button> |
| 292 | </Form> |
| 293 | </details> |
| 294 | ) : null} |
| 295 | <div class="box" data-fragment="runs"> |
| 296 | <div class="head"> |
| 297 | <b> |
| 298 | {runs.total} workflow run{runs.total === 1 ? "" : "s"} |
| 299 | </b> |
| 300 | <span class="grow" /> |
| 301 | <form method="get" action={`${base}/actions`} class="row" style="gap:6px"> |
| 302 | {workflow ? <input type="hidden" name="workflow" value={workflow} /> : null} |
| 303 | <KSelect |
| 304 | name="branch" |
| 305 | size="sm" |
| 306 | label="Branch" |
| 307 | autosubmit |
| 308 | value={branch && names.branches.includes(branch) ? branch : ""} |
| 309 | options={[{ value: "", label: "All branches" }, ...names.branches.map((b) => ({ value: b, label: b }))]} |
| 310 | /> |
| 311 | <KSelect |
| 312 | name="status" |
| 313 | size="sm" |
| 314 | label="Status" |
| 315 | autosubmit |
| 316 | value={status ?? ""} |
| 317 | options={[{ value: "", label: "Any status" }, ...["queued", "in_progress", "completed", "success", "failure", "cancelled"].map((s) => ({ value: s, label: s.replace("_", " ") }))]} |
| 318 | /> |
| 319 | <button class="btn sm">Filter</button> |
| 320 | </form> |
| 321 | </div> |
| 322 | {runs.runs.length ? ( |
| 323 | <ul class="list"> |
| 324 | {runs.runs.map((r) => ( |
| 325 | <RunRow base={base} r={r} /> |
| 326 | ))} |
| 327 | </ul> |
| 328 | ) : ( |
| 329 | <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> |
| 330 | )} |
| 331 | </div> |
| 332 | <Pager page={page} hasNext={page * per < runs.total} href={(p) => href({ page: p > 1 ? p : undefined })} /> |
| 333 | </div> |
| 334 | </div>, |
| 335 | ); |
| 336 | }); |
| 337 | |
| 338 | post(web, "/:owner/:repo/actions/dispatch", async (c, f) => { |
| 339 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); |
| 340 | if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); |
| 341 | const base = repoUrl(l.repo); |
| 342 | const workflow = f.s("workflow"); |
| 343 | const inputs: Record<string, unknown> = {}; |
| 344 | for (const key of f.keys()) if (key.startsWith("in_")) inputs[key.slice(3)] = f.all(key).pop(); |
| 345 | return act(c, `${base}/actions${qs({ workflow })}`, async () => { |
| 346 | const run = await l.stub.dispatchWorkflow(workflow, f.s("ref") || l.repo.default_branch, inputs, l.caller); |
| 347 | return `${base}/actions/runs/${run.id}`; |
| 348 | }); |
| 349 | }); |
| 350 | |
| 351 | web.get("/:owner/:repo/actions/runs/:runId", async (c) => { |
| 352 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 353 | const { repo, caller, stub } = l; |
| 354 | const base = repoUrl(repo); |
| 355 | const runId = prm(c, "runId"); |
| 356 | // The run, its artifacts and the viewer side by side, then its deployments (they need the run's own id and status). |
| 357 | const switches = actionsSwitches(c.env, repo.id); |
| 358 | const [run, artifacts, viewer] = await Promise.all([ |
| 359 | stub.run(runId) as unknown as Promise<RunWithJobs>, |
| 360 | stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>), |
| 361 | viewerFor(c, repo), |
| 362 | ]); |
| 363 | const principal = c.get("principal"); |
| 364 | const [pending, reviews] = await Promise.all([run.status !== "completed" ? stub.pendingDeployments(run.id, viewer) : Promise.resolve([] as PendingDeployment[]), stub.deploymentReviews(run.id)]); |
| 365 | const groups = new Map<string, JobInfo[]>(); |
| 366 | for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]); |
| 367 | const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived; |
| 368 | const t = csrfToken(c); |
| 369 | const behalf = run.actor.onBehalfOf; |
| 370 | return render( |
| 371 | c, |
| 372 | { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined }, |
| 373 | <div data-fragment="run"> |
| 374 | <ActionsBlockedBanner env={c.env} repoId={repo.id} switches={switches} /> |
| 375 | <div class="row between mb" style="align-items:flex-start"> |
| 376 | <div class="grow"> |
| 377 | <div class="muted small"> |
| 378 | <a href={`${base}/actions${qs({ workflow: run.workflowPath })}`}>{run.workflowName}</a> · run #{run.number} |
| 379 | {run.attempt > 1 ? ` · attempt ${run.attempt}` : ""} |
| 380 | </div> |
| 381 | <h1 class="row" style="gap:8px;margin-top:4px"> |
| 382 | <Status status={run.status} conclusion={run.conclusion} /> {run.title || run.workflowName} |
| 383 | </h1> |
| 384 | </div> |
| 385 | <div class="row"> |
| 386 | {canWrite && run.status !== "completed" ? ( |
| 387 | <Form action={`${base}/actions/runs/${run.id}/cancel`} t={t} class="inline" confirm="Cancel this run?"> |
| 388 | <button class="btn danger">{raw(ICONS.cancelled)} Cancel run</button> |
| 389 | </Form> |
| 390 | ) : null} |
| 391 | {canWrite && run.status === "completed" && run.jobs.some((j) => !KEPT_ON_RERUN.has(j.conclusion ?? "")) ? ( |
| 392 | <Form action={`${base}/actions/runs/${run.id}/rerun-failed`} t={t} class="inline"> |
| 393 | <button class="btn">{raw(ICONS.history)} Re-run failed jobs</button> |
| 394 | </Form> |
| 395 | ) : null} |
| 396 | {canWrite && run.status === "completed" ? ( |
| 397 | <Form action={`${base}/actions/runs/${run.id}/rerun`} t={t} class="inline"> |
| 398 | <button class="btn">{raw(ICONS.history)} Re-run all jobs</button> |
| 399 | </Form> |
| 400 | ) : null} |
| 401 | </div> |
| 402 | </div> |
| 403 | <div class="box"> |
| 404 | <div class="body"> |
| 405 | <dl class="kv"> |
| 406 | <dt>Status</dt> |
| 407 | <dd> |
| 408 | <b>{run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace("_", " ")}</b> |
| 409 | {run.status === "completed" ? <span class="muted"> in {runDuration(run)}</span> : <span class="muted"> for {runDuration(run)}</span>} |
| 410 | </dd> |
| 411 | <dt>Triggered</dt> |
| 412 | <dd> |
| 413 | {run.event.replace(/_/g, " ")} by <Actor login={run.actor.login} kind={run.actor.kind} onBehalfOf={behalf} avatar={false} /> <Time at={run.createdAt} /> |
| 414 | </dd> |
| 415 | <dt>Commit</dt> |
| 416 | <dd> |
| 417 | <a class="sha" href={`${base}/commit/${run.sha}`}> |
| 418 | {shortSha(run.sha)} |
| 419 | </a>{" "} |
| 420 | on{" "} |
| 421 | <a class="mono" href={`${base}/tree/${encPath(refLabel(run.ref))}`}> |
| 422 | {refLabel(run.ref)} |
| 423 | </a> |
| 424 | {run.prNumber ? ( |
| 425 | <> |
| 426 | {" "} |
| 427 | · pull request <a href={`${base}/pull/${run.prNumber}`}>#{run.prNumber}</a> |
| 428 | </> |
| 429 | ) : null} |
| 430 | </dd> |
| 431 | <dt>Workflow file</dt> |
| 432 | <dd> |
| 433 | <a class="mono" href={`${base}/blob/${run.sha}/${encPath(run.workflowPath)}`}> |
| 434 | {run.workflowPath} |
| 435 | </a> |
| 436 | </dd> |
| 437 | </dl> |
| 438 | </div> |
| 439 | </div> |
| 440 | {pending.length ? ( |
| 441 | <ReviewDeployments base={base} runId={run.id} pending={pending} reviews={reviews} t={t} canAct={principal.type === "account" && principal.account.kind === "user" && !repo.archived} /> |
| 442 | ) : reviews.length ? ( |
| 443 | <div class="box"> |
| 444 | <div class="head"> |
| 445 | <h3>Deployment reviews</h3> |
| 446 | </div> |
| 447 | <ReviewHistory reviews={reviews} /> |
| 448 | </div> |
| 449 | ) : null} |
| 450 | {[...groups.entries()].map(([key, jobs]) => ( |
| 451 | <div class="box jobgroup"> |
| 452 | <div class="head"> |
| 453 | <b class="mono">{key}</b> |
| 454 | {jobs.length > 1 ? <span class="muted small">{jobs.length} jobs (matrix)</span> : null} |
| 455 | </div> |
| 456 | {jobs.map((j) => ( |
| 457 | <a class="job" href={`${base}/actions/runs/${run.id}/job/${j.id}`}> |
| 458 | <Status status={j.status} conclusion={j.conclusion} /> |
| 459 | <div class="grow"> |
| 460 | <b>{j.name}</b> |
| 461 | {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null} |
| 462 | {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null} |
| 463 | {j.carriedFrom ? <div class="small muted">Kept from an earlier attempt</div> : null} |
| 464 | </div> |
| 465 | <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status === "approval" ? "waiting for approval" : j.status}</span> |
| 466 | </a> |
| 467 | ))} |
| 468 | </div> |
| 469 | ))} |
| 470 | {run.jobs.length === 0 ? <div class="blank mb">{run.status === "completed" ? "This run has no jobs." : "Waiting for jobs to be scheduled…"}</div> : null} |
| 471 | {artifacts.length ? ( |
| 472 | <div class="box"> |
| 473 | <div class="head"> |
| 474 | <h3>{raw(ICONS.download)} Artifacts</h3> |
| 475 | </div> |
| 476 | <ul class="list"> |
| 477 | {artifacts.map((a) => ( |
| 478 | <li> |
| 479 | <div class="grow"> |
| 480 | <a class="title" href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(a.name)}`}> |
| 481 | {a.name} |
| 482 | </a> |
| 483 | <div class="meta"> |
| 484 | {fmtBytes(a.size)} · <Time at={a.createdAt} /> |
| 485 | </div> |
| 486 | </div> |
| 487 | </li> |
| 488 | ))} |
| 489 | </ul> |
| 490 | </div> |
| 491 | ) : null} |
| 492 | </div>, |
| 493 | ); |
| 494 | }); |
| 495 | |
| 496 | post(web, "/:owner/:repo/actions/runs/:runId/review", async (c, f) => { |
| 497 | // Listed reviewers may have read access only; the rules are checked again where the decision is made. |
| 498 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 499 | const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`; |
| 500 | const state = f.s("state"); |
| 501 | return act( |
| 502 | c, |
| 503 | back, |
| 504 | async () => { |
| 505 | await reviewDeployments(c, l, prm(c, "runId"), { environment_ids: f.all("environment").map(Number), state, comment: f.s("comment") }); |
| 506 | }, |
| 507 | state === "approved" ? "Approved. The jobs start once any wait timer has passed." : "Rejected. The waiting jobs fail.", |
| 508 | ); |
| 509 | }); |
| 510 | |
| 511 | post(web, "/:owner/:repo/actions/runs/:runId/cancel", async (c) => { |
| 512 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); |
| 513 | const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`; |
| 514 | return act(c, back, async () => l.stub.cancelRun(prm(c, "runId"), l.caller), "Cancellation requested."); |
| 515 | }); |
| 516 | |
| 517 | for (const [path, scope] of [["rerun", "all"], ["rerun-failed", "failed"]] as const) { |
| 518 | post(web, `/:owner/:repo/actions/runs/:runId/${path}`, async (c) => { |
| 519 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); |
| 520 | const base = repoUrl(l.repo); |
| 521 | return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => { |
| 522 | const run = await l.stub.rerun(prm(c, "runId"), l.caller, scope); |
| 523 | return `${base}/actions/runs/${run.id}`; |
| 524 | }); |
| 525 | }); |
| 526 | } |
| 527 | |
| 528 | post(web, "/:owner/:repo/actions/runs/:runId/job/:jobId/rerun", async (c) => { |
| 529 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); |
| 530 | const base = repoUrl(l.repo); |
| 531 | return act(c, `${base}/actions/runs/${prm(c, "runId")}/job/${prm(c, "jobId")}`, async () => { |
| 532 | const job = (await l.stub.job(prm(c, "jobId"))) as unknown as JobInfo; |
| 533 | if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found"); |
| 534 | const run = await l.stub.rerunJob(job.id, l.caller); |
| 535 | return `${base}/actions/runs/${run.id}`; |
| 536 | }); |
| 537 | }); |
| 538 | |
| 539 | web.get("/:owner/:repo/actions/runs/:runId/job/:jobId", async (c) => { |
| 540 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 541 | const { repo, caller, stub } = l; |
| 542 | const base = repoUrl(repo); |
| 543 | const runId = prm(c, "runId"); |
| 544 | const jobId = prm(c, "jobId"); |
| 545 | const run = (await stub.run(runId)) as unknown as RunWithJobs; |
| 546 | const job = (await stub.job(jobId)) as unknown as JobInfo; |
| 547 | if (job.runId !== run.id) throw new HttpError(404, "job not found"); |
| 548 | const logs = await allLogs(l, jobId); |
| 549 | const byStep = new Map<number, LogLine[]>(); |
| 550 | for (const line of logs.lines) byStep.set(line.step, [...(byStep.get(line.step) ?? []), line]); |
| 551 | const stepIdx = new Set<number>([...job.steps.map((s) => s.index), ...byStep.keys()]); |
| 552 | const sections = [...stepIdx].sort((a, b) => a - b); |
| 553 | const stepOf = new Map(job.steps.map((s) => [s.index, s])); |
| 554 | const lastSeq = logs.lines.length ? logs.lines[logs.lines.length - 1]!.seq : -1; |
| 555 | const live = job.status !== "completed"; |
| 556 | const failedStep = job.failure?.step; |
| 557 | const canRerun = run.status === "completed" && atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived; |
| 558 | return render( |
| 559 | c, |
| 560 | { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id}` : undefined, liveMode: "log", lastSeq, wide: true }, |
| 561 | <div class="grid left"> |
| 562 | <aside class="side"> |
| 563 | <section> |
| 564 | <a href={`${base}/actions/runs/${run.id}`} class="small"> |
| 565 | ← {run.workflowName} #{run.number} |
| 566 | </a> |
| 567 | <h3 class="mt">Jobs</h3> |
| 568 | <div class="nav"> |
| 569 | {run.jobs.map((j) => ( |
| 570 | <a href={`${base}/actions/runs/${run.id}/job/${j.id}`} class={j.id === job.id ? "on" : ""} style="display:flex;gap:6px;align-items:center"> |
| 571 | <Status status={j.status} conclusion={j.conclusion} /> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{j.name}</span> |
| 572 | </a> |
| 573 | ))} |
| 574 | </div> |
| 575 | </section> |
| 576 | </aside> |
| 577 | <div> |
| 578 | <div class="row between mb"> |
| 579 | <h1 class="row m0" style="gap:8px"> |
| 580 | <Status status={job.status} conclusion={job.conclusion} /> {job.name} |
| 581 | </h1> |
| 582 | <div class="row"> |
| 583 | <span class="muted small"> |
| 584 | {job.startedAt ? ( |
| 585 | <> |
| 586 | {job.status === "completed" ? "took" : "running for"} {duration(job.startedAt, job.completedAt)} |
| 587 | </> |
| 588 | ) : ( |
| 589 | job.status |
| 590 | )} |
| 591 | </span> |
| 592 | <a class="btn sm" href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}> |
| 593 | {raw(ICONS.download)} Raw logs |
| 594 | </a> |
| 595 | {canRerun ? ( |
| 596 | <Form action={`${base}/actions/runs/${run.id}/job/${job.id}/rerun`} t={csrfToken(c)} class="inline"> |
| 597 | <button class="btn sm">{raw(ICONS.history)} Re-run this job</button> |
| 598 | </Form> |
| 599 | ) : null} |
| 600 | </div> |
| 601 | </div> |
| 602 | {matrixLabel(job.matrix) ? <p class="muted small">Matrix: {matrixLabel(job.matrix)}</p> : null} |
| 603 | {job.carriedFrom ? <p class="muted small">Kept from an earlier attempt of this run: it didn't run again in attempt {run.attempt}.</p> : null} |
| 604 | {job.failure ? ( |
| 605 | <div class="failure"> |
| 606 | <b> |
| 607 | {raw(ICONS.failure)} {job.conclusion === "cancelled" ? "Cancelled" : job.conclusion === "timed_out" ? "Timed out" : "Failed"} |
| 608 | {failedStep !== undefined && failedStep !== null ? ( |
| 609 | <> |
| 610 | {" "} |
| 611 | at{" "} |
| 612 | <a href={`#step-${failedStep}`} class="bad"> |
| 613 | {stepOf.get(failedStep)?.name ?? `step ${failedStep + 1}`} |
| 614 | </a> |
| 615 | </> |
| 616 | ) : null} |
| 617 | </b> |
| 618 | <pre>{job.failure.message}</pre> |
| 619 | </div> |
| 620 | ) : null} |
| 621 | {logs.truncated ? ( |
| 622 | <div class="flash info"> |
| 623 | 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>. |
| 624 | </div> |
| 625 | ) : null} |
| 626 | <div class="log"> |
| 627 | {sections.length === 0 ? <div style="padding:14px;color:#9aa3ad">{live ? "Waiting for the runner to start…" : "No log output."}</div> : null} |
| 628 | {sections.map((idx) => { |
| 629 | const s = stepOf.get(idx); |
| 630 | const lines = byStep.get(idx) ?? []; |
| 631 | const failed = s?.conclusion === "failure" || failedStep === idx; |
| 632 | const running = s?.status === "in_progress"; |
| 633 | return ( |
| 634 | <details data-step={String(idx)} id={`step-${idx}`} open={failed || running || (live && idx === sections[sections.length - 1])}> |
| 635 | <summary> |
| 636 | {s ? ( |
| 637 | <Status status={s.status} conclusion={s.conclusion} /> |
| 638 | ) : idx < 0 ? ( |
| 639 | <Status conclusion={job.startedAt ? "success" : "queued"} /> |
| 640 | ) : ( |
| 641 | <Status conclusion="neutral" /> |
| 642 | )} |
| 643 | {idx < 0 ? "Set up job" : (s?.name ?? `Step ${idx + 1}`)} |
| 644 | <span class="dur">{s?.startedAt ? duration(s.startedAt, s.completedAt) : ""}</span> |
| 645 | </summary> |
| 646 | <pre> |
| 647 | {lines.map((ln) => { |
| 648 | const k = classify(ln.text); |
| 649 | return <span class={k.cls || undefined}>{k.text}</span>; |
| 650 | })} |
| 651 | </pre> |
| 652 | </details> |
| 653 | ); |
| 654 | })} |
| 655 | </div> |
| 656 | {job.summary ? ( |
| 657 | <div class="box"> |
| 658 | <div class="head"> |
| 659 | <h3>Job summary</h3> |
| 660 | </div> |
| 661 | <div class="body"> |
| 662 | <Markdown text={job.summary} /> |
| 663 | </div> |
| 664 | </div> |
| 665 | ) : null} |
| 666 | {Object.keys(job.outputs ?? {}).length ? ( |
| 667 | <div class="box"> |
| 668 | <div class="head"> |
| 669 | <h3>Outputs</h3> |
| 670 | </div> |
| 671 | <div class="body"> |
| 672 | <dl class="kv"> |
| 673 | {Object.entries(job.outputs).map(([k, v]) => ( |
| 674 | <> |
| 675 | <dt class="mono">{k}</dt> |
| 676 | <dd class="mono" style="word-break:break-all"> |
| 677 | {v} |
| 678 | </dd> |
| 679 | </> |
| 680 | ))} |
| 681 | </dl> |
| 682 | </div> |
| 683 | </div> |
| 684 | ) : null} |
| 685 | </div> |
| 686 | </div>, |
| 687 | ); |
| 688 | }); |
| 689 | |
| 690 | web.get("/:owner/:repo/actions/runs/:runId/job/:jobId/logs", async (c) => { |
| 691 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 692 | const job = await l.stub.job(prm(c, "jobId")); |
| 693 | if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found"); |
| 694 | const out: string[] = []; |
| 695 | let after = -1; |
| 696 | for (let i = 0; i < 40; i++) { |
| 697 | const page = await l.stub.jobLogs(job.id, after); |
| 698 | for (const ln of page.lines) out.push(`${ln.t} ${ln.text.replace(ANSI, "")}`); |
| 699 | if (page.lines.length < 5000) break; |
| 700 | after = page.lines[page.lines.length - 1]!.seq; |
| 701 | } |
| 702 | return new Response(`${out.join("\n")}\n`, { |
| 703 | headers: { |
| 704 | "content-type": "text/plain; charset=utf-8", |
| 705 | "x-content-type-options": "nosniff", |
| 706 | "content-security-policy": "sandbox", |
| 707 | "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(`${job.name}.log`)}`, |
| 708 | "cache-control": "private, no-store", |
| 709 | }, |
| 710 | }); |
| 711 | }); |
| 712 | } |