diff --git a/action/index.mjs b/action/index.mjs index 2ea9684..7fd9572 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -229423,10 +229423,129 @@ var orgSettingsCycle = { } }; +// src/cycles/repo-settings.ts +var MANAGED_REPO_KEYS = [ + "description", + "websiteUrl", + "private", + "hasIssues", + "hasProjects", + "hasWiki", + "defaultBranch", + "allowSquashMerge", + "allowMergeCommit", + "allowRebaseMerge", + "deleteBranchOnMerge", + "topics" +]; +function hasManagedRepoSettings(repo) { + return MANAGED_REPO_KEYS.some((k) => repo[k] !== void 0); +} +function mapRepoToLive(raw) { + const live = {}; + if (raw.description != null) live.description = raw.description; + if (raw.homepage != null) live.websiteUrl = raw.homepage; + if (typeof raw.private === "boolean") live.private = raw.private; + if (typeof raw.has_issues === "boolean") live.hasIssues = raw.has_issues; + if (typeof raw.has_projects === "boolean") live.hasProjects = raw.has_projects; + if (typeof raw.has_wiki === "boolean") live.hasWiki = raw.has_wiki; + if (raw.default_branch != null) live.defaultBranch = raw.default_branch; + if (typeof raw.allow_squash_merge === "boolean") live.allowSquashMerge = raw.allow_squash_merge; + if (typeof raw.allow_merge_commit === "boolean") live.allowMergeCommit = raw.allow_merge_commit; + if (typeof raw.allow_rebase_merge === "boolean") live.allowRebaseMerge = raw.allow_rebase_merge; + if (typeof raw.delete_branch_on_merge === "boolean") live.deleteBranchOnMerge = raw.delete_branch_on_merge; + if (Array.isArray(raw.topics)) live.topics = raw.topics; + return live; +} +function buildRepoPatchBody(desired) { + const body = {}; + if (desired.description !== void 0) body.description = desired.description; + if (desired.websiteUrl !== void 0) body.homepage = desired.websiteUrl; + if (desired.private !== void 0) body.private = desired.private; + if (desired.hasIssues !== void 0) body.has_issues = desired.hasIssues; + if (desired.hasProjects !== void 0) body.has_projects = desired.hasProjects; + if (desired.hasWiki !== void 0) body.has_wiki = desired.hasWiki; + if (desired.defaultBranch !== void 0) body.default_branch = desired.defaultBranch; + if (desired.allowSquashMerge !== void 0) body.allow_squash_merge = desired.allowSquashMerge; + if (desired.allowMergeCommit !== void 0) body.allow_merge_commit = desired.allowMergeCommit; + if (desired.allowRebaseMerge !== void 0) body.allow_rebase_merge = desired.allowRebaseMerge; + if (desired.deleteBranchOnMerge !== void 0) body.delete_branch_on_merge = desired.deleteBranchOnMerge; + return body; +} +var repoSettingsCycle = { + name: "repo-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(); + } + const repos = scope.repos; + if (!repos || Object.keys(repos).length === 0) { + return { repos: {} }; + } + return fetchLiveRepoSettings(client, orgLogin, repos, budget); + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + if (!orgConfig.repos) return {}; + const repos = {}; + for (const [name, repoConfig] of Object.entries(orgConfig.repos)) { + if (!hasManagedRepoSettings(repoConfig)) continue; + const stripped = {}; + for (const key of MANAGED_REPO_KEYS) { + if (repoConfig[key] !== void 0) { + stripped[key] = repoConfig[key]; + } + } + repos[name] = stripped; + } + return { repos }; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + if (entry.resourceType !== "repo") { + return; + } + if (entry.kind === "delete") return; + const repoName = entry.key; + const desired = entry.after; + const body = buildRepoPatchBody(desired); + if (Object.keys(body).length > 0) { + budget.use(1); + await client.request("PATCH", `/repos/${orgLogin}/${repoName}`, body); + } + if (desired.topics !== void 0) { + budget.use(1); + await client.request("PUT", `/repos/${orgLogin}/${repoName}/topics`, { + names: desired.topics + }); + } + } +}; +async function fetchLiveRepoSettings(client, orgLogin, repos, budget) { + const liveRepos = {}; + for (const [name, repoConfig] of Object.entries(repos)) { + if (!hasManagedRepoSettings(repoConfig)) continue; + if (budget.exhausted) break; + budget.use(1); + let raw; + try { + raw = await client.request("GET", `/repos/${orgLogin}/${name}`); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) continue; + throw err; + } + liveRepos[name] = mapRepoToLive(raw); + } + return { repos: liveRepos }; +} + // src/cli/registry.ts var CYCLE_REGISTRY = { [branchProtectionCycle.name]: branchProtectionCycle, - [orgSettingsCycle.name]: orgSettingsCycle + [orgSettingsCycle.name]: orgSettingsCycle, + [repoSettingsCycle.name]: repoSettingsCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 4565e91..2194274 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -11,6 +11,7 @@ import type { Cycle } from "../reconcile/runner.js"; import { branchProtectionCycle } from "../cycles/branch-protection.js"; import { orgSettingsCycle } from "../cycles/org-settings.js"; +import { repoSettingsCycle } from "../cycles/repo-settings.js"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -22,4 +23,5 @@ import { orgSettingsCycle } from "../cycles/org-settings.js"; export const CYCLE_REGISTRY: Record = { [branchProtectionCycle.name]: branchProtectionCycle, [orgSettingsCycle.name]: orgSettingsCycle, + [repoSettingsCycle.name]: repoSettingsCycle, }; diff --git a/src/cycles/repo-settings.test.ts b/src/cycles/repo-settings.test.ts new file mode 100644 index 0000000..dd88cc0 --- /dev/null +++ b/src/cycles/repo-settings.test.ts @@ -0,0 +1,407 @@ +/** + * Tests for the repo-settings cycle. + * + * All tests use a mock AppClient — no network calls. + * Coverage: + * - buildDesired: keeps managed settings, strips branchProtection, omits bare repos + * - fetchLive (via fetchLiveRepoSettings): maps GitHub repo response; 404 → skip + * - diff over the cycle: create / update / no-op / topics + * - apply: PATCH partial settings + PUT topics; ignores foreign / delete + * - runner integration: dry-run plan + apply + */ + +import { describe, it, expect } from "vitest"; +import { repoSettingsCycle, buildRepoPatchBody, fetchLiveRepoSettings } from "./repo-settings.js"; +import type { RepoSettingsScope } from "./repo-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: RepoSettingsScope = {}; + +// --------------------------------------------------------------------------- +// 1. buildDesired +// --------------------------------------------------------------------------- + +describe("repoSettingsCycle.buildDesired", () => { + it("returns empty config when no repos are defined", () => { + const desired = repoSettingsCycle.buildDesired({}, "test-org", scope); + expect(desired.repos).toBeUndefined(); + }); + + it("omits repos with no managed settings (e.g. only branchProtection)", () => { + const orgConfig: OrgConfig = { + repos: { + "bp-only": { branchProtection: [{ pattern: "main", requirePullRequestReviews: true }] }, + }, + }; + const desired = repoSettingsCycle.buildDesired(orgConfig, "test-org", scope); + expect(desired.repos).toEqual({}); + }); + + it("keeps managed settings and strips branchProtection", () => { + const orgConfig: OrgConfig = { + repos: { + managed: { + description: "svc", + hasWiki: false, + topics: ["api", "go"], + branchProtection: [{ pattern: "main" }], + }, + }, + }; + const desired = repoSettingsCycle.buildDesired(orgConfig, "test-org", scope); + expect(desired.repos!["managed"]).toEqual({ + description: "svc", + hasWiki: false, + topics: ["api", "go"], + }); + }); +}); + +// --------------------------------------------------------------------------- +// 2. buildRepoPatchBody +// --------------------------------------------------------------------------- + +describe("buildRepoPatchBody", () => { + it("maps declared fields to GitHub PATCH keys and excludes topics", () => { + const body = buildRepoPatchBody({ + description: "svc", + websiteUrl: "https://x.test", + private: true, + hasIssues: false, + hasProjects: false, + hasWiki: true, + defaultBranch: "main", + allowSquashMerge: true, + allowMergeCommit: false, + allowRebaseMerge: false, + deleteBranchOnMerge: true, + topics: ["a"], + }); + expect(body).toEqual({ + description: "svc", + homepage: "https://x.test", + private: true, + has_issues: false, + has_projects: false, + has_wiki: true, + default_branch: "main", + allow_squash_merge: true, + allow_merge_commit: false, + allow_rebase_merge: false, + delete_branch_on_merge: true, + }); + expect(body).not.toHaveProperty("topics"); + }); + + it("returns empty body when only topics declared", () => { + expect(buildRepoPatchBody({ topics: ["a"] })).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// 3. fetchLiveRepoSettings — mapping +// --------------------------------------------------------------------------- + +describe("fetchLiveRepoSettings", () => { + it("maps the GitHub repo response to LiveRepoConfig", async () => { + const client = makeMockClient({ + "GET /repos/test-org/svc": { + description: "service", + homepage: "https://svc.test", + private: true, + has_issues: true, + has_projects: false, + has_wiki: false, + default_branch: "main", + allow_squash_merge: true, + allow_merge_commit: false, + allow_rebase_merge: false, + delete_branch_on_merge: true, + topics: ["api"], + }, + }); + + const live = await fetchLiveRepoSettings( + client, + "test-org", + { svc: { description: "x" } }, + makeBudget(), + ); + + expect(live.repos!["svc"]).toEqual({ + description: "service", + websiteUrl: "https://svc.test", + private: true, + hasIssues: true, + hasProjects: false, + hasWiki: false, + defaultBranch: "main", + allowSquashMerge: true, + allowMergeCommit: false, + allowRebaseMerge: false, + deleteBranchOnMerge: true, + topics: ["api"], + }); + }); + + it("skips repos with no managed settings (zero API calls)", async () => { + const client = makeMockClient(); + const live = await fetchLiveRepoSettings( + client, + "test-org", + { "bp-only": { branchProtection: [{ pattern: "main" }] } }, + makeBudget(), + ); + expect(live.repos).toEqual({}); + expect(client.calls).toHaveLength(0); + }); + + it("treats a 404 as no live entry", 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 fetchLiveRepoSettings( + client, + "test-org", + { ghost: { description: "x" } }, + makeBudget(), + ); + expect(live.repos).toEqual({}); + }); + + it("charges the budget one call per managed repo and stops when exhausted", async () => { + const client = makeMockClient(); + const budget = makeBudget(1); + await fetchLiveRepoSettings( + client, + "test-org", + { a: { description: "x" }, b: { description: "y" } }, + budget, + ); + expect(client.calls).toHaveLength(1); + expect(budget.remaining).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// 4. diff over the cycle +// --------------------------------------------------------------------------- + +describe("diff integration with repo-settings cycle", () => { + const desiredConfig: OrgConfig = { + repos: { svc: { hasWiki: false, topics: ["api"] } }, + }; + + it("emits create when no live repo exists", () => { + const desired = repoSettingsCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, { repos: {} }); + expect(cs.entries).toHaveLength(1); + expect(cs.entries[0]!.kind).toBe("create"); + expect(cs.entries[0]!.resourceType).toBe("repo"); + expect(cs.entries[0]!.key).toBe("svc"); + }); + + it("emits update when a managed field or topics differ", () => { + const live: LiveOrgState = { repos: { svc: { hasWiki: true, topics: ["old"] } } }; + const desired = repoSettingsCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, live); + expect(cs.entries).toHaveLength(1); + expect(cs.entries[0]!.kind).toBe("update"); + const fieldNames = cs.entries[0]!.fields!.map((f) => f.field); + expect(fieldNames).toContain("hasWiki"); + expect(fieldNames).toContain("topics"); + }); + + it("emits no entries when live matches desired", () => { + const live: LiveOrgState = { repos: { svc: { hasWiki: false, topics: ["api"] } } }; + const desired = repoSettingsCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, live); + expect(cs.entries).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. apply — settings + topics +// --------------------------------------------------------------------------- + +describe("repoSettingsCycle.apply", () => { + it("PATCHes settings and PUTs topics for an update", async () => { + const client = makeMockClient(); + const entry = { + kind: "update" as const, + resourceType: "repo", + key: "svc", + after: { hasWiki: false, topics: ["api", "go"] }, + }; + await repoSettingsCycle.apply(client, entry, "my-org", scope, makeBudget()); + + expect(client.calls).toHaveLength(2); + const patch = client.calls.find((c) => c.method === "PATCH")!; + expect(patch.path).toBe("/repos/my-org/svc"); + expect(patch.body).toEqual({ has_wiki: false }); + const put = client.calls.find((c) => c.method === "PUT")!; + expect(put.path).toBe("/repos/my-org/svc/topics"); + expect(put.body).toEqual({ names: ["api", "go"] }); + }); + + it("only PATCHes when no topics declared", async () => { + const client = makeMockClient(); + await repoSettingsCycle.apply( + client, + { kind: "update", resourceType: "repo", key: "svc", after: { private: true } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]!.method).toBe("PATCH"); + expect(client.calls[0]!.body).toEqual({ private: true }); + }); + + it("only PUTs topics when no patchable settings declared", async () => { + const client = makeMockClient(); + await repoSettingsCycle.apply( + client, + { kind: "create", resourceType: "repo", key: "svc", after: { topics: ["x"] } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]!.method).toBe("PUT"); + expect(client.calls[0]!.path).toBe("/repos/my-org/svc/topics"); + }); + + it("charges the budget per network call", async () => { + const client = makeMockClient(); + const budget = makeBudget(5); + await repoSettingsCycle.apply( + client, + { kind: "update", resourceType: "repo", key: "svc", after: { private: true, topics: ["x"] } }, + "my-org", + scope, + budget, + ); + expect(budget.remaining).toBe(3); // one PATCH + one PUT + }); + + it("ignores delete entries", async () => { + const client = makeMockClient(); + await repoSettingsCycle.apply( + client, + { kind: "delete", resourceType: "repo", key: "svc", before: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); + + it("skips non-repo entries", async () => { + const client = makeMockClient(); + await repoSettingsCycle.apply( + client, + { kind: "create", resourceType: "branch-protection", key: "svc/main", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Runner integration +// --------------------------------------------------------------------------- + +describe("repoSettingsCycle via runReconcile", () => { + const config: GovernanceConfig = { + orgs: { "test-org": { repos: { svc: { hasWiki: false } } } }, + }; + + it("dry-run: plan reports an update without mutating", async () => { + const client = makeMockClient({ "GET /repos/test-org/svc": { has_wiki: true } }); + const scopeWithRepos: RepoSettingsScope = { repos: config.orgs["test-org"]!.repos }; + const result = await runReconcile({ + config, + client, + cycles: [repoSettingsCycle], + scope: scopeWithRepos, + mode: "dry-run", + }); + expect(result.completed).toBe(true); + expect(client.calls.every((c) => c.method === "GET")).toBe(true); + expect(result.cycles[0]!.counts.update).toBe(1); + }); + + it("apply: PATCHes after fetching live", async () => { + const client = makeMockClient({ "GET /repos/test-org/svc": { has_wiki: true } }); + const scopeWithRepos: RepoSettingsScope = { repos: config.orgs["test-org"]!.repos }; + const result = await runReconcile({ + config, + client, + cycles: [repoSettingsCycle], + scope: scopeWithRepos, + mode: "apply", + allowGuardrailOverride: true, // no org members in fixture → adminFloor would block + }); + expect(result.completed).toBe(true); + expect(result.cycles[0]!.applied).toHaveLength(1); + const patch = client.calls.find((c) => c.method === "PATCH"); + expect(patch!.path).toBe("/repos/test-org/svc"); + expect(patch!.body).toEqual({ has_wiki: false }); + }); +}); diff --git a/src/cycles/repo-settings.ts b/src/cycles/repo-settings.ts new file mode 100644 index 0000000..ba3dd40 --- /dev/null +++ b/src/cycles/repo-settings.ts @@ -0,0 +1,278 @@ +/** + * Repo-settings cycle. + * + * Reconciles per-repository settings — description, website, visibility, + * feature toggles (issues/projects/wiki), merge settings, default branch, and + * topics — to match the declared config. + * + * GET /repos/{owner}/{repo} — read live settings + * PATCH /repos/{owner}/{repo} — update declared settings (partial) + * PUT /repos/{owner}/{repo}/topics — replace topics (full replacement) + * + * Follows the four-part `Cycle` structure of the branch-protection template + * (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * ## Scope and creation + * + * This cycle reconciles settings of EXISTING repos; it never creates a repo — + * repository provisioning/templating is the remit of #10. As with + * branch-protection, live state is fetched only for repos passed in + * `scope.repos`; with no scope (e.g. the current CLI wiring) `fetchLive` + * returns empty and every declared repo is emitted as a create, which `apply` + * services with a PATCH (idempotent against an existing repo). A PATCH against + * a genuinely non-existent repo 404s and is recorded as a failed entry rather + * than silently creating anything. + * + * ## Why PATCH needs no RMW merge + * + * `PATCH /repos/{owner}/{repo}` is a *partial* update — GitHub touches only the + * keys in the body. Selective-by-omission therefore holds by sending only + * declared fields. Topics, however, use a full-replacement PUT, so the topics + * list is sent verbatim from config (it is managed as a whole list, not merged). + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { OrgConfig, RepoConfig } from "../config/types.js"; +import type { ChangeSetEntry, LiveOrgState, LiveRepoConfig } from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** + * Scope for the repo-settings cycle. + * + * Pass `repos` (typically `orgConfig.repos`) to enable accurate live-fetch. + * Omit it for the fast path where every declared repo is treated as a create. + * The org login is supplied per-org by the runner as `orgLogin`, not via scope. + */ +export interface RepoSettingsScope { + repos?: Record; +} + +// --------------------------------------------------------------------------- +// GitHub REST API response shape (only the fields we read) +// --------------------------------------------------------------------------- + +/** Minimal shape of the `GET /repos/{owner}/{repo}` response we care about. */ +interface GhRepo { + description?: string | null; + homepage?: string | null; + private?: boolean | null; + has_issues?: boolean | null; + has_projects?: boolean | null; + has_wiki?: boolean | null; + default_branch?: string | null; + allow_squash_merge?: boolean | null; + allow_merge_commit?: boolean | null; + allow_rebase_merge?: boolean | null; + delete_branch_on_merge?: boolean | null; + topics?: string[] | null; +} + +/** + * Settings keys this cycle manages (everything in `RepoConfig` except + * `branchProtection`, which is owned by the branch-protection cycle). + */ +const MANAGED_REPO_KEYS: Array = [ + "description", + "websiteUrl", + "private", + "hasIssues", + "hasProjects", + "hasWiki", + "defaultBranch", + "allowSquashMerge", + "allowMergeCommit", + "allowRebaseMerge", + "deleteBranchOnMerge", + "topics", +]; + +/** True when a repo config declares at least one managed setting. */ +function hasManagedRepoSettings(repo: RepoConfig): boolean { + return MANAGED_REPO_KEYS.some((k) => repo[k] !== undefined); +} + +// --------------------------------------------------------------------------- +// Live-state mapping +// --------------------------------------------------------------------------- + +/** Map the GitHub repo GET response to the `LiveRepoConfig` diff shape. */ +function mapRepoToLive(raw: GhRepo): LiveRepoConfig { + const live: LiveRepoConfig = {}; + + if (raw.description != null) live.description = raw.description; + if (raw.homepage != null) live.websiteUrl = raw.homepage; + if (typeof raw.private === "boolean") live.private = raw.private; + if (typeof raw.has_issues === "boolean") live.hasIssues = raw.has_issues; + if (typeof raw.has_projects === "boolean") live.hasProjects = raw.has_projects; + if (typeof raw.has_wiki === "boolean") live.hasWiki = raw.has_wiki; + if (raw.default_branch != null) live.defaultBranch = raw.default_branch; + if (typeof raw.allow_squash_merge === "boolean") live.allowSquashMerge = raw.allow_squash_merge; + if (typeof raw.allow_merge_commit === "boolean") live.allowMergeCommit = raw.allow_merge_commit; + if (typeof raw.allow_rebase_merge === "boolean") live.allowRebaseMerge = raw.allow_rebase_merge; + if (typeof raw.delete_branch_on_merge === "boolean") live.deleteBranchOnMerge = raw.delete_branch_on_merge; + if (Array.isArray(raw.topics)) live.topics = raw.topics; + + return live; +} + +// --------------------------------------------------------------------------- +// PATCH body builder +// --------------------------------------------------------------------------- + +/** + * Build the `PATCH /repos/{owner}/{repo}` body from the declared settings. + * Only declared keys are emitted; `topics` is excluded (handled by a separate + * PUT). Returns an empty object when nothing patchable is declared. + */ +export function buildRepoPatchBody(desired: RepoConfig): Record { + const body: Record = {}; + + if (desired.description !== undefined) body.description = desired.description; + if (desired.websiteUrl !== undefined) body.homepage = desired.websiteUrl; + if (desired.private !== undefined) body.private = desired.private; + if (desired.hasIssues !== undefined) body.has_issues = desired.hasIssues; + if (desired.hasProjects !== undefined) body.has_projects = desired.hasProjects; + if (desired.hasWiki !== undefined) body.has_wiki = desired.hasWiki; + if (desired.defaultBranch !== undefined) body.default_branch = desired.defaultBranch; + if (desired.allowSquashMerge !== undefined) body.allow_squash_merge = desired.allowSquashMerge; + if (desired.allowMergeCommit !== undefined) body.allow_merge_commit = desired.allowMergeCommit; + if (desired.allowRebaseMerge !== undefined) body.allow_rebase_merge = desired.allowRebaseMerge; + if (desired.deleteBranchOnMerge !== undefined) body.delete_branch_on_merge = desired.deleteBranchOnMerge; + + return body; +} + +// --------------------------------------------------------------------------- +// repoSettingsCycle — implements Cycle +// --------------------------------------------------------------------------- + +/** + * Governance cycle for repository settings. + * + * Reconciles the non-branch-protection fields of each repo in the config's + * `repos` map. Repos and fields absent from config are left untouched. + */ +export const repoSettingsCycle: Cycle = { + name: "repo-settings", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + scope: RepoSettingsScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + const repos = scope.repos; + if (!repos || Object.keys(repos).length === 0) { + return { repos: {} }; + } + + return fetchLiveRepoSettings(client, orgLogin, repos, budget); + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: RepoSettingsScope): OrgConfig { + if (!orgConfig.repos) return {}; + + const repos: Record = {}; + for (const [name, repoConfig] of Object.entries(orgConfig.repos)) { + if (!hasManagedRepoSettings(repoConfig)) continue; + // Keep only managed settings keys — strip branchProtection (other cycle). + const stripped: RepoConfig = {}; + for (const key of MANAGED_REPO_KEYS) { + if (repoConfig[key] !== undefined) { + (stripped as Record)[key] = repoConfig[key]; + } + } + repos[name] = stripped; + } + + return { repos }; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: RepoSettingsScope, + budget: RateBudget, + ): Promise { + if (entry.resourceType !== "repo") { + // Safety: this cycle only handles repo-settings entries. + return; + } + + // This cycle never deletes repos (deletion is destructive and out of scope). + if (entry.kind === "delete") return; + + const repoName = entry.key; + const desired = entry.after as RepoConfig; + + // 1. PATCH the partial settings body (if anything patchable is declared). + const body = buildRepoPatchBody(desired); + if (Object.keys(body).length > 0) { + budget.use(1); + await client.request("PATCH", `/repos/${orgLogin}/${repoName}`, body); + } + + // 2. Topics are a separate full-replacement PUT. + if (desired.topics !== undefined) { + budget.use(1); + await client.request("PUT", `/repos/${orgLogin}/${repoName}/topics`, { + names: desired.topics, + }); + } + }, +}; + +// --------------------------------------------------------------------------- +// fetchLiveRepoSettings — low-level helper (also used directly in tests) +// --------------------------------------------------------------------------- + +/** + * Fetch live settings for a set of repos. One API call per repo that declares + * at least one managed setting; repos with no managed settings are skipped + * (zero API calls). A 404 yields no entry for that repo (treated as a create). + * The budget is checked before each call and the partial result is returned + * when exhausted mid-loop. + */ +export async function fetchLiveRepoSettings( + client: AppClient, + orgLogin: string, + repos: Record, + budget: RateBudget, +): Promise { + const liveRepos: LiveOrgState["repos"] = {}; + + for (const [name, repoConfig] of Object.entries(repos)) { + if (!hasManagedRepoSettings(repoConfig)) continue; + if (budget.exhausted) break; + + budget.use(1); + let raw: GhRepo; + try { + raw = await client.request("GET", `/repos/${orgLogin}/${name}`); + } catch (err) { + // 404 → repo not found live; emit no entry (diff will treat as create). + if (err instanceof Error && err.message.includes("404")) continue; + throw err; + } + + liveRepos[name] = mapRepoToLive(raw); + } + + return { repos: liveRepos }; +} diff --git a/src/index.ts b/src/index.ts index 5395722..a11b80a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,8 @@ export { runReconcile, BudgetExhaustedError } from "./reconcile/runner.js"; export { branchProtectionCycle, fetchLiveForOrg } from "./cycles/branch-protection.js"; export { orgSettingsCycle, buildOrgPatchBody } from "./cycles/org-settings.js"; export type { OrgSettingsScope } from "./cycles/org-settings.js"; +export { repoSettingsCycle, buildRepoPatchBody, fetchLiveRepoSettings } from "./cycles/repo-settings.js"; +export type { RepoSettingsScope } from "./cycles/repo-settings.js"; // Reconcile: dump (export live state to desired-state config) export type { DumpOrgOptions, DumpResult } from "./reconcile/dump.js";