| 1 | import { readdir, readFile, stat } from "node:fs/promises"; |
| 2 | import path from "node:path"; |
| 3 | import { isWarning, parseWorkflow } from "@diffinery/workflow-yaml"; |
| 4 | import { enc } from "../api.js"; |
| 5 | import { list, repoPath, str, type Command, type Group } from "../cli.js"; |
| 6 | import { gitCapture } from "../git.js"; |
| 7 | import { CliError } from "../io.js"; |
| 8 | import { json, table } from "../output.js"; |
| 9 | import type { RepoJson } from "./repo.js"; |
| 10 | |
| 11 | export const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; |
| 12 | const isYaml = (f: string) => /\.ya?ml$/i.test(f); |
| 13 | |
| 14 | /** Parses -f key=value pairs into workflow_dispatch inputs. */ |
| 15 | export 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 | |
| 25 | const 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 | |
| 52 | const 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 | |
| 68 | async 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 | |
| 98 | const 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 |
| 103 | current 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 | |
| 140 | export const workflowGroup: Group = { name: "workflow", summary: "Run and validate workflows", commands: [run, listCmd, validate] }; |