Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
73 changes: 70 additions & 3 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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];
Expand All @@ -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}`);
}
Expand Down Expand Up @@ -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 <org>/<repo>: ${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 <org>/<repo>",
" github-sane-defaults apply <org>/<repo>",
" github-sane-defaults plan <org> --all",
" github-sane-defaults apply <org> --all",
" github-sane-defaults plan --org <org> --repo <repo>",
" github-sane-defaults apply --org <org> --repo <repo>",
" github-sane-defaults apply --org <org> --all"
" github-sane-defaults apply --org <org> --repo <repo>"
].join("\n");
}
104 changes: 89 additions & 15 deletions src/cli/format.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 4 additions & 3 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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) => {
Expand Down
Loading
Loading