diff --git a/action/index.mjs b/action/index.mjs index 699778d..9f2a976 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -229599,12 +229599,202 @@ var membershipCycle = { } }; +// src/cycles/teams.ts +var PER_PAGE2 = 100; +var VALID_PRIVACY = /* @__PURE__ */ new Set(["secret", "closed"]); +var VALID_TEAM_PERMISSIONS = ["admin", "maintain", "push", "triage", "pull"]; +async function paginate(client, makePath, budget) { + const out = []; + let page = 1; + for (; ; ) { + if (budget.exhausted) break; + budget.use(1); + const batch = await client.request("GET", makePath(page)); + if (!Array.isArray(batch) || batch.length === 0) break; + out.push(...batch); + if (batch.length < PER_PAGE2) break; + page++; + } + return out; +} +function mapTeamRepoPermission(repo) { + if (repo.role_name && VALID_TEAM_PERMISSIONS.includes(repo.role_name)) { + return repo.role_name; + } + const p = repo.permissions ?? {}; + if (p.admin) return "admin"; + if (p.maintain) return "maintain"; + if (p.push) return "push"; + if (p.triage) return "triage"; + return "pull"; +} +async function fetchTeamMembers(client, org, slug, budget) { + const maintainers = await paginate( + client, + (page) => `/orgs/${org}/teams/${slug}/members?role=maintainer&per_page=${PER_PAGE2}&page=${page}`, + budget + ); + const members = await paginate( + client, + (page) => `/orgs/${org}/teams/${slug}/members?role=member&per_page=${PER_PAGE2}&page=${page}`, + budget + ); + return [ + ...maintainers.map((m) => ({ login: m.login, role: "maintainer" })), + ...members.map((m) => ({ login: m.login, role: "member" })) + ]; +} +async function fetchTeamRepos(client, org, slug, budget) { + const repos = await paginate( + client, + (page) => `/orgs/${org}/teams/${slug}/repos?per_page=${PER_PAGE2}&page=${page}`, + budget + ); + return repos.map((r) => ({ name: r.name, permission: mapTeamRepoPermission(r) })); +} +async function buildTeamBody(client, org, slug, desired, includeName, budget) { + const body = {}; + if (includeName) body.name = slug; + if (desired.description !== void 0) body.description = desired.description; + if (desired.privacy !== void 0) body.privacy = desired.privacy; + if (desired.parentTeamSlug !== void 0) { + if (desired.parentTeamSlug === "") { + body.parent_team_id = null; + } else { + budget.use(1); + const parent = await client.request( + "GET", + `/orgs/${org}/teams/${desired.parentTeamSlug}` + ); + body.parent_team_id = parent.id; + } + } + return body; +} +function splitKey(key, resourceType) { + const idx = key.indexOf("/"); + if (idx === -1) { + throw new Error(`teams: malformed ${resourceType} key "${key}" \u2014 expected "/"`); + } + return [key.slice(0, idx), key.slice(idx + 1)]; +} +var teamsCycle = { + name: "teams", + // ── 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 ghTeams = await paginate( + client, + (page) => `/orgs/${orgLogin}/teams?per_page=${PER_PAGE2}&page=${page}`, + budget + ); + const teams = {}; + for (const t of ghTeams) { + if (!t || typeof t.slug !== "string") continue; + const live = {}; + if (t.description != null) live.description = t.description; + if (t.privacy != null && VALID_PRIVACY.has(t.privacy)) { + live.privacy = t.privacy; + } + if (t.parent?.slug) live.parentTeamSlug = t.parent.slug; + const scopeTeam = scope.teams?.[t.slug]; + if (scopeTeam?.members !== void 0 && !budget.exhausted) { + live.members = await fetchTeamMembers(client, orgLogin, t.slug, budget); + } + if (scopeTeam?.repos !== void 0 && !budget.exhausted) { + live.repos = await fetchTeamRepos(client, orgLogin, t.slug, budget); + } + teams[t.slug] = live; + } + return { teams }; + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + if (!orgConfig.teams) return {}; + return { teams: orgConfig.teams }; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + switch (entry.resourceType) { + case "team": + return applyTeam(client, entry, orgLogin, budget); + case "team-member": + return applyTeamMember(client, entry, orgLogin, budget); + case "team-repo": + return applyTeamRepo(client, entry, orgLogin, budget); + default: + return; + } + } +}; +async function applyTeam(client, entry, org, budget) { + const slug = entry.key; + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", `/orgs/${org}/teams/${slug}`); + return; + } + const desired = entry.after; + if (entry.kind === "create") { + const body2 = await buildTeamBody(client, org, slug, desired, true, budget); + budget.use(1); + await client.request("POST", `/orgs/${org}/teams`, body2); + for (const m of desired.members ?? []) { + budget.use(1); + await client.request( + "PUT", + `/orgs/${org}/teams/${slug}/memberships/${encodeURIComponent(m.login)}`, + { role: m.role ?? "member" } + ); + } + for (const r of desired.repos ?? []) { + budget.use(1); + await client.request("PUT", `/orgs/${org}/teams/${slug}/repos/${org}/${r.name}`, { + permission: r.permission + }); + } + return; + } + const body = await buildTeamBody(client, org, slug, desired, false, budget); + if (Object.keys(body).length === 0) return; + budget.use(1); + await client.request("PATCH", `/orgs/${org}/teams/${slug}`, body); +} +async function applyTeamMember(client, entry, org, budget) { + const [slug, login] = splitKey(entry.key, "team-member"); + const path = `/orgs/${org}/teams/${slug}/memberships/${encodeURIComponent(login)}`; + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", path); + return; + } + const after = entry.after; + budget.use(1); + await client.request("PUT", path, { role: after.role ?? "member" }); +} +async function applyTeamRepo(client, entry, org, budget) { + const [slug, repo] = splitKey(entry.key, "team-repo"); + const path = `/orgs/${org}/teams/${slug}/repos/${org}/${repo}`; + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", path); + return; + } + const after = entry.after; + budget.use(1); + await client.request("PUT", path, { permission: after.permission }); +} + // src/cli/registry.ts var CYCLE_REGISTRY = { [branchProtectionCycle.name]: branchProtectionCycle, [orgSettingsCycle.name]: orgSettingsCycle, [repoSettingsCycle.name]: repoSettingsCycle, - [membershipCycle.name]: membershipCycle + [membershipCycle.name]: membershipCycle, + [teamsCycle.name]: teamsCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 82a0995..4156135 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -13,6 +13,7 @@ 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"; +import { teamsCycle } from "../cycles/teams.js"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -26,4 +27,5 @@ export const CYCLE_REGISTRY: Record = { [orgSettingsCycle.name]: orgSettingsCycle, [repoSettingsCycle.name]: repoSettingsCycle, [membershipCycle.name]: membershipCycle, + [teamsCycle.name]: teamsCycle, }; diff --git a/src/config/types.ts b/src/config/types.ts index 28fd663..f153b15 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -64,6 +64,15 @@ export interface TeamConfig { privacy?: "secret" | "closed"; /** Parent team slug, if this team is nested under another. */ parentTeamSlug?: string; + /** + * Former slug of this team. When set, a rename is reconciled as an update + * rather than a delete+create: the reconcile guardrails (`resolveRenames`) + * collapse a `delete(previously)` + `create()` pair into a single + * update, preserving the team's members, repos, and history. + * + * Not written to GitHub — it is a reconcile-time hint only. + */ + previously?: string; /** * Team members and their roles. * Absent means membership is not managed by chant. diff --git a/src/cycles/teams.test.ts b/src/cycles/teams.test.ts new file mode 100644 index 0000000..0d879cd --- /dev/null +++ b/src/cycles/teams.test.ts @@ -0,0 +1,464 @@ +/** + * Tests for the teams cycle. + * + * All tests use a mock AppClient — no network calls. + * Coverage: + * - buildDesired: keeps teams only; omits when absent + * - mapTeamRepoPermission: role_name and permissions-boolean fallbacks + * - fetchLive: maps team list + members + repos (scope-gated sub-fetch) + * - diff over the cycle: team / team-member / team-repo entries + * - apply: team create (parent resolution) / update / delete; member; repo + * - guardrails: previously alias collapses a rename into an update + * - runner integration: dry-run plan + */ + +import { describe, it, expect } from "vitest"; +import { teamsCycle, mapTeamRepoPermission } from "./teams.js"; +import type { TeamsScope } from "./teams.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, resolveRenames } 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; + // Any unstubbed list endpoint returns an empty page. + if (method === "GET" && (path.includes("?") || path.endsWith("/teams"))) 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: TeamsScope = {}; + +// --------------------------------------------------------------------------- +// 1. buildDesired +// --------------------------------------------------------------------------- + +describe("teamsCycle.buildDesired", () => { + it("returns empty config when teams are absent", () => { + expect(teamsCycle.buildDesired({ members: [] }, "test-org", scope).teams).toBeUndefined(); + }); + + it("keeps only the teams map", () => { + const orgConfig: OrgConfig = { teams: { backend: { privacy: "closed" } }, members: [] }; + expect(teamsCycle.buildDesired(orgConfig, "test-org", scope)).toEqual({ + teams: { backend: { privacy: "closed" } }, + }); + }); +}); + +// --------------------------------------------------------------------------- +// 2. mapTeamRepoPermission +// --------------------------------------------------------------------------- + +describe("mapTeamRepoPermission", () => { + it("prefers a valid role_name", () => { + expect(mapTeamRepoPermission({ name: "r", role_name: "maintain" })).toBe("maintain"); + }); + it("falls back to the highest permission boolean", () => { + expect(mapTeamRepoPermission({ name: "r", permissions: { push: true, pull: true } })).toBe("push"); + expect(mapTeamRepoPermission({ name: "r", permissions: { admin: true } })).toBe("admin"); + }); + it("defaults to pull when nothing is set", () => { + expect(mapTeamRepoPermission({ name: "r" })).toBe("pull"); + }); +}); + +// --------------------------------------------------------------------------- +// 3. fetchLive +// --------------------------------------------------------------------------- + +describe("teamsCycle.fetchLive", () => { + it("maps the team list and scope-managed sub-resources", async () => { + const org = "test-org"; + const client = makeMockClient({ + [`GET /orgs/${org}/teams?per_page=100&page=1`]: [ + { slug: "backend", description: "Backend", privacy: "closed", parent: { slug: "eng" } }, + ], + [`GET /orgs/${org}/teams/backend/members?role=maintainer&per_page=100&page=1`]: [{ login: "alice" }], + [`GET /orgs/${org}/teams/backend/members?role=member&per_page=100&page=1`]: [{ login: "bob" }], + [`GET /orgs/${org}/teams/backend/repos?per_page=100&page=1`]: [{ name: "svc", role_name: "push" }], + }); + + const scopeWithTeams: TeamsScope = { + teams: { backend: { members: [], repos: [] } }, + }; + const live = await teamsCycle.fetchLive(client, org, scopeWithTeams, makeBudget()); + + expect(live.teams!["backend"]).toEqual({ + description: "Backend", + privacy: "closed", + parentTeamSlug: "eng", + members: [ + { login: "alice", role: "maintainer" }, + { login: "bob", role: "member" }, + ], + repos: [{ name: "svc", permission: "push" }], + }); + }); + + it("does not fetch sub-resources for teams not managing them", async () => { + const org = "test-org"; + const client = makeMockClient({ + [`GET /orgs/${org}/teams?per_page=100&page=1`]: [{ slug: "backend", privacy: "secret" }], + }); + // No scope.teams entry → only the team list call is made. + const live = await teamsCycle.fetchLive(client, org, {}, makeBudget()); + expect(live.teams!["backend"]).toEqual({ privacy: "secret" }); + expect(client.calls).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// 4. diff over the cycle +// --------------------------------------------------------------------------- + +describe("diff integration with teams cycle", () => { + it("emits a single team create with members/repos embedded for a new team", () => { + // The diff embeds members/repos in the team create entry for brand-new + // teams (it does not emit separate child entries); applyTeam attaches them. + const desired = teamsCycle.buildDesired( + { + teams: { + backend: { + privacy: "closed", + members: [{ login: "alice", role: "maintainer" }], + repos: [{ name: "svc", permission: "push" }], + }, + }, + }, + "test-org", + scope, + ); + const cs = diff("test-org", desired, { teams: {} }); + expect(cs.entries).toHaveLength(1); + const entry = cs.entries[0]!; + expect(entry.resourceType).toBe("team"); + expect(entry.kind).toBe("create"); + const after = entry.after as { members?: unknown[]; repos?: unknown[] }; + expect(after.members).toEqual([{ login: "alice", role: "maintainer" }]); + expect(after.repos).toEqual([{ name: "svc", permission: "push" }]); + }); + + it("emits separate team-member/team-repo entries for an existing team", () => { + const live: LiveOrgState = { teams: { backend: { members: [], repos: [] } } }; + const desired = teamsCycle.buildDesired( + { + teams: { + backend: { + members: [{ login: "alice", role: "maintainer" }], + repos: [{ name: "svc", permission: "push" }], + }, + }, + }, + "test-org", + scope, + ); + const cs = diff("test-org", desired, live); + const types = cs.entries.map((e) => `${e.resourceType}:${e.kind}`); + expect(types).toContain("team-member:create"); + expect(types).toContain("team-repo:create"); + // canonical ordering: team-member before team-repo + const order = cs.entries.map((e) => e.resourceType); + expect(order.indexOf("team-member")).toBeLessThan(order.indexOf("team-repo")); + }); + + it("emits a team-member update when a role changes", () => { + const live: LiveOrgState = { + teams: { backend: { members: [{ login: "alice", role: "member" }] } }, + }; + const desired = teamsCycle.buildDesired( + { teams: { backend: { members: [{ login: "alice", role: "maintainer" }] } } }, + "test-org", + scope, + ); + const cs = diff("test-org", desired, live); + expect(cs.entries).toHaveLength(1); + expect(cs.entries[0]!.resourceType).toBe("team-member"); + expect(cs.entries[0]!.kind).toBe("update"); + }); +}); + +// --------------------------------------------------------------------------- +// 5. apply +// --------------------------------------------------------------------------- + +describe("teamsCycle.apply", () => { + it("creates a team, resolving parentTeamSlug to an id", async () => { + const client = makeMockClient({ "GET /orgs/my-org/teams/eng": { id: 42 } }); + await teamsCycle.apply( + client, + { + kind: "create", + resourceType: "team", + key: "backend", + after: { description: "Backend", privacy: "closed", parentTeamSlug: "eng" }, + }, + "my-org", + scope, + makeBudget(), + ); + const post = client.calls.find((c) => c.method === "POST")!; + expect(post.path).toBe("/orgs/my-org/teams"); + expect(post.body).toEqual({ + name: "backend", + description: "Backend", + privacy: "closed", + parent_team_id: 42, + }); + }); + + it("attaches embedded members and repos when creating a team", async () => { + const client = makeMockClient(); + await teamsCycle.apply( + client, + { + kind: "create", + resourceType: "team", + key: "backend", + after: { + privacy: "closed", + members: [{ login: "alice", role: "maintainer" }], + repos: [{ name: "svc", permission: "push" }], + }, + }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("POST"); + const member = client.calls.find((c) => c.path.includes("/memberships/"))!; + expect(member.method).toBe("PUT"); + expect(member.path).toBe("/orgs/my-org/teams/backend/memberships/alice"); + expect(member.body).toEqual({ role: "maintainer" }); + const repo = client.calls.find((c) => c.path.includes("/repos/"))!; + expect(repo.path).toBe("/orgs/my-org/teams/backend/repos/my-org/svc"); + expect(repo.body).toEqual({ permission: "push" }); + }); + + it("does not write the previously hint to GitHub on create", async () => { + const client = makeMockClient(); + await teamsCycle.apply( + client, + { kind: "create", resourceType: "team", key: "platform", after: { previously: "infra", privacy: "secret" } }, + "my-org", + scope, + makeBudget(), + ); + const post = client.calls.find((c) => c.method === "POST")!; + expect(post.body).toEqual({ name: "platform", privacy: "secret" }); + expect(post.body).not.toHaveProperty("previously"); + }); + + it("PATCHes declared fields on update", async () => { + const client = makeMockClient(); + await teamsCycle.apply( + client, + { kind: "update", resourceType: "team", key: "backend", after: { description: "new" }, fields: [] }, + "my-org", + scope, + makeBudget(), + ); + const patch = client.calls.find((c) => c.method === "PATCH")!; + expect(patch.path).toBe("/orgs/my-org/teams/backend"); + expect(patch.body).toEqual({ description: "new" }); + }); + + it("clears the parent when parentTeamSlug is empty", async () => { + const client = makeMockClient(); + await teamsCycle.apply( + client, + { kind: "update", resourceType: "team", key: "backend", after: { parentTeamSlug: "" }, fields: [] }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.body).toEqual({ parent_team_id: null }); + }); + + it("deletes a team", async () => { + const client = makeMockClient(); + await teamsCycle.apply( + client, + { kind: "delete", resourceType: "team", key: "old", before: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("DELETE"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/teams/old"); + }); + + it("adds/roles and removes team members", async () => { + const client = makeMockClient(); + await teamsCycle.apply( + client, + { kind: "create", resourceType: "team-member", key: "backend/alice", after: { login: "alice", role: "maintainer" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("PUT"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/teams/backend/memberships/alice"); + expect(client.calls[0]!.body).toEqual({ role: "maintainer" }); + + await teamsCycle.apply( + client, + { kind: "delete", resourceType: "team-member", key: "backend/bob", before: { login: "bob", role: "member" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[1]!.method).toBe("DELETE"); + expect(client.calls[1]!.path).toBe("/orgs/my-org/teams/backend/memberships/bob"); + }); + + it("sets and removes team repo permissions", async () => { + const client = makeMockClient(); + await teamsCycle.apply( + client, + { kind: "update", resourceType: "team-repo", key: "backend/svc", after: { name: "svc", permission: "admin" } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls[0]!.method).toBe("PUT"); + expect(client.calls[0]!.path).toBe("/orgs/my-org/teams/backend/repos/my-org/svc"); + expect(client.calls[0]!.body).toEqual({ permission: "admin" }); + }); + + it("ignores foreign resource types", async () => { + const client = makeMockClient(); + await teamsCycle.apply( + client, + { kind: "create", resourceType: "member", key: "alice", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); + + it("throws on a malformed team-member key", async () => { + const client = makeMockClient(); + await expect( + teamsCycle.apply( + client, + { kind: "create", resourceType: "team-member", key: "no-slash", after: { login: "x" } }, + "my-org", + scope, + makeBudget(), + ), + ).rejects.toThrow("malformed team-member key"); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Rename-without-loss (guardrail collapse) +// --------------------------------------------------------------------------- + +describe("teams rename-without-loss", () => { + it("collapses delete(previously)+create(slug) into an update and spares removalDeltaCap", () => { + // Live has 4 teams; config renames one (infra → platform) and keeps the rest. + const live: LiveOrgState = { + teams: { + infra: { privacy: "closed" }, + backend: { privacy: "closed" }, + frontend: { privacy: "closed" }, + data: { privacy: "closed" }, + }, + }; + const desired = teamsCycle.buildDesired( + { + teams: { + platform: { privacy: "closed", previously: "infra" }, + backend: { privacy: "closed" }, + frontend: { privacy: "closed" }, + data: { privacy: "closed" }, + }, + }, + "test-org", + scope, + ); + // Ownership enabled so the old slug is eligible for deletion. + const cs = diff("test-org", desired, live, { isOwned: () => true }); + + // Raw change set: one create(platform) + one delete(infra). + expect(cs.entries.some((e) => e.kind === "create" && e.key === "platform")).toBe(true); + expect(cs.entries.some((e) => e.kind === "delete" && e.key === "infra")).toBe(true); + + // resolveRenames collapses them into a single update; no deletes remain. + const resolved = resolveRenames(cs); + expect(resolved.entries.some((e) => e.kind === "delete")).toBe(false); + expect(resolved.entries.some((e) => e.kind === "update" && e.key === "platform")).toBe(true); + + // The rename therefore does NOT trip removalDeltaCap. (adminFloor still + // trips on this memberless fixture, so assert the specific guardrail.) + const gr = runGuardrails(cs, live); + const tripped = gr.ok ? [] : gr.diagnostics.map((d) => d.guardrail); + expect(tripped).not.toContain("removalDeltaCap"); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Runner integration +// --------------------------------------------------------------------------- + +describe("teamsCycle via runReconcile", () => { + it("dry-run: reports a team create plan", async () => { + const org = "test-org"; + const client = makeMockClient({ + [`GET /orgs/${org}/teams?per_page=100&page=1`]: [], + }); + const config: GovernanceConfig = { + orgs: { [org]: { teams: { backend: { privacy: "closed" } } } }, + }; + const result = await runReconcile({ config, client, cycles: [teamsCycle], 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/teams.ts b/src/cycles/teams.ts new file mode 100644 index 0000000..5f45b9f --- /dev/null +++ b/src/cycles/teams.ts @@ -0,0 +1,414 @@ +/** + * Teams cycle. + * + * Reconciles organization teams — the team tree (privacy, parent), team + * membership/roles, and team→repo permissions. + * + * GET /orgs/{org}/teams — list teams + * GET /orgs/{org}/teams/{slug} — resolve parent id + * POST /orgs/{org}/teams — create team + * PATCH /orgs/{org}/teams/{slug} — update team + * DELETE /orgs/{org}/teams/{slug} — delete team + * GET /orgs/{org}/teams/{slug}/members?role=... — list members + * PUT /orgs/{org}/teams/{slug}/memberships/{user} — add/role member + * DELETE /orgs/{org}/teams/{slug}/memberships/{user} — remove member + * GET /orgs/{org}/teams/{slug}/repos — list team repos + * PUT /orgs/{org}/teams/{slug}/repos/{owner}/{repo} — set repo perm + * DELETE /orgs/{org}/teams/{slug}/repos/{owner}/{repo} — remove repo + * + * Follows the four-part `Cycle` structure of the branch-protection template + * (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * The diff emits three resource types for teams — `team`, `team-member`, and + * `team-repo` — in that canonical order, so a newly-created team exists before + * its members and repos are attached. This cycle's `apply` dispatches on all + * three. + * + * ## Scope and sub-resource fetch + * + * Like branch-protection/repo-settings, sub-resources (members, repos) are + * fetched only for teams present in `scope.teams` that manage them. The team + * list itself is always fetched so team creates/updates/deletes are detected. + * + * ## Rename-without-loss + * + * A `TeamConfig.previously` slug marks a rename. Its effect is at the GUARDRAIL + * layer: `resolveRenames` collapses a `delete(previously)` + `create(slug)` + * pair into a single update so the rename does not count against + * `removalDeltaCap` (a rename is not a mass-deletion). The delete half only + * exists when the old slug is owned (`DiffOptions.isOwned`); with the safe + * default (no ownership predicate) a renamed team is emitted purely as a + * create, leaving the old team in place — nothing is deleted, so nothing is + * lost. `previously` is a reconcile-time hint and is never written to GitHub. + * + * NOTE: the runner applies the raw (non-resolved) change set, so it does not + * yet perform a single atomic GitHub rename. Atomic apply-time rename (PATCH + * the old team's name in place) is a runner-level follow-up; this cycle wires + * up the config field and guardrail support that it builds on. + * + * ## Team name vs slug + * + * Teams are keyed by slug in config; `TeamConfig` carries no separate display + * name. On create the slug is sent as the `name` (GitHub re-slugifies it). On + * update the name is not sent, so an existing team's slug is never disturbed. + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { + OrgConfig, + TeamConfig, + TeamMember, + TeamRepo, + TeamRepoPermission, +} from "../config/types.js"; +import type { + ChangeSetEntry, + LiveOrgState, + LiveTeamConfig, + LiveTeamMember, + LiveTeamRepo, +} from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** + * Scope for the teams cycle. Pass `teams` (typically `orgConfig.teams`) so + * `fetchLive` knows which teams' members/repos to fetch. The org login is + * supplied per-org by the runner as `orgLogin`, not via scope. + */ +export interface TeamsScope { + teams?: Record; +} + +// --------------------------------------------------------------------------- +// GitHub REST API response shapes (only the fields we read) +// --------------------------------------------------------------------------- + +interface GhTeam { + slug: string; + name?: string; + description?: string | null; + privacy?: string | null; + parent?: { slug?: string } | null; +} + +interface GhTeamMember { + login: string; +} + +interface GhTeamRepo { + name: string; + role_name?: string | null; + permissions?: { + admin?: boolean; + maintain?: boolean; + push?: boolean; + triage?: boolean; + pull?: boolean; + } | null; +} + +const PER_PAGE = 100; + +const VALID_PRIVACY = new Set(["secret", "closed"]); +const VALID_TEAM_PERMISSIONS: TeamRepoPermission[] = ["admin", "maintain", "push", "triage", "pull"]; + +// --------------------------------------------------------------------------- +// Pagination helper +// --------------------------------------------------------------------------- + +/** + * Page through a list endpoint, charging the budget per page and stopping when + * a short page is returned or the budget is exhausted. `makePath(page)` builds + * the request path for a given 1-based page number. + */ +async function paginate( + client: AppClient, + makePath: (page: number) => string, + budget: RateBudget, +): Promise { + const out: T[] = []; + let page = 1; + for (;;) { + if (budget.exhausted) break; + budget.use(1); + const batch = await client.request("GET", makePath(page)); + if (!Array.isArray(batch) || batch.length === 0) break; + out.push(...batch); + if (batch.length < PER_PAGE) break; + page++; + } + return out; +} + +// --------------------------------------------------------------------------- +// Live-state mapping helpers +// --------------------------------------------------------------------------- + +/** Map a team repo's GitHub permission shape to our permission enum. */ +export function mapTeamRepoPermission(repo: GhTeamRepo): TeamRepoPermission { + if (repo.role_name && (VALID_TEAM_PERMISSIONS as string[]).includes(repo.role_name)) { + return repo.role_name as TeamRepoPermission; + } + const p = repo.permissions ?? {}; + if (p.admin) return "admin"; + if (p.maintain) return "maintain"; + if (p.push) return "push"; + if (p.triage) return "triage"; + return "pull"; +} + +/** Fetch the maintainer/member roster for one team. */ +async function fetchTeamMembers( + client: AppClient, + org: string, + slug: string, + budget: RateBudget, +): Promise { + const maintainers = await paginate( + client, + (page) => `/orgs/${org}/teams/${slug}/members?role=maintainer&per_page=${PER_PAGE}&page=${page}`, + budget, + ); + const members = await paginate( + client, + (page) => `/orgs/${org}/teams/${slug}/members?role=member&per_page=${PER_PAGE}&page=${page}`, + budget, + ); + return [ + ...maintainers.map((m) => ({ login: m.login, role: "maintainer" as const })), + ...members.map((m) => ({ login: m.login, role: "member" as const })), + ]; +} + +/** Fetch the repo permissions for one team. */ +async function fetchTeamRepos( + client: AppClient, + org: string, + slug: string, + budget: RateBudget, +): Promise { + const repos = await paginate( + client, + (page) => `/orgs/${org}/teams/${slug}/repos?per_page=${PER_PAGE}&page=${page}`, + budget, + ); + return repos.map((r) => ({ name: r.name, permission: mapTeamRepoPermission(r) })); +} + +// --------------------------------------------------------------------------- +// Apply helpers +// --------------------------------------------------------------------------- + +/** + * Build the create/update body for a team. Resolves `parentTeamSlug` to a + * `parent_team_id` (one extra GET) when declared. `includeName` adds the slug + * as the team name (create only). + */ +async function buildTeamBody( + client: AppClient, + org: string, + slug: string, + desired: TeamConfig, + includeName: boolean, + budget: RateBudget, +): Promise> { + const body: Record = {}; + if (includeName) body.name = slug; + if (desired.description !== undefined) body.description = desired.description; + if (desired.privacy !== undefined) body.privacy = desired.privacy; + if (desired.parentTeamSlug !== undefined) { + if (desired.parentTeamSlug === "") { + body.parent_team_id = null; + } else { + budget.use(1); + const parent = await client.request<{ id: number }>( + "GET", + `/orgs/${org}/teams/${desired.parentTeamSlug}`, + ); + body.parent_team_id = parent.id; + } + } + return body; +} + +function splitKey(key: string, resourceType: string): [string, string] { + const idx = key.indexOf("/"); + if (idx === -1) { + throw new Error(`teams: malformed ${resourceType} key "${key}" — expected "/"`); + } + return [key.slice(0, idx), key.slice(idx + 1)]; +} + +// --------------------------------------------------------------------------- +// teamsCycle — implements Cycle +// --------------------------------------------------------------------------- + +export const teamsCycle: Cycle = { + name: "teams", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + scope: TeamsScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + const ghTeams = await paginate( + client, + (page) => `/orgs/${orgLogin}/teams?per_page=${PER_PAGE}&page=${page}`, + budget, + ); + + const teams: Record = {}; + for (const t of ghTeams) { + if (!t || typeof t.slug !== "string") continue; + const live: LiveTeamConfig = {}; + if (t.description != null) live.description = t.description; + if (t.privacy != null && VALID_PRIVACY.has(t.privacy)) { + live.privacy = t.privacy as LiveTeamConfig["privacy"]; + } + if (t.parent?.slug) live.parentTeamSlug = t.parent.slug; + + // Fetch sub-resources only for teams whose config manages them. + const scopeTeam = scope.teams?.[t.slug]; + if (scopeTeam?.members !== undefined && !budget.exhausted) { + live.members = await fetchTeamMembers(client, orgLogin, t.slug, budget); + } + if (scopeTeam?.repos !== undefined && !budget.exhausted) { + live.repos = await fetchTeamRepos(client, orgLogin, t.slug, budget); + } + + teams[t.slug] = live; + } + + return { teams }; + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: TeamsScope): OrgConfig { + if (!orgConfig.teams) return {}; + return { teams: orgConfig.teams }; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: TeamsScope, + budget: RateBudget, + ): Promise { + switch (entry.resourceType) { + case "team": + return applyTeam(client, entry, orgLogin, budget); + case "team-member": + return applyTeamMember(client, entry, orgLogin, budget); + case "team-repo": + return applyTeamRepo(client, entry, orgLogin, budget); + default: + // Not ours — ignore. + return; + } + }, +}; + +async function applyTeam( + client: AppClient, + entry: ChangeSetEntry, + org: string, + budget: RateBudget, +): Promise { + const slug = entry.key; + + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", `/orgs/${org}/teams/${slug}`); + return; + } + + const desired = entry.after as TeamConfig; + + if (entry.kind === "create") { + const body = await buildTeamBody(client, org, slug, desired, true, budget); + budget.use(1); + await client.request("POST", `/orgs/${org}/teams`, body); + + // For a brand-new team the diff embeds members/repos in this create entry + // (it does not emit separate team-member/team-repo entries), so attach them + // here. Existing teams get their members/repos reconciled as their own + // entries by applyTeamMember/applyTeamRepo. + for (const m of desired.members ?? []) { + budget.use(1); + await client.request( + "PUT", + `/orgs/${org}/teams/${slug}/memberships/${encodeURIComponent(m.login)}`, + { role: m.role ?? "member" }, + ); + } + for (const r of desired.repos ?? []) { + budget.use(1); + await client.request("PUT", `/orgs/${org}/teams/${slug}/repos/${org}/${r.name}`, { + permission: r.permission, + }); + } + return; + } + + // update — PATCH only the declared top-level fields. + const body = await buildTeamBody(client, org, slug, desired, false, budget); + if (Object.keys(body).length === 0) return; + budget.use(1); + await client.request("PATCH", `/orgs/${org}/teams/${slug}`, body); +} + +async function applyTeamMember( + client: AppClient, + entry: ChangeSetEntry, + org: string, + budget: RateBudget, +): Promise { + const [slug, login] = splitKey(entry.key, "team-member"); + const path = `/orgs/${org}/teams/${slug}/memberships/${encodeURIComponent(login)}`; + + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", path); + return; + } + + const after = entry.after as TeamMember; + budget.use(1); + await client.request("PUT", path, { role: after.role ?? "member" }); +} + +async function applyTeamRepo( + client: AppClient, + entry: ChangeSetEntry, + org: string, + budget: RateBudget, +): Promise { + const [slug, repo] = splitKey(entry.key, "team-repo"); + const path = `/orgs/${org}/teams/${slug}/repos/${org}/${repo}`; + + if (entry.kind === "delete") { + budget.use(1); + await client.request("DELETE", path); + return; + } + + const after = entry.after as TeamRepo; + budget.use(1); + await client.request("PUT", path, { permission: after.permission }); +} diff --git a/src/index.ts b/src/index.ts index f0dabda..758877c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,8 @@ export { repoSettingsCycle, buildRepoPatchBody, fetchLiveRepoSettings } from "./ export type { RepoSettingsScope } from "./cycles/repo-settings.js"; export { membershipCycle, listOrgMembers } from "./cycles/membership.js"; export type { MembershipScope } from "./cycles/membership.js"; +export { teamsCycle, mapTeamRepoPermission } from "./cycles/teams.js"; +export type { TeamsScope } from "./cycles/teams.js"; // Reconcile: dump (export live state to desired-state config) export type { DumpOrgOptions, DumpResult } from "./reconcile/dump.js";