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.

140 lines · 5.9 KBRaw History
1import { readdir, readFile, stat } from "node:fs/promises";
2import path from "node:path";
3import { isWarning, parseWorkflow } from "@diffinery/workflow-yaml";
4import { enc } from "../api.js";
5import { list, repoPath, str, type Command, type Group } from "../cli.js";
6import { gitCapture } from "../git.js";
7import { CliError } from "../io.js";
8import { json, table } from "../output.js";
9import type { RepoJson } from "./repo.js";
10
11export const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"];
12const isYaml = (f: string) => /\.ya?ml$/i.test(f);
13
14/** Parses -f key=value pairs into workflow_dispatch inputs. */
15export function parseInputs(pairs: string[]): Record<string, string> {
16 const out: Record<string, string> = {};
17 for (const p of pairs) {
18 const i = p.indexOf("=");
19 if (i <= 0) throw new CliError(`-f expects key=value, got "${p}"`, 2);
20 out[p.slice(0, i)] = p.slice(i + 1);
21 }
22 return out;
23}
24
25const run: Command = {
26 name: "workflow run",
27 summary: "Start a workflow_dispatch run",
28 args: "<workflow-file>",
29 description: "The workflow is matched by path (.github/workflows/deploy.yml) or file name (deploy.yml).",
30 options: {
31 ref: { type: "string", short: "r", desc: "Branch or tag to run on (default: the default branch)", arg: "REF" },
32 field: { type: "string", short: "f", multiple: true, desc: "Input value", arg: "KEY=VALUE" },
33 },
34 repo: true,
35 json: true,
36 minArgs: 1,
37 maxArgs: 1,
38 async run(ctx, o, [file]) {
39 const inputs = parseInputs(list(o, "field"));
40 const repo = await ctx.repo();
41 const api = await ctx.authedApi();
42 const ref = str(o, "ref") ?? (await api.get<RepoJson>(repoPath(repo))).default_branch;
43 const wf = file!.replace(/^\.\//, "");
44 const r = await api.post<{ run_id: string; html_url: string }>(`${repoPath(repo)}/actions/workflows/${enc(wf)}/dispatches`, { ref, inputs });
45 if (ctx.json) return json(ctx.io, r);
46 ctx.out(`${ctx.c.green("✓")} Started run ${r.run_id} of ${wf} on ${ref}`);
47 ctx.out(r.html_url);
48 ctx.err(ctx.c.dim(`Follow it: diffinery run watch ${r.run_id} -R ${repo.owner}/${repo.name}`));
49 },
50};
51
52const listCmd: Command = {
53 name: "workflow list",
54 summary: "List the repository's workflows",
55 repo: true,
56 json: true,
57 maxArgs: 0,
58 async run(ctx) {
59 const repo = await ctx.repo();
60 const res = await (await ctx.api()).get<{ workflows: Array<{ name: string; path: string; state: string; errors: string[]; triggers?: string[] }> }>(`${repoPath(repo)}/actions/workflows`);
61 if (ctx.json) return json(ctx.io, res.workflows);
62 if (!res.workflows.length) return ctx.err("No workflows.");
63 const c = ctx.c;
64 ctx.io.stdout.write(table(res.workflows.map((w) => [w.name, c.dim(w.path), w.state === "active" ? c.green(w.state) : c.red(w.state), c.dim((w.triggers ?? []).join(", "))])));
65 },
66};
67
68async function collectFiles(cwd: string, root: string, args: string[]): Promise<string[]> {
69 const out: string[] = [];
70 const addDir = async (dir: string, explicit: boolean) => {
71 let entries: string[];
72 try {
73 entries = await readdir(dir);
74 } catch (e) {
75 if (!explicit && (e as NodeJS.ErrnoException).code === "ENOENT") return;
76 throw new CliError(`cannot read ${dir}: ${(e as Error).message}`);
77 }
78 for (const f of entries.sort()) if (isYaml(f)) out.push(path.join(dir, f));
79 };
80 if (!args.length) {
81 for (const d of WORKFLOW_DIRS) await addDir(path.join(root, d), false);
82 return out;
83 }
84 for (const a of args) {
85 const p = path.resolve(cwd, a);
86 let s;
87 try {
88 s = await stat(p);
89 } catch {
90 throw new CliError(`no such file: ${a}`);
91 }
92 if (s.isDirectory()) await addDir(p, true);
93 else out.push(p);
94 }
95 return out;
96}
97
98const validate: Command = {
99 name: "workflow validate",
100 summary: "Check workflow files for errors",
101 args: "[files or directories...]",
102 description: `Default: every .yml/.yaml file in .github/workflows and .diffinery/workflows of the
103current repository. Uses the same parser as the server. Exits 1 when any file has errors.`,
104 json: true,
105 async run(ctx, _o, args) {
106 const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"]);
107 const root = top.code === 0 && top.stdout.trim() ? top.stdout.trim() : ctx.io.cwd;
108 const files = await collectFiles(ctx.io.cwd, root, args);
109 if (!files.length) {
110 if (ctx.json) return json(ctx.io, []);
111 ctx.err(`No workflow files found in ${WORKFLOW_DIRS.join(" or ")}.`);
112 return 0;
113 }
114 const c = ctx.c;
115 const results = [];
116 let errors = 0;
117 let warnings = 0;
118 for (const f of files) {
119 const rel = path.relative(ctx.io.cwd, f) || f;
120 const relRoot = path.relative(root, f).split(path.sep).join("/");
121 const source = await readFile(f, "utf8");
122 const res = parseWorkflow(source, relRoot.startsWith("..") ? rel : relRoot);
123 const items = res.errors.map((e) => ({ ...e, severity: isWarning(e) ? "warning" : "error", message: isWarning(e) ? e.message.replace(/^warning:\s*/, "") : e.message }));
124 results.push({ file: rel, valid: !items.some((i) => i.severity === "error"), problems: items });
125 for (const i of items) {
126 if (i.severity === "error") errors++;
127 else warnings++;
128 if (ctx.json) continue;
129 const loc = `${rel}${i.line ? `:${i.line}${i.col ? `:${i.col}` : ""}` : ""}`;
130 ctx.out(`${c.bold(loc)}: ${i.severity === "error" ? c.red("error") : c.yellow("warning")}: ${i.message}${i.path ? c.dim(` (${i.path})`) : ""}`);
131 }
132 if (!ctx.json && !items.length) ctx.out(`${c.green("✓")} ${rel}`);
133 }
134 if (ctx.json) json(ctx.io, results);
135 else ctx.err(`${files.length} file${files.length === 1 ? "" : "s"} checked: ${errors ? c.red(`${errors} error${errors === 1 ? "" : "s"}`) : "no errors"}, ${warnings} warning${warnings === 1 ? "" : "s"}`);
136 return errors ? 1 : 0;
137 },
138};
139
140export const workflowGroup: Group = { name: "workflow", summary: "Run and validate workflows", commands: [run, listCmd, validate] };