diff --git a/action/index.mjs b/action/index.mjs index 7fd9572..699778d 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -229541,11 +229541,70 @@ async function fetchLiveRepoSettings(client, orgLogin, repos, budget) { return { repos: liveRepos }; } +// src/cycles/membership.ts +var PER_PAGE = 100; +async function listOrgMembers(client, orgLogin, role, budget) { + const logins = []; + let page = 1; + for (; ; ) { + if (budget.exhausted) break; + budget.use(1); + const path = `/orgs/${orgLogin}/members?role=${role}&per_page=${PER_PAGE}&page=${page}`; + const batch = await client.request("GET", path); + if (!Array.isArray(batch) || batch.length === 0) break; + for (const u of batch) { + if (u && typeof u.login === "string") logins.push(u.login); + } + if (batch.length < PER_PAGE) break; + page++; + } + return logins; +} +var membershipCycle = { + name: "membership", + // ── 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 admins = await listOrgMembers(client, orgLogin, "admin", budget); + const members = await listOrgMembers(client, orgLogin, "member", budget); + const live = [ + ...admins.map((login) => ({ login, role: "admin" })), + ...members.map((login) => ({ login, role: "member" })) + ]; + return { members: live }; + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + if (!orgConfig.members) return {}; + return { members: orgConfig.members }; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + if (entry.resourceType !== "member") { + return; + } + const login = encodeURIComponent(entry.key); + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", `/orgs/${orgLogin}/memberships/${login}`); + return; + } + const after = entry.after; + const role = after.role ?? "member"; + budget.use(1); + await client.request("PUT", `/orgs/${orgLogin}/memberships/${login}`, { role }); + } +}; + // src/cli/registry.ts var CYCLE_REGISTRY = { [branchProtectionCycle.name]: branchProtectionCycle, [orgSettingsCycle.name]: orgSettingsCycle, - [repoSettingsCycle.name]: repoSettingsCycle + [repoSettingsCycle.name]: repoSettingsCycle, + [membershipCycle.name]: membershipCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 2194274..82a0995 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -12,6 +12,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"; +import { membershipCycle } from "../cycles/membership.js"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -24,4 +25,5 @@ export const CYCLE_REGISTRY: Record = { [branchProtectionCycle.name]: branchProtectionCycle, [orgSettingsCycle.name]: orgSettingsCycle, [repoSettingsCycle.name]: repoSettingsCycle, + [membershipCycle.name]: membershipCycle, }; diff --git a/src/cycles/membership.test.ts b/src/cycles/membership.test.ts new file mode 100644 index 0000000..bc5cf7d --- /dev/null +++ b/src/cycles/membership.test.ts @@ -0,0 +1,313 @@ +/** + * Tests for the membership & roles cycle. + * + * All tests use a mock AppClient — no network calls. + * Coverage: + * - buildDesired: keeps members only; omits when absent + * - fetchLive: lists admins + members across pagination → LiveMemberConfig[] + * - diff over the cycle: create / update (role) / ownership-gated delete + * - apply: PUT membership for add/role; DELETE for removal; foreign skip + * - runner integration: dry-run plan; adminFloor & removalDeltaCap trip + */ + +import { describe, it, expect } from "vitest"; +import { membershipCycle, listOrgMembers } from "./membership.js"; +import type { MembershipScope } from "./membership.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 { runGuardrails } from "../reconcile/guardrails.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; + // Member listing defaults to an empty page (terminates pagination). + if (method === "GET" && path.includes("/members?")) return [] as T; + return {} as T; + }, + }; +} + +function makeBudget(initial = 100): RateBudget { + let remaining = initial; + return { + get remaining() { + return remaining; + }, + get exhausted() { + return remaining <= 0; + }, + use(n = 1) { + if (remaining <= 0) throw new BudgetExhaustedError(); + remaining = Math.max(0, remaining - n); + }, + }; +} + +const scope: MembershipScope = {}; + +const adminPage = (org: string, page = 1) => + `GET /orgs/${org}/members?role=admin&per_page=100&page=${page}`; +const memberPage = (org: string, page = 1) => + `GET /orgs/${org}/members?role=member&per_page=100&page=${page}`; + +// --------------------------------------------------------------------------- +// 1. buildDesired +// --------------------------------------------------------------------------- + +describe("membershipCycle.buildDesired", () => { + it("returns empty config when members are absent", () => { + const desired = membershipCycle.buildDesired({ teams: {} }, "test-org", scope); + expect(desired.members).toBeUndefined(); + }); + + it("keeps only the members array", () => { + const orgConfig: OrgConfig = { + members: [{ login: "alice", role: "admin" }], + teams: { backend: {} }, + }; + const desired = membershipCycle.buildDesired(orgConfig, "test-org", scope); + expect(desired).toEqual({ members: [{ login: "alice", role: "admin" }] }); + }); +}); + +// --------------------------------------------------------------------------- +// 2. fetchLive / listOrgMembers +// --------------------------------------------------------------------------- + +describe("membershipCycle.fetchLive", () => { + it("lists admins and members and tags roles", async () => { + const client = makeMockClient({ + [adminPage("test-org")]: [{ login: "alice" }, { login: "bob" }], + [memberPage("test-org")]: [{ login: "carol" }], + }); + const live = await membershipCycle.fetchLive(client, "test-org", scope, makeBudget()); + expect(live.members).toEqual([ + { login: "alice", role: "admin" }, + { login: "bob", role: "admin" }, + { login: "carol", role: "member" }, + ]); + }); + + it("follows pagination until a short page", async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ login: `u${i}` })); + const client = makeMockClient({ + [adminPage("test-org", 1)]: fullPage, + [adminPage("test-org", 2)]: [{ login: "last" }], + [memberPage("test-org", 1)]: [], + }); + const logins = await listOrgMembers(client, "test-org", "admin", makeBudget()); + expect(logins).toHaveLength(101); + expect(logins[100]).toBe("last"); + }); + + it("charges the budget per page", async () => { + const client = makeMockClient({ + [adminPage("test-org")]: [{ login: "a" }], + [memberPage("test-org")]: [{ login: "b" }], + }); + const budget = makeBudget(10); + await membershipCycle.fetchLive(client, "test-org", scope, budget); + // one admin page + one member page + expect(budget.remaining).toBe(8); + }); +}); + +// --------------------------------------------------------------------------- +// 3. diff over the cycle +// --------------------------------------------------------------------------- + +describe("diff integration with membership cycle", () => { + it("emits create for a new member and update for a role change", () => { + const live: LiveOrgState = { + members: [{ login: "alice", role: "member" }], + }; + const desired = membershipCycle.buildDesired( + { members: [{ login: "alice", role: "admin" }, { login: "bob", role: "member" }] }, + "test-org", + scope, + ); + const cs = diff("test-org", desired, live); + const byKind = Object.fromEntries(cs.entries.map((e) => [e.key, e.kind])); + expect(byKind["alice"]).toBe("update"); + expect(byKind["bob"]).toBe("create"); + }); + + it("only emits delete for unmanaged members when ownership predicate allows", () => { + const live: LiveOrgState = { + members: [{ login: "alice", role: "member" }, { login: "ghost", role: "member" }], + }; + const desired = membershipCycle.buildDesired( + { members: [{ login: "alice", role: "member" }] }, + "test-org", + scope, + ); + // No predicate → no deletes. + expect(diff("test-org", desired, live).entries).toHaveLength(0); + // Predicate → ghost deleted. + const owned = diff("test-org", desired, live, { isOwned: (_t, k) => k === "ghost" }); + const del = owned.entries.find((e) => e.kind === "delete"); + expect(del!.key).toBe("ghost"); + }); +}); + +// --------------------------------------------------------------------------- +// 4. apply +// --------------------------------------------------------------------------- + +describe("membershipCycle.apply", () => { + it("PUTs membership with the desired role for create/update", async () => { + const client = makeMockClient(); + await membershipCycle.apply( + client, + { kind: "create", resourceType: "member", key: "alice", after: { login: "alice", role: "admin" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("PUT"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/memberships/alice"); + expect(client.calls[0]!.body).toEqual({ role: "admin" }); + }); + + it("defaults role to member when unset", async () => { + const client = makeMockClient(); + await membershipCycle.apply( + client, + { kind: "create", resourceType: "member", key: "bob", after: { login: "bob" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.body).toEqual({ role: "member" }); + }); + + it("DELETEs membership for a delete entry", async () => { + const client = makeMockClient(); + await membershipCycle.apply( + client, + { kind: "delete", resourceType: "member", key: "ghost", before: { login: "ghost", role: "member" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("DELETE"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/memberships/ghost"); + }); + + it("skips non-member entries", async () => { + const client = makeMockClient(); + await membershipCycle.apply( + client, + { kind: "create", resourceType: "team", key: "backend", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Runner integration + guardrails +// --------------------------------------------------------------------------- + +describe("membershipCycle via runReconcile", () => { + it("dry-run: reports add + role-change plan", async () => { + const client = makeMockClient({ + [adminPage("test-org")]: [{ login: "alice" }], + [memberPage("test-org")]: [], + }); + const config: GovernanceConfig = { + orgs: { + "test-org": { + members: [ + { login: "alice", role: "admin" }, + { login: "dave", role: "member" }, + ], + }, + }, + }; + const result = await runReconcile({ config, client, cycles: [membershipCycle], mode: "dry-run" }); + expect(result.completed).toBe(true); + const cr = result.cycles[0]!; + expect(cr.counts.create).toBe(1); // dave + expect(cr.counts.update).toBe(0); // alice already admin + }); + + it("apply: adminFloor blocks an apply that would drop below 2 admins", async () => { + // Live: two admins. Desired demotes one → only 1 admin would remain. + const client = makeMockClient({ + [adminPage("test-org")]: [{ login: "alice" }, { login: "bob" }], + [memberPage("test-org")]: [], + }); + const config: GovernanceConfig = { + orgs: { + "test-org": { + members: [ + { login: "alice", role: "member" }, // demote + { login: "bob", role: "admin" }, + ], + }, + }, + }; + const result = await runReconcile({ + config, + client, + cycles: [membershipCycle], + mode: "apply", + }); + const cr = result.cycles[0]!; + expect(cr.guardrailBlocked).toBe(true); + expect(cr.applied).toHaveLength(0); + expect(cr.guardrails.ok).toBe(false); + if (!cr.guardrails.ok) { + expect(cr.guardrails.diagnostics.some((d) => d.guardrail === "adminFloor")).toBe(true); + } + // No mutating calls were made. + expect(client.calls.every((c) => c.method === "GET")).toBe(true); + }); + + it("removalDeltaCap trips when too many members would be removed", () => { + const live: LiveOrgState = { + members: Array.from({ length: 10 }, (_, i) => ({ login: `u${i}`, role: "member" as const })), + }; + const desired = membershipCycle.buildDesired( + { members: [{ login: "u0", role: "member" }] }, + "test-org", + scope, + ); + // All live members owned → 9 deletes against 10 pre-existing → 90% > 25%. + const cs = diff("test-org", desired, live, { isOwned: () => true }); + const gr = runGuardrails(cs, live); + expect(gr.ok).toBe(false); + if (!gr.ok) { + expect(gr.diagnostics.some((d) => d.guardrail === "removalDeltaCap")).toBe(true); + } + }); +}); diff --git a/src/cycles/membership.ts b/src/cycles/membership.ts new file mode 100644 index 0000000..f42c4cc --- /dev/null +++ b/src/cycles/membership.ts @@ -0,0 +1,165 @@ +/** + * Membership & roles cycle. + * + * Reconciles organization membership and roles — who is a member, who is an + * admin. + * + * GET /orgs/{org}/members?role=admin — live admins (paginated) + * GET /orgs/{org}/members?role=member — live members (paginated) + * PUT /orgs/{org}/memberships/{user} — add / set role (create + update) + * DELETE /orgs/{org}/memberships/{user} — remove from org (delete) + * + * Follows the four-part `Cycle` structure of the branch-protection template + * (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * ## Scope: org members & roles (not outside collaborators) + * + * The shared config model (`MemberConfig` = `{ login, role: member|admin }`) + * describes org membership and role. Outside collaborators are a distinct, + * per-repo concept that the config does not model, so they are intentionally + * out of scope for this cycle and tracked as future inventory work. + * + * ## Safety: deletes are ownership-gated + * + * `diffMembers` only emits a `delete` for a live member absent from config when + * the caller supplies an ownership predicate (`DiffOptions.isOwned`). With the + * default runner wiring (no predicate) this cycle only ADDS or re-roles + * declared members and never removes anyone — the safe default. When removals + * ARE enabled, the `adminFloor`, `requiredAdmins`, `requireSelf`, and + * `removalDeltaCap` guardrails (all member-aware) gate the apply. + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { OrgConfig, MemberConfig, OrgMemberRole } from "../config/types.js"; +import type { ChangeSetEntry, LiveOrgState, LiveMemberConfig } from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** + * Scope for the membership cycle. Org members are addressed by `orgLogin` + * (supplied per-org by the runner); there is no sub-resource selector. + */ +export type MembershipScope = Record; + +// --------------------------------------------------------------------------- +// GitHub REST API response shapes (only the fields we read) +// --------------------------------------------------------------------------- + +/** One entry in the `GET /orgs/{org}/members` list response. */ +interface GhUser { + login: string; +} + +/** Page size for member listing. GitHub caps this at 100. */ +const PER_PAGE = 100; + +// --------------------------------------------------------------------------- +// Live-state fetch helpers +// --------------------------------------------------------------------------- + +/** + * List org members filtered by role, following pagination. One API call per + * page; the budget is charged per page and pagination stops when exhausted. + */ +export async function listOrgMembers( + client: AppClient, + orgLogin: string, + role: "admin" | "member", + budget: RateBudget, +): Promise { + const logins: string[] = []; + let page = 1; + + for (;;) { + if (budget.exhausted) break; + budget.use(1); + const path = `/orgs/${orgLogin}/members?role=${role}&per_page=${PER_PAGE}&page=${page}`; + const batch = await client.request("GET", path); + if (!Array.isArray(batch) || batch.length === 0) break; + for (const u of batch) { + if (u && typeof u.login === "string") logins.push(u.login); + } + if (batch.length < PER_PAGE) break; + page++; + } + + return logins; +} + +// --------------------------------------------------------------------------- +// membershipCycle — implements Cycle +// --------------------------------------------------------------------------- + +/** + * Governance cycle for org membership and roles. + * + * Reconciles the `members` array of each org in the config. Members absent from + * config are left untouched unless an ownership predicate enables removal. + */ +export const membershipCycle: Cycle = { + name: "membership", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + _scope: MembershipScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + const admins = await listOrgMembers(client, orgLogin, "admin", budget); + const members = await listOrgMembers(client, orgLogin, "member", budget); + + const live: LiveMemberConfig[] = [ + ...admins.map((login) => ({ login, role: "admin" as const })), + ...members.map((login) => ({ login, role: "member" as const })), + ]; + + return { members: live }; + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: MembershipScope): OrgConfig { + if (!orgConfig.members) return {}; + return { members: orgConfig.members }; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: MembershipScope, + budget: RateBudget, + ): Promise { + if (entry.resourceType !== "member") { + // Safety: this cycle only handles org-member entries. + return; + } + + const login = encodeURIComponent(entry.key); + + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", `/orgs/${orgLogin}/memberships/${login}`); + return; + } + + // create or update — both set the desired role via the memberships PUT, + // which invites a non-member and updates an existing member's role. + const after = entry.after as MemberConfig; + const role: OrgMemberRole = after.role ?? "member"; + budget.use(1); + await client.request("PUT", `/orgs/${orgLogin}/memberships/${login}`, { role }); + }, +}; diff --git a/src/index.ts b/src/index.ts index a11b80a..f0dabda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,8 @@ 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"; +export { membershipCycle, listOrgMembers } from "./cycles/membership.js"; +export type { MembershipScope } from "./cycles/membership.js"; // Reconcile: dump (export live state to desired-state config) export type { DumpOrgOptions, DumpResult } from "./reconcile/dump.js";