Skip to content

Commit d41b546

Browse files
lex00claude
andauthored
feat(cycle): repository baseline / templating cycle (#10) (#32)
Periodic provisioning backstop: ensures repos declared in repoBaselines EXIST in the org, creating a missing repo (empty or from a template via the generate endpoint; private by default). New RepoBaselineConfig + OrgConfig.repoBaselines + repo-baseline diff type (existence-only: create-when-missing, never update/delete). Per-repo settings stay owned by the other cycles. Registered, exported, action bundle rebuilt. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 06afaa1 commit d41b546

7 files changed

Lines changed: 511 additions & 1 deletion

File tree

action/index.mjs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ function diff(org, desired, live, opts = {}) {
277277
diffRulesets("", "org-ruleset", desired.rulesets, live.rulesets ?? [], opts, entries);
278278
diffSecrets("", "org-secret", desired.secrets, live.secrets ?? [], opts, entries);
279279
diffVariables("", "org-variable", desired.variables, live.variables ?? [], opts, entries);
280+
diffRepoBaselines(desired.repoBaselines, live.repos ?? {}, entries);
280281
diffTeams(desired.teams, live.teams ?? {}, opts, entries);
281282
diffMembers(desired.members, live.members ?? [], opts, entries);
282283
diffRepos(desired.repos, live.repos ?? {}, opts, entries);
@@ -687,6 +688,14 @@ function diffVariables(keyPrefix, resourceType, desired, live, opts, out) {
687688
}
688689
}
689690
}
691+
function diffRepoBaselines(desired, liveRepos, out) {
692+
if (desired === void 0) return;
693+
for (const baseline of desired) {
694+
if (!Object.prototype.hasOwnProperty.call(liveRepos, baseline.name)) {
695+
out.push({ kind: "create", resourceType: "repo-baseline", key: baseline.name, after: baseline });
696+
}
697+
}
698+
}
690699
function diffDependabot(repoName, desired, live, out) {
691700
if (desired === void 0) return;
692701
if (live === void 0 || live.content === void 0) {
@@ -776,6 +785,7 @@ var init_diff = __esm({
776785
"org-ruleset",
777786
"org-secret",
778787
"org-variable",
788+
"repo-baseline",
779789
"team",
780790
"team-member",
781791
"team-repo",
@@ -230556,6 +230566,72 @@ var dependencyHygieneCycle = {
230556230566
}
230557230567
};
230558230568

230569+
// src/cycles/repo-baseline.ts
230570+
var PER_PAGE5 = 100;
230571+
async function listOrgRepoNames(client, orgLogin, budget) {
230572+
const repos = {};
230573+
let page = 1;
230574+
for (; ; ) {
230575+
if (budget.exhausted) break;
230576+
budget.use(1);
230577+
const batch = await client.request(
230578+
"GET",
230579+
`/orgs/${orgLogin}/repos?per_page=${PER_PAGE5}&page=${page}`
230580+
);
230581+
if (!Array.isArray(batch) || batch.length === 0) break;
230582+
for (const r of batch) {
230583+
if (r && typeof r.name === "string") repos[r.name] = {};
230584+
}
230585+
if (batch.length < PER_PAGE5) break;
230586+
page++;
230587+
}
230588+
return repos;
230589+
}
230590+
var repoBaselineCycle = {
230591+
name: "repo-baseline",
230592+
// ── Part 2: fetchLive ──────────────────────────────────────────────────────
230593+
async fetchLive(client, orgLogin, _scope, budget) {
230594+
if (budget.exhausted) {
230595+
const { BudgetExhaustedError: BudgetExhaustedError2 } = await Promise.resolve().then(() => (init_runner(), runner_exports));
230596+
throw new BudgetExhaustedError2();
230597+
}
230598+
return { repos: await listOrgRepoNames(client, orgLogin, budget) };
230599+
},
230600+
// ── Part 3: buildDesired ───────────────────────────────────────────────────
230601+
buildDesired(orgConfig, _orgLogin, _scope) {
230602+
if (!orgConfig.repoBaselines) return {};
230603+
return { repoBaselines: orgConfig.repoBaselines };
230604+
},
230605+
// ── Part 4: apply ──────────────────────────────────────────────────────────
230606+
async apply(client, entry, orgLogin, _scope, budget) {
230607+
if (entry.resourceType !== "repo-baseline") return;
230608+
if (entry.kind !== "create") return;
230609+
const baseline = entry.after;
230610+
const isPrivate = baseline.private ?? true;
230611+
budget.use(1);
230612+
if (baseline.template) {
230613+
const slashIdx = baseline.template.indexOf("/");
230614+
if (slashIdx === -1) {
230615+
throw new Error(
230616+
`repo-baseline: malformed template "${baseline.template}" \u2014 expected "owner/repo"`
230617+
);
230618+
}
230619+
const tmplOwner = baseline.template.slice(0, slashIdx);
230620+
const tmplRepo = baseline.template.slice(slashIdx + 1);
230621+
await client.request("POST", `/repos/${tmplOwner}/${tmplRepo}/generate`, {
230622+
owner: orgLogin,
230623+
name: baseline.name,
230624+
private: isPrivate
230625+
});
230626+
return;
230627+
}
230628+
await client.request("POST", `/orgs/${orgLogin}/repos`, {
230629+
name: baseline.name,
230630+
private: isPrivate
230631+
});
230632+
}
230633+
};
230634+
230559230635
// src/cli/registry.ts
230560230636
var CYCLE_REGISTRY = {
230561230637
[branchProtectionCycle.name]: branchProtectionCycle,
@@ -230567,7 +230643,8 @@ var CYCLE_REGISTRY = {
230567230643
[securityFeaturesCycle.name]: securityFeaturesCycle,
230568230644
[environmentsCycle.name]: environmentsCycle,
230569230645
[secretsVariablesCycle.name]: secretsVariablesCycle,
230570-
[dependencyHygieneCycle.name]: dependencyHygieneCycle
230646+
[dependencyHygieneCycle.name]: dependencyHygieneCycle,
230647+
[repoBaselineCycle.name]: repoBaselineCycle
230571230648
};
230572230649

230573230650
// 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
@@ -19,6 +19,7 @@ import { securityFeaturesCycle } from "../cycles/security-features.js";
1919
import { environmentsCycle } from "../cycles/environments.js";
2020
import { secretsVariablesCycle } from "../cycles/secrets-variables.js";
2121
import { dependencyHygieneCycle } from "../cycles/dependency-hygiene.js";
22+
import { repoBaselineCycle } from "../cycles/repo-baseline.js";
2223

2324
/**
2425
* Registry of all available governance cycles, keyed by the name accepted by
@@ -38,4 +39,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
3839
[environmentsCycle.name]: environmentsCycle,
3940
[secretsVariablesCycle.name]: secretsVariablesCycle,
4041
[dependencyHygieneCycle.name]: dependencyHygieneCycle,
42+
[repoBaselineCycle.name]: repoBaselineCycle,
4143
};

src/config/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,32 @@ export interface RulesetConfig {
255255
rules?: Array<Record<string, unknown>>;
256256
}
257257

258+
// ---------------------------------------------------------------------------
259+
// Repository baseline / provisioning
260+
// ---------------------------------------------------------------------------
261+
262+
/**
263+
* A repo that should EXIST in the org (provisioning, not pure reconcile). The
264+
* baseline cycle creates the repo when it is missing — optionally from a
265+
* template — so a periodic run guarantees declared repos exist. Per-repo
266+
* SETTINGS (description, visibility, branch protection, …) are reconciled by
267+
* the other cycles via the `repos` map; this only ensures existence.
268+
*
269+
* Existence-only: the baseline cycle never deletes a repo.
270+
*/
271+
export interface RepoBaselineConfig {
272+
/** Repository name (without the org prefix). */
273+
name: string;
274+
/**
275+
* Template repo to generate from, as "owner/repo". When set, a missing repo
276+
* is created via the template-generate endpoint; otherwise an empty repo is
277+
* created.
278+
*/
279+
template?: string;
280+
/** Whether a newly-created repo is private. Defaults to true (safe default). */
281+
private?: boolean;
282+
}
283+
258284
// ---------------------------------------------------------------------------
259285
// Repos
260286
// ---------------------------------------------------------------------------
@@ -400,6 +426,11 @@ export interface OrgConfig {
400426
* Absent means variables are not managed by chant.
401427
*/
402428
variables?: VariableConfig[];
429+
/**
430+
* Repositories that must exist in the org (provisioning/templating).
431+
* Absent means repo provisioning is not managed by chant.
432+
*/
433+
repoBaselines?: RepoBaselineConfig[];
403434
}
404435

405436
/**

src/cycles/repo-baseline.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* Tests for the repo-baseline cycle.
3+
*
4+
* All tests use a mock AppClient — no network calls.
5+
* Coverage:
6+
* - buildDesired: keeps repoBaselines
7+
* - listOrgRepoNames: pagination → presence map
8+
* - diff: create only for missing repos; no entry when present; no delete
9+
* - apply: POST create (empty) / template generate / private default
10+
* - runner integration: dry-run plan
11+
*/
12+
13+
import { describe, it, expect } from "vitest";
14+
import { repoBaselineCycle, listOrgRepoNames } from "./repo-baseline.js";
15+
import type { RepoBaselineScope } from "./repo-baseline.js";
16+
import type { AppClient } from "../auth/app-client.js";
17+
import type { RateBudget } from "../reconcile/runner.js";
18+
import { runReconcile, BudgetExhaustedError } from "../reconcile/runner.js";
19+
import { diff } from "../reconcile/diff.js";
20+
import type { LiveOrgState } from "../reconcile/diff.js";
21+
import type { GovernanceConfig, OrgConfig } from "../config/types.js";
22+
23+
// ---------------------------------------------------------------------------
24+
// Mock helpers
25+
// ---------------------------------------------------------------------------
26+
27+
interface MockCall {
28+
method: string;
29+
path: string;
30+
body?: unknown;
31+
}
32+
33+
interface MockClient extends AppClient {
34+
calls: MockCall[];
35+
responses: Map<string, unknown>;
36+
}
37+
38+
function makeMockClient(responses: Record<string, unknown> = {}): MockClient {
39+
const calls: MockCall[] = [];
40+
const responseMap = new Map(Object.entries(responses));
41+
return {
42+
calls,
43+
responses: responseMap,
44+
async request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
45+
calls.push({ method, path, body });
46+
const key = `${method} ${path}`;
47+
if (responseMap.has(key)) return responseMap.get(key) as T;
48+
if (method === "GET" && path.includes("/repos?")) return [] as T;
49+
return {} as T;
50+
},
51+
};
52+
}
53+
54+
function makeBudget(initial = 100): RateBudget {
55+
let remaining = initial;
56+
return {
57+
get remaining() {
58+
return remaining;
59+
},
60+
get exhausted() {
61+
return remaining <= 0;
62+
},
63+
use(n = 1) {
64+
if (remaining <= 0) throw new BudgetExhaustedError();
65+
remaining = Math.max(0, remaining - n);
66+
},
67+
};
68+
}
69+
70+
const scope: RepoBaselineScope = {};
71+
72+
// ---------------------------------------------------------------------------
73+
// 1. buildDesired
74+
// ---------------------------------------------------------------------------
75+
76+
describe("repoBaselineCycle.buildDesired", () => {
77+
it("returns empty when repoBaselines absent", () => {
78+
expect(repoBaselineCycle.buildDesired({ repos: {} }, "test-org", scope).repoBaselines).toBeUndefined();
79+
});
80+
81+
it("keeps only repoBaselines", () => {
82+
const orgConfig: OrgConfig = { repoBaselines: [{ name: "svc" }], members: [] };
83+
expect(repoBaselineCycle.buildDesired(orgConfig, "test-org", scope)).toEqual({
84+
repoBaselines: [{ name: "svc" }],
85+
});
86+
});
87+
});
88+
89+
// ---------------------------------------------------------------------------
90+
// 2. listOrgRepoNames
91+
// ---------------------------------------------------------------------------
92+
93+
describe("listOrgRepoNames", () => {
94+
it("paginates and returns a presence map", async () => {
95+
const full = Array.from({ length: 100 }, (_, i) => ({ name: `r${i}` }));
96+
const client = makeMockClient({
97+
"GET /orgs/test-org/repos?per_page=100&page=1": full,
98+
"GET /orgs/test-org/repos?per_page=100&page=2": [{ name: "last" }],
99+
});
100+
const repos = await listOrgRepoNames(client, "test-org", makeBudget());
101+
expect(Object.keys(repos)).toHaveLength(101);
102+
expect(repos).toHaveProperty("last");
103+
});
104+
});
105+
106+
// ---------------------------------------------------------------------------
107+
// 3. diff
108+
// ---------------------------------------------------------------------------
109+
110+
describe("diff integration with repo-baseline cycle", () => {
111+
const desiredConfig: OrgConfig = { repoBaselines: [{ name: "svc" }, { name: "new-repo" }] };
112+
113+
it("emits create only for the missing repo", () => {
114+
const live: LiveOrgState = { repos: { svc: {} } };
115+
const desired = repoBaselineCycle.buildDesired(desiredConfig, "test-org", scope);
116+
const cs = diff("test-org", desired, live);
117+
expect(cs.entries).toHaveLength(1);
118+
expect(cs.entries[0]!.resourceType).toBe("repo-baseline");
119+
expect(cs.entries[0]!.kind).toBe("create");
120+
expect(cs.entries[0]!.key).toBe("new-repo");
121+
});
122+
123+
it("emits nothing when all declared repos exist", () => {
124+
const live: LiveOrgState = { repos: { svc: {}, "new-repo": {} } };
125+
const desired = repoBaselineCycle.buildDesired(desiredConfig, "test-org", scope);
126+
expect(diff("test-org", desired, live).entries).toHaveLength(0);
127+
});
128+
});
129+
130+
// ---------------------------------------------------------------------------
131+
// 4. apply
132+
// ---------------------------------------------------------------------------
133+
134+
describe("repoBaselineCycle.apply", () => {
135+
it("POSTs a new empty private repo by default", async () => {
136+
const client = makeMockClient();
137+
await repoBaselineCycle.apply(
138+
client,
139+
{ kind: "create", resourceType: "repo-baseline", key: "svc", after: { name: "svc" } },
140+
"my-org",
141+
scope,
142+
makeBudget(),
143+
);
144+
expect(client.calls[0]!.method).toBe("POST");
145+
expect(client.calls[0]!.path).toBe("/orgs/my-org/repos");
146+
expect(client.calls[0]!.body).toEqual({ name: "svc", private: true });
147+
});
148+
149+
it("honours an explicit private:false", async () => {
150+
const client = makeMockClient();
151+
await repoBaselineCycle.apply(
152+
client,
153+
{ kind: "create", resourceType: "repo-baseline", key: "svc", after: { name: "svc", private: false } },
154+
"my-org",
155+
scope,
156+
makeBudget(),
157+
);
158+
expect(client.calls[0]!.body).toEqual({ name: "svc", private: false });
159+
});
160+
161+
it("generates from a template when declared", async () => {
162+
const client = makeMockClient();
163+
await repoBaselineCycle.apply(
164+
client,
165+
{ kind: "create", resourceType: "repo-baseline", key: "svc", after: { name: "svc", template: "my-org/tmpl" } },
166+
"my-org",
167+
scope,
168+
makeBudget(),
169+
);
170+
expect(client.calls[0]!.path).toBe("/repos/my-org/tmpl/generate");
171+
expect(client.calls[0]!.body).toEqual({ owner: "my-org", name: "svc", private: true });
172+
});
173+
174+
it("throws on a malformed template", async () => {
175+
const client = makeMockClient();
176+
await expect(
177+
repoBaselineCycle.apply(
178+
client,
179+
{ kind: "create", resourceType: "repo-baseline", key: "svc", after: { name: "svc", template: "no-slash" } },
180+
"my-org",
181+
scope,
182+
makeBudget(),
183+
),
184+
).rejects.toThrow("malformed template");
185+
});
186+
187+
it("ignores foreign resource types", async () => {
188+
const client = makeMockClient();
189+
await repoBaselineCycle.apply(
190+
client,
191+
{ kind: "create", resourceType: "repo", key: "svc", after: {} },
192+
"my-org",
193+
scope,
194+
makeBudget(),
195+
);
196+
expect(client.calls).toHaveLength(0);
197+
});
198+
});
199+
200+
// ---------------------------------------------------------------------------
201+
// 5. Runner integration
202+
// ---------------------------------------------------------------------------
203+
204+
describe("repoBaselineCycle via runReconcile", () => {
205+
it("dry-run: reports a create for the missing repo", async () => {
206+
const client = makeMockClient({
207+
"GET /orgs/test-org/repos?per_page=100&page=1": [{ name: "existing" }],
208+
});
209+
const config: GovernanceConfig = {
210+
orgs: { "test-org": { repoBaselines: [{ name: "existing" }, { name: "fresh" }] } },
211+
};
212+
const result = await runReconcile({ config, client, cycles: [repoBaselineCycle], mode: "dry-run" });
213+
expect(result.completed).toBe(true);
214+
expect(result.cycles[0]!.counts.create).toBe(1);
215+
expect(client.calls.every((c) => c.method === "GET")).toBe(true);
216+
});
217+
});

0 commit comments

Comments
 (0)