Skip to content

Commit 3f2cc3a

Browse files
lex00claude
andcommitted
feat(cycle): teams reconcile cycle (#7)
Reconciles teams, team membership/roles, and team→repo permissions (team / team-member / team-repo resource types). Create resolves parentTeamSlug→parent_team_id and attaches members/repos embedded in the new-team diff entry. Adds TeamConfig.previously so resolveRenames collapses a rename, sparing removalDeltaCap (apply-time atomic rename is a documented runner follow-up). Registered, exported, action bundle rebuilt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 53f046f commit 3f2cc3a

6 files changed

Lines changed: 1082 additions & 1 deletion

File tree

action/index.mjs

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229599,12 +229599,202 @@ var membershipCycle = {
229599229599
}
229600229600
};
229601229601

229602+
// src/cycles/teams.ts
229603+
var PER_PAGE2 = 100;
229604+
var VALID_PRIVACY = /* @__PURE__ */ new Set(["secret", "closed"]);
229605+
var VALID_TEAM_PERMISSIONS = ["admin", "maintain", "push", "triage", "pull"];
229606+
async function paginate(client, makePath, budget) {
229607+
const out = [];
229608+
let page = 1;
229609+
for (; ; ) {
229610+
if (budget.exhausted) break;
229611+
budget.use(1);
229612+
const batch = await client.request("GET", makePath(page));
229613+
if (!Array.isArray(batch) || batch.length === 0) break;
229614+
out.push(...batch);
229615+
if (batch.length < PER_PAGE2) break;
229616+
page++;
229617+
}
229618+
return out;
229619+
}
229620+
function mapTeamRepoPermission(repo) {
229621+
if (repo.role_name && VALID_TEAM_PERMISSIONS.includes(repo.role_name)) {
229622+
return repo.role_name;
229623+
}
229624+
const p = repo.permissions ?? {};
229625+
if (p.admin) return "admin";
229626+
if (p.maintain) return "maintain";
229627+
if (p.push) return "push";
229628+
if (p.triage) return "triage";
229629+
return "pull";
229630+
}
229631+
async function fetchTeamMembers(client, org, slug, budget) {
229632+
const maintainers = await paginate(
229633+
client,
229634+
(page) => `/orgs/${org}/teams/${slug}/members?role=maintainer&per_page=${PER_PAGE2}&page=${page}`,
229635+
budget
229636+
);
229637+
const members = await paginate(
229638+
client,
229639+
(page) => `/orgs/${org}/teams/${slug}/members?role=member&per_page=${PER_PAGE2}&page=${page}`,
229640+
budget
229641+
);
229642+
return [
229643+
...maintainers.map((m) => ({ login: m.login, role: "maintainer" })),
229644+
...members.map((m) => ({ login: m.login, role: "member" }))
229645+
];
229646+
}
229647+
async function fetchTeamRepos(client, org, slug, budget) {
229648+
const repos = await paginate(
229649+
client,
229650+
(page) => `/orgs/${org}/teams/${slug}/repos?per_page=${PER_PAGE2}&page=${page}`,
229651+
budget
229652+
);
229653+
return repos.map((r) => ({ name: r.name, permission: mapTeamRepoPermission(r) }));
229654+
}
229655+
async function buildTeamBody(client, org, slug, desired, includeName, budget) {
229656+
const body = {};
229657+
if (includeName) body.name = slug;
229658+
if (desired.description !== void 0) body.description = desired.description;
229659+
if (desired.privacy !== void 0) body.privacy = desired.privacy;
229660+
if (desired.parentTeamSlug !== void 0) {
229661+
if (desired.parentTeamSlug === "") {
229662+
body.parent_team_id = null;
229663+
} else {
229664+
budget.use(1);
229665+
const parent = await client.request(
229666+
"GET",
229667+
`/orgs/${org}/teams/${desired.parentTeamSlug}`
229668+
);
229669+
body.parent_team_id = parent.id;
229670+
}
229671+
}
229672+
return body;
229673+
}
229674+
function splitKey(key, resourceType) {
229675+
const idx = key.indexOf("/");
229676+
if (idx === -1) {
229677+
throw new Error(`teams: malformed ${resourceType} key "${key}" \u2014 expected "<slug>/<child>"`);
229678+
}
229679+
return [key.slice(0, idx), key.slice(idx + 1)];
229680+
}
229681+
var teamsCycle = {
229682+
name: "teams",
229683+
// ── Part 2: fetchLive ──────────────────────────────────────────────────────
229684+
async fetchLive(client, orgLogin, scope, budget) {
229685+
if (budget.exhausted) {
229686+
const { BudgetExhaustedError: BudgetExhaustedError2 } = await Promise.resolve().then(() => (init_runner(), runner_exports));
229687+
throw new BudgetExhaustedError2();
229688+
}
229689+
const ghTeams = await paginate(
229690+
client,
229691+
(page) => `/orgs/${orgLogin}/teams?per_page=${PER_PAGE2}&page=${page}`,
229692+
budget
229693+
);
229694+
const teams = {};
229695+
for (const t of ghTeams) {
229696+
if (!t || typeof t.slug !== "string") continue;
229697+
const live = {};
229698+
if (t.description != null) live.description = t.description;
229699+
if (t.privacy != null && VALID_PRIVACY.has(t.privacy)) {
229700+
live.privacy = t.privacy;
229701+
}
229702+
if (t.parent?.slug) live.parentTeamSlug = t.parent.slug;
229703+
const scopeTeam = scope.teams?.[t.slug];
229704+
if (scopeTeam?.members !== void 0 && !budget.exhausted) {
229705+
live.members = await fetchTeamMembers(client, orgLogin, t.slug, budget);
229706+
}
229707+
if (scopeTeam?.repos !== void 0 && !budget.exhausted) {
229708+
live.repos = await fetchTeamRepos(client, orgLogin, t.slug, budget);
229709+
}
229710+
teams[t.slug] = live;
229711+
}
229712+
return { teams };
229713+
},
229714+
// ── Part 3: buildDesired ───────────────────────────────────────────────────
229715+
buildDesired(orgConfig, _orgLogin, _scope) {
229716+
if (!orgConfig.teams) return {};
229717+
return { teams: orgConfig.teams };
229718+
},
229719+
// ── Part 4: apply ──────────────────────────────────────────────────────────
229720+
async apply(client, entry, orgLogin, _scope, budget) {
229721+
switch (entry.resourceType) {
229722+
case "team":
229723+
return applyTeam(client, entry, orgLogin, budget);
229724+
case "team-member":
229725+
return applyTeamMember(client, entry, orgLogin, budget);
229726+
case "team-repo":
229727+
return applyTeamRepo(client, entry, orgLogin, budget);
229728+
default:
229729+
return;
229730+
}
229731+
}
229732+
};
229733+
async function applyTeam(client, entry, org, budget) {
229734+
const slug = entry.key;
229735+
if (entry.kind === "delete") {
229736+
budget.use(1);
229737+
await client.request("DELETE", `/orgs/${org}/teams/${slug}`);
229738+
return;
229739+
}
229740+
const desired = entry.after;
229741+
if (entry.kind === "create") {
229742+
const body2 = await buildTeamBody(client, org, slug, desired, true, budget);
229743+
budget.use(1);
229744+
await client.request("POST", `/orgs/${org}/teams`, body2);
229745+
for (const m of desired.members ?? []) {
229746+
budget.use(1);
229747+
await client.request(
229748+
"PUT",
229749+
`/orgs/${org}/teams/${slug}/memberships/${encodeURIComponent(m.login)}`,
229750+
{ role: m.role ?? "member" }
229751+
);
229752+
}
229753+
for (const r of desired.repos ?? []) {
229754+
budget.use(1);
229755+
await client.request("PUT", `/orgs/${org}/teams/${slug}/repos/${org}/${r.name}`, {
229756+
permission: r.permission
229757+
});
229758+
}
229759+
return;
229760+
}
229761+
const body = await buildTeamBody(client, org, slug, desired, false, budget);
229762+
if (Object.keys(body).length === 0) return;
229763+
budget.use(1);
229764+
await client.request("PATCH", `/orgs/${org}/teams/${slug}`, body);
229765+
}
229766+
async function applyTeamMember(client, entry, org, budget) {
229767+
const [slug, login] = splitKey(entry.key, "team-member");
229768+
const path = `/orgs/${org}/teams/${slug}/memberships/${encodeURIComponent(login)}`;
229769+
if (entry.kind === "delete") {
229770+
budget.use(1);
229771+
await client.request("DELETE", path);
229772+
return;
229773+
}
229774+
const after = entry.after;
229775+
budget.use(1);
229776+
await client.request("PUT", path, { role: after.role ?? "member" });
229777+
}
229778+
async function applyTeamRepo(client, entry, org, budget) {
229779+
const [slug, repo] = splitKey(entry.key, "team-repo");
229780+
const path = `/orgs/${org}/teams/${slug}/repos/${org}/${repo}`;
229781+
if (entry.kind === "delete") {
229782+
budget.use(1);
229783+
await client.request("DELETE", path);
229784+
return;
229785+
}
229786+
const after = entry.after;
229787+
budget.use(1);
229788+
await client.request("PUT", path, { permission: after.permission });
229789+
}
229790+
229602229791
// src/cli/registry.ts
229603229792
var CYCLE_REGISTRY = {
229604229793
[branchProtectionCycle.name]: branchProtectionCycle,
229605229794
[orgSettingsCycle.name]: orgSettingsCycle,
229606229795
[repoSettingsCycle.name]: repoSettingsCycle,
229607-
[membershipCycle.name]: membershipCycle
229796+
[membershipCycle.name]: membershipCycle,
229797+
[teamsCycle.name]: teamsCycle
229608229798
};
229609229799

