diff --git a/README.md b/README.md index 8bded7f..0a76226 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ Apply repeatable GitHub repository defaults and branch rulesets. ## Install ```sh -npm install -g @dutifuldev/github-sane-defaults +npm install -g github-sane-defaults ``` Or run without installing: ```sh -npx @dutifuldev/github-sane-defaults plan --org dutifuldev --repo scratch +npx github-sane-defaults plan dutifuldev/scratch ``` ## Authentication @@ -31,21 +31,23 @@ repositories. Preview changes for one repository: ```sh -github-sane-defaults plan --org dutifuldev --repo scratch +github-sane-defaults plan dutifuldev/scratch ``` Apply changes to one repository: ```sh -github-sane-defaults apply --org dutifuldev --repo scratch +github-sane-defaults apply dutifuldev/scratch ``` Apply changes to every non-archived repository in an organization: ```sh -github-sane-defaults apply --org dutifuldev --all +github-sane-defaults apply dutifuldev --all ``` +The legacy `--org dutifuldev --repo scratch` form is still accepted. + ## Defaults Repository settings: diff --git a/package-lock.json b/package-lock.json index a7c89cd..487de49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@dutifuldev/github-sane-defaults", + "name": "github-sane-defaults", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@dutifuldev/github-sane-defaults", + "name": "github-sane-defaults", "version": "0.1.0", "license": "MIT", "bin": { diff --git a/package.json b/package.json index 383cba7..c687321 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@dutifuldev/github-sane-defaults", + "name": "github-sane-defaults", "version": "0.1.0", "description": "Apply sane GitHub repository defaults and branch rulesets.", "license": "MIT", diff --git a/src/cli/args.ts b/src/cli/args.ts index 7341966..6a77424 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -33,6 +33,8 @@ export function parseArgs(args: string[]): CliOptions { } function validateFlags(parsed: ParsedFlags): asserts parsed is ParsedFlags & { org: string } { + applyPositionalTargets(parsed); + if (parsed.org === undefined) { throw new Error("Missing required --org option.\n\n" + usage()); } @@ -50,11 +52,12 @@ type ParsedFlags = { org?: string; token?: string; repos: string[]; + targets: string[]; all: boolean; }; function parseFlags(args: string[]): ParsedFlags { - const flags: ParsedFlags = { repos: [], all: false }; + const flags: ParsedFlags = { repos: [], targets: [], all: false }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -76,11 +79,20 @@ function parseFlag(args: string[], index: number, flags: ParsedFlags): number { throw new Error("Unexpected missing argument."); } + if (!arg.startsWith("--")) { + flags.targets.push(arg); + return index; + } + if (arg === "--all") { flags.all = true; return index; } + return parseValueFlag(args, index, flags, arg); +} + +function parseValueFlag(args: string[], index: number, flags: ParsedFlags, arg: string): number { if (arg !== "--org" && arg !== "--repo" && arg !== "--token") { throw new Error(`Unknown option: ${arg}`); } @@ -109,11 +121,66 @@ function setFlag(flags: ParsedFlags, arg: string, value: string): void { flags.token = value; } +function applyPositionalTargets(flags: ParsedFlags): void { + if (flags.targets.length === 0) { + return; + } + + if (flags.all) { + applyOrgTarget(flags); + return; + } + + for (const target of flags.targets) { + applyRepoTarget(flags, target); + } +} + +function applyOrgTarget(flags: ParsedFlags): void { + if (flags.targets.length !== 1 || flags.targets[0]?.includes("/") === true) { + throw new Error("Use an organization name with --all, for example: dutifuldev --all."); + } + + setTargetOrg(flags, flags.targets[0]); +} + +function applyRepoTarget(flags: ParsedFlags, target: string): void { + const [org, repo, extra] = target.split("/"); + + if ( + org === undefined || + repo === undefined || + org.length === 0 || + repo.length === 0 || + extra !== undefined + ) { + throw new Error(`Repository targets must look like /: ${target}`); + } + + setTargetOrg(flags, org); + flags.repos.push(repo); +} + +function setTargetOrg(flags: ParsedFlags, org: string | undefined): void { + if (org === undefined) { + throw new Error("Missing organization target."); + } + + if (flags.org !== undefined && flags.org !== org) { + throw new Error(`Conflicting organization targets: ${flags.org} and ${org}.`); + } + + flags.org = org; +} + export function usage(): string { return [ "Usage:", + " github-sane-defaults plan /", + " github-sane-defaults apply /", + " github-sane-defaults plan --all", + " github-sane-defaults apply --all", " github-sane-defaults plan --org --repo ", - " github-sane-defaults apply --org --repo ", - " github-sane-defaults apply --org --all" + " github-sane-defaults apply --org --repo " ].join("\n"); } diff --git a/src/cli/format.ts b/src/cli/format.ts index bf5673c..7fa8880 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,42 +1,116 @@ import type { ApplySummary, RepoPlan } from "../app/types.js"; -export function formatPlan(plans: RepoPlan[]): string { +type FormatOptions = { + color?: boolean; +}; + +const ansi = { + reset: "\u001B[0m", + bold: "\u001B[1m", + dim: "\u001B[2m", + green: "\u001B[32m", + yellow: "\u001B[33m", + cyan: "\u001B[36m" +}; + +export function shouldUseColor(): boolean { + return process.env["NO_COLOR"] === undefined && process.stdout.isTTY; +} + +export function formatPlan(plans: RepoPlan[], options: FormatOptions = {}): string { if (plans.length === 0) { return "No repositories selected."; } - return plans.map(formatRepoPlan).join("\n\n"); -} + const style = createStyle(options.color === true); + const changed = plans.filter(hasChanges).length; -export function formatApplySummary(summary: ApplySummary): string { return [ - `Applied sane defaults to ${String(summary.applied)} repository/repositories.`, - formatPlan(summary.planned) + style.title(`Plan: ${formatCount(plans.length, "repository")}`), + style.dim(`${String(changed)} with changes, ${String(plans.length - changed)} already clean`), + "", + ...plans.map((plan) => formatRepoPlan(plan, style)).flatMap((lines) => [...lines, ""]) ] - .filter((line) => line.length > 0) - .join("\n\n"); + .join("\n") + .trimEnd(); } -function formatRepoPlan(plan: RepoPlan): string { - const lines = [plan.fullName]; +export function formatApplySummary(summary: ApplySummary, options: FormatOptions = {}): string { + const style = createStyle(options.color === true); + + return [ + style.success(`Applied sane defaults to ${formatCount(summary.applied, "repository")}.`), + "", + formatPlan(summary.planned, options) + ].join("\n"); +} + +function formatRepoPlan(plan: RepoPlan, style: Style): string[] { + const status = hasChanges(plan) ? style.warn("changes") : style.success("clean"); + const lines = [`${status} ${style.repo(plan.fullName)}`]; if (plan.settingChanges.length === 0) { - lines.push(" settings: no changes"); + lines.push(` Settings ${style.success("no changes")}`); } else { - lines.push(" settings:"); + lines.push(" Settings"); lines.push( ...plan.settingChanges.map( (change) => - ` ${change.key}: ${formatValue(change.current)} -> ${formatValue(change.desired)}` + ` ${change.key.padEnd(27)} ${formatValue(change.current)} -> ${formatValue(change.desired)}` ) ); } - lines.push(` ruleset: ${plan.ruleset.action}`); + lines.push(` Ruleset ${formatRulesetAction(plan.ruleset.action, style)}`); - return lines.join("\n"); + return lines; } function formatValue(value: boolean | string | null): string { return value === null ? "unset" : String(value); } + +function formatRulesetAction(action: RepoPlan["ruleset"]["action"], style: Style): string { + if (action === "none") { + return style.success("no changes"); + } + + if (action === "create") { + return style.warn("create"); + } + + return style.warn("update"); +} + +function hasChanges(plan: RepoPlan): boolean { + return plan.settingChanges.length > 0 || plan.ruleset.action !== "none"; +} + +function formatCount(count: number, noun: string): string { + const suffix = noun.endsWith("y") ? "ies" : "s"; + const plural = noun.endsWith("y") ? noun.slice(0, -1) : noun; + + return `${String(count)} ${count === 1 ? noun : plural + suffix}`; +} + +type Style = { + title(value: string): string; + repo(value: string): string; + success(value: string): string; + warn(value: string): string; + dim(value: string): string; +}; + +function createStyle(enabled: boolean): Style { + return { + title: (value) => colorize(value, `${ansi.bold}${ansi.cyan}`, enabled), + repo: (value) => colorize(value, ansi.bold, enabled), + success: (value) => colorize(value, ansi.green, enabled), + warn: (value) => colorize(value, ansi.yellow, enabled), + dim: (value) => colorize(value, ansi.dim, enabled) + }; +} + +function colorize(value: string, code: string, enabled: boolean): string { + return enabled ? `${code}${value}${ansi.reset}` : value; +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 8175962..7c2ab49 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -2,19 +2,20 @@ import { applyDefaults } from "../app/apply.js"; import { buildPlan } from "../app/planner.js"; import { RestGitHubClient, resolveToken } from "../github/client.js"; -import { formatApplySummary, formatPlan } from "./format.js"; +import { formatApplySummary, formatPlan, shouldUseColor } from "./format.js"; import { parseArgs } from "./args.js"; async function main(): Promise { const options = parseArgs(process.argv.slice(2)); const client = new RestGitHubClient(resolveToken(options.token)); + const formatOptions = { color: shouldUseColor() }; if (options.command === "plan") { - console.log(formatPlan(await buildPlan(client, options))); + console.log(formatPlan(await buildPlan(client, options), formatOptions)); return; } - console.log(formatApplySummary(await applyDefaults(client, options))); + console.log(formatApplySummary(await applyDefaults(client, options), formatOptions)); } main().catch((error: unknown) => { diff --git a/tests/args.test.ts b/tests/args.test.ts index 3fbd77a..c81097a 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -3,7 +3,16 @@ import { describe, expect, it } from "vitest"; import { parseArgs } from "../src/cli/args.js"; describe("parseArgs", () => { - it("parses a targeted plan command", () => { + it("parses a positional repository plan command", () => { + expect(parseArgs(["plan", "dutifuldev/scratch"])).toEqual({ + command: "plan", + org: "dutifuldev", + repos: ["scratch"], + all: false + }); + }); + + it("parses a legacy targeted plan command", () => { expect(parseArgs(["plan", "--org", "dutifuldev", "--repo", "scratch"])).toEqual({ command: "plan", org: "dutifuldev", @@ -13,7 +22,7 @@ describe("parseArgs", () => { }); it("parses an org-wide apply command", () => { - expect(parseArgs(["apply", "--org", "dutifuldev", "--all"])).toEqual({ + expect(parseArgs(["apply", "dutifuldev", "--all"])).toEqual({ command: "apply", org: "dutifuldev", repos: [], @@ -33,6 +42,18 @@ describe("parseArgs", () => { ); }); + it("rejects malformed positional repository targets", () => { + expect(() => parseArgs(["plan", "scratch"])).toThrow( + "Repository targets must look like /" + ); + }); + + it("rejects conflicting organization targets", () => { + expect(() => parseArgs(["plan", "--org", "other", "dutifuldev/scratch"])).toThrow( + "Conflicting organization targets" + ); + }); + it("rejects missing option values", () => { expect(() => parseArgs(["plan", "--org"])).toThrow("--org requires a value."); }); @@ -44,14 +65,12 @@ describe("parseArgs", () => { }); it("parses an explicit token", () => { - expect(parseArgs(["plan", "--org", "dutifuldev", "--repo", "scratch", "--token", "t"])).toEqual( - { - command: "plan", - org: "dutifuldev", - repos: ["scratch"], - all: false, - token: "t" - } - ); + expect(parseArgs(["plan", "dutifuldev/scratch", "--token", "t"])).toEqual({ + command: "plan", + org: "dutifuldev", + repos: ["scratch"], + all: false, + token: "t" + }); }); }); diff --git a/tests/format.test.ts b/tests/format.test.ts index f5cc1c8..b494954 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -20,19 +20,62 @@ describe("formatPlan", () => { ]) ).toBe( [ - "dutifuldev/scratch", - " settings:", - " allow_auto_merge: false -> true", - " ruleset: create" + "Plan: 1 repository", + "1 with changes, 0 already clean", + "", + "changes dutifuldev/scratch", + " Settings", + " allow_auto_merge false -> true", + " Ruleset create" ].join("\n") ); }); + + it("formats clean repositories", () => { + expect( + formatPlan([ + { + name: "scratch", + fullName: "dutifuldev/scratch", + archived: false, + settingChanges: [], + ruleset: { action: "none" } + } + ]) + ).toBe( + [ + "Plan: 1 repository", + "0 with changes, 1 already clean", + "", + "clean dutifuldev/scratch", + " Settings no changes", + " Ruleset no changes" + ].join("\n") + ); + }); + + it("formats colored output when requested", () => { + expect( + formatPlan( + [ + { + name: "scratch", + fullName: "dutifuldev/scratch", + archived: false, + settingChanges: [], + ruleset: { action: "none" } + } + ], + { color: true } + ) + ).toContain("\u001B[32mclean\u001B[0m"); + }); }); describe("formatApplySummary", () => { it("formats the apply count and nested plan", () => { expect(formatApplySummary({ planned: [], applied: 0 })).toBe( - "Applied sane defaults to 0 repository/repositories.\n\nNo repositories selected." + "Applied sane defaults to 0 repositories.\n\nNo repositories selected." ); }); });