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.

213 lines · 8.2 KBRaw History
1import { enc } from "../api.js";
2import { positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js";
3import { CliError, ExitCode } from "../io.js";
4import { ago, json, statusText, symbol, table, truncate } from "../output.js";
5
6export 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
25export 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
38type RunDetail = RunJson & { jobs: JobJson[] };
39
40function 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
48const 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
85function 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
95const 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
111const 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
165const 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
182const 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
199const 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
213export const runGroup: Group = { name: "run", summary: "Inspect and control workflow runs", commands: [listCmd, view, watch, logs, rerun, cancel] };