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
167 changes: 164 additions & 3 deletions action/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ var init_app_client = __esm({
function diff(org, desired, live, opts = {}) {
const entries = [];
diffSettings(desired.settings, live.settings, entries);
diffRulesets("", "org-ruleset", desired.rulesets, live.rulesets ?? [], opts, entries);
diffTeams(desired.teams, live.teams ?? {}, opts, entries);
diffMembers(desired.members, live.members ?? [], opts, entries);
diffRepos(desired.repos, live.repos ?? {}, opts, entries);
Expand Down Expand Up @@ -485,6 +486,7 @@ function diffRepos(desired, live, opts, out) {
});
}
diffBranchProtection(name, dr.branchProtection, lr.branchProtection ?? [], opts, out);
diffRulesets(`${name}/`, "repo-ruleset", dr.rulesets, lr.rulesets ?? [], opts, out);
}
for (const name of Object.keys(live)) {
if (!Object.prototype.hasOwnProperty.call(desired, name)) {
Expand Down Expand Up @@ -553,6 +555,35 @@ function diffBranchProtection(repoName, desired, live, opts, out) {
}
}
}
function diffRulesets(keyPrefix, resourceType, desired, live, opts, out) {
if (desired === void 0) return;
const desiredByName = new Map(desired.map((r) => [r.name, r]));
const liveByName = new Map(live.map((r) => [r.name, r]));
for (const [name, dr] of desiredByName) {
const lr = liveByName.get(name);
const key = `${keyPrefix}${name}`;
if (!lr) {
out.push({ kind: "create", resourceType, key, after: dr });
continue;
}
const fields = diffObjectKeys(
dr,
lr,
RULESET_FIELDS
);
if (fields.length > 0) {
out.push({ kind: "update", resourceType, key, before: lr, after: dr, fields });
}
}
for (const [name, lr] of liveByName) {
if (!desiredByName.has(name)) {
const key = `${keyPrefix}${name}`;
if (opts.isOwned?.(resourceType, key)) {
out.push({ kind: "delete", resourceType, key, before: lr });
}
}
}
}
function diffObject(desired, live) {
const fields = [];
for (const key of Object.keys(desired)) {
Expand Down Expand Up @@ -616,19 +647,22 @@ function fmt(v) {
const json2 = JSON.stringify(v);
return json2.length > 60 ? `${json2.slice(0, 57)}...` : json2;
}
var RESOURCE_TYPE_ORDER;
var RESOURCE_TYPE_ORDER, RULESET_FIELDS;
var init_diff = __esm({
"src/reconcile/diff.ts"() {
"use strict";
RESOURCE_TYPE_ORDER = [
"org-settings",
"org-ruleset",
"team",
"team-member",
"team-repo",
"member",
"repo",
"branch-protection"
"branch-protection",
"repo-ruleset"
];
RULESET_FIELDS = ["target", "enforcement", "bypassActors", "conditions", "rules"];
}
});

Expand Down Expand Up @@ -229788,13 +229822,140 @@ async function applyTeamRepo(client, entry, org, budget) {
await client.request("PUT", path, { permission: after.permission });
}

// src/cycles/rulesets.ts
var PER_PAGE3 = 100;
async function fetchRulesets(client, basePath, budget) {
const summaries = [];
let page = 1;
for (; ; ) {
if (budget.exhausted) break;
budget.use(1);
let batch;
try {
batch = await client.request(
"GET",
`${basePath}?per_page=${PER_PAGE3}&page=${page}`
);
} catch (err) {
if (err instanceof Error && err.message.includes("404")) return [];
throw err;
}
if (!Array.isArray(batch) || batch.length === 0) break;
summaries.push(...batch);
if (batch.length < PER_PAGE3) break;
page++;
}
const out = [];
for (const s of summaries) {
if (!s || typeof s.id !== "number") continue;
if (budget.exhausted) break;
budget.use(1);
const detail = await client.request("GET", `${basePath}/${s.id}`);
out.push(mapRulesetToLive(detail));
}
return out;
}
function mapRulesetToLive(raw) {
const live = { id: raw.id, name: raw.name };
if (raw.target != null) live.target = raw.target;
if (raw.enforcement != null) live.enforcement = raw.enforcement;
if (raw.bypass_actors != null) live.bypassActors = raw.bypass_actors;
if (raw.conditions != null) live.conditions = raw.conditions;
if (raw.rules != null) live.rules = raw.rules;
return live;
}
function buildRulesetBody(desired) {
const body = { name: desired.name };
if (desired.target !== void 0) body.target = desired.target;
if (desired.enforcement !== void 0) body.enforcement = desired.enforcement;
if (desired.bypassActors !== void 0) body.bypass_actors = desired.bypassActors;
if (desired.conditions !== void 0) body.conditions = desired.conditions;
if (desired.rules !== void 0) body.rules = desired.rules;
return body;
}
async function applyRuleset(client, entry, basePath, budget) {
if (entry.kind === "delete") {
const id2 = entry.before?.id;
if (id2 === void 0) {
throw new Error(`rulesets: delete entry "${entry.key}" is missing the live ruleset id`);
}
budget.use(1);
await client.request("DELETE", `${basePath}/${id2}`);
return;
}
const desired = entry.after;
const body = buildRulesetBody(desired);
if (entry.kind === "create") {
budget.use(1);
await client.request("POST", basePath, body);
return;
}
const id = entry.before?.id;
if (id === void 0) {
throw new Error(`rulesets: update entry "${entry.key}" is missing the live ruleset id`);
}
budget.use(1);
await client.request("PUT", `${basePath}/${id}`, body);
}
var rulesetsCycle = {
name: "rulesets",
// ── 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 orgRulesets = await fetchRulesets(client, `/orgs/${orgLogin}/rulesets`, budget);
const repos = {};
for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) {
if (repoConfig.rulesets === void 0) continue;
if (budget.exhausted) break;
const rs = await fetchRulesets(client, `/repos/${orgLogin}/${name}/rulesets`, budget);
repos[name] = { rulesets: rs };
}
return { rulesets: orgRulesets, repos };
},
// ── Part 3: buildDesired ───────────────────────────────────────────────────
buildDesired(orgConfig, _orgLogin, _scope) {
const out = {};
if (orgConfig.rulesets) out.rulesets = orgConfig.rulesets;
if (orgConfig.repos) {
const repos = {};
for (const [name, repoConfig] of Object.entries(orgConfig.repos)) {
if (repoConfig.rulesets && repoConfig.rulesets.length > 0) {
repos[name] = { rulesets: repoConfig.rulesets };
}
}
out.repos = repos;
}
return out;
},
// ── Part 4: apply ──────────────────────────────────────────────────────────
async apply(client, entry, orgLogin, _scope, budget) {
if (entry.resourceType === "org-ruleset") {
return applyRuleset(client, entry, `/orgs/${orgLogin}/rulesets`, budget);
}
if (entry.resourceType === "repo-ruleset") {
const slashIdx = entry.key.indexOf("/");
if (slashIdx === -1) {
throw new Error(
`rulesets: malformed repo-ruleset key "${entry.key}" \u2014 expected "<repo>/<name>"`
);
}
const repo = entry.key.slice(0, slashIdx);
return applyRuleset(client, entry, `/repos/${orgLogin}/${repo}/rulesets`, budget);
}
}
};

