diff --git a/action/index.mjs b/action/index.mjs index 2c00c54..2ea9684 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -229338,9 +229338,95 @@ async function fetchLiveForOrg(client, orgLogin, repos, budget) { return { repos: liveRepos }; } +// src/cycles/org-settings.ts +var VALID_DEFAULT_PERMISSIONS = /* @__PURE__ */ new Set(["none", "read", "write", "admin"]); +function mapOrgToLive(raw) { + const live = {}; + if (raw.description != null) live.description = raw.description; + if (raw.email != null) live.email = raw.email; + if (raw.blog != null) live.websiteUrl = raw.blog; + if (raw.default_repository_permission != null && VALID_DEFAULT_PERMISSIONS.has(raw.default_repository_permission)) { + live.defaultRepositoryPermission = raw.default_repository_permission; + } + if (typeof raw.members_can_create_public_repositories === "boolean") { + live.membersCanCreatePublicRepositories = raw.members_can_create_public_repositories; + } + if (typeof raw.members_can_create_private_repositories === "boolean") { + live.membersCanCreatePrivateRepositories = raw.members_can_create_private_repositories; + } + if (typeof raw.members_can_create_internal_repositories === "boolean") { + live.membersCanCreateInternalRepositories = raw.members_can_create_internal_repositories; + } + if (typeof raw.two_factor_requirement_enabled === "boolean") { + live.requireTwoFactorAuthentication = raw.two_factor_requirement_enabled; + } + return live; +} +function buildOrgPatchBody(desired) { + const body = {}; + if (desired.description !== void 0) body.description = desired.description; + if (desired.email !== void 0) body.email = desired.email; + if (desired.websiteUrl !== void 0) body.blog = desired.websiteUrl; + if (desired.defaultRepositoryPermission !== void 0) { + body.default_repository_permission = desired.defaultRepositoryPermission; + } + if (desired.membersCanCreatePublicRepositories !== void 0) { + body.members_can_create_public_repositories = desired.membersCanCreatePublicRepositories; + } + if (desired.membersCanCreatePrivateRepositories !== void 0) { + body.members_can_create_private_repositories = desired.membersCanCreatePrivateRepositories; + } + if (desired.membersCanCreateInternalRepositories !== void 0) { + body.members_can_create_internal_repositories = desired.membersCanCreateInternalRepositories; + } + if (desired.requireTwoFactorAuthentication !== void 0) { + body.two_factor_requirement_enabled = desired.requireTwoFactorAuthentication; + } + return body; +} +var orgSettingsCycle = { + name: "org-settings", + // ── 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(); + } + budget.use(1); + let raw; + try { + raw = await client.request("GET", `/orgs/${orgLogin}`); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + return {}; + } + throw err; + } + return { settings: mapOrgToLive(raw) }; + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + if (!orgConfig.settings) return {}; + return { settings: orgConfig.settings }; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + if (entry.resourceType !== "org-settings") { + return; + } + if (entry.kind === "delete") return; + const desired = entry.after; + const body = buildOrgPatchBody(desired); + if (Object.keys(body).length === 0) return; + budget.use(1); + await client.request("PATCH", `/orgs/${orgLogin}`, body); + } +}; + // src/cli/registry.ts var CYCLE_REGISTRY = { - [branchProtectionCycle.name]: branchProtectionCycle + [branchProtectionCycle.name]: branchProtectionCycle, + [orgSettingsCycle.name]: orgSettingsCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index d00b29f..4565e91 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -10,6 +10,7 @@ import type { Cycle } from "../reconcile/runner.js"; import { branchProtectionCycle } from "../cycles/branch-protection.js"; +import { orgSettingsCycle } from "../cycles/org-settings.js"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -20,4 +21,5 @@ import { branchProtectionCycle } from "../cycles/branch-protection.js"; */ export const CYCLE_REGISTRY: Record = { [branchProtectionCycle.name]: branchProtectionCycle, + [orgSettingsCycle.name]: orgSettingsCycle, }; diff --git a/src/cycles/org-settings.test.ts b/src/cycles/org-settings.test.ts new file mode 100644 index 0000000..a49b51a --- /dev/null +++ b/src/cycles/org-settings.test.ts @@ -0,0 +1,387 @@ +/** + * Tests for the org-settings cycle. + * + * All tests use a mock AppClient — no network calls. + * Coverage: + * - buildDesired: strips non-settings fields; omits when settings absent + * - fetchLive: maps GitHub org GET response → LiveOrgSettings; 404 → empty + * - diff over the cycle: create / update / no-op entries + * - apply: PATCH with only declared fields; ignores foreign / delete entries + * - runner integration: dry-run plan + apply + */ + +import { describe, it, expect } from "vitest"; +import { orgSettingsCycle, buildOrgPatchBody } from "./org-settings.js"; +import type { OrgSettingsScope } from "./org-settings.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; + 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: OrgSettingsScope = {}; + +// --------------------------------------------------------------------------- +// 1. buildDesired +// --------------------------------------------------------------------------- + +describe("orgSettingsCycle.buildDesired", () => { + it("returns empty config when settings are absent", () => { + const orgConfig: OrgConfig = { repos: { a: { description: "x" } } }; + const desired = orgSettingsCycle.buildDesired(orgConfig, "test-org", scope); + expect(desired.settings).toBeUndefined(); + expect(desired.repos).toBeUndefined(); + }); + + it("keeps only the settings block, stripping other domains", () => { + const orgConfig: OrgConfig = { + settings: { defaultRepositoryPermission: "read" }, + teams: { backend: {} }, + repos: { a: {} }, + }; + const desired = orgSettingsCycle.buildDesired(orgConfig, "test-org", scope); + expect(desired).toEqual({ settings: { defaultRepositoryPermission: "read" } }); + }); +}); + +// --------------------------------------------------------------------------- +// 2. buildOrgPatchBody +// --------------------------------------------------------------------------- + +describe("buildOrgPatchBody", () => { + it("maps declared fields to GitHub PATCH keys", () => { + const body = buildOrgPatchBody({ + description: "desc", + email: "ops@example.com", + websiteUrl: "https://example.com", + defaultRepositoryPermission: "read", + membersCanCreatePublicRepositories: false, + membersCanCreatePrivateRepositories: true, + membersCanCreateInternalRepositories: false, + requireTwoFactorAuthentication: true, + }); + expect(body).toEqual({ + description: "desc", + email: "ops@example.com", + blog: "https://example.com", + default_repository_permission: "read", + members_can_create_public_repositories: false, + members_can_create_private_repositories: true, + members_can_create_internal_repositories: false, + two_factor_requirement_enabled: true, + }); + }); + + it("omits undeclared fields (selective-by-omission)", () => { + const body = buildOrgPatchBody({ defaultRepositoryPermission: "none" }); + expect(body).toEqual({ default_repository_permission: "none" }); + }); + + it("returns an empty body when nothing is declared", () => { + expect(buildOrgPatchBody({})).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// 3. fetchLive — mapping +// --------------------------------------------------------------------------- + +describe("orgSettingsCycle.fetchLive", () => { + it("maps the GitHub org response to LiveOrgSettings", async () => { + const client = makeMockClient({ + "GET /orgs/test-org": { + description: "Acme Corp", + email: "ops@acme.test", + blog: "https://acme.test", + default_repository_permission: "write", + members_can_create_public_repositories: false, + members_can_create_private_repositories: true, + members_can_create_internal_repositories: false, + two_factor_requirement_enabled: true, + }, + }); + + const live = await orgSettingsCycle.fetchLive(client, "test-org", scope, makeBudget()); + expect(live.settings).toEqual({ + description: "Acme Corp", + email: "ops@acme.test", + websiteUrl: "https://acme.test", + defaultRepositoryPermission: "write", + membersCanCreatePublicRepositories: false, + membersCanCreatePrivateRepositories: true, + membersCanCreateInternalRepositories: false, + requireTwoFactorAuthentication: true, + }); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]!.method).toBe("GET"); + expect(client.calls[0]!.path).toBe("/orgs/test-org"); + }); + + it("ignores null/absent fields and invalid permission values", async () => { + const client = makeMockClient({ + "GET /orgs/test-org": { + description: null, + blog: null, + default_repository_permission: "bogus", + }, + }); + const live = await orgSettingsCycle.fetchLive(client, "test-org", scope, makeBudget()); + expect(live.settings).toEqual({}); + }); + + it("charges the budget once", async () => { + const client = makeMockClient({ "GET /orgs/test-org": {} }); + const budget = makeBudget(5); + await orgSettingsCycle.fetchLive(client, "test-org", scope, budget); + expect(budget.remaining).toBe(4); + }); + + it("treats a 404 as empty live state", async () => { + const client: MockClient = makeMockClient(); + client.request = async (method: string, path: string): Promise => { + client.calls.push({ method, path }); + throw new Error("GET /orgs/missing returned 404: Not Found"); + }; + const live = await orgSettingsCycle.fetchLive(client, "missing", scope, makeBudget()); + expect(live.settings).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. diff over the cycle +// --------------------------------------------------------------------------- + +describe("diff integration with org-settings cycle", () => { + const desiredConfig: OrgConfig = { + settings: { defaultRepositoryPermission: "read", membersCanCreatePublicRepositories: false }, + }; + + it("emits create when no live settings exist", () => { + const live: LiveOrgState = {}; + const desired = orgSettingsCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, live); + expect(cs.entries).toHaveLength(1); + expect(cs.entries[0]!.kind).toBe("create"); + expect(cs.entries[0]!.resourceType).toBe("org-settings"); + }); + + it("emits update when a managed field differs", () => { + const live: LiveOrgState = { + settings: { defaultRepositoryPermission: "write", membersCanCreatePublicRepositories: false }, + }; + const desired = orgSettingsCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, live); + expect(cs.entries).toHaveLength(1); + expect(cs.entries[0]!.kind).toBe("update"); + expect(cs.entries[0]!.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: "defaultRepositoryPermission", before: "write", after: "read" }), + ]), + ); + }); + + it("emits no entries when live matches desired", () => { + const live: LiveOrgState = { + settings: { defaultRepositoryPermission: "read", membersCanCreatePublicRepositories: false }, + }; + const desired = orgSettingsCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, live); + expect(cs.entries).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. apply — create / update / delete / foreign +// --------------------------------------------------------------------------- + +describe("orgSettingsCycle.apply", () => { + it("sends PATCH with only declared fields for an update", async () => { + const client = makeMockClient(); + const entry = { + kind: "update" as const, + resourceType: "org-settings", + key: "org-settings", + before: { defaultRepositoryPermission: "write" }, + after: { defaultRepositoryPermission: "read" }, + fields: [{ field: "defaultRepositoryPermission", before: "write", after: "read" }], + }; + await orgSettingsCycle.apply(client, entry, "my-org", scope, makeBudget()); + + expect(client.calls).toHaveLength(1); + expect(client.calls[0]!.method).toBe("PATCH"); + expect(client.calls[0]!.path).toBe("/orgs/my-org"); + expect(client.calls[0]!.body).toEqual({ default_repository_permission: "read" }); + }); + + it("sends PATCH for a create entry", async () => { + const client = makeMockClient(); + const entry = { + kind: "create" as const, + resourceType: "org-settings", + key: "org-settings", + after: { description: "new" }, + }; + await orgSettingsCycle.apply(client, entry, "my-org", scope, makeBudget()); + expect(client.calls[0]!.method).toBe("PATCH"); + expect(client.calls[0]!.body).toEqual({ description: "new" }); + }); + + it("charges the budget once per apply", async () => { + const client = makeMockClient(); + const budget = makeBudget(5); + await orgSettingsCycle.apply( + client, + { kind: "update", resourceType: "org-settings", key: "org-settings", after: { email: "x@y.z" } }, + "my-org", + scope, + budget, + ); + expect(budget.remaining).toBe(4); + }); + + it("skips an empty-body apply (no declared writable fields)", async () => { + const client = makeMockClient(); + await orgSettingsCycle.apply( + client, + { kind: "update", resourceType: "org-settings", key: "org-settings", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); + + it("ignores delete entries", async () => { + const client = makeMockClient(); + await orgSettingsCycle.apply( + client, + { kind: "delete", resourceType: "org-settings", key: "org-settings", before: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); + + it("skips non-org-settings entries", async () => { + const client = makeMockClient(); + await orgSettingsCycle.apply( + client, + { kind: "create", resourceType: "team", key: "backend", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Runner integration +// --------------------------------------------------------------------------- + +describe("orgSettingsCycle via runReconcile", () => { + const config: GovernanceConfig = { + orgs: { + "test-org": { + settings: { defaultRepositoryPermission: "read" }, + }, + }, + }; + + it("dry-run: plan reports the update without mutating", async () => { + const client = makeMockClient({ + "GET /orgs/test-org": { default_repository_permission: "admin" }, + }); + const result = await runReconcile({ + config, + client, + cycles: [orgSettingsCycle], + mode: "dry-run", + }); + expect(result.completed).toBe(true); + expect(client.calls.every((c) => c.method === "GET")).toBe(true); + const cr = result.cycles[0]!; + expect(cr.counts.update).toBe(1); + expect(cr.plan).toContain("1 to update"); + }); + + it("apply: sends one PATCH after a GET", async () => { + const client = makeMockClient({ + "GET /orgs/test-org": { default_repository_permission: "admin" }, + }); + const result = await runReconcile({ + config, + client, + cycles: [orgSettingsCycle], + mode: "apply", + // No org members in this fixture → adminFloor would otherwise block. + allowGuardrailOverride: true, + }); + expect(result.completed).toBe(true); + expect(result.cycles[0]!.applied).toHaveLength(1); + const patch = client.calls.find((c) => c.method === "PATCH"); + expect(patch).toBeDefined(); + expect(patch!.path).toBe("/orgs/test-org"); + expect(patch!.body).toEqual({ default_repository_permission: "read" }); + }); + + it("selective-by-omission: no settings in config → no entries", async () => { + const client = makeMockClient({ "GET /orgs/test-org": { default_repository_permission: "admin" } }); + const result = await runReconcile({ + config: { orgs: { "test-org": {} } }, + client, + cycles: [orgSettingsCycle], + mode: "dry-run", + }); + const cr = result.cycles[0]!; + expect(cr.counts.create + cr.counts.update + cr.counts.delete).toBe(0); + }); +}); diff --git a/src/cycles/org-settings.ts b/src/cycles/org-settings.ts new file mode 100644 index 0000000..3507f3b --- /dev/null +++ b/src/cycles/org-settings.ts @@ -0,0 +1,230 @@ +/** + * Org-settings cycle. + * + * Reconciles organization-level settings — default repository permission, + * member repository-creation privileges, public org metadata (description, + * email, website), and the surfaced 2FA-requirement flag. + * + * GET /orgs/{org} — read live org settings + * PATCH /orgs/{org} — update declared settings + * + * Follows the four-part `Cycle` structure established by the branch-protection + * template (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * ## Why this cycle does NOT do a read-modify-write merge + * + * Unlike the branch-protection PUT (a FULL replacement, which forces an RMW to + * avoid nulling undeclared fields), `PATCH /orgs/{org}` is a *partial* update: + * GitHub touches only the keys present in the request body and leaves every + * other org setting untouched. So selective-by-omission is honoured simply by + * sending only the fields the config declares — there is no undeclared-field to + * preserve, hence no live re-fetch in `apply`. + * + * ## Scope + * + * Org settings are addressed entirely by `orgLogin` (supplied per-org by the + * runner). There is no per-repo or per-resource scope, so `OrgSettingsScope` is + * an empty object kept only for template/type symmetry with other cycles. + * + * ## Platform note: two-factor enforcement + * + * `requireTwoFactorAuthentication` (`two_factor_requirement_enabled`) is + * surfaced from the GET response so dry-runs can report drift, and is forwarded + * in the PATCH body when declared. GitHub treats this key as read-only on most + * plans (2FA enforcement is toggled via org security settings, not this + * endpoint); where that is the case GitHub ignores the key rather than erroring. + * Hardened 2FA / security-feature enforcement is the remit of the + * security-feature cycle (#13), not this one. + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { OrgConfig, OrgSettings } from "../config/types.js"; +import type { ChangeSetEntry, LiveOrgState, LiveOrgSettings } from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** + * Scope for the org-settings cycle. + * + * Org settings have no sub-resource selector — the org is identified by + * `orgLogin`, supplied per-org by the runner. This empty object exists only for + * symmetry with the other cycles' typed scopes. + */ +export type OrgSettingsScope = Record; + +// --------------------------------------------------------------------------- +// GitHub REST API response shape (only the fields we read) +// --------------------------------------------------------------------------- + +/** Minimal shape of the `GET /orgs/{org}` response we care about. */ +interface GhOrg { + description?: string | null; + email?: string | null; + /** GitHub stores the org website URL under `blog`. */ + blog?: string | null; + default_repository_permission?: string | null; + members_can_create_public_repositories?: boolean | null; + members_can_create_private_repositories?: boolean | null; + members_can_create_internal_repositories?: boolean | null; + two_factor_requirement_enabled?: boolean | null; +} + +const VALID_DEFAULT_PERMISSIONS = new Set(["none", "read", "write", "admin"]); + +// --------------------------------------------------------------------------- +// Live-state mapping +// --------------------------------------------------------------------------- + +/** + * Map the GitHub org GET response to the `LiveOrgSettings` shape used by the + * diff. Only the fields this cycle manages are mapped; absent/null fields are + * left unset so the diff treats them as "not present live". + */ +function mapOrgToLive(raw: GhOrg): LiveOrgSettings { + const live: LiveOrgSettings = {}; + + if (raw.description != null) live.description = raw.description; + if (raw.email != null) live.email = raw.email; + if (raw.blog != null) live.websiteUrl = raw.blog; + + if (raw.default_repository_permission != null && VALID_DEFAULT_PERMISSIONS.has(raw.default_repository_permission)) { + live.defaultRepositoryPermission = raw.default_repository_permission as LiveOrgSettings["defaultRepositoryPermission"]; + } + + if (typeof raw.members_can_create_public_repositories === "boolean") { + live.membersCanCreatePublicRepositories = raw.members_can_create_public_repositories; + } + if (typeof raw.members_can_create_private_repositories === "boolean") { + live.membersCanCreatePrivateRepositories = raw.members_can_create_private_repositories; + } + if (typeof raw.members_can_create_internal_repositories === "boolean") { + live.membersCanCreateInternalRepositories = raw.members_can_create_internal_repositories; + } + if (typeof raw.two_factor_requirement_enabled === "boolean") { + live.requireTwoFactorAuthentication = raw.two_factor_requirement_enabled; + } + + return live; +} + +// --------------------------------------------------------------------------- +// PATCH body builder +// --------------------------------------------------------------------------- + +/** + * Build the `PATCH /orgs/{org}` request body from the declared settings. + * + * Only keys present in `desired` are emitted (selective-by-omission). Because + * the GitHub PATCH is a partial update, undeclared org settings are left + * untouched by GitHub — no live merge required. + */ +export function buildOrgPatchBody(desired: OrgSettings): Record { + const body: Record = {}; + + if (desired.description !== undefined) body.description = desired.description; + if (desired.email !== undefined) body.email = desired.email; + if (desired.websiteUrl !== undefined) body.blog = desired.websiteUrl; + if (desired.defaultRepositoryPermission !== undefined) { + body.default_repository_permission = desired.defaultRepositoryPermission; + } + if (desired.membersCanCreatePublicRepositories !== undefined) { + body.members_can_create_public_repositories = desired.membersCanCreatePublicRepositories; + } + if (desired.membersCanCreatePrivateRepositories !== undefined) { + body.members_can_create_private_repositories = desired.membersCanCreatePrivateRepositories; + } + if (desired.membersCanCreateInternalRepositories !== undefined) { + body.members_can_create_internal_repositories = desired.membersCanCreateInternalRepositories; + } + // Forwarded best-effort; GitHub may treat this as read-only (see file header). + if (desired.requireTwoFactorAuthentication !== undefined) { + body.two_factor_requirement_enabled = desired.requireTwoFactorAuthentication; + } + + return body; +} + +// --------------------------------------------------------------------------- +// orgSettingsCycle — implements Cycle +// --------------------------------------------------------------------------- + +/** + * Governance cycle for organization-level settings. + * + * Reconciles the `settings` block of each org in the config. Leaves the + * `settings` block — and every individual field within it — untouched when + * absent from config (selective-by-omission). + */ +export const orgSettingsCycle: Cycle = { + name: "org-settings", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + _scope: OrgSettingsScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + budget.use(1); + let raw: GhOrg; + try { + raw = await client.request("GET", `/orgs/${orgLogin}`); + } catch (err) { + // A missing org (404) means there is nothing live to diff against — the + // diff will emit a create. Surfacing it as empty rather than throwing + // keeps the run going for other orgs/cycles. + if (err instanceof Error && err.message.includes("404")) { + return {}; + } + throw err; + } + + return { settings: mapOrgToLive(raw) }; + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: OrgSettingsScope): OrgConfig { + // Only the org settings are managed by this cycle; strip everything else so + // the diff focuses on the org-settings domain. + if (!orgConfig.settings) return {}; + return { settings: orgConfig.settings }; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: OrgSettingsScope, + budget: RateBudget, + ): Promise { + if (entry.resourceType !== "org-settings") { + // Safety: this cycle only handles org-settings entries. + return; + } + + // Org settings are never deleted — the diff only ever emits create/update + // for this resource type. Ignore a delete defensively. + if (entry.kind === "delete") return; + + const desired = entry.after as OrgSettings; + const body = buildOrgPatchBody(desired); + + // Nothing declared → nothing to do (avoids an empty PATCH). + if (Object.keys(body).length === 0) return; + + budget.use(1); + await client.request("PATCH", `/orgs/${orgLogin}`, body); + }, +}; diff --git a/src/index.ts b/src/index.ts index 06e8dad..5395722 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,8 @@ export { runReconcile, BudgetExhaustedError } from "./reconcile/runner.js"; // Cycles export { branchProtectionCycle, fetchLiveForOrg } from "./cycles/branch-protection.js"; +export { orgSettingsCycle, buildOrgPatchBody } from "./cycles/org-settings.js"; +export type { OrgSettingsScope } from "./cycles/org-settings.js"; // Reconcile: dump (export live state to desired-state config) export type { DumpOrgOptions, DumpResult } from "./reconcile/dump.js";