diff --git a/CHANGELOG.md b/CHANGELOG.md index 0028637..379cff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ releases before `0.4.1` are not recorded here. ## Unreleased +- New `reconcile --plan-json ` writes the change sets a run planned, one per + cycle and org, as a JSON array of chant's reconcile `ChangeSet`. chant's + change-set document reads it (`readChangeSetPart` with `planner: "warden"`). + It cannot be combined with `--mode apply`. `runReconcile` also returns them + as `changeSets`. - New `checks` subcommand reads each member's generated pipeline job names from `chant workspace ls --json` and prints the ruleset that requires them as status checks. The chant range moves to `^0.101.0`, which publishes the names. diff --git a/CLI.md b/CLI.md index 4e3b556..5c0950d 100644 --- a/CLI.md +++ b/CLI.md @@ -31,7 +31,7 @@ shape exits 2 with the offending field path. ``` github-warden reconcile --config [auth flags] [--mode dry-run|apply] [--cycles a,b,c] [--allow-guardrail-override] - [--removal-cap-fraction ] + [--removal-cap-fraction ] [--plan-json ] ``` | Flag | Default | Meaning | @@ -44,6 +44,7 @@ github-warden reconcile --config [auth flags] [--mode dry-run|apply] | `--installation-id-env ` | — | Env var holding the installation ID. | | `--allow-guardrail-override` | off | Apply even when guardrails trip. | | `--removal-cap-fraction ` | `0.25` | `removalDeltaCap` threshold: the max fraction of any one resource type's live managed entries the plan may delete. Must be in (0,1]; anything else exits 2. See [the canonical cap description in POLICY.md](POLICY.md#what-the-policy-does-not-declare). | +| `--plan-json ` | — | Write the change sets the run planned (one per cycle and org, a JSON array of chant's reconcile `ChangeSet`) to the file, for chant's change-set document (`readChangeSetPart` with `planner: "warden"`). Nothing is applied: combining it with `--mode apply` exits 2. | Deletes come from the policy, not from a flag: a live resource missing from the policy is planned for deletion only in an org whose policy declares diff --git a/README.md b/README.md index ce8d1b5..ccac846 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ npx @intentius/github-warden reconcile --config .github/governance.yml --token-e ### `reconcile` ``` -github-warden reconcile --config [auth] [--mode dry-run|apply] [--cycles a,b,c] [--allow-guardrail-override] [--removal-cap-fraction ] +github-warden reconcile --config [auth] [--mode dry-run|apply] [--cycles a,b,c] [--allow-guardrail-override] [--removal-cap-fraction ] [--plan-json ] ``` | Flag | Meaning | @@ -88,6 +88,7 @@ github-warden reconcile --config [auth] [--mode dry-run|apply] [--cycles | `--cycles ` | Subset of cycles to run (default: all). | | `--allow-guardrail-override` | Apply even when guardrails trip. | | `--removal-cap-fraction ` | `removalDeltaCap` threshold in (0,1] (default 0.25). | +| `--plan-json ` | Write the planned reconcile change sets (one per cycle and org) as JSON for chant's change-set document. Dry-run only. | ### `audit` diff --git a/src/cli.ts b/src/cli.ts index 7a680f8..3b72d62 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,6 +23,9 @@ * --removal-cap-fraction Max fraction of live managed entries the plan * may delete per resource type, in (0,1]. * Default: 0.25. + * --plan-json Write the reconcile ChangeSets this run planned + * (one per cycle and org) as a JSON array, for + * chant's change-set document. Dry-run only. * * Auth modes (mutually exclusive, precedence: token-env > app-id-env): * 1. --token-env GH_TOKEN @@ -77,6 +80,8 @@ export interface ReconcileArgs { allowGuardrailOverride: boolean; /** removalDeltaCap threshold in (0,1]; undefined keeps chant's default. */ removalCapFraction: number | undefined; + /** File to write the planned reconcile ChangeSets to, as JSON. */ + planJson: string | undefined; } /** @@ -113,6 +118,7 @@ export function parseReconcileArgs(argv: string[]): ReconcileArgs { tokenEnv: undefined, allowGuardrailOverride: false, removalCapFraction: undefined, + planJson: undefined, }; const knownFlags = new Set([ @@ -124,6 +130,7 @@ export function parseReconcileArgs(argv: string[]): ReconcileArgs { "--token-env", "--allow-guardrail-override", "--removal-cap-fraction", + "--plan-json", ]); let i = 0; @@ -206,12 +213,22 @@ export function parseReconcileArgs(argv: string[]): ReconcileArgs { args.removalCapFraction = fraction; break; } + case "--plan-json": { + const val = argv[++i]; + if (val === undefined || val.startsWith("--")) + throw new CliError(2, "--plan-json requires a value"); + args.planJson = val; + break; + } } i++; } // Validate required flags. if (!args.config) throw new CliError(2, "--config is required"); + if (args.planJson !== undefined && args.mode === "apply") { + throw new CliError(2, "--plan-json writes a plan without applying it; it cannot be combined with --mode apply"); + } const hasTokenAuth = !!args.tokenEnv; const hasAppAuth = !!(args.appIdEnv && args.installationIdEnv); @@ -666,6 +683,14 @@ async function main(argv: string[] = process.argv.slice(2)) { // ── Output ──────────────────────────────────────────────────────────────── + if (args.planJson !== undefined) { + try { + writeFileSync(args.planJson, `${JSON.stringify(result.changeSets, null, 2)}\n`, "utf-8"); + } catch (err) { + die(3, `failed to write plan to "${args.planJson}": ${errMsg(err)}`); + } + } + // Print plan summary for every cycle that ran. for (const cr of result.cycles) { process.stdout.write(`\n=== ${cr.name} @ ${cr.org} ===\n`); diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index a33c92a..e335f59 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -132,6 +132,18 @@ describe("parseReconcileArgs", () => { ).toBe(1); }); + it("parses --plan-json, defaulting to undefined, and refuses it with --mode apply", () => { + const base = ["--config", "g.yml", "--token-env", "GH_TOKEN"]; + expect(parseReconcileArgs(base).planJson).toBeUndefined(); + expect(parseReconcileArgs([...base, "--plan-json", "plan.json"]).planJson).toBe("plan.json"); + expect(() => parseReconcileArgs([...base, "--plan-json"])).toThrow( + expect.objectContaining({ code: 2 }), + ); + expect(() => parseReconcileArgs([...base, "--mode", "apply", "--plan-json", "plan.json"])).toThrow( + expect.objectContaining({ code: 2 }), + ); + }); + it("throws code 2 for a --removal-cap-fraction outside (0, 1]", () => { const base = ["--config", "g.yml", "--token-env", "GH_TOKEN"]; for (const bad of ["0", "-0.5", "1.5", "abc"]) { @@ -323,6 +335,19 @@ describe("runReconcile integration (mocked client)", () => { } }); + it("returns the change set it planned, one per cycle and org", async () => { + const result = await runReconcile({ + config: makeSimpleConfig(), + client: makeMockClient(), + cycles: [makeCreateCycle()], + mode: "dry-run", + }); + + expect(result.changeSets).toHaveLength(result.cycles.length); + expect(result.changeSets.map((cs) => cs.org)).toEqual(result.cycles.map((cr) => cr.org)); + expect(result.changeSets.flatMap((cs) => cs.entries).length).toBeGreaterThan(0); + }); + it("apply mode applies entries and records them", async () => { const cycle = makeCreateCycle(); const result = await runReconcile({ diff --git a/src/reconcile/runner.ts b/src/reconcile/runner.ts index 7515816..04739bd 100644 --- a/src/reconcile/runner.ts +++ b/src/reconcile/runner.ts @@ -18,6 +18,7 @@ import type { GuardrailConfig } from "./guardrails.js"; import { drainNotes } from "../cycles/notes.js"; import { runReconcile as coreRunReconcile } from "./core.js"; import type { + ChangeSet, Cycle as CoreCycle, ReconcileResult, } from "./core.js"; @@ -89,10 +90,14 @@ function ownedPredicate( */ export async function runReconcile( opts: RunReconcileOptions, -): Promise { +): Promise { // Clear notes a previous (crashed/errored) run may have left behind. drainNotes(); + // Every change set the run diffed, one per cycle and org, in run order. + // This is the plan chant's change-set document reads (`--plan-json`). + const changeSets: ChangeSet[] = []; + const result = await coreRunReconcile({ client: opts.client, scopes: opts.config.orgs, @@ -103,12 +108,15 @@ export async function runReconcile( // removalDeltaCap) on each change set it returns, so every guardrail // evaluation carries its own live counts — no side channel between the // diff and guardrail callbacks. - diff: (scopeId, desired, live, dopts) => - diff(scopeId, desired, live, { + diff: (scopeId, desired, live, dopts) => { + const changeSet = diff(scopeId, desired, live, { ...dopts, isOwned: dopts.isOwned ?? ownedPredicate(opts.config.orgs[scopeId]?.owned), nowMs: dopts.nowMs ?? Date.now(), - }), + }); + changeSets.push(changeSet); + return changeSet; + }, guardrails: (changeSet, live) => runGuardrails(changeSet, live, opts.guardrails ?? {}), diffOptions: opts.diffOptions, @@ -126,5 +134,5 @@ export async function runReconcile( if (cr) cr.plan = `${cr.plan}\n${n.note}`; } - return result; + return { ...result, changeSets }; }