229610229800
// node_modules/@intentius/chant/src/audit/fetch.ts

src/cli/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { branchProtectionCycle } from "../cycles/branch-protection.js";
1313
import { orgSettingsCycle } from "../cycles/org-settings.js";
1414
import { repoSettingsCycle } from "../cycles/repo-settings.js";
1515
import { membershipCycle } from "../cycles/membership.js";
16+
import { teamsCycle } from "../cycles/teams.js";
1617

1718
/**
1819
* Registry of all available governance cycles, keyed by the name accepted by
@@ -26,4 +27,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
2627
[orgSettingsCycle.name]: orgSettingsCycle,
2728
[repoSettingsCycle.name]: repoSettingsCycle,
2829
[membershipCycle.name]: membershipCycle,
30+
[teamsCycle.name]: teamsCycle,
2931
};

src/config/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ export interface TeamConfig {
6464
privacy?: "secret" | "closed";
6565
/** Parent team slug, if this team is nested under another. */
6666
parentTeamSlug?: string;
67+
/**
68+
* Former slug of this team. When set, a rename is reconciled as an update
69+
* rather than a delete+create: the reconcile guardrails (`resolveRenames`)
70+
* collapse a `delete(previously)` + `create(<this slug>)` pair into a single
71+
* update, preserving the team's members, repos, and history.
72+
*
73+
* Not written to GitHub — it is a reconcile-time hint only.
74+
*/
75+
previously?: string;
6776
/**
6877
* Team members and their roles.
6978
* Absent means membership is not managed by chant.

0 commit comments

Comments
 (0)