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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ github-sane-defaults apply example-org/example-repo --yes
The `--owner example-owner --repo example-repo` form is also accepted. The legacy
`--org example-org --repo example-repo` form remains supported.

Long owner-wide runs show a single-line progress bar on `stderr` when running in
an interactive terminal. The status line covers repository detail loading,
planning, and the mutation phase of `apply`. The final plan and apply summaries
still print to `stdout`, so scripted output stays clean. Use `--no-progress` to
disable the status bar or `--progress` to force it.

## Defaults

Repository settings:
Expand Down
6 changes: 6 additions & 0 deletions slophammer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,23 @@ typescript:
- src/cli
- src/github/parse.ts
- src/policy
- src/shared
dependency_boundaries:
- from: src/cli
allow:
- src/app
- src/github
- src/shared
- from: src/app
allow:
- src/github
- src/policy
- src/shared
- from: src/github
allow:
- src/policy
- src/shared
- from: src/policy
allow: []
- from: src/shared
allow: []
36 changes: 29 additions & 7 deletions src/app/apply.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,44 @@
import type { GitHubClient } from "../github/client.js";
import { desiredRulesetPayload, RULESET_NAME } from "../policy/defaults.js";
import type { ApplySummary, RepoPlan, TargetSelection } from "./types.js";
import type { ApplyOptions, ApplySummary, RepoPlan, TargetSelection } from "./types.js";
import { buildPlan } from "./planner.js";
import { mergeRulesetPayload } from "./planner.js";

export async function applyDefaults(
client: GitHubClient,
selection: TargetSelection
selection: TargetSelection,
options: ApplyOptions = {}
): Promise<ApplySummary> {
const planned = await buildPlan(client, selection);
const planned = await buildPlan(client, selection, options);

await applyPlannedDefaults(client, selection.owner, planned);
await applyPlannedDefaults(client, selection.owner, planned, options);

return {
planned,
applied: planned.length
applied: countChangedRepos(planned)
};
}

export async function applyPlannedDefaults(
client: GitHubClient,
owner: string,
planned: RepoPlan[]
planned: RepoPlan[],
options: ApplyOptions = {}
): Promise<void> {
for (const plan of planned) {
const changedPlans = planned.filter(repoPlanHasChanges);
let completed = 0;

options.progress?.applyingRepos?.({ owner, total: changedPlans.length });

for (const plan of changedPlans) {
await applyRepoPlan(client, owner, plan);
completed += 1;
options.progress?.appliedRepo?.({
owner,
total: changedPlans.length,
completed,
current: plan.fullName
});
}
}

Expand Down Expand Up @@ -60,3 +74,11 @@ async function applyRepoPlan(client: GitHubClient, owner: string, plan: RepoPlan
mergeRulesetPayload(existingRuleset, desired)
);
}

function countChangedRepos(planned: RepoPlan[]): number {
return planned.filter(repoPlanHasChanges).length;
}

function repoPlanHasChanges(plan: RepoPlan): boolean {
return plan.settingChanges.length > 0 || plan.ruleset.action !== "none";
}
65 changes: 53 additions & 12 deletions src/app/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,66 @@ import type { GitHubRuleset, RulesetSummary } from "../github/types.js";
import type { GitHubRepo } from "../github/types.js";
import { desiredRulesetPayload, repoSettingChanges, RULESET_NAME } from "../policy/defaults.js";
import type { ExistingRulesetRule, RefNameCondition, RulesetPayload } from "../policy/types.js";
import type { RepoPlan, RulesetPlan, TargetSelection } from "./types.js";
import { mapWithConcurrency } from "../shared/concurrency.js";
import type { BuildPlanOptions, RepoPlan, RulesetPlan, TargetSelection } from "./types.js";

const DEFAULT_PLAN_CONCURRENCY = 8;

export async function buildPlan(
client: GitHubClient,
selection: TargetSelection
selection: TargetSelection,
options: BuildPlanOptions = {}
): Promise<RepoPlan[]> {
const repos = await selectRepos(client, selection);
const activeRepos = repos.filter((repo) => !repo.archived && !repo.disabled);
const plans: RepoPlan[] = [];
options.progress?.loadingRepos?.(selection.owner);

for (const repo of activeRepos) {
plans.push(await buildRepoPlan(client, selection.owner, repo));
}

return plans;
const repos = await selectRepos(client, selection, options);
const activeRepos = repos.filter((repo) => !repo.archived && !repo.disabled);
const skipped = repos.length - activeRepos.length;
const progressState = { completed: 0, changed: 0, clean: 0 };

options.progress?.selectedRepos?.({
owner: selection.owner,
total: activeRepos.length,
skipped
});

return mapWithConcurrency(
activeRepos,
options.concurrency ?? DEFAULT_PLAN_CONCURRENCY,
async (repo) => {
const plan = await buildRepoPlan(client, selection.owner, repo);
progressState.completed += 1;

if (repoPlanHasChanges(plan)) {
progressState.changed += 1;
} else {
progressState.clean += 1;
}

options.progress?.plannedRepo?.({
owner: selection.owner,
total: activeRepos.length,
completed: progressState.completed,
changed: progressState.changed,
clean: progressState.clean,
skipped,
current: repo.full_name
});

return plan;
}
);
}

