From 7658bd1ad3825521b749cb27595b88f38010036f Mon Sep 17 00:00:00 2001 From: lex00 Date: Fri, 19 Jun 2026 16:27:06 -0600 Subject: [PATCH] refactor(reconcile): consume the shared runner from chant 0.11.0 (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace warden's vendored runReconcile/Cycle with the generic harness now in @intentius/chant/reconcile. runner.ts (443 → ~90 lines) is a thin adapter that wires warden's GitHub diff (nowMs-defaulted) + member-aware guardrails + config.orgs into the shared loop, and re-exports the harness types so the in-repo surface is unchanged. Bump chant deps to ^0.11.0. This completes github-warden#20: the full provider-agnostic core (change set, diffCollection, guardrails, AND the runner) lives in chant — a gitlab/forgejo warden builds on it instead of vendoring. 469 tests green; action bundle rebuilt. Co-Authored-By: Claude Opus 4.8 --- action/index.mjs | 302 +++++++++++++------------- package-lock.json | 18 +- package.json | 4 +- src/reconcile/runner.ts | 469 +++++----------------------------------- 4 files changed, 215 insertions(+), 578 deletions(-) diff --git a/action/index.mjs b/action/index.mjs index 6fe0a41..2c9d50d 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -413,8 +413,121 @@ function removalDeltaCap(changeSet, opts = {}) { } return null; } +function errMsg(err) { + return err instanceof Error ? err.message : String(err); +} +async function runReconcile(opts) { + const { + scopes, + client, + cycles, + scope, + mode = "dry-run", + diff: diffFn, + guardrails = () => ({ ok: true }), + diffOptions = {}, + allowGuardrailOverride = false, + requestBudget = 1e3 + } = opts; + const budget = new MutableRateBudget(requestBudget); + const cycleResults = []; + const erroredCycles = []; + const deferred = { skippedCycles: [], skippedEntries: [] }; + const scopeEntries = Object.entries(scopes); + for (const cycle of cycles) { + for (const [scopeId, scopeConfig] of scopeEntries) { + if (budget.exhausted) { + deferred.skippedCycles.push(`${cycle.name}@${scopeId}`); + continue; + } + let live; + try { + live = await cycle.fetchLive(client, scopeId, scope, budget); + } catch (err) { + if (err instanceof BudgetExhaustedError) { + deferred.skippedCycles.push(`${cycle.name}@${scopeId}`); + continue; + } + erroredCycles.push({ name: cycle.name, org: scopeId, stage: "fetchLive", error: errMsg(err) }); + continue; + } + let desired; + try { + desired = cycle.buildDesired(scopeConfig, scopeId, scope); + } catch (err) { + erroredCycles.push({ name: cycle.name, org: scopeId, stage: "buildDesired", error: errMsg(err) }); + continue; + } + const changeSet = diffFn(scopeId, desired, live, diffOptions); + const guardrailResult = guardrails(changeSet, live); + const counts = { create: 0, update: 0, delete: 0 }; + for (const e of changeSet.entries) counts[e.kind]++; + const cycleResult = { + name: cycle.name, + org: scopeId, + counts, + guardrails: guardrailResult, + applied: [], + failed: [], + plan: renderChangeSet(changeSet), + guardrailBlocked: false + }; + if (mode === "dry-run") { + cycleResults.push(cycleResult); + continue; + } + if (!guardrailResult.ok && !allowGuardrailOverride) { + cycleResult.guardrailBlocked = true; + cycleResults.push(cycleResult); + continue; + } + for (const entry of changeSet.entries) { + if (budget.exhausted) { + deferred.skippedEntries.push({ cycleName: cycle.name, entry }); + continue; + } + try { + await cycle.apply(client, entry, scopeId, scope, budget); + cycleResult.applied.push(entry); + } catch (err) { + if (err instanceof BudgetExhaustedError) { + deferred.skippedEntries.push({ cycleName: cycle.name, entry }); + continue; + } + cycleResult.failed.push({ entry, error: errMsg(err) }); + } + } + cycleResults.push(cycleResult); + } + } + const completed = deferred.skippedCycles.length === 0 && deferred.skippedEntries.length === 0 && erroredCycles.length === 0; + return { mode, completed, cycles: cycleResults, errored: erroredCycles, deferred, budgetRemaining: budget.remaining }; +} +var BudgetExhaustedError, MutableRateBudget; var init_reconcile = __esm({ "node_modules/@intentius/chant/src/reconcile.ts"() { + BudgetExhaustedError = class extends Error { + constructor(message = "rate budget exhausted") { + super(message); + this.name = "BudgetExhaustedError"; + } + }; + MutableRateBudget = class { + _remaining; + constructor(initial) { + this._remaining = initial; + } + get remaining() { + return this._remaining; + } + get exhausted() { + return this._remaining <= 0; + } + use(n = 1) { + if (this.exhausted) throw new BudgetExhaustedError(); + this._remaining = Math.max(0, this._remaining - n); + } + }; } }); @@ -962,148 +1075,29 @@ var init_guardrails = __esm({ var runner_exports = {}; __export(runner_exports, { BudgetExhaustedError: () => BudgetExhaustedError, - runReconcile: () => runReconcile + runReconcile: () => runReconcile2 }); -async function runReconcile(opts) { - const { - config: config2, - client, - cycles, - scope, - mode = "dry-run", - guardrails: guardrailConfig = {}, - diffOptions = {}, - allowGuardrailOverride = false, - requestBudget = 1e3 - } = opts; - const budget = new MutableRateBudget(requestBudget); - const cycleResults = []; - const erroredCycles = []; - const deferred = { skippedCycles: [], skippedEntries: [] }; - const orgs = Object.entries(config2.orgs); - for (const cycle of cycles) { - for (const [orgLogin, orgConfig] of orgs) { - if (budget.exhausted) { - deferred.skippedCycles.push(`${cycle.name}@${orgLogin}`); - continue; - } - let live; - try { - live = await cycle.fetchLive(client, orgLogin, scope, budget); - } catch (err) { - if (err instanceof BudgetExhaustedError) { - deferred.skippedCycles.push(`${cycle.name}@${orgLogin}`); - continue; - } - erroredCycles.push({ - name: cycle.name, - org: orgLogin, - stage: "fetchLive", - error: err instanceof Error ? err.message : String(err) - }); - continue; - } - let desired; - try { - desired = cycle.buildDesired(orgConfig, orgLogin, scope); - } catch (err) { - erroredCycles.push({ - name: cycle.name, - org: orgLogin, - stage: "buildDesired", - error: err instanceof Error ? err.message : String(err) - }); - continue; - } - const changeSet = diff(orgLogin, desired, live, { - ...diffOptions, - nowMs: diffOptions.nowMs ?? Date.now() - }); - const guardrailResult = runGuardrails(changeSet, live, guardrailConfig); - const counts = { create: 0, update: 0, delete: 0 }; - for (const e of changeSet.entries) counts[e.kind]++; - const plan = renderChangeSet(changeSet); - const cycleResult = { - name: cycle.name, - org: orgLogin, - counts, - guardrails: guardrailResult, - applied: [], - failed: [], - plan, - guardrailBlocked: false - }; - if (mode === "dry-run") { - cycleResults.push(cycleResult); - continue; - } - if (!guardrailResult.ok && !allowGuardrailOverride) { - cycleResult.guardrailBlocked = true; - cycleResults.push(cycleResult); - continue; - } - for (const entry of changeSet.entries) { - if (budget.exhausted) { - deferred.skippedEntries.push({ cycleName: cycle.name, entry }); - continue; - } - try { - await cycle.apply(client, entry, orgLogin, scope, budget); - cycleResult.applied.push(entry); - } catch (err) { - if (err instanceof BudgetExhaustedError) { - deferred.skippedEntries.push({ cycleName: cycle.name, entry }); - continue; - } - cycleResult.failed.push({ - entry, - error: err instanceof Error ? err.message : String(err) - }); - } - } - cycleResults.push(cycleResult); - } - } - const completed = deferred.skippedCycles.length === 0 && deferred.skippedEntries.length === 0 && erroredCycles.length === 0; - return { - mode, - completed, - cycles: cycleResults, - errored: erroredCycles, - deferred, - budgetRemaining: budget.remaining - }; +async function runReconcile2(opts) { + return runReconcile({ + client: opts.client, + scopes: opts.config.orgs, + cycles: opts.cycles, + scope: opts.scope, + mode: opts.mode, + diff: (scopeId, desired, live, dopts) => diff(scopeId, desired, live, { ...dopts, nowMs: dopts.nowMs ?? Date.now() }), + guardrails: (changeSet, live) => runGuardrails(changeSet, live, opts.guardrails ?? {}), + diffOptions: opts.diffOptions, + allowGuardrailOverride: opts.allowGuardrailOverride, + requestBudget: opts.requestBudget + }); } -var BudgetExhaustedError, MutableRateBudget; var init_runner = __esm({ "src/reconcile/runner.ts"() { "use strict"; init_diff(); init_guardrails(); - BudgetExhaustedError = class extends Error { - constructor(message = "rate budget exhausted") { - super(message); - this.name = "BudgetExhaustedError"; - } - }; - MutableRateBudget = class { - _remaining; - constructor(initial) { - this._remaining = initial; - } - get remaining() { - return this._remaining; - } - get exhausted() { - return this._remaining <= 0; - } - use(n = 1) { - if (this.exhausted) { - throw new BudgetExhaustedError(); - } - this._remaining = Math.max(0, this._remaining - n); - } - }; + init_core(); + init_core(); } }); @@ -234407,19 +234401,19 @@ async function main(argv = process.argv.slice(2)) { const text = readFileSync2(args.config, "utf-8"); rawConfig = parseConfigFile(args.config, text); } catch (err) { - die(3, `failed to read config file "${args.config}": ${errMsg(err)}`); + die(3, `failed to read config file "${args.config}": ${errMsg2(err)}`); } let config2; try { config2 = loadGovernanceConfig(rawConfig); } catch (err) { - die(2, `invalid governance config: ${errMsg(err)}`); + die(2, `invalid governance config: ${errMsg2(err)}`); } let client; try { client = buildClient(args); } catch (err) { - die(3, `auth setup failed: ${errMsg(err)}`); + die(3, `auth setup failed: ${errMsg2(err)}`); } let cycles; if (args.cycles.length === 0) { @@ -234437,7 +234431,7 @@ async function main(argv = process.argv.slice(2)) { } let result; try { - result = await runReconcile({ + result = await runReconcile2({ config: config2, client, cycles, @@ -234445,7 +234439,7 @@ async function main(argv = process.argv.slice(2)) { allowGuardrailOverride: args.allowGuardrailOverride }); } catch (err) { - die(3, `reconcile failed: ${errMsg(err)}`); + die(3, `reconcile failed: ${errMsg2(err)}`); } for (const cr of result.cycles) { process.stdout.write(` @@ -234502,7 +234496,7 @@ function die(code, message) { `); process.exit(code); } -function errMsg(err) { +function errMsg2(err) { return err instanceof Error ? err.message : String(err); } function parseConfigFile(filePath, text) { @@ -234653,13 +234647,13 @@ async function runAudit(argv) { const text = readFileSync2(auditArgs.config, "utf-8"); rawConfig = parseConfigFile(auditArgs.config, text); } catch (err) { - die(3, `failed to read config file "${auditArgs.config}": ${errMsg(err)}`); + die(3, `failed to read config file "${auditArgs.config}": ${errMsg2(err)}`); } let config2; try { config2 = loadGovernanceConfig(rawConfig); } catch (err) { - die(2, `invalid governance config: ${errMsg(err)}`); + die(2, `invalid governance config: ${errMsg2(err)}`); } const repoUrls = []; for (const [orgName, orgCfg] of Object.entries(config2.orgs)) { @@ -234689,13 +234683,13 @@ async function runAudit(argv) { void client; } } catch (err) { - die(3, `auth setup failed: ${errMsg(err)}`); + die(3, `auth setup failed: ${errMsg2(err)}`); } let report; try { report = await auditRepos(repoUrls, token); } catch (err) { - die(3, `audit failed: ${errMsg(err)}`); + die(3, `audit failed: ${errMsg2(err)}`); } process.stdout.write(renderPostureSummary(report)); if (shouldFail(report, auditArgs.failOn)) { @@ -234720,19 +234714,19 @@ async function runReport(argv) { const text = readFileSync2(reportArgs.config, "utf-8"); rawConfig = parseConfigFile(reportArgs.config, text); } catch (err) { - die(3, `failed to read config file "${reportArgs.config}": ${errMsg(err)}`); + die(3, `failed to read config file "${reportArgs.config}": ${errMsg2(err)}`); } let config2; try { config2 = loadGovernanceConfig(rawConfig); } catch (err) { - die(2, `invalid governance config: ${errMsg(err)}`); + die(2, `invalid governance config: ${errMsg2(err)}`); } let client; try { client = buildClient(reportArgs); } catch (err) { - die(3, `auth setup failed: ${errMsg(err)}`); + die(3, `auth setup failed: ${errMsg2(err)}`); } let cycles; if (reportArgs.cycles.length === 0) { @@ -234749,9 +234743,9 @@ async function runReport(argv) { } let result; try { - result = await runReconcile({ config: config2, client, cycles, mode: "dry-run" }); + result = await runReconcile2({ config: config2, client, cycles, mode: "dry-run" }); } catch (err) { - die(3, `reconcile failed: ${errMsg(err)}`); + die(3, `reconcile failed: ${errMsg2(err)}`); } let auditReport; if (reportArgs.audit) { @@ -234776,7 +234770,7 @@ async function runReport(argv) { } auditReport = await auditRepos(repoUrls, token); } catch (err) { - die(3, `audit failed: ${errMsg(err)}`); + die(3, `audit failed: ${errMsg2(err)}`); } } } @@ -234809,7 +234803,7 @@ async function runReport(argv) { } identityReport = buildIdentityReport(installations, memberLogins, machineUsers); } catch (err) { - die(3, `identity pass failed: ${errMsg(err)}`); + die(3, `identity pass failed: ${errMsg2(err)}`); } } const report = buildComplianceReport([result], auditReport, identityReport); @@ -234821,7 +234815,7 @@ async function runReport(argv) { process.stdout.write(`wrote artifact: ${reportArgs.out} `); } catch (err) { - die(3, `failed to write artifact "${reportArgs.out}": ${errMsg(err)}`); + die(3, `failed to write artifact "${reportArgs.out}": ${errMsg2(err)}`); } } if (reportArgs.failOn === "attention" && !report.clean) { @@ -234910,7 +234904,7 @@ async function run(argv) { var invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; if (import.meta.url === invokedPath && false) { main().catch((err) => { - process.stderr.write(`github-warden: fatal: ${errMsg(err)} + process.stderr.write(`github-warden: fatal: ${errMsg2(err)} `); process.exit(3); }); diff --git a/package-lock.json b/package-lock.json index 69158b9..e672b04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@intentius/chant": "^0.10.0", - "@intentius/chant-lexicon-github": "^0.10.0" + "@intentius/chant": "^0.11.0", + "@intentius/chant-lexicon-github": "^0.11.0" }, "bin": { "github-warden": "bin/github-warden.js" @@ -475,9 +475,9 @@ } }, "node_modules/@intentius/chant": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.10.0.tgz", - "integrity": "sha512-6zbZigpXPtQFmc0RDZLbB8T5cisk/7xXtSv4FQlV5Aj5Lcs0djAmMpRnaS2WnE+taWJUGgEiz0Y3LKwRImiztQ==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.11.0.tgz", + "integrity": "sha512-Vs6eA98mPINryYeUVvmo3STDu0Zm5iNb4+hAfCjYlxib2a3AoJLhPcxoUdhjRl36FUEeKONFU8uklGy1JrTR2Q==", "license": "Apache-2.0", "dependencies": { "fflate": "^0.8.2", @@ -491,12 +491,12 @@ } }, "node_modules/@intentius/chant-lexicon-github": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-github/-/chant-lexicon-github-0.10.0.tgz", - "integrity": "sha512-f1ba0/EBu8tZ8GxCLkuw99GvFC1P/r4y0YYnnERl3f36HnWdURvrLk28CEIcU5aJcQj6QeXzcU/PlNsfxie51A==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-github/-/chant-lexicon-github-0.11.0.tgz", + "integrity": "sha512-1mKfo3oaG11WIY7W4r2gKs4hIctWkS20i1kWo2IglaAWp9QwIynsm9WEk7GZCqfcbDvD7i4yhZoOIxC64v0WlQ==", "license": "Apache-2.0", "peerDependencies": { - "@intentius/chant": "^0.10.0" + "@intentius/chant": "^0.11.0" } }, "node_modules/@jridgewell/sourcemap-codec": { diff --git a/package.json b/package.json index 9d4655e..291bcda 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@intentius/chant": "^0.10.0", - "@intentius/chant-lexicon-github": "^0.10.0" + "@intentius/chant": "^0.11.0", + "@intentius/chant-lexicon-github": "^0.11.0" }, "devDependencies": { "@types/libsodium-wrappers": "^0.7.14", diff --git a/src/reconcile/runner.ts b/src/reconcile/runner.ts index 7554f1e..2dbd3ec 100644 --- a/src/reconcile/runner.ts +++ b/src/reconcile/runner.ts @@ -1,442 +1,85 @@ /** - * Governance reconcile runner. + * GitHub reconcile runner. * - * Ties the primitives together into one reconcile run: - * load config → fetch live → diff → guardrails → dry-run or apply - * - * Dry-run is the default. In dry-run mode, no mutations are performed; the - * computed ChangeSet is returned with a rendered summary. In apply mode, each - * ChangeSet entry is forwarded to the cycle's `apply` handler, but only when - * all guardrails pass (or `allowGuardrailOverride` is set). - * - * Rate budgeting: every API call (fetchLive + each apply) decrements a shared - * request counter. When the counter hits zero the run stops cleanly and - * records deferred cycles in the result. No silent truncation. + * A thin adapter over the provider-agnostic `runReconcile` / `Cycle` harness in + * `@intentius/chant/reconcile` (consumed via `./core.js`). It wires warden's + * GitHub-specific pieces into the shared loop — the `diff` (which injects + * `nowMs` for time-based diffs), the member-aware `runGuardrails`, and each org + * in `config.orgs` as a reconcile scope — and re-exports the harness types so + * the in-repo import surface (`./runner.js`) is unchanged. */ import type { AppClient } from "../auth/app-client.js"; import type { GovernanceConfig, OrgConfig } from "../config/types.js"; -import { diff, renderChangeSet } from "./diff.js"; -import type { ChangeSet, ChangeSetEntry, LiveOrgState, DiffOptions } from "./diff.js"; +import { diff } from "./diff.js"; +import type { LiveOrgState, DiffOptions } from "./diff.js"; import { runGuardrails } from "./guardrails.js"; -import type { GuardrailConfig, GuardrailResult } from "./guardrails.js"; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- +import type { GuardrailConfig } from "./guardrails.js"; +import { runReconcile as coreRunReconcile } from "./core.js"; +import type { + Cycle as CoreCycle, + ReconcileResult, +} from "./core.js"; + +// Re-export the shared harness types/values so existing imports from +// "./runner.js" keep resolving. +export { BudgetExhaustedError } from "./core.js"; +export type { + RateBudget, + CycleResult, + CycleError, + DeferredWork, + ReconcileResult, +} from "./core.js"; /** - * A governance cycle: knows how to fetch live state for one resource domain - * (e.g. branch protection), build desired state from config, and apply a - * single ChangeSet entry back to GitHub. - * - * Concrete cycle implementations live in separate modules (#455 onwards). - * The runner is agnostic to what a cycle manages — it only drives the loop. - * - * ## Multi-org scope - * - * The runner iterates over every org in `config.orgs` and calls each method - * once per org, passing the current `orgLogin` explicitly. Cycles MUST use - * `orgLogin` (not `scope.org` or any org name embedded in `scope`) when - * constructing GitHub API URLs. This ensures a config with two orgs applies - * each org's rules to the correct org, not to whatever name appeared in the - * caller-supplied `scope`. - * - * `scope` carries caller-supplied context that does NOT vary by org iteration — - * e.g. a repo filter list or a pagination cursor. Org-varying data is passed - * via `orgLogin`. + * A GitHub governance cycle — the shared `Cycle` specialized to warden's types + * (GitHub `AppClient`, `OrgConfig`, `LiveOrgState`). Cycle implementations are + * unchanged; this alias just keeps `Cycle` working. */ -export interface Cycle { - /** Human-readable name, e.g. "branch-protection". Used in run output. */ - name: string; - - /** - * Fetch the live state for the given org + scope. - * - * `orgLogin` is the current org being iterated. Use it (not `scope`) for - * any org-derived GitHub API path. - * - * Each GitHub API page call made inside this method MUST count against the - * shared rate budget: call `budget.use(n)` (where `n` is the number of - * requests consumed) and check `budget.exhausted` before paginating further. - * When the budget is exhausted mid-fetch a cycle may either return the - * partial state it has gathered or throw `BudgetExhaustedError`; either way - * the runner records the cycle as deferred so nothing is silently truncated. - */ - fetchLive(client: AppClient, orgLogin: string, scope: TScope, budget: RateBudget): Promise; +export type Cycle = CoreCycle; - /** - * Build the desired state from the governance config + scope. - * Pure — must not perform any I/O. - * - * `orgLogin` is the current org being iterated. - */ - buildDesired(config: OrgConfig, orgLogin: string, scope: TScope): OrgConfig; - - /** - * Apply a single ChangeSet entry to GitHub. - * - * `orgLogin` is the current org being iterated. Use it (not `scope`) for - * any org-derived GitHub API path. - * - * Called once per entry when mode is "apply" and guardrails pass. - * Each network call made inside `apply` MUST count against the budget via - * `budget.use(n)`. - */ - apply( - client: AppClient, - entry: ChangeSetEntry, - orgLogin: string, - scope: TScope, - budget: RateBudget, - ): Promise; -} - -/** Controls how a Cycle tracks its API usage against the shared budget. */ -export interface RateBudget { - /** Remaining request capacity for this run. */ - readonly remaining: number; - /** True once `remaining` has reached zero. */ - readonly exhausted: boolean; - /** - * Decrement the budget by `n` (default: 1). - * Throws `BudgetExhaustedError` if the budget has already been exhausted. - */ - use(n?: number): void; -} - -/** Thrown when a cycle or apply step attempts to use an exhausted budget. */ -export class BudgetExhaustedError extends Error { - constructor(message = "rate budget exhausted") { - super(message); - this.name = "BudgetExhaustedError"; - } -} - -/** Per-cycle outcome recorded in the run result. */ -export interface CycleResult { - /** Cycle name. */ - name: string; - /** Org this result is for. */ - org: string; - /** Number of entries per change kind in the ChangeSet. */ - counts: { create: number; update: number; delete: number }; - /** Guardrail outcome. */ - guardrails: GuardrailResult; - /** Entries successfully applied (only populated in apply mode). */ - applied: ChangeSetEntry[]; - /** Entries that failed to apply with their error. */ - failed: Array<{ entry: ChangeSetEntry; error: string }>; - /** Human-readable plan summary (always present). */ - plan: string; - /** Whether this cycle was skipped because guardrails tripped and override was not set. */ - guardrailBlocked: boolean; -} - -/** - * A cycle that could not run because `fetchLive` or `buildDesired` threw a - * non-budget error. Recorded per-cycle so the run can continue rather than - * rejecting the whole `runReconcile` call. - */ -export interface CycleError { - /** Cycle name. */ - name: string; - /** Org this error is for. */ - org: string; - /** The stage at which the cycle failed. */ - stage: "fetchLive" | "buildDesired"; - /** Error message. */ - error: string; -} - -/** Summary of work that could not be completed due to rate budget exhaustion. */ -export interface DeferredWork { - /** - * Cycles (by name) that were never started because the budget was exhausted - * before they could run. - */ - skippedCycles: string[]; - /** - * In apply mode: entries that were not applied because the budget was - * exhausted mid-cycle. - */ - skippedEntries: Array<{ cycleName: string; entry: ChangeSetEntry }>; -} - -/** Structured result from a single `runReconcile` call. */ -export interface ReconcileResult { - /** Run mode used. */ - mode: "dry-run" | "apply"; - /** - * Whether the full run completed cleanly (false when budget was exhausted - * early or any cycle errored). - */ - completed: boolean; - /** Per-cycle outcomes (only for cycles that were started). */ - cycles: CycleResult[]; - /** - * Cycles that errored during `fetchLive`/`buildDesired` with a non-budget - * error. The run continues past these; they are not silently dropped. - */ - errored: CycleError[]; - /** Work deferred due to budget exhaustion (empty when `completed` is true). */ - deferred: DeferredWork; - /** Remaining budget at the end of the run. */ - budgetRemaining: number; -} - -/** Options for `runReconcile`. */ +/** Options for warden's `runReconcile` (config-based — same shape as before). */ export interface RunReconcileOptions { - /** - * Loaded governance config. - */ + /** Loaded governance config. */ config: GovernanceConfig; - - /** - * Authed GitHub App client (created by `createAppClient`). - */ + /** Authed GitHub App client. */ client: AppClient; - - /** - * Cycles to run. Each cycle is run against every org in `config.orgs`. - */ + /** Cycles to run; each runs against every org in `config.orgs`. */ cycles: Cycle[]; - - /** - * Scope forwarded to each cycle's `fetchLive`, `buildDesired`, and `apply`. - * Typically an org-level filter or pagination cursor. - * Defaults to `undefined` when omitted. - */ + /** Scope forwarded to each cycle (filter/cursor); does not vary by org. */ scope?: TScope; - - /** - * Run mode. Defaults to "dry-run". - * - "dry-run": compute + report the change set, mutate nothing. - * - "apply": apply each entry after guardrails pass. - */ + /** "dry-run" (default) or "apply". */ mode?: "dry-run" | "apply"; - - /** - * Guardrail configuration forwarded to `runGuardrails`. - */ + /** Member-aware guardrail config. */ guardrails?: GuardrailConfig; - - /** - * Diff options forwarded to `diff()` (ownership predicate, etc.). - */ + /** Diff options (ownership predicate, etc.). */ diffOptions?: DiffOptions; - - /** - * When true, apply proceeds even when guardrails have tripped. - * Use with caution — bypasses all safety checks. - * Defaults to false. - */ + /** Apply even when guardrails trip. Default false. */ allowGuardrailOverride?: boolean; - - /** - * Maximum number of GitHub API requests for this run (across all cycles). - * - * GitHub App installations are subject to a per-installation rate ceiling. - * The runner stops cleanly when the budget is exhausted and records deferred - * work in the result so callers can resume or alert. No silent truncation. - * - * Defaults to 1000 (a conservative floor well under typical ceilings). - */ + /** Max GitHub API requests for the run. Default 1000. */ requestBudget?: number; } -// --------------------------------------------------------------------------- -// RateBudget implementation -// --------------------------------------------------------------------------- - -class MutableRateBudget implements RateBudget { - private _remaining: number; - - constructor(initial: number) { - this._remaining = initial; - } - - get remaining(): number { - return this._remaining; - } - - get exhausted(): boolean { - return this._remaining <= 0; - } - - use(n = 1): void { - if (this.exhausted) { - throw new BudgetExhaustedError(); - } - this._remaining = Math.max(0, this._remaining - n); - } -} - -// --------------------------------------------------------------------------- -// runReconcile -// --------------------------------------------------------------------------- - /** - * Run the governance reconcile loop. - * - * For each org in `config.orgs` and each cycle in `cycles`: - * 1. `fetchLive` — fetch live state from GitHub (counts against budget). - * 2. `buildDesired` — build desired state from config (pure). - * 3. `diff()` — compute the ChangeSet. - * 4. `runGuardrails()` — check safety rules. - * 5a. dry-run: render the plan, return without mutating. - * 5b. apply: call `cycle.apply` for each entry (if guardrails pass or override). - * - * Returns a structured `ReconcileResult` with per-cycle outcomes and any - * deferred work. + * Run the GitHub governance reconcile loop by delegating to the shared runner + * with warden's diff (org login as scope id; `nowMs` defaulted for time-based + * diffs) and member-aware guardrails wired in. */ export async function runReconcile( opts: RunReconcileOptions, ): Promise { - const { - config, - client, - cycles, - scope, - mode = "dry-run", - guardrails: guardrailConfig = {}, - diffOptions = {}, - allowGuardrailOverride = false, - requestBudget = 1000, - } = opts; - - const budget = new MutableRateBudget(requestBudget); - const cycleResults: CycleResult[] = []; - const erroredCycles: CycleError[] = []; - const deferred: DeferredWork = { skippedCycles: [], skippedEntries: [] }; - - const orgs = Object.entries(config.orgs); - - for (const cycle of cycles) { - for (const [orgLogin, orgConfig] of orgs) { - // Stop entirely if budget is exhausted before we start a new cycle. - if (budget.exhausted) { - deferred.skippedCycles.push(`${cycle.name}@${orgLogin}`); - continue; - } - - // Step 1: fetchLive — the cycle itself tracks budget usage internally. - // We wrap in a try/catch so a BudgetExhaustedError mid-fetch is recorded - // as a deferred skip rather than a hard crash. - // orgLogin is passed explicitly so multi-org configs target the right org. - let live: LiveOrgState; - try { - live = await cycle.fetchLive(client, orgLogin, scope as TScope, budget); - } catch (err) { - if (err instanceof BudgetExhaustedError) { - deferred.skippedCycles.push(`${cycle.name}@${orgLogin}`); - continue; - } - // Any other error: record this cycle as errored and move on so the - // run isn't abandoned and remaining cycles/orgs still execute. - erroredCycles.push({ - name: cycle.name, - org: orgLogin, - stage: "fetchLive", - error: err instanceof Error ? err.message : String(err), - }); - continue; - } - - // Step 2: buildDesired (pure). - let desired: OrgConfig; - try { - desired = cycle.buildDesired(orgConfig, orgLogin, scope as TScope); - } catch (err) { - erroredCycles.push({ - name: cycle.name, - org: orgLogin, - stage: "buildDesired", - error: err instanceof Error ? err.message : String(err), - }); - continue; - } - - // Step 3: diff. Inject a default "now" for time-based diffs (token grants) - // unless the caller supplied one (tests pass an explicit value). - const changeSet: ChangeSet = diff(orgLogin, desired, live, { - ...diffOptions, - nowMs: diffOptions.nowMs ?? Date.now(), - }); - - // Step 4: guardrails. - const guardrailResult = runGuardrails(changeSet, live, guardrailConfig); - - // Counts. - const counts = { create: 0, update: 0, delete: 0 }; - for (const e of changeSet.entries) counts[e.kind]++; - - // Plan summary. - const plan = renderChangeSet(changeSet); - - const cycleResult: CycleResult = { - name: cycle.name, - org: orgLogin, - counts, - guardrails: guardrailResult, - applied: [], - failed: [], - plan, - guardrailBlocked: false, - }; - - // Step 5a: dry-run — nothing more to do. - if (mode === "dry-run") { - cycleResults.push(cycleResult); - continue; - } - - // Step 5b: apply. - - // If guardrails failed and override is not set, block the apply. - if (!guardrailResult.ok && !allowGuardrailOverride) { - cycleResult.guardrailBlocked = true; - cycleResults.push(cycleResult); - continue; - } - - // Apply each entry. - for (const entry of changeSet.entries) { - if (budget.exhausted) { - deferred.skippedEntries.push({ cycleName: cycle.name, entry }); - continue; - } - - try { - await cycle.apply(client, entry, orgLogin, scope as TScope, budget); - cycleResult.applied.push(entry); - } catch (err) { - if (err instanceof BudgetExhaustedError) { - deferred.skippedEntries.push({ cycleName: cycle.name, entry }); - continue; - } - cycleResult.failed.push({ - entry, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - cycleResults.push(cycleResult); - } - } - - const completed = - deferred.skippedCycles.length === 0 && - deferred.skippedEntries.length === 0 && - erroredCycles.length === 0; - - return { - mode, - completed, - cycles: cycleResults, - errored: erroredCycles, - deferred, - budgetRemaining: budget.remaining, - }; + return coreRunReconcile({ + client: opts.client, + scopes: opts.config.orgs, + cycles: opts.cycles, + scope: opts.scope, + mode: opts.mode, + diff: (scopeId, desired, live, dopts) => + diff(scopeId, desired, live, { ...dopts, nowMs: dopts.nowMs ?? Date.now() }), + guardrails: (changeSet, live) => runGuardrails(changeSet, live, opts.guardrails ?? {}), + diffOptions: opts.diffOptions, + allowGuardrailOverride: opts.allowGuardrailOverride, + requestBudget: opts.requestBudget, + }); }