Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 191 additions & 1 deletion action/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<slug>/<child>"`);
}
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
Expand Down
2 changes: 2 additions & 0 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,4 +27,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
[orgSettingsCycle.name]: orgSettingsCycle,
[repoSettingsCycle.name]: repoSettingsCycle,
[membershipCycle.name]: membershipCycle,
[teamsCycle.name]: teamsCycle,
};
9 changes: 9 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(<this slug>)` 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.
Expand Down
Loading
Loading