diff --git a/action/index.mjs b/action/index.mjs index 4b707b2..5e01a52 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -488,6 +488,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); diffRepoSecurity(name, dr.security, lr.security, out); + diffEnvironments(name, dr.environments, lr.environments ?? [], opts, out); } for (const name of Object.keys(live)) { if (!Object.prototype.hasOwnProperty.call(desired, name)) { @@ -603,6 +604,35 @@ function diffRepoSecurity(repoName, desired, live, out) { }); } } +function diffEnvironments(repoName, desired, live, opts, out) { + if (desired === void 0) return; + const desiredByName = new Map(desired.map((e) => [e.name, e])); + const liveByName = new Map(live.map((e) => [e.name, e])); + for (const [name, de] of desiredByName) { + const le = liveByName.get(name); + const key = `${repoName}/${name}`; + if (!le) { + out.push({ kind: "create", resourceType: "environment", key, after: de }); + continue; + } + const fields = diffObjectKeys( + de, + le, + ENVIRONMENT_FIELDS + ); + if (fields.length > 0) { + out.push({ kind: "update", resourceType: "environment", key, before: le, after: de, fields }); + } + } + for (const [name, le] of liveByName) { + if (!desiredByName.has(name)) { + const key = `${repoName}/${name}`; + if (opts.isOwned?.("environment", key)) { + out.push({ kind: "delete", resourceType: "environment", key, before: le }); + } + } + } +} function diffObject(desired, live) { const fields = []; for (const key of Object.keys(desired)) { @@ -666,7 +696,7 @@ function fmt(v) { const json2 = JSON.stringify(v); return json2.length > 60 ? `${json2.slice(0, 57)}...` : json2; } -var RESOURCE_TYPE_ORDER, RULESET_FIELDS; +var RESOURCE_TYPE_ORDER, RULESET_FIELDS, ENVIRONMENT_FIELDS; var init_diff = __esm({ "src/reconcile/diff.ts"() { "use strict"; @@ -679,10 +709,17 @@ var init_diff = __esm({ "member", "repo", "repo-security", + "environment", "branch-protection", "repo-ruleset" ]; RULESET_FIELDS = ["target", "enforcement", "bypassActors", "conditions", "rules"]; + ENVIRONMENT_FIELDS = [ + "waitTimer", + "preventSelfReview", + "reviewers", + "deploymentBranchPolicy" + ]; } }); @@ -230082,6 +230119,127 @@ var securityFeaturesCycle = { } }; +// src/cycles/environments.ts +function mapEnvironmentToLive(raw) { + const live = { name: raw.name }; + for (const rule of raw.protection_rules ?? []) { + if (rule.type === "wait_timer" && typeof rule.wait_timer === "number") { + live.waitTimer = rule.wait_timer; + } else if (rule.type === "required_reviewers") { + if (typeof rule.prevent_self_review === "boolean") { + live.preventSelfReview = rule.prevent_self_review; + } + live.reviewers = (rule.reviewers ?? []).filter((r) => typeof r.reviewer?.id === "number").map((r) => ({ type: r.type, id: r.reviewer.id })); + } + } + if (raw.deployment_branch_policy === null) { + live.deploymentBranchPolicy = null; + } else if (raw.deployment_branch_policy) { + live.deploymentBranchPolicy = { + protectedBranches: raw.deployment_branch_policy.protected_branches ?? false, + customBranchPolicies: raw.deployment_branch_policy.custom_branch_policies ?? false + }; + } + return live; +} +function dbpToApi(dbp) { + if (dbp === null) return null; + return { + protected_branches: dbp.protectedBranches ?? false, + custom_branch_policies: dbp.customBranchPolicies ?? false + }; +} +function reviewersToApi(reviewers) { + return reviewers.map((r) => ({ type: r.type, id: r.id })); +} +function buildEnvironmentBody(desired, live) { + const body = {}; + if (live) { + if (live.waitTimer !== void 0) body.wait_timer = live.waitTimer; + if (live.preventSelfReview !== void 0) body.prevent_self_review = live.preventSelfReview; + if (live.reviewers !== void 0) body.reviewers = reviewersToApi(live.reviewers); + if (live.deploymentBranchPolicy !== void 0) { + body.deployment_branch_policy = dbpToApi(live.deploymentBranchPolicy); + } + } + if (desired.waitTimer !== void 0) body.wait_timer = desired.waitTimer; + if (desired.preventSelfReview !== void 0) body.prevent_self_review = desired.preventSelfReview; + if (desired.reviewers !== void 0) body.reviewers = reviewersToApi(desired.reviewers); + if (desired.deploymentBranchPolicy !== void 0) { + body.deployment_branch_policy = dbpToApi(desired.deploymentBranchPolicy); + } + return body; +} +async function fetchRepoEnvironments(client, org, repo, budget) { + budget.use(1); + let data; + try { + data = await client.request("GET", `/repos/${org}/${repo}/environments`); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) return []; + throw err; + } + return (data.environments ?? []).map(mapEnvironmentToLive); +} +var environmentsCycle = { + name: "environments", + // ── 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 repos = {}; + for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) { + if (repoConfig.environments === void 0) continue; + if (budget.exhausted) break; + repos[name] = { environments: await fetchRepoEnvironments(client, orgLogin, name, budget) }; + } + return { repos }; + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + if (!orgConfig.repos) return {}; + const repos = {}; + for (const [name, repoConfig] of Object.entries(orgConfig.repos)) { + if (repoConfig.environments && repoConfig.environments.length > 0) { + repos[name] = { environments: repoConfig.environments }; + } + } + return { repos }; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + if (entry.resourceType !== "environment") return; + const slashIdx = entry.key.indexOf("/"); + if (slashIdx === -1) { + throw new Error( + `environments: malformed entry key "${entry.key}" \u2014 expected "/"` + ); + } + const repo = entry.key.slice(0, slashIdx); + const env2 = entry.key.slice(slashIdx + 1); + const path = `/repos/${orgLogin}/${repo}/environments/${encodeURIComponent(env2)}`; + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", path); + return; + } + const desired = entry.after; + let live = null; + if (entry.kind === "update") { + live = entry.before ?? null; + if (!live) { + const fetched = await fetchRepoEnvironments(client, orgLogin, repo, budget); + live = fetched.find((e) => e.name === env2) ?? null; + } + } + const body = buildEnvironmentBody(desired, live); + budget.use(1); + await client.request("PUT", path, body); + } +}; + // src/cli/registry.ts var CYCLE_REGISTRY = { [branchProtectionCycle.name]: branchProtectionCycle, @@ -230090,7 +230248,8 @@ var CYCLE_REGISTRY = { [membershipCycle.name]: membershipCycle, [teamsCycle.name]: teamsCycle, [rulesetsCycle.name]: rulesetsCycle, - [securityFeaturesCycle.name]: securityFeaturesCycle + [securityFeaturesCycle.name]: securityFeaturesCycle, + [environmentsCycle.name]: environmentsCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f310085..8786d40 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -16,6 +16,7 @@ import { membershipCycle } from "../cycles/membership.js"; import { teamsCycle } from "../cycles/teams.js"; import { rulesetsCycle } from "../cycles/rulesets.js"; import { securityFeaturesCycle } from "../cycles/security-features.js"; +import { environmentsCycle } from "../cycles/environments.js"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -32,4 +33,5 @@ export const CYCLE_REGISTRY: Record = { [teamsCycle.name]: teamsCycle, [rulesetsCycle.name]: rulesetsCycle, [securityFeaturesCycle.name]: securityFeaturesCycle, + [environmentsCycle.name]: environmentsCycle, }; diff --git a/src/config/types.ts b/src/config/types.ts index dce3da6..77ddd64 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -100,6 +100,46 @@ export interface MemberConfig { role?: OrgMemberRole; } +// --------------------------------------------------------------------------- +// Deployment environments +// --------------------------------------------------------------------------- + +/** A required reviewer for an environment (a user or a team, by numeric id). */ +export interface EnvironmentReviewer { + /** "User" or "Team". */ + type: "User" | "Team"; + /** GitHub numeric id of the user or team. */ + id: number; +} + +/** + * Deployment-branch policy for an environment. At most one of the two flags is + * true. `null` (as a declared value) disables the policy entirely. + */ +export interface DeploymentBranchPolicy { + /** Restrict deployments to branches matching the repo's protection rules. */ + protectedBranches?: boolean; + /** Restrict deployments to branches matching custom name patterns. */ + customBranchPolicies?: boolean; +} + +/** Desired state for a single deployment environment. Absent fields are not managed. */ +export interface EnvironmentConfig { + /** Environment name (the identity key within a repo). */ + name: string; + /** Wait timer in minutes before a deployment can proceed (0–43200). */ + waitTimer?: number; + /** Prevent a deployment's actor from approving their own run. */ + preventSelfReview?: boolean; + /** Required reviewers for deployments to this environment. */ + reviewers?: EnvironmentReviewer[]; + /** + * Deployment branch policy. An object configures it; `null` disables it. + * Absent means the branch policy is not managed. + */ + deploymentBranchPolicy?: DeploymentBranchPolicy | null; +} + // --------------------------------------------------------------------------- // Repository security features // --------------------------------------------------------------------------- @@ -241,6 +281,11 @@ export interface RepoConfig { * Absent means security features are not managed by chant. */ security?: RepoSecurityConfig; + /** + * Deployment environments and their protection rules. + * Absent means environments are not managed by chant. + */ + environments?: EnvironmentConfig[]; } // --------------------------------------------------------------------------- diff --git a/src/cycles/environments.test.ts b/src/cycles/environments.test.ts new file mode 100644 index 0000000..97e4eac --- /dev/null +++ b/src/cycles/environments.test.ts @@ -0,0 +1,324 @@ +/** + * Tests for the environments cycle. + * + * All tests use a mock AppClient — no network calls. + * Coverage: + * - buildDesired: keeps repos with environments + * - mapEnvironmentToLive: protection_rules + deployment_branch_policy mapping + * - buildEnvironmentBody: RMW seed-from-live + overlay declared + * - diff over the cycle: environment create / update / ownership-gated delete + * - apply: PUT (create + RMW update) / DELETE + * - runner integration: dry-run plan + */ + +import { describe, it, expect } from "vitest"; +import { + environmentsCycle, + mapEnvironmentToLive, + buildEnvironmentBody, +} from "./environments.js"; +import type { EnvironmentsScope } from "./environments.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, LiveEnvironment } 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: EnvironmentsScope = {}; + +// --------------------------------------------------------------------------- +// 1. buildDesired +// --------------------------------------------------------------------------- + +describe("environmentsCycle.buildDesired", () => { + it("keeps only repos that declare environments", () => { + const orgConfig: OrgConfig = { + repos: { + svc: { environments: [{ name: "prod" }], description: "x" }, + bare: { description: "no envs" }, + }, + }; + const desired = environmentsCycle.buildDesired(orgConfig, "test-org", scope); + expect(desired.repos!["svc"]).toEqual({ environments: [{ name: "prod" }] }); + expect(desired.repos).not.toHaveProperty("bare"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. mapEnvironmentToLive +// --------------------------------------------------------------------------- + +describe("mapEnvironmentToLive", () => { + it("maps protection rules and branch policy", () => { + const live = mapEnvironmentToLive({ + name: "prod", + protection_rules: [ + { type: "wait_timer", wait_timer: 30 }, + { + type: "required_reviewers", + prevent_self_review: true, + reviewers: [ + { type: "User", reviewer: { id: 1 } }, + { type: "Team", reviewer: { id: 2 } }, + ], + }, + ], + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false }, + }); + expect(live).toEqual({ + name: "prod", + waitTimer: 30, + preventSelfReview: true, + reviewers: [ + { type: "User", id: 1 }, + { type: "Team", id: 2 }, + ], + deploymentBranchPolicy: { protectedBranches: true, customBranchPolicies: false }, + }); + }); + + it("maps a null branch policy to null", () => { + const live = mapEnvironmentToLive({ name: "staging", deployment_branch_policy: null }); + expect(live.deploymentBranchPolicy).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. buildEnvironmentBody (RMW) +// --------------------------------------------------------------------------- + +describe("buildEnvironmentBody", () => { + it("sends only declared fields on create", () => { + expect(buildEnvironmentBody({ name: "prod", waitTimer: 15 })).toEqual({ wait_timer: 15 }); + }); + + it("seeds from live and overlays declared fields on update (RMW)", () => { + const live: LiveEnvironment = { + name: "prod", + waitTimer: 30, + preventSelfReview: true, + reviewers: [{ type: "User", id: 1 }], + deploymentBranchPolicy: { protectedBranches: true, customBranchPolicies: false }, + }; + // Config changes ONLY the wait timer. + const body = buildEnvironmentBody({ name: "prod", waitTimer: 5 }, live); + expect(body).toEqual({ + wait_timer: 5, // overlaid + prevent_self_review: true, // preserved + reviewers: [{ type: "User", id: 1 }], // preserved + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false }, // preserved + }); + }); + + it("maps a null branch policy through to null", () => { + expect(buildEnvironmentBody({ name: "p", deploymentBranchPolicy: null })).toEqual({ + deployment_branch_policy: null, + }); + }); +}); + +// --------------------------------------------------------------------------- +// 4. diff over the cycle +// --------------------------------------------------------------------------- + +describe("diff integration with environments cycle", () => { + const desiredConfig: OrgConfig = { + repos: { svc: { environments: [{ name: "prod", waitTimer: 10 }] } }, + }; + + it("emits create when the environment is absent live", () => { + const desired = environmentsCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, { repos: { svc: { environments: [] } } }); + expect(cs.entries).toHaveLength(1); + expect(cs.entries[0]!.resourceType).toBe("environment"); + expect(cs.entries[0]!.key).toBe("svc/prod"); + expect(cs.entries[0]!.kind).toBe("create"); + }); + + it("emits update when a declared field differs", () => { + const live: LiveOrgState = { repos: { svc: { environments: [{ name: "prod", waitTimer: 30 }] } } }; + const desired = environmentsCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, live); + expect(cs.entries[0]!.kind).toBe("update"); + expect(cs.entries[0]!.fields!.map((f) => f.field)).toEqual(["waitTimer"]); + }); + + it("emits ownership-gated delete for an unmanaged environment", () => { + const live: LiveOrgState = { + repos: { svc: { environments: [{ name: "prod", waitTimer: 10 }, { name: "stray" }] } }, + }; + const desired = environmentsCycle.buildDesired(desiredConfig, "test-org", scope); + expect(diff("test-org", desired, live).entries).toHaveLength(0); + const owned = diff("test-org", desired, live, { isOwned: (_t, k) => k === "svc/stray" }); + expect(owned.entries.find((e) => e.kind === "delete")!.key).toBe("svc/stray"); + }); +}); + +// --------------------------------------------------------------------------- +// 5. apply +// --------------------------------------------------------------------------- + +describe("environmentsCycle.apply", () => { + it("PUTs a create with only declared fields", async () => { + const client = makeMockClient(); + await environmentsCycle.apply( + client, + { kind: "create", resourceType: "environment", key: "svc/prod", after: { name: "prod", waitTimer: 10 } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("PUT"); + expect(client.calls[0]!.path).toBe("/repos/my-org/svc/environments/prod"); + expect(client.calls[0]!.body).toEqual({ wait_timer: 10 }); + }); + + it("PUTs an RMW update preserving undeclared protection", async () => { + const client = makeMockClient(); + const before: LiveEnvironment = { + name: "prod", + waitTimer: 30, + reviewers: [{ type: "Team", id: 9 }], + deploymentBranchPolicy: { protectedBranches: true }, + }; + await environmentsCycle.apply( + client, + { kind: "update", resourceType: "environment", key: "svc/prod", before, after: { name: "prod", waitTimer: 5 }, fields: [] }, + "my-org", + scope, + makeBudget(), + ); + const body = client.calls[0]!.body as Record; + expect(body.wait_timer).toBe(5); + expect(body.reviewers).toEqual([{ type: "Team", id: 9 }]); + expect(body.deployment_branch_policy).toEqual({ protected_branches: true, custom_branch_policies: false }); + }); + + it("re-fetches live when an update entry lacks before", async () => { + const client = makeMockClient({ + "GET /repos/my-org/svc/environments": { + environments: [{ name: "prod", protection_rules: [{ type: "wait_timer", wait_timer: 60 }] }], + }, + }); + const budget = makeBudget(5); + await environmentsCycle.apply( + client, + { kind: "update", resourceType: "environment", key: "svc/prod", after: { name: "prod", preventSelfReview: true }, fields: [] }, + "my-org", + scope, + budget, + ); + // one GET (re-fetch) + one PUT + expect(budget.remaining).toBe(3); + const put = client.calls.find((c) => c.method === "PUT")!; + const body = put.body as Record; + expect(body.wait_timer).toBe(60); // preserved from re-fetched live + expect(body.prevent_self_review).toBe(true); // declared + }); + + it("DELETEs an environment", async () => { + const client = makeMockClient(); + await environmentsCycle.apply( + client, + { kind: "delete", resourceType: "environment", key: "svc/old", before: { name: "old" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("DELETE"); + expect(client.calls[0]!.path).toBe("/repos/my-org/svc/environments/old"); + }); + + it("ignores foreign entries and throws on a malformed key", async () => { + const client = makeMockClient(); + await environmentsCycle.apply( + client, + { kind: "create", resourceType: "repo", key: "svc", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + await expect( + environmentsCycle.apply( + client, + { kind: "create", resourceType: "environment", key: "no-slash", after: { name: "x" } }, + "my-org", + scope, + makeBudget(), + ), + ).rejects.toThrow("malformed entry key"); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Runner integration +// --------------------------------------------------------------------------- + +describe("environmentsCycle via runReconcile", () => { + it("dry-run: reports a create plan", async () => { + const config: GovernanceConfig = { + orgs: { "test-org": { repos: { svc: { environments: [{ name: "prod", waitTimer: 10 }] } } } }, + }; + const client = makeMockClient({ + "GET /repos/test-org/svc/environments": { environments: [] }, + }); + const result = await runReconcile({ + config, + client, + cycles: [environmentsCycle], + scope: { repos: config.orgs["test-org"]!.repos } satisfies EnvironmentsScope, + 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); + }); +}); diff --git a/src/cycles/environments.ts b/src/cycles/environments.ts new file mode 100644 index 0000000..bd92280 --- /dev/null +++ b/src/cycles/environments.ts @@ -0,0 +1,263 @@ +/** + * Environments & deployment-protection cycle. + * + * Reconciles repository deployment environments — required reviewers, wait + * timers, self-review prevention, and deployment branch policies. + * + * GET /repos/{o}/{r}/environments — list environments + * PUT /repos/{o}/{r}/environments/{env} — create / update (RMW) + * DELETE /repos/{o}/{r}/environments/{env} — delete + * + * Follows the four-part `Cycle` structure of the branch-protection template + * (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * ## Read-modify-write: preserve undeclared protection + * + * The environment PUT replaces the configuration it is given, so — like + * branch-protection — `apply` seeds the request body from the LIVE environment + * (carried on the change-set `before`, or re-fetched if absent) and overlays + * ONLY the fields the config declares. A config that sets `waitTimer` therefore + * does not wipe required reviewers or the branch policy. + * + * ## Scope + * + * Live state is fetched for repos in `scope.repos` that declare `environments` + * (the branch-protection scope pattern). + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { + OrgConfig, + RepoConfig, + EnvironmentConfig, + EnvironmentReviewer, + DeploymentBranchPolicy, +} from "../config/types.js"; +import type { ChangeSetEntry, LiveOrgState, LiveEnvironment } from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** Scope for the environments cycle. Pass `repos` (typically `orgConfig.repos`). */ +export interface EnvironmentsScope { + repos?: Record; +} + +// --------------------------------------------------------------------------- +// GitHub REST API response shapes (only the fields we read) +// --------------------------------------------------------------------------- + +interface GhProtectionRule { + type: string; + wait_timer?: number; + prevent_self_review?: boolean; + reviewers?: Array<{ type: "User" | "Team"; reviewer?: { id?: number } }>; +} + +interface GhDeploymentBranchPolicy { + protected_branches?: boolean; + custom_branch_policies?: boolean; +} + +interface GhEnvironment { + name: string; + protection_rules?: GhProtectionRule[]; + deployment_branch_policy?: GhDeploymentBranchPolicy | null; +} + +interface GhEnvironmentsList { + environments?: GhEnvironment[]; +} + +// --------------------------------------------------------------------------- +// Live-state mapping +// --------------------------------------------------------------------------- + +/** Map a GitHub environment response to the `LiveEnvironment` diff shape. */ +export function mapEnvironmentToLive(raw: GhEnvironment): LiveEnvironment { + const live: LiveEnvironment = { name: raw.name }; + + for (const rule of raw.protection_rules ?? []) { + if (rule.type === "wait_timer" && typeof rule.wait_timer === "number") { + live.waitTimer = rule.wait_timer; + } else if (rule.type === "required_reviewers") { + if (typeof rule.prevent_self_review === "boolean") { + live.preventSelfReview = rule.prevent_self_review; + } + live.reviewers = (rule.reviewers ?? []) + .filter((r) => typeof r.reviewer?.id === "number") + .map((r) => ({ type: r.type, id: r.reviewer!.id! })); + } + } + + if (raw.deployment_branch_policy === null) { + live.deploymentBranchPolicy = null; + } else if (raw.deployment_branch_policy) { + live.deploymentBranchPolicy = { + protectedBranches: raw.deployment_branch_policy.protected_branches ?? false, + customBranchPolicies: raw.deployment_branch_policy.custom_branch_policies ?? false, + }; + } + + return live; +} + +// --------------------------------------------------------------------------- +// Apply body builder (read-modify-write) +// --------------------------------------------------------------------------- + +function dbpToApi(dbp: DeploymentBranchPolicy | null): unknown { + if (dbp === null) return null; + return { + protected_branches: dbp.protectedBranches ?? false, + custom_branch_policies: dbp.customBranchPolicies ?? false, + }; +} + +function reviewersToApi(reviewers: EnvironmentReviewer[]): unknown { + return reviewers.map((r) => ({ type: r.type, id: r.id })); +} + +/** + * Build the environment PUT body: seed every field from `live` (so the + * full-replacement PUT preserves undeclared protection), then overlay only the + * fields the config declares. For a create (`live` null) only declared fields + * are sent. + */ +export function buildEnvironmentBody( + desired: EnvironmentConfig, + live?: LiveEnvironment | null, +): Record { + const body: Record = {}; + + if (live) { + if (live.waitTimer !== undefined) body.wait_timer = live.waitTimer; + if (live.preventSelfReview !== undefined) body.prevent_self_review = live.preventSelfReview; + if (live.reviewers !== undefined) body.reviewers = reviewersToApi(live.reviewers); + if (live.deploymentBranchPolicy !== undefined) { + body.deployment_branch_policy = dbpToApi(live.deploymentBranchPolicy); + } + } + + if (desired.waitTimer !== undefined) body.wait_timer = desired.waitTimer; + if (desired.preventSelfReview !== undefined) body.prevent_self_review = desired.preventSelfReview; + if (desired.reviewers !== undefined) body.reviewers = reviewersToApi(desired.reviewers); + if (desired.deploymentBranchPolicy !== undefined) { + body.deployment_branch_policy = dbpToApi(desired.deploymentBranchPolicy); + } + + return body; +} + +// --------------------------------------------------------------------------- +// Live-state fetch +// --------------------------------------------------------------------------- + +/** Fetch live environments for one repo (one list call). */ +async function fetchRepoEnvironments( + client: AppClient, + org: string, + repo: string, + budget: RateBudget, +): Promise { + budget.use(1); + let data: GhEnvironmentsList; + try { + data = await client.request("GET", `/repos/${org}/${repo}/environments`); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) return []; + throw err; + } + return (data.environments ?? []).map(mapEnvironmentToLive); +} + +// --------------------------------------------------------------------------- +// environmentsCycle — implements Cycle +// --------------------------------------------------------------------------- + +export const environmentsCycle: Cycle = { + name: "environments", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + scope: EnvironmentsScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + const repos: NonNullable = {}; + for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) { + if (repoConfig.environments === undefined) continue; + if (budget.exhausted) break; + repos[name] = { environments: await fetchRepoEnvironments(client, orgLogin, name, budget) }; + } + + return { repos }; + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: EnvironmentsScope): OrgConfig { + if (!orgConfig.repos) return {}; + const repos: Record = {}; + for (const [name, repoConfig] of Object.entries(orgConfig.repos)) { + if (repoConfig.environments && repoConfig.environments.length > 0) { + repos[name] = { environments: repoConfig.environments }; + } + } + return { repos }; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: EnvironmentsScope, + budget: RateBudget, + ): Promise { + if (entry.resourceType !== "environment") return; + + // key format: "/" + const slashIdx = entry.key.indexOf("/"); + if (slashIdx === -1) { + throw new Error( + `environments: malformed entry key "${entry.key}" — expected "/"`, + ); + } + const repo = entry.key.slice(0, slashIdx); + const env = entry.key.slice(slashIdx + 1); + const path = `/repos/${orgLogin}/${repo}/environments/${encodeURIComponent(env)}`; + + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", path); + return; + } + + // create or update — PUT is idempotent. For an update, seed from live so + // the full-replacement PUT preserves undeclared protection. + const desired = entry.after as EnvironmentConfig; + let live: LiveEnvironment | null = null; + if (entry.kind === "update") { + live = (entry.before as LiveEnvironment | undefined) ?? null; + if (!live) { + const fetched = await fetchRepoEnvironments(client, orgLogin, repo, budget); + live = fetched.find((e) => e.name === env) ?? null; + } + } + + const body = buildEnvironmentBody(desired, live); + budget.use(1); + await client.request("PUT", path, body); + }, +}; diff --git a/src/index.ts b/src/index.ts index dd11f1c..3264eb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,9 @@ export type { RulesetTarget, RulesetEnforcement, RepoSecurityConfig, + EnvironmentConfig, + EnvironmentReviewer, + DeploymentBranchPolicy, } from "./config/types.js"; // Config loader @@ -41,6 +44,7 @@ export type { LiveRepoConfig, LiveRuleset, LiveRepoSecurity, + LiveEnvironment, LiveOrgState, } from "./reconcile/diff.js"; export { diff, summarizeChangeSet, renderChangeSet } from "./reconcile/diff.js"; @@ -89,6 +93,8 @@ export { rulesetsCycle, fetchRulesets, buildRulesetBody, mapRulesetToLive } from export type { RulesetsScope } from "./cycles/rulesets.js"; export { securityFeaturesCycle, fetchRepoSecurity, buildSecurityAnalysisBody } from "./cycles/security-features.js"; export type { SecurityFeaturesScope } from "./cycles/security-features.js"; +export { environmentsCycle, mapEnvironmentToLive, buildEnvironmentBody } from "./cycles/environments.js"; +export type { EnvironmentsScope } from "./cycles/environments.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 1539ed6..3295d54 100644 --- a/src/reconcile/diff.ts +++ b/src/reconcile/diff.ts @@ -21,6 +21,9 @@ import type { BranchProtectionConfig, RulesetConfig, RepoSecurityConfig, + EnvironmentConfig, + EnvironmentReviewer, + DeploymentBranchPolicy, } from "../config/types.js"; // --------------------------------------------------------------------------- @@ -187,6 +190,16 @@ export interface LiveRepoConfig { topics?: string[]; rulesets?: LiveRuleset[]; security?: LiveRepoSecurity; + environments?: LiveEnvironment[]; +} + +/** Live snapshot of a deployment environment. Mirrors `EnvironmentConfig`. */ +export interface LiveEnvironment { + name: string; + waitTimer?: number; + preventSelfReview?: boolean; + reviewers?: EnvironmentReviewer[]; + deploymentBranchPolicy?: DeploymentBranchPolicy | null; } /** Live snapshot of a repo's security-feature toggles. Mirrors `RepoSecurityConfig`. */ @@ -219,6 +232,7 @@ const RESOURCE_TYPE_ORDER = [ "member", "repo", "repo-security", + "environment", "branch-protection", "repo-ruleset", ] as const; @@ -537,6 +551,9 @@ function diffRepos( // Repository security features diffRepoSecurity(name, dr.security, lr.security, out); + + // Deployment environments + diffEnvironments(name, dr.environments, lr.environments ?? [], opts, out); } for (const name of Object.keys(live)) { @@ -710,6 +727,64 @@ function diffRepoSecurity( } } +// --------------------------------------------------------------------------- +// Deployment environments +// --------------------------------------------------------------------------- + +const ENVIRONMENT_FIELDS: string[] = [ + "waitTimer", + "preventSelfReview", + "reviewers", + "deploymentBranchPolicy", +]; + +/** + * Diff a repo's deployment environments, keyed by environment name. Resource + * type "environment", key "/". Deletes are ownership-gated. + * + * `reviewers` and `deploymentBranchPolicy` are compared structurally + * (deep-equal). The live `id` of each reviewer is part of the comparison, so + * authored reviewers must use the same numeric ids the API returns. + */ +function diffEnvironments( + repoName: string, + desired: EnvironmentConfig[] | undefined, + live: LiveEnvironment[], + opts: DiffOptions, + out: ChangeSetEntry[], +): void { + if (desired === undefined) return; + + const desiredByName = new Map(desired.map((e) => [e.name, e])); + const liveByName = new Map(live.map((e) => [e.name, e])); + + for (const [name, de] of desiredByName) { + const le = liveByName.get(name); + const key = `${repoName}/${name}`; + if (!le) { + out.push({ kind: "create", resourceType: "environment", key, after: de }); + continue; + } + const fields = diffObjectKeys( + de as unknown as Record, + le as unknown as Record, + ENVIRONMENT_FIELDS, + ); + if (fields.length > 0) { + out.push({ kind: "update", resourceType: "environment", key, before: le, after: de, fields }); + } + } + + for (const [name, le] of liveByName) { + if (!desiredByName.has(name)) { + const key = `${repoName}/${name}`; + if (opts.isOwned?.("environment", key)) { + out.push({ kind: "delete", resourceType: "environment", key, before: le }); + } + } + } +} + // --------------------------------------------------------------------------- // Object-level field diffing helpers // ---------------------------------------------------------------------------