// src/cli/registry.ts
var CYCLE_REGISTRY = {
[branchProtectionCycle.name]: branchProtectionCycle,
[orgSettingsCycle.name]: orgSettingsCycle,
[repoSettingsCycle.name]: repoSettingsCycle,
[membershipCycle.name]: membershipCycle,
[teamsCycle.name]: teamsCycle
[teamsCycle.name]: teamsCycle,
[rulesetsCycle.name]: rulesetsCycle
};

// 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 @@ -14,6 +14,7 @@ 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";
import { rulesetsCycle } from "../cycles/rulesets.js";

/**
* Registry of all available governance cycles, keyed by the name accepted by
Expand All @@ -28,4 +29,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
[repoSettingsCycle.name]: repoSettingsCycle,
[membershipCycle.name]: membershipCycle,
[teamsCycle.name]: teamsCycle,
[rulesetsCycle.name]: rulesetsCycle,
};
46 changes: 46 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,42 @@ export interface MemberConfig {
role?: OrgMemberRole;
}

// ---------------------------------------------------------------------------
// Rulesets (repo + org)
// ---------------------------------------------------------------------------

/** What a ruleset targets. */
export type RulesetTarget = "branch" | "tag" | "push";

/** How a ruleset is enforced. */
export type RulesetEnforcement = "active" | "evaluate" | "disabled";

/**
* A repository or organization ruleset — the modern replacement for classic
* branch protection (a separate REST API). Identified within its scope by
* `name`. Absent fields are not managed (selective-by-omission).
*
* `bypassActors`, `conditions`, and `rules` are passed through in GitHub's
* native (snake_case) JSON shape — e.g. a rule is `{ type, parameters? }`, a
* condition is `{ ref_name: { include, exclude } }`, a bypass actor is
* `{ actor_id, actor_type, bypass_mode }`. Authoring these mirrors the GitHub
* API request body so the cycle can forward them verbatim.
*/
export interface RulesetConfig {
/** Ruleset name — the identity key within its scope (org or repo). */
name: string;
/** Target ref type. GitHub defaults to "branch" on create when omitted. */
target?: RulesetTarget;
/** Enforcement level. */
enforcement?: RulesetEnforcement;
/** Bypass actors, GitHub-native shape: `{ actor_id, actor_type, bypass_mode }`. */
bypassActors?: Array<Record<string, unknown>>;
/** Conditions, GitHub-native shape: `{ ref_name: { include, exclude }, ... }`. */
conditions?: Record<string, unknown>;
/** Rules, GitHub-native shape: `[{ type, parameters? }]`. */
rules?: Array<Record<string, unknown>>;
}

// ---------------------------------------------------------------------------
// Repos
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -166,6 +202,11 @@ export interface RepoConfig {
* Absent means topics are not managed by chant.
*/
topics?: string[];
/**
* Repository rulesets (the modern branch-protection replacement).
* Absent means repo rulesets are not managed by chant.
*/
rulesets?: RulesetConfig[];
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -200,6 +241,11 @@ export interface OrgConfig {
* Absent means repositories are not managed by chant.
*/
repos?: Record<string, RepoConfig>;
/**
* Organization-level rulesets.
* Absent means org rulesets are not managed by chant.
*/
rulesets?: RulesetConfig[];
}

/**
Expand Down
Loading
Loading