| 1 | import { enc } from "../api.js"; |
| 2 | import { positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js"; |
| 3 | import { CliError, ExitCode } from "../io.js"; |
| 4 | import { ago, json, statusText, symbol, table, truncate } from "../output.js"; |
| 5 | |
| 6 | export interface RunJson { |
| 7 | id: string; |
| 8 | run_number: number; |
| 9 | run_attempt: number; |
| 10 | name: string; |
| 11 | display_title: string; |
| 12 | path: string; |
| 13 | event: string; |
| 14 | head_branch: string; |
| 15 | head_sha: string; |
| 16 | status: string; |
| 17 | conclusion: string | null; |
| 18 | actor: { login: string }; |
| 19 | created_at: string; |
| 20 | updated_at: string; |
| 21 | html_url: string; |
| 22 | pull_request_number: number | null; |
| 23 | } |
| 24 | |
| 25 | export interface JobJson { |
| 26 | id: string; |
| 27 | runId: string; |
| 28 | key: string; |
| 29 | name: string; |
| 30 | status: string; |
| 31 | conclusion: string | null; |
| 32 | startedAt: string | null; |
| 33 | completedAt: string | null; |
| 34 | steps: Array<{ index: number; name?: string; status?: string; conclusion?: string | null }>; |
| 35 | failure: { message?: string } | null; |
| 36 | } |
| 37 | |
| 38 | type RunDetail = RunJson & { jobs: JobJson[] }; |
| 39 | |
| 40 | function duration(a: string | null, b: string | null): string { |
| 41 | if (!a) return ""; |
| 42 | const ms = (b ? Date.parse(b) : Date.now()) - Date.parse(a); |
| 43 | if (!Number.isFinite(ms) || ms < 0) return ""; |
| 44 | const s = Math.round(ms / 1000); |
| 45 | return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; |
| 46 | } |
| 47 | |
| 48 | const listCmd: Command = { |
| 49 | name: "run list", |
| 50 | summary: "List workflow runs", |
| 51 | options: { |
| 52 | branch: { type: "string", short: "b", desc: "Only runs for this branch", arg: "BRANCH" }, |
| 53 | workflow: { type: "string", short: "w", desc: "Only runs of this workflow (file name or path)", arg: "FILE" }, |
| 54 | status: { type: "string", short: "s", desc: "Only runs with this status (queued, in_progress, completed, ...)", arg: "STATUS" }, |
| 55 | event: { type: "string", short: "e", desc: "Only runs triggered by this event", arg: "EVENT" }, |
| 56 | commit: { type: "string", short: "c", desc: "Only runs for this commit SHA", arg: "SHA" }, |
| 57 | limit: { type: "string", short: "L", desc: "Maximum number to show (default 20)", arg: "N" }, |
| 58 | }, |
| 59 | repo: true, |
| 60 | json: true, |
| 61 | maxArgs: 0, |
| 62 | async run(ctx, o) { |
| 63 | const repo = await ctx.repo(); |
| 64 | const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 20; |
| 65 | const res = await (await ctx.api()).get<{ total_count: number; workflow_runs: RunJson[] }>(`${repoPath(repo)}/actions/runs`, { |
| 66 | branch: str(o, "branch"), |
| 67 | workflow: str(o, "workflow"), |
| 68 | status: str(o, "status"), |
| 69 | event: str(o, "event"), |
| 70 | head_sha: str(o, "commit"), |
| 71 | per_page: Math.min(limit, 100), |
| 72 | }); |
| 73 | if (ctx.json) return json(ctx.io, res.workflow_runs); |
| 74 | if (!res.workflow_runs.length) return ctx.err("No runs found."); |
| 75 | const c = ctx.c; |
| 76 | ctx.io.stdout.write( |
| 77 | table([ |
| 78 | ["", "TITLE", "WORKFLOW", "BRANCH", "EVENT", "ID", "AGE"].map((h) => c.dim(h)), |
| 79 | ...res.workflow_runs.map((r) => [symbol(c, r.status, r.conclusion), truncate(r.display_title, 50), r.name, c.cyan(r.head_branch), r.event, r.id, c.dim(ago(r.created_at))]), |
| 80 | ]), |
| 81 | ); |
| 82 | }, |
| 83 | }; |
| 84 | |
| 85 | function printRun(ctx: Ctx, r: RunDetail): void { |
| 86 | const c = ctx.c; |
| 87 | ctx.out(`${symbol(c, r.status, r.conclusion)} ${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number}${r.run_attempt > 1 ? ` (attempt ${r.run_attempt})` : ""}`)}`); |
| 88 | ctx.out(`${statusText(c, r.status, r.conclusion)} · ${r.event} on ${c.cyan(r.head_branch)} ${c.dim(r.head_sha.slice(0, 7))} · ${r.actor.login} · ${ago(r.created_at)}`); |
| 89 | ctx.out(""); |
| 90 | ctx.io.stdout.write(table(r.jobs.map((j) => [symbol(c, j.status, j.conclusion), j.name, statusText(c, j.status, j.conclusion), c.dim(duration(j.startedAt, j.completedAt)), c.dim(`job ${j.id}`)]))); |
| 91 | for (const j of r.jobs) if (j.failure?.message) ctx.out(`${c.red("X")} ${j.name}: ${j.failure.message}`); |
| 92 | ctx.out(c.dim(r.html_url)); |
| 93 | } |
| 94 | |
| 95 | const view: Command = { |
| 96 | name: "run view", |
| 97 | summary: "Show a run and its jobs", |
| 98 | args: "<run-id>", |
| 99 | repo: true, |
| 100 | json: true, |
| 101 | minArgs: 1, |
| 102 | maxArgs: 1, |
| 103 | async run(ctx, _o, [id]) { |
| 104 | const repo = await ctx.repo(); |
| 105 | const r = await (await ctx.api()).get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`); |
| 106 | if (ctx.json) return json(ctx.io, r); |
| 107 | printRun(ctx, r); |
| 108 | }, |
| 109 | }; |
| 110 | |
| 111 | const watch: Command = { |
| 112 | name: "run watch", |
| 113 | summary: "Follow a run until it completes", |
| 114 | description: "Polls every 3 seconds and prints job status changes. Exits non-zero unless the run succeeds.", |
| 115 | args: "<run-id>", |
| 116 | options: { interval: { type: "string", short: "i", desc: "Seconds between polls (default 3)", arg: "SECONDS" } }, |
| 117 | repo: true, |
| 118 | minArgs: 1, |
| 119 | maxArgs: 1, |
| 120 | async run(ctx, o, [id]) { |
| 121 | const repo = await ctx.repo(); |
| 122 | const api = await ctx.api(); |
| 123 | const interval = Math.max(0, Number(str(o, "interval") ?? 3)) * 1000; |
| 124 | if (!Number.isFinite(interval)) throw new CliError("--interval must be a number", 2); |
| 125 | const c = ctx.c; |
| 126 | const seen = new Map<string, string>(); |
| 127 | let first = true; |
| 128 | let failures = 0; |
| 129 | for (;;) { |
| 130 | let r: RunDetail; |
| 131 | try { |
| 132 | r = await api.get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`); |
| 133 | failures = 0; |
| 134 | } catch (e) { |
| 135 | // Ride out transient network/server errors; give up on client errors. |
| 136 | const status = (e as { status?: number }).status; |
| 137 | if ((status && status < 500 && status !== 429) || ++failures > 5) throw e; |
| 138 | await ctx.io.sleep(interval); |
| 139 | continue; |
| 140 | } |
| 141 | if (first) { |
| 142 | ctx.out(`${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number} · ${r.html_url}`)}`); |
| 143 | first = false; |
| 144 | } |
| 145 | for (const j of r.jobs) { |
| 146 | const key = `${j.status}/${j.conclusion ?? ""}`; |
| 147 | if (seen.get(j.id) === key) continue; |
| 148 | seen.set(j.id, key); |
| 149 | const extra = j.status === "completed" ? ` ${c.dim(duration(j.startedAt, j.completedAt))}` : ""; |
| 150 | ctx.out(`${symbol(c, j.status, j.conclusion)} ${j.name}: ${statusText(c, j.status, j.conclusion)}${extra}${j.failure?.message && j.status === "completed" ? ` ${c.dim(`(${j.failure.message})`)}` : ""}`); |
| 151 | } |
| 152 | if (r.status === "completed") { |
| 153 | ctx.out(`${symbol(c, r.status, r.conclusion)} Run ${r.id} completed: ${statusText(c, r.status, r.conclusion)}`); |
| 154 | if (r.conclusion !== "success") { |
| 155 | const failed = r.jobs.find((j) => j.conclusion === "failure" || j.conclusion === "timed_out"); |
| 156 | if (failed) ctx.err(c.dim(`See the log: diffinery run logs ${failed.id} -R ${repo.owner}/${repo.name}`)); |
| 157 | } |
| 158 | throw new ExitCode(r.conclusion === "success" ? 0 : 1); |
| 159 | } |
| 160 | await ctx.io.sleep(interval); |
| 161 | } |
| 162 | }, |
| 163 | }; |
| 164 | |
| 165 | const logs: Command = { |
| 166 | name: "run logs", |
| 167 | summary: "Print a job's log", |
| 168 | args: "<job-id>", |
| 169 | repo: true, |
| 170 | json: true, |
| 171 | minArgs: 1, |
| 172 | maxArgs: 1, |
| 173 | async run(ctx, _o, [id]) { |
| 174 | const repo = await ctx.repo(); |
| 175 | const api = await ctx.api(); |
| 176 | if (ctx.json) return json(ctx.io, await api.get(`${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { format: "json" })); |
| 177 | const res = await api.raw<string>("GET", `${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { text: true }); |
| 178 | ctx.io.stdout.write(res.data.endsWith("\n") || !res.data ? res.data : `${res.data}\n`); |
| 179 | }, |
| 180 | }; |
| 181 | |
| 182 | const rerun: Command = { |
| 183 | name: "run rerun", |
| 184 | summary: "Re-run a workflow run", |
| 185 | args: "<run-id>", |
| 186 | options: { failed: { type: "boolean", desc: "Only re-run failed jobs (where supported)" } }, |
| 187 | repo: true, |
| 188 | json: true, |
| 189 | minArgs: 1, |
| 190 | maxArgs: 1, |
| 191 | async run(ctx, o, [id]) { |
| 192 | const repo = await ctx.repo(); |
| 193 | const r = await (await ctx.authedApi()).post<{ run_id: string }>(`${repoPath(repo)}/actions/runs/${enc(id!)}/${o.failed ? "rerun-failed-jobs" : "rerun"}`); |
| 194 | if (ctx.json) return json(ctx.io, r); |
| 195 | ctx.out(`${ctx.c.green("✓")} Re-running as run ${r.run_id}`); |
| 196 | }, |
| 197 | }; |
| 198 | |
| 199 | const cancel: Command = { |
| 200 | name: "run cancel", |
| 201 | summary: "Cancel a queued or running run", |
| 202 | args: "<run-id>", |
| 203 | repo: true, |
| 204 | minArgs: 1, |
| 205 | maxArgs: 1, |
| 206 | async run(ctx, _o, [id]) { |
| 207 | const repo = await ctx.repo(); |
| 208 | await (await ctx.authedApi()).post(`${repoPath(repo)}/actions/runs/${enc(id!)}/cancel`); |
| 209 | ctx.out(`${ctx.c.green("✓")} Cancellation requested for run ${id}`); |
| 210 | }, |
| 211 | }; |
| 212 | |
| 213 | export const runGroup: Group = { name: "run", summary: "Inspect and control workflow runs", commands: [listCmd, view, watch, logs, rerun, cancel] }; |