From 5b8c5100c5bd887073906f44e103f383df314683 Mon Sep 17 00:00:00 2001 From: lex00 Date: Fri, 19 Jun 2026 08:51:25 -0600 Subject: [PATCH] feat(cycle): rulesets reconcile cycle, repo + org (#9) Adds the rulesets API (separate from classic branch protection): new RulesetConfig on OrgConfig/RepoConfig, LiveRuleset + diffRulesets in the diff (org-ruleset/repo-ruleset resource types, ownership-gated deletes, id captured-not-diffed), and the cycle. fetchLive lists + GETs detail per ruleset; apply POSTs creates and addresses updates/deletes by live id. Completes the npm-publish gate (#5-#9). Registered, exported, action bundle rebuilt. Co-Authored-By: Claude Opus 4.8 --- action/index.mjs | 167 ++++++++++++++- src/cli/registry.ts | 2 + src/config/types.ts | 46 +++++ src/cycles/rulesets.test.ts | 394 ++++++++++++++++++++++++++++++++++++ src/cycles/rulesets.ts | 267 ++++++++++++++++++++++++ src/index.ts | 6 + src/reconcile/diff.ts | 81 ++++++++ 7 files changed, 960 insertions(+), 3 deletions(-) create mode 100644 src/cycles/rulesets.test.ts create mode 100644 src/cycles/rulesets.ts diff --git a/action/index.mjs b/action/index.mjs index 9f2a976..e94fbde 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -274,6 +274,7 @@ var init_app_client = __esm({ function diff(org, desired, live, opts = {}) { const entries = []; diffSettings(desired.settings, live.settings, entries); + diffRulesets("", "org-ruleset", desired.rulesets, live.rulesets ?? [], opts, entries); diffTeams(desired.teams, live.teams ?? {}, opts, entries); diffMembers(desired.members, live.members ?? [], opts, entries); diffRepos(desired.repos, live.repos ?? {}, opts, entries); @@ -485,6 +486,7 @@ function diffRepos(desired, live, opts, out) { }); } diffBranchProtection(name, dr.branchProtection, lr.branchProtection ?? [], opts, out); + diffRulesets(`${name}/`, "repo-ruleset", dr.rulesets, lr.rulesets ?? [], opts, out); } for (const name of Object.keys(live)) { if (!Object.prototype.hasOwnProperty.call(desired, name)) { @@ -553,6 +555,35 @@ function diffBranchProtection(repoName, desired, live, opts, out) { } } } +function diffRulesets(keyPrefix, resourceType, desired, live, opts, out) { + if (desired === void 0) return; + const desiredByName = new Map(desired.map((r) => [r.name, r])); + const liveByName = new Map(live.map((r) => [r.name, r])); + for (const [name, dr] of desiredByName) { + const lr = liveByName.get(name); + const key = `${keyPrefix}${name}`; + if (!lr) { + out.push({ kind: "create", resourceType, key, after: dr }); + continue; + } + const fields = diffObjectKeys( + dr, + lr, + RULESET_FIELDS + ); + if (fields.length > 0) { + out.push({ kind: "update", resourceType, key, before: lr, after: dr, fields }); + } + } + for (const [name, lr] of liveByName) { + if (!desiredByName.has(name)) { + const key = `${keyPrefix}${name}`; + if (opts.isOwned?.(resourceType, key)) { + out.push({ kind: "delete", resourceType, key, before: lr }); + } + } + } +} function diffObject(desired, live) { const fields = []; for (const key of Object.keys(desired)) { @@ -616,19 +647,22 @@ function fmt(v) { const json2 = JSON.stringify(v); return json2.length > 60 ? `${json2.slice(0, 57)}...` : json2; } -var RESOURCE_TYPE_ORDER; +var RESOURCE_TYPE_ORDER, RULESET_FIELDS; var init_diff = __esm({ "src/reconcile/diff.ts"() { "use strict"; RESOURCE_TYPE_ORDER = [ "org-settings", + "org-ruleset", "team", "team-member", "team-repo", "member", "repo", - "branch-protection" + "branch-protection", + "repo-ruleset" ]; + RULESET_FIELDS = ["target", "enforcement", "bypassActors", "conditions", "rules"]; } }); @@ -229788,13 +229822,140 @@ async function applyTeamRepo(client, entry, org, budget) { await client.request("PUT", path, { permission: after.permission }); } +// src/cycles/rulesets.ts +var PER_PAGE3 = 100; +async function fetchRulesets(client, basePath, budget) { + const summaries = []; + let page = 1; + for (; ; ) { + if (budget.exhausted) break; + budget.use(1); + let batch; + try { + batch = await client.request( + "GET", + `${basePath}?per_page=${PER_PAGE3}&page=${page}` + ); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) return []; + throw err; + } + if (!Array.isArray(batch) || batch.length === 0) break; + summaries.push(...batch); + if (batch.length < PER_PAGE3) break; + page++; + } + const out = []; + for (const s of summaries) { + if (!s || typeof s.id !== "number") continue; + if (budget.exhausted) break; + budget.use(1); + const detail = await client.request("GET", `${basePath}/${s.id}`); + out.push(mapRulesetToLive(detail)); + } + return out; +} +function mapRulesetToLive(raw) { + const live = { id: raw.id, name: raw.name }; + if (raw.target != null) live.target = raw.target; + if (raw.enforcement != null) live.enforcement = raw.enforcement; + if (raw.bypass_actors != null) live.bypassActors = raw.bypass_actors; + if (raw.conditions != null) live.conditions = raw.conditions; + if (raw.rules != null) live.rules = raw.rules; + return live; +} +function buildRulesetBody(desired) { + const body = { name: desired.name }; + if (desired.target !== void 0) body.target = desired.target; + if (desired.enforcement !== void 0) body.enforcement = desired.enforcement; + if (desired.bypassActors !== void 0) body.bypass_actors = desired.bypassActors; + if (desired.conditions !== void 0) body.conditions = desired.conditions; + if (desired.rules !== void 0) body.rules = desired.rules; + return body; +} +async function applyRuleset(client, entry, basePath, budget) { + if (entry.kind === "delete") { + const id2 = entry.before?.id; + if (id2 === void 0) { + throw new Error(`rulesets: delete entry "${entry.key}" is missing the live ruleset id`); + } + budget.use(1); + await client.request("DELETE", `${basePath}/${id2}`); + return; + } + const desired = entry.after; + const body = buildRulesetBody(desired); + if (entry.kind === "create") { + budget.use(1); + await client.request("POST", basePath, body); + return; + } + const id = entry.before?.id; + if (id === void 0) { + throw new Error(`rulesets: update entry "${entry.key}" is missing the live ruleset id`); + } + budget.use(1); + await client.request("PUT", `${basePath}/${id}`, body); +} +var rulesetsCycle = { + name: "rulesets", + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + async fetchLive(client, orgLogin, scope, budget) { + if (budget.exhausted) { + const { BudgetExhaustedError: BudgetExhaustedError2 } = await Promise.resolve().then(() => (init_runner(), runner_exports)); + throw new BudgetExhaustedError2(); + } + const orgRulesets = await fetchRulesets(client, `/orgs/${orgLogin}/rulesets`, budget); + const repos = {}; + for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) { + if (repoConfig.rulesets === void 0) continue; + if (budget.exhausted) break; + const rs = await fetchRulesets(client, `/repos/${orgLogin}/${name}/rulesets`, budget); + repos[name] = { rulesets: rs }; + } + return { rulesets: orgRulesets, repos }; + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + const out = {}; + if (orgConfig.rulesets) out.rulesets = orgConfig.rulesets; + if (orgConfig.repos) { + const repos = {}; + for (const [name, repoConfig] of Object.entries(orgConfig.repos)) { + if (repoConfig.rulesets && repoConfig.rulesets.length > 0) { + repos[name] = { rulesets: repoConfig.rulesets }; + } + } + out.repos = repos; + } + return out; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + if (entry.resourceType === "org-ruleset") { + return applyRuleset(client, entry, `/orgs/${orgLogin}/rulesets`, budget); + } + if (entry.resourceType === "repo-ruleset") { + const slashIdx = entry.key.indexOf("/"); + if (slashIdx === -1) { + throw new Error( + `rulesets: malformed repo-ruleset key "${entry.key}" \u2014 expected "/"` + ); + } + const repo = entry.key.slice(0, slashIdx); + return applyRuleset(client, entry, `/repos/${orgLogin}/${repo}/rulesets`, budget); + } + } +}; + // src/cli/registry.ts var CYCLE_REGISTRY = { [branchProtectionCycle.name]: branchProtectionCycle, [orgSettingsCycle.name]: orgSettingsCycle, [repoSettingsCycle.name]: repoSettingsCycle, [membershipCycle.name]: membershipCycle, - [teamsCycle.name]: teamsCycle + [teamsCycle.name]: teamsCycle, + [rulesetsCycle.name]: rulesetsCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 4156135..b50d654 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -14,6 +14,7 @@ import { orgSettingsCycle } from "../cycles/org-settings.js"; import { repoSettingsCycle } from "../cycles/repo-settings.js"; import { membershipCycle } from "../cycles/membership.js"; import { teamsCycle } from "../cycles/teams.js"; +import { rulesetsCycle } from "../cycles/rulesets.js"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -28,4 +29,5 @@ export const CYCLE_REGISTRY: Record = { [repoSettingsCycle.name]: repoSettingsCycle, [membershipCycle.name]: membershipCycle, [teamsCycle.name]: teamsCycle, + [rulesetsCycle.name]: rulesetsCycle, }; diff --git a/src/config/types.ts b/src/config/types.ts index f153b15..384d131 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -100,6 +100,42 @@ export interface MemberConfig { role?: OrgMemberRole; } +// --------------------------------------------------------------------------- +// Rulesets (repo + org) +// --------------------------------------------------------------------------- + +/** What a ruleset targets. */ +export type RulesetTarget = "branch" | "tag" | "push"; + +/** How a ruleset is enforced. */ +export type RulesetEnforcement = "active" | "evaluate" | "disabled"; + +/** + * A repository or organization ruleset — the modern replacement for classic + * branch protection (a separate REST API). Identified within its scope by + * `name`. Absent fields are not managed (selective-by-omission). + * + * `bypassActors`, `conditions`, and `rules` are passed through in GitHub's + * native (snake_case) JSON shape — e.g. a rule is `{ type, parameters? }`, a + * condition is `{ ref_name: { include, exclude } }`, a bypass actor is + * `{ actor_id, actor_type, bypass_mode }`. Authoring these mirrors the GitHub + * API request body so the cycle can forward them verbatim. + */ +export interface RulesetConfig { + /** Ruleset name — the identity key within its scope (org or repo). */ + name: string; + /** Target ref type. GitHub defaults to "branch" on create when omitted. */ + target?: RulesetTarget; + /** Enforcement level. */ + enforcement?: RulesetEnforcement; + /** Bypass actors, GitHub-native shape: `{ actor_id, actor_type, bypass_mode }`. */ + bypassActors?: Array>; + /** Conditions, GitHub-native shape: `{ ref_name: { include, exclude }, ... }`. */ + conditions?: Record; + /** Rules, GitHub-native shape: `[{ type, parameters? }]`. */ + rules?: Array>; +} + // --------------------------------------------------------------------------- // Repos // --------------------------------------------------------------------------- @@ -166,6 +202,11 @@ export interface RepoConfig { * Absent means topics are not managed by chant. */ topics?: string[]; + /** + * Repository rulesets (the modern branch-protection replacement). + * Absent means repo rulesets are not managed by chant. + */ + rulesets?: RulesetConfig[]; } // --------------------------------------------------------------------------- @@ -200,6 +241,11 @@ export interface OrgConfig { * Absent means repositories are not managed by chant. */ repos?: Record; + /** + * Organization-level rulesets. + * Absent means org rulesets are not managed by chant. + */ + rulesets?: RulesetConfig[]; } /** diff --git a/src/cycles/rulesets.test.ts b/src/cycles/rulesets.test.ts new file mode 100644 index 0000000..e0fc5bd --- /dev/null +++ b/src/cycles/rulesets.test.ts @@ -0,0 +1,394 @@ +/** + * Tests for the rulesets cycle (repo + org). + * + * All tests use a mock AppClient — no network calls. + * Coverage: + * - buildDesired: keeps org + repo rulesets; omits bare repos + * - fetchRulesets: list + detail mapping; 404 → empty; budget + * - diff over the cycle: org-ruleset + repo-ruleset create/update/delete + * - apply: POST create; PUT update by id; DELETE by id; foreign skip + * - runner integration: dry-run plan + */ + +import { describe, it, expect } from "vitest"; +import { + rulesetsCycle, + fetchRulesets, + buildRulesetBody, + mapRulesetToLive, +} from "./rulesets.js"; +import type { RulesetsScope } from "./rulesets.js"; +import type { AppClient } from "../auth/app-client.js"; +import type { RateBudget } from "../reconcile/runner.js"; +import { runReconcile, BudgetExhaustedError } from "../reconcile/runner.js"; +import { diff } from "../reconcile/diff.js"; +import type { LiveOrgState } from "../reconcile/diff.js"; +import type { GovernanceConfig, OrgConfig } from "../config/types.js"; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +interface MockCall { + method: string; + path: string; + body?: unknown; +} + +interface MockClient extends AppClient { + calls: MockCall[]; + responses: Map; +} + +function makeMockClient(responses: Record = {}): MockClient { + const calls: MockCall[] = []; + const responseMap = new Map(Object.entries(responses)); + return { + calls, + responses: responseMap, + async request(method: string, path: string, body?: unknown): Promise { + calls.push({ method, path, body }); + const key = `${method} ${path}`; + if (responseMap.has(key)) return responseMap.get(key) as T; + if (method === "GET" && path.includes("/rulesets?")) return [] as T; + return {} as T; + }, + }; +} + +function makeBudget(initial = 100): RateBudget { + let remaining = initial; + return { + get remaining() { + return remaining; + }, + get exhausted() { + return remaining <= 0; + }, + use(n = 1) { + if (remaining <= 0) throw new BudgetExhaustedError(); + remaining = Math.max(0, remaining - n); + }, + }; +} + +const scope: RulesetsScope = {}; + +const sampleRule = { type: "pull_request", parameters: { required_approving_review_count: 1 } }; + +// --------------------------------------------------------------------------- +// 1. buildDesired +// --------------------------------------------------------------------------- + +describe("rulesetsCycle.buildDesired", () => { + it("keeps org rulesets and repo rulesets, omitting repos without them", () => { + const orgConfig: OrgConfig = { + rulesets: [{ name: "org-main", enforcement: "active" }], + repos: { + svc: { rulesets: [{ name: "svc-main", enforcement: "active" }], description: "x" }, + bare: { description: "no rulesets" }, + }, + }; + const desired = rulesetsCycle.buildDesired(orgConfig, "test-org", scope); + expect(desired.rulesets).toEqual([{ name: "org-main", enforcement: "active" }]); + expect(desired.repos!["svc"]).toEqual({ rulesets: [{ name: "svc-main", enforcement: "active" }] }); + expect(desired.repos).not.toHaveProperty("bare"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. fetchRulesets / mapRulesetToLive +// --------------------------------------------------------------------------- + +describe("fetchRulesets", () => { + it("lists then fetches detail and maps to LiveRuleset", async () => { + const client = makeMockClient({ + "GET /orgs/test-org/rulesets?per_page=100&page=1": [{ id: 7, name: "org-main" }], + "GET /orgs/test-org/rulesets/7": { + id: 7, + name: "org-main", + target: "branch", + enforcement: "active", + bypass_actors: [{ actor_id: 1, actor_type: "Team", bypass_mode: "always" }], + conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } }, + rules: [sampleRule], + }, + }); + const live = await fetchRulesets(client, "/orgs/test-org/rulesets", makeBudget()); + expect(live).toHaveLength(1); + expect(live[0]).toEqual({ + id: 7, + name: "org-main", + target: "branch", + enforcement: "active", + bypassActors: [{ actor_id: 1, actor_type: "Team", bypass_mode: "always" }], + conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } }, + rules: [sampleRule], + }); + }); + + it("returns empty on a 404", async () => { + const client: MockClient = makeMockClient(); + client.request = async (method: string, path: string): Promise => { + client.calls.push({ method, path }); + throw new Error("GET ... returned 404: Not Found"); + }; + const live = await fetchRulesets(client, "/repos/test-org/ghost/rulesets", makeBudget()); + expect(live).toEqual([]); + }); + + it("charges the budget: one list page + one detail per ruleset", async () => { + const client = makeMockClient({ + "GET /orgs/test-org/rulesets?per_page=100&page=1": [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ], + "GET /orgs/test-org/rulesets/1": { id: 1, name: "a" }, + "GET /orgs/test-org/rulesets/2": { id: 2, name: "b" }, + }); + const budget = makeBudget(10); + await fetchRulesets(client, "/orgs/test-org/rulesets", budget); + expect(budget.remaining).toBe(7); // 1 list + 2 detail + }); + + it("maps a minimal detail (only id + name)", () => { + expect(mapRulesetToLive({ id: 3, name: "x" })).toEqual({ id: 3, name: "x" }); + }); +}); + +// --------------------------------------------------------------------------- +// 3. buildRulesetBody +// --------------------------------------------------------------------------- + +describe("buildRulesetBody", () => { + it("maps declared fields to the GitHub body shape", () => { + expect( + buildRulesetBody({ + name: "main", + target: "branch", + enforcement: "active", + bypassActors: [{ actor_id: 5 }], + conditions: { ref_name: { include: ["main"], exclude: [] } }, + rules: [sampleRule], + }), + ).toEqual({ + name: "main", + target: "branch", + enforcement: "active", + bypass_actors: [{ actor_id: 5 }], + conditions: { ref_name: { include: ["main"], exclude: [] } }, + rules: [sampleRule], + }); + }); + + it("emits only name when nothing else declared", () => { + expect(buildRulesetBody({ name: "x" })).toEqual({ name: "x" }); + }); +}); + +// --------------------------------------------------------------------------- +// 4. diff over the cycle +// --------------------------------------------------------------------------- + +describe("diff integration with rulesets cycle", () => { + it("emits org-ruleset and repo-ruleset creates", () => { + const desired = rulesetsCycle.buildDesired( + { + rulesets: [{ name: "org-main", enforcement: "active" }], + repos: { svc: { rulesets: [{ name: "svc-main", enforcement: "active" }] } }, + }, + "test-org", + scope, + ); + // live has the repo (so the ruleset diff runs) but no rulesets yet + const live: LiveOrgState = { rulesets: [], repos: { svc: { rulesets: [] } } }; + const cs = diff("test-org", desired, live); + const byType = cs.entries.map((e) => `${e.resourceType}:${e.key}`); + expect(byType).toContain("org-ruleset:org-main"); + expect(byType).toContain("repo-ruleset:svc/svc-main"); + }); + + it("emits update when a ruleset field differs", () => { + const desired = rulesetsCycle.buildDesired( + { rulesets: [{ name: "org-main", enforcement: "active" }] }, + "test-org", + scope, + ); + const live: LiveOrgState = { rulesets: [{ id: 9, name: "org-main", enforcement: "disabled" }] }; + const cs = diff("test-org", desired, live); + expect(cs.entries).toHaveLength(1); + expect(cs.entries[0]!.kind).toBe("update"); + expect((cs.entries[0]!.before as { id?: number }).id).toBe(9); + }); + + it("does not diff the live id (no spurious update)", () => { + const desired = rulesetsCycle.buildDesired( + { rulesets: [{ name: "org-main", enforcement: "active" }] }, + "test-org", + scope, + ); + const live: LiveOrgState = { rulesets: [{ id: 9, name: "org-main", enforcement: "active" }] }; + expect(diff("test-org", desired, live).entries).toHaveLength(0); + }); + + it("emits ownership-gated delete for an unmanaged org ruleset", () => { + const desired = rulesetsCycle.buildDesired( + { rulesets: [{ name: "keep", enforcement: "active" }] }, + "test-org", + scope, + ); + const live: LiveOrgState = { + rulesets: [ + { id: 1, name: "keep", enforcement: "active" }, + { id: 2, name: "stray", enforcement: "active" }, + ], + }; + expect(diff("test-org", desired, live).entries).toHaveLength(0); // no predicate + const owned = diff("test-org", desired, live, { isOwned: (_t, k) => k === "stray" }); + const del = owned.entries.find((e) => e.kind === "delete")!; + expect(del.resourceType).toBe("org-ruleset"); + expect((del.before as { id?: number }).id).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// 5. apply +// --------------------------------------------------------------------------- + +describe("rulesetsCycle.apply", () => { + it("POSTs an org-ruleset create", async () => { + const client = makeMockClient(); + await rulesetsCycle.apply( + client, + { kind: "create", resourceType: "org-ruleset", key: "org-main", after: { name: "org-main", enforcement: "active" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("POST"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/rulesets"); + expect(client.calls[0]!.body).toEqual({ name: "org-main", enforcement: "active" }); + }); + + it("PUTs an org-ruleset update by live id", async () => { + const client = makeMockClient(); + await rulesetsCycle.apply( + client, + { + kind: "update", + resourceType: "org-ruleset", + key: "org-main", + before: { id: 11, name: "org-main" }, + after: { name: "org-main", enforcement: "active" }, + fields: [], + }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("PUT"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/rulesets/11"); + }); + + it("DELETEs an org-ruleset by live id", async () => { + const client = makeMockClient(); + await rulesetsCycle.apply( + client, + { kind: "delete", resourceType: "org-ruleset", key: "stray", before: { id: 22, name: "stray" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("DELETE"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/rulesets/22"); + }); + + it("routes repo-ruleset to the repo path", async () => { + const client = makeMockClient(); + await rulesetsCycle.apply( + client, + { kind: "create", resourceType: "repo-ruleset", key: "svc/svc-main", after: { name: "svc-main", enforcement: "active" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.path).toBe("/repos/my-org/svc/rulesets"); + expect(client.calls[0]!.body).toEqual({ name: "svc-main", enforcement: "active" }); + }); + + it("throws when an update is missing the live id", async () => { + const client = makeMockClient(); + await expect( + rulesetsCycle.apply( + client, + { kind: "update", resourceType: "org-ruleset", key: "x", after: { name: "x" }, fields: [] }, + "my-org", + scope, + makeBudget(), + ), + ).rejects.toThrow("missing the live ruleset id"); + }); + + it("throws on a malformed repo-ruleset key", async () => { + const client = makeMockClient(); + await expect( + rulesetsCycle.apply( + client, + { kind: "create", resourceType: "repo-ruleset", key: "no-slash", after: { name: "x" } }, + "my-org", + scope, + makeBudget(), + ), + ).rejects.toThrow("malformed repo-ruleset key"); + }); + + it("ignores foreign resource types", async () => { + const client = makeMockClient(); + await rulesetsCycle.apply( + client, + { kind: "create", resourceType: "branch-protection", key: "svc/main", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Runner integration +// --------------------------------------------------------------------------- + +describe("rulesetsCycle via runReconcile", () => { + it("dry-run: reports an org-ruleset create plan", async () => { + const client = makeMockClient({ + "GET /orgs/test-org/rulesets?per_page=100&page=1": [], + }); + const config: GovernanceConfig = { + orgs: { "test-org": { rulesets: [{ name: "org-main", enforcement: "active" }] } }, + }; + const result = await runReconcile({ config, client, cycles: [rulesetsCycle], mode: "dry-run" }); + expect(result.completed).toBe(true); + expect(result.cycles[0]!.counts.create).toBe(1); + expect(client.calls.every((c) => c.method === "GET")).toBe(true); + }); + + it("apply: POSTs the org-ruleset after listing", async () => { + const client = makeMockClient({ + "GET /orgs/test-org/rulesets?per_page=100&page=1": [], + }); + const config: GovernanceConfig = { + orgs: { "test-org": { rulesets: [{ name: "org-main", enforcement: "active" }] } }, + }; + const result = await runReconcile({ + config, + client, + cycles: [rulesetsCycle], + mode: "apply", + allowGuardrailOverride: true, // no members in fixture → adminFloor would block + }); + expect(result.completed).toBe(true); + expect(result.cycles[0]!.applied).toHaveLength(1); + expect(client.calls.find((c) => c.method === "POST")!.path).toBe("/orgs/test-org/rulesets"); + }); +}); diff --git a/src/cycles/rulesets.ts b/src/cycles/rulesets.ts new file mode 100644 index 0000000..7d3d16e --- /dev/null +++ b/src/cycles/rulesets.ts @@ -0,0 +1,267 @@ +/** + * Rulesets cycle (repo + org). + * + * Reconciles organization and repository rulesets — the modern replacement for + * classic branch protection, a SEPARATE REST API. Rulesets are identified by + * name within their scope; GitHub assigns a numeric id used to address them for + * update/delete. + * + * GET /orgs/{org}/rulesets — list org rulesets + * GET /orgs/{org}/rulesets/{id} — org ruleset detail + * POST /orgs/{org}/rulesets — create org ruleset + * PUT /orgs/{org}/rulesets/{id} — update org ruleset + * DELETE /orgs/{org}/rulesets/{id} — delete org ruleset + * …and the analogous /repos/{owner}/{repo}/rulesets endpoints. + * + * Follows the four-part `Cycle` structure of the branch-protection template + * (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * ## RMW: preserve undeclared rules + * + * The ruleset PUT is a full replacement of the ruleset body, but + * selective-by-omission here operates at the WHOLE-RULESET granularity: a + * ruleset absent from config is never touched, and within a managed ruleset the + * declared `rules`/`conditions`/`bypassActors` lists are the source of truth + * for that ruleset. We send the declared body verbatim; we do not merge + * individual rules from live (a ruleset is authored as a unit). Undeclared + * rulesets — the thing selective-by-omission protects — are left entirely alone. + * + * ## Scope + * + * Org rulesets are fetched by `orgLogin`. Repo rulesets are fetched for the + * repos in `scope.repos` that declare `rulesets` (the branch-protection scope + * pattern). Each ruleset costs one list page plus one detail GET so the diff + * can compare full `rules`/`conditions`/`bypassActors`. + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { OrgConfig, RepoConfig, RulesetConfig } from "../config/types.js"; +import type { ChangeSetEntry, LiveOrgState, LiveRuleset } from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** + * Scope for the rulesets cycle. Pass `repos` (typically `orgConfig.repos`) so + * repo rulesets are fetched for repos that declare them. Org rulesets are + * always fetched via `orgLogin`. + */ +export interface RulesetsScope { + repos?: Record; +} + +// --------------------------------------------------------------------------- +// GitHub REST API response shapes (only the fields we read) +// --------------------------------------------------------------------------- + +interface GhRulesetSummary { + id: number; + name: string; +} + +interface GhRulesetDetail { + id: number; + name: string; + target?: string | null; + enforcement?: string | null; + bypass_actors?: Array> | null; + conditions?: Record | null; + rules?: Array> | null; +} + +const PER_PAGE = 100; + +// --------------------------------------------------------------------------- +// Live-state fetch +// --------------------------------------------------------------------------- + +/** + * Fetch all rulesets under a base path (`/orgs/{org}/rulesets` or + * `/repos/{o}/{r}/rulesets`): list (paginated) then GET each ruleset's detail + * so `rules`/`conditions`/`bypassActors` are populated for diffing. Charges the + * budget per request and stops when exhausted. A 404 (rulesets unsupported / + * repo missing) yields an empty list. + */ +export async function fetchRulesets( + client: AppClient, + basePath: string, + budget: RateBudget, +): Promise { + // 1. List (paginated). + const summaries: GhRulesetSummary[] = []; + let page = 1; + for (;;) { + if (budget.exhausted) break; + budget.use(1); + let batch: GhRulesetSummary[]; + try { + batch = await client.request( + "GET", + `${basePath}?per_page=${PER_PAGE}&page=${page}`, + ); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) return []; + throw err; + } + if (!Array.isArray(batch) || batch.length === 0) break; + summaries.push(...batch); + if (batch.length < PER_PAGE) break; + page++; + } + + // 2. Detail per ruleset. + const out: LiveRuleset[] = []; + for (const s of summaries) { + if (!s || typeof s.id !== "number") continue; + if (budget.exhausted) break; + budget.use(1); + const detail = await client.request("GET", `${basePath}/${s.id}`); + out.push(mapRulesetToLive(detail)); + } + + return out; +} + +/** Map a GitHub ruleset detail response to the `LiveRuleset` diff shape. */ +export function mapRulesetToLive(raw: GhRulesetDetail): LiveRuleset { + const live: LiveRuleset = { id: raw.id, name: raw.name }; + if (raw.target != null) live.target = raw.target; + if (raw.enforcement != null) live.enforcement = raw.enforcement; + if (raw.bypass_actors != null) live.bypassActors = raw.bypass_actors; + if (raw.conditions != null) live.conditions = raw.conditions; + if (raw.rules != null) live.rules = raw.rules; + return live; +} + +// --------------------------------------------------------------------------- +// Apply helpers +// --------------------------------------------------------------------------- + +/** Build the create/update body for a ruleset from declared fields. */ +export function buildRulesetBody(desired: RulesetConfig): Record { + const body: Record = { name: desired.name }; + if (desired.target !== undefined) body.target = desired.target; + if (desired.enforcement !== undefined) body.enforcement = desired.enforcement; + if (desired.bypassActors !== undefined) body.bypass_actors = desired.bypassActors; + if (desired.conditions !== undefined) body.conditions = desired.conditions; + if (desired.rules !== undefined) body.rules = desired.rules; + return body; +} + +/** Apply one ruleset change against a base path. */ +async function applyRuleset( + client: AppClient, + entry: ChangeSetEntry, + basePath: string, + budget: RateBudget, +): Promise { + if (entry.kind === "delete") { + const id = (entry.before as LiveRuleset | undefined)?.id; + if (id === undefined) { + throw new Error(`rulesets: delete entry "${entry.key}" is missing the live ruleset id`); + } + budget.use(1); + await client.request("DELETE", `${basePath}/${id}`); + return; + } + + const desired = entry.after as RulesetConfig; + const body = buildRulesetBody(desired); + + if (entry.kind === "create") { + budget.use(1); + await client.request("POST", basePath, body); + return; + } + + // update — address the ruleset by its live id. + const id = (entry.before as LiveRuleset | undefined)?.id; + if (id === undefined) { + throw new Error(`rulesets: update entry "${entry.key}" is missing the live ruleset id`); + } + budget.use(1); + await client.request("PUT", `${basePath}/${id}`, body); +} + +// --------------------------------------------------------------------------- +// rulesetsCycle — implements Cycle +// --------------------------------------------------------------------------- + +export const rulesetsCycle: Cycle = { + name: "rulesets", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + scope: RulesetsScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + const orgRulesets = await fetchRulesets(client, `/orgs/${orgLogin}/rulesets`, budget); + + const repos: NonNullable = {}; + for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) { + if (repoConfig.rulesets === undefined) continue; + if (budget.exhausted) break; + const rs = await fetchRulesets(client, `/repos/${orgLogin}/${name}/rulesets`, budget); + repos[name] = { rulesets: rs }; + } + + return { rulesets: orgRulesets, repos }; + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: RulesetsScope): OrgConfig { + const out: OrgConfig = {}; + if (orgConfig.rulesets) out.rulesets = orgConfig.rulesets; + + if (orgConfig.repos) { + const repos: Record = {}; + for (const [name, repoConfig] of Object.entries(orgConfig.repos)) { + if (repoConfig.rulesets && repoConfig.rulesets.length > 0) { + repos[name] = { rulesets: repoConfig.rulesets }; + } + } + out.repos = repos; + } + + return out; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: RulesetsScope, + budget: RateBudget, + ): Promise { + if (entry.resourceType === "org-ruleset") { + return applyRuleset(client, entry, `/orgs/${orgLogin}/rulesets`, budget); + } + + if (entry.resourceType === "repo-ruleset") { + // key format: "/" + const slashIdx = entry.key.indexOf("/"); + if (slashIdx === -1) { + throw new Error( + `rulesets: malformed repo-ruleset key "${entry.key}" — expected "/"`, + ); + } + const repo = entry.key.slice(0, slashIdx); + return applyRuleset(client, entry, `/repos/${orgLogin}/${repo}/rulesets`, budget); + } + + // Not ours — ignore. + }, +}; diff --git a/src/index.ts b/src/index.ts index 758877c..b16a258 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,9 @@ export type { OrgMemberRole, RepoConfig, BranchProtectionConfig, + RulesetConfig, + RulesetTarget, + RulesetEnforcement, } from "./config/types.js"; // Config loader @@ -35,6 +38,7 @@ export type { LiveMemberConfig, LiveBranchProtectionConfig, LiveRepoConfig, + LiveRuleset, LiveOrgState, } from "./reconcile/diff.js"; export { diff, summarizeChangeSet, renderChangeSet } from "./reconcile/diff.js"; @@ -79,6 +83,8 @@ export { membershipCycle, listOrgMembers } from "./cycles/membership.js"; export type { MembershipScope } from "./cycles/membership.js"; export { teamsCycle, mapTeamRepoPermission } from "./cycles/teams.js"; export type { TeamsScope } from "./cycles/teams.js"; +export { rulesetsCycle, fetchRulesets, buildRulesetBody, mapRulesetToLive } from "./cycles/rulesets.js"; +export type { RulesetsScope } from "./cycles/rulesets.js"; // Reconcile: dump (export live state to desired-state config) export type { DumpOrgOptions, DumpResult } from "./reconcile/dump.js"; diff --git a/src/reconcile/diff.ts b/src/reconcile/diff.ts index 2c25045..d51283d 100644 --- a/src/reconcile/diff.ts +++ b/src/reconcile/diff.ts @@ -19,6 +19,7 @@ import type { MemberConfig, RepoConfig, BranchProtectionConfig, + RulesetConfig, } from "../config/types.js"; // --------------------------------------------------------------------------- @@ -153,6 +154,22 @@ export interface LiveBranchProtectionConfig { enforceAdmins?: boolean; } +/** + * Live snapshot of a single ruleset. Mirrors `RulesetConfig` plus the GitHub + * numeric `id` (not a desired field, so never diffed) which the apply path + * needs to address the ruleset for update/delete. + */ +export interface LiveRuleset { + /** GitHub-assigned ruleset id. Captured for apply (PUT/DELETE), never diffed. */ + id?: number; + name: string; + target?: string; + enforcement?: string; + bypassActors?: Array>; + conditions?: Record; + rules?: Array>; +} + export interface LiveRepoConfig { description?: string; websiteUrl?: string; @@ -167,6 +184,7 @@ export interface LiveRepoConfig { deleteBranchOnMerge?: boolean; branchProtection?: LiveBranchProtectionConfig[]; topics?: string[]; + rulesets?: LiveRuleset[]; } export interface LiveOrgState { @@ -174,6 +192,7 @@ export interface LiveOrgState { teams?: Record; members?: LiveMemberConfig[]; repos?: Record; + rulesets?: LiveRuleset[]; } // --------------------------------------------------------------------------- @@ -182,12 +201,14 @@ export interface LiveOrgState { const RESOURCE_TYPE_ORDER = [ "org-settings", + "org-ruleset", "team", "team-member", "team-repo", "member", "repo", "branch-protection", + "repo-ruleset", ] as const; // --------------------------------------------------------------------------- @@ -211,6 +232,7 @@ export function diff( const entries: ChangeSetEntry[] = []; diffSettings(desired.settings, live.settings, entries); + diffRulesets("", "org-ruleset", desired.rulesets, live.rulesets ?? [], opts, entries); diffTeams(desired.teams, live.teams ?? {}, opts, entries); diffMembers(desired.members, live.members ?? [], opts, entries); diffRepos(desired.repos, live.repos ?? {}, opts, entries); @@ -497,6 +519,9 @@ function diffRepos( // Branch protection rules diffBranchProtection(name, dr.branchProtection, lr.branchProtection ?? [], opts, out); + + // Repository rulesets + diffRulesets(`${name}/`, "repo-ruleset", dr.rulesets, lr.rulesets ?? [], opts, out); } for (const name of Object.keys(live)) { @@ -579,6 +604,62 @@ function diffBranchProtection( } } +// --------------------------------------------------------------------------- +// Rulesets (org + repo) +// --------------------------------------------------------------------------- + +const RULESET_FIELDS: string[] = ["target", "enforcement", "bypassActors", "conditions", "rules"]; + +/** + * Diff a list of rulesets (org-level or repo-level), keyed by ruleset name. + * + * @param keyPrefix - "" for org rulesets, "/" for repo rulesets. + * @param resourceType - "org-ruleset" or "repo-ruleset". + * + * The live `id` is not part of `RULESET_FIELDS`, so it is never diffed; it is + * carried on the `before` snapshot for the apply path. Deletes are ownership- + * gated like every other managed collection. + */ +function diffRulesets( + keyPrefix: string, + resourceType: "org-ruleset" | "repo-ruleset", + desired: RulesetConfig[] | undefined, + live: LiveRuleset[], + opts: DiffOptions, + out: ChangeSetEntry[], +): void { + if (desired === undefined) return; + + const desiredByName = new Map(desired.map((r) => [r.name, r])); + const liveByName = new Map(live.map((r) => [r.name, r])); + + for (const [name, dr] of desiredByName) { + const lr = liveByName.get(name); + const key = `${keyPrefix}${name}`; + if (!lr) { + out.push({ kind: "create", resourceType, key, after: dr }); + continue; + } + const fields = diffObjectKeys( + dr as unknown as Record, + lr as unknown as Record, + RULESET_FIELDS, + ); + if (fields.length > 0) { + out.push({ kind: "update", resourceType, key, before: lr, after: dr, fields }); + } + } + + for (const [name, lr] of liveByName) { + if (!desiredByName.has(name)) { + const key = `${keyPrefix}${name}`; + if (opts.isOwned?.(resourceType, key)) { + out.push({ kind: "delete", resourceType, key, before: lr }); + } + } + } +} + // --------------------------------------------------------------------------- // Object-level field diffing helpers // ---------------------------------------------------------------------------