async function selectRepos(
client: GitHubClient,
selection: TargetSelection
selection: TargetSelection,
options: BuildPlanOptions
): Promise<GitHubRepo[]> {
if (selection.all) {
return client.listOwnerRepos(selection.owner);
return options.progress === undefined
? client.listOwnerRepos(selection.owner)
: client.listOwnerRepos(selection.owner, { progress: options.progress });
}

return Promise.all(selection.repos.map((repo) => client.getRepo(selection.owner, repo)));
Expand Down Expand Up @@ -101,6 +138,10 @@ function managedRulesetSatisfiesDesired(existing: GitHubRuleset, desired: Rulese
return existing.name === desired.name && rulesetSatisfiesDesired(existing, desired);
}

function repoPlanHasChanges(plan: RepoPlan): boolean {
return plan.settingChanges.length > 0 || plan.ruleset.action !== "none";
}

export function rulesetSatisfiesDesired(existing: GitHubRuleset, desired: RulesetPayload): boolean {
return (
existing.enforcement === desired.enforcement &&
Expand Down
62 changes: 62 additions & 0 deletions src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,68 @@ export type TargetSelection = {
all: boolean;
};

export type PlanProgressSelectedRepos = {
owner: string;
total: number;
skipped: number;
};

export type PlanProgressLoadingRepoDetails = {
owner: string;
total: number;
};

export type PlanProgressLoadedRepoDetails = {
owner: string;
total: number;
completed: number;
current: string;
};

export type PlanProgressPlannedRepo = {
owner: string;
total: number;
completed: number;
changed: number;
clean: number;
skipped: number;
current: string;
};

export type PlanProgress = {
loadingRepos?(owner: string): void;
loadingRepoDetails?(state: PlanProgressLoadingRepoDetails): void;
loadedRepoDetails?(state: PlanProgressLoadedRepoDetails): void;
selectedRepos?(state: PlanProgressSelectedRepos): void;
plannedRepo?(state: PlanProgressPlannedRepo): void;
};

export type BuildPlanOptions = {
concurrency?: number;
progress?: PlanProgress;
};

export type ApplyProgressSelectedRepos = {
owner: string;
total: number;
};

export type ApplyProgressAppliedRepo = {
owner: string;
total: number;
completed: number;
current: string;
};

export type ApplyProgress = {
applyingRepos?(state: ApplyProgressSelectedRepos): void;
appliedRepo?(state: ApplyProgressAppliedRepo): void;
};

export type ApplyOptions = {
progress?: ApplyProgress & PlanProgress;
};

export type RulesetPlan = {
action: "create" | "update" | "none";
coveredBy?: string;
Expand Down
29 changes: 27 additions & 2 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { TargetSelection } from "../app/types.js";
import type { ProgressMode } from "./progress.js";

export type CliCommand = "plan" | "apply";

export type CliOptions = TargetSelection & {
command: CliCommand;
progress?: ProgressMode;
token?: string;
yes: boolean;
};
Expand Down Expand Up @@ -31,6 +33,10 @@ export function parseArgs(args: string[]): CliOptions {
options.token = parsed.token;
}

if (parsed.progress !== "auto") {
options.progress = parsed.progress;
}

return options;
}

Expand All @@ -52,6 +58,7 @@ function validateFlags(parsed: ParsedFlags): asserts parsed is ParsedFlags & { o

type ParsedFlags = {
owner?: string;
progress: ProgressMode;
token?: string;
repos: string[];
targets: string[];
Expand All @@ -60,7 +67,13 @@ type ParsedFlags = {
};

function parseFlags(args: string[]): ParsedFlags {
const flags: ParsedFlags = { repos: [], targets: [], all: false, yes: false };
const flags: ParsedFlags = {
repos: [],
targets: [],
all: false,
progress: "auto",
yes: false
};

for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
Expand Down Expand Up @@ -92,6 +105,16 @@ function parseFlag(args: string[], index: number, flags: ParsedFlags): number {
return index;
}

if (arg === "--progress") {
flags.progress = "always";
return index;
}

if (arg === "--no-progress") {
flags.progress = "never";
return index;
}

if (!arg.startsWith("--")) {
flags.targets.push(arg);
return index;
Expand Down Expand Up @@ -192,6 +215,8 @@ export function usage(): string {
" github-sane-defaults apply --owner <owner> --repo <repo>",
"",
"Options:",
" -y, --yes Skip apply confirmation"
" -y, --yes Skip apply confirmation",
" --progress Show progress even when stderr is not a TTY",
" --no-progress Disable progress output"
].join("\n");
}
Loading
Loading