clud-bug 0.7.0-rc.21 → 0.7.0-rc.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/data/canonical-v1.json +39 -34
  2. package/data/rulesets/baseline.json +29 -0
  3. package/data/rulesets/clud-bug.json +39 -0
  4. package/data/rulesets/public-guard.json +29 -0
  5. package/data/rulesets/skdd.json +42 -0
  6. package/dist/cli/configure-github.d.ts +13 -4
  7. package/dist/cli/configure-github.d.ts.map +1 -1
  8. package/dist/cli/configure-github.js +73 -27
  9. package/dist/cli/configure-github.js.map +1 -1
  10. package/dist/cli/hooks.d.ts.map +1 -1
  11. package/dist/cli/hooks.js +5 -1
  12. package/dist/cli/hooks.js.map +1 -1
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/main.js +33 -10
  15. package/dist/cli/main.js.map +1 -1
  16. package/dist/cli/post-check-run.d.ts +2 -0
  17. package/dist/cli/post-check-run.d.ts.map +1 -1
  18. package/dist/cli/post-check-run.js +124 -5
  19. package/dist/cli/post-check-run.js.map +1 -1
  20. package/dist/cli/review-prompt.d.ts +11 -1
  21. package/dist/cli/review-prompt.d.ts.map +1 -1
  22. package/dist/cli/review-prompt.js +141 -37
  23. package/dist/cli/review-prompt.js.map +1 -1
  24. package/dist/core/check-verdict.d.ts +1 -1
  25. package/dist/core/check-verdict.d.ts.map +1 -1
  26. package/dist/core/check-verdict.js +14 -1
  27. package/dist/core/check-verdict.js.map +1 -1
  28. package/dist/core/configure-github.d.ts +184 -119
  29. package/dist/core/configure-github.d.ts.map +1 -1
  30. package/dist/core/configure-github.js +407 -209
  31. package/dist/core/configure-github.js.map +1 -1
  32. package/dist/core/index.d.ts +4 -1
  33. package/dist/core/index.d.ts.map +1 -1
  34. package/dist/core/index.js +12 -1
  35. package/dist/core/index.js.map +1 -1
  36. package/dist/core/invariants.d.ts +46 -0
  37. package/dist/core/invariants.d.ts.map +1 -0
  38. package/dist/core/invariants.js +96 -0
  39. package/dist/core/invariants.js.map +1 -0
  40. package/dist/core/notary-bundle.d.ts +95 -0
  41. package/dist/core/notary-bundle.d.ts.map +1 -0
  42. package/dist/core/notary-bundle.js +129 -0
  43. package/dist/core/notary-bundle.js.map +1 -0
  44. package/dist/core/notary-validate.d.ts +86 -0
  45. package/dist/core/notary-validate.d.ts.map +1 -0
  46. package/dist/core/notary-validate.js +270 -0
  47. package/dist/core/notary-validate.js.map +1 -0
  48. package/dist/core/prompt-builder.js +2 -2
  49. package/dist/core/review-schema-zod.d.ts +48 -0
  50. package/dist/core/review-schema-zod.d.ts.map +1 -1
  51. package/dist/core/review-schema-zod.js +5 -0
  52. package/dist/core/review-schema-zod.js.map +1 -1
  53. package/dist/core/review-schema.d.ts +3 -0
  54. package/dist/core/review-schema.d.ts.map +1 -1
  55. package/dist/core/review-schema.js +9 -0
  56. package/dist/core/review-schema.js.map +1 -1
  57. package/dist/core/version.d.ts +1 -1
  58. package/dist/core/version.js +1 -1
  59. package/package.json +1 -1
  60. package/src/cli/configure-github.ts +108 -31
  61. package/src/cli/hooks.ts +5 -1
  62. package/src/cli/main.ts +32 -10
  63. package/src/cli/post-check-run.ts +149 -5
  64. package/src/cli/review-prompt.ts +162 -36
  65. package/src/core/check-verdict.ts +14 -2
  66. package/src/core/configure-github.ts +591 -349
  67. package/src/core/index.ts +46 -0
  68. package/src/core/invariants.ts +123 -0
  69. package/src/core/notary-bundle.ts +196 -0
  70. package/src/core/notary-validate.ts +343 -0
  71. package/src/core/prompt-builder.ts +2 -2
  72. package/src/core/review-schema-zod.ts +5 -0
  73. package/src/core/review-schema.ts +12 -0
  74. package/src/core/version.ts +1 -1
  75. package/templates/skills/design/designing-elite-ui.md +57 -0
  76. package/templates/workflow-py.yml.tmpl +1 -1
  77. package/templates/workflow-ts.yml.tmpl +1 -1
  78. package/templates/workflow.yml.tmpl +1 -1
@@ -1,9 +1,33 @@
1
1
  // SPEC §7 canonical-ruleset applier — pure core half of `configure-github`.
2
2
  //
3
+ // `configure-github` is the one-stop repo-setup command. It does two things,
4
+ // in order, for the selected PRESET:
5
+ // 1. Repo conveniences (universal hygiene, ALL presets): squash-only merges,
6
+ // auto-merge + delete-branch-on-merge on, PR title/body as the squash
7
+ // commit message. These live on the REPOSITORY (v2 rulesets can't express
8
+ // them), so they're diffed + PATCHed via repos.get / repos.update.
9
+ // 2. The canonical branch RULESET for the chosen preset.
10
+ //
11
+ // Presets are the purpose-named taxonomy vendored from reporulez (the canonical
12
+ // source) into `data/rulesets/<name>.json`:
13
+ // - baseline structural hygiene only (no required checks)
14
+ // - clud-bug baseline + the `clud-bug-review` merge-gate check
15
+ // - skdd clud-bug + SkDD derived-docs checks (DEFAULT)
16
+ // - public-guard stricter posture: 1 approval + code-owner + last-push
17
+ //
3
18
  // External users installing the App expect "best practice branch protection"
4
19
  // applied automatically. This module diffs the current GitHub state against
5
- // the canonical ruleset (bundled at `data/canonical-v1.json`) and emits the
6
- // minimal set of PATCH calls to converge.
20
+ // the selected preset (bundled under `data/rulesets/`) and emits the minimal
21
+ // set of repo-settings + rulesets-API calls to converge.
22
+ //
23
+ // v2 (SPEC §7): the canonical migrated from the RETIRED classic
24
+ // branch-protection API (`PUT …/branches/{b}/protection`, 1 required approval,
25
+ // a required `test` status context) to GitHub's RULESETS API
26
+ // (`GET/POST/PUT …/repos/{o}/{r}/rulesets`, 0 approvals, the `clud-bug-review`
27
+ // check as the merge gate). Rulesets layer, are org-portable, and are the
28
+ // modern mechanism; classic protection is legacy. The reference applier is
29
+ // reporulez `bin/apply.sh` (list existing rulesets → find by name →
30
+ // create-if-absent / update-if-present).
7
31
  //
8
32
  // Architectural shape mirrors `formal-review.ts` and `review-writeback.ts`:
9
33
  // the pure rule-table + diff logic lives in core (no Octokit dep at compile
@@ -19,59 +43,76 @@
19
43
  // // b.alreadyCanonical === true; b.changes.length === 0
20
44
  //
21
45
  // A second `apply()` call against a freshly-converged repo MUST produce
22
- // `alreadyCanonical: true` with `changes: []` and zero PATCH calls. This
23
- // lets external automation (CI, dispatch loops, dashboard probes) call
46
+ // `alreadyCanonical: true` with `changes: []` and zero create/update calls.
47
+ // This lets external automation (CI, dispatch loops, dashboard probes) call
24
48
  // `apply()` defensively without rate-limiting itself out of the API.
25
49
  //
26
- // SPEC pins honored here:
27
- // - canonical-v1.json schema (frozen at v1; major bumps require coordinated
28
- // tool releases per the `$comment` field).
29
- // - `required_status_checks.contexts` are TREATED AS A SUPERSET: if the
50
+ // SPEC pins honored here (SPEC §7.4):
51
+ // - The canonical ruleset is sourced from `canonical-v1.json` (frozen at
52
+ // `version: v2`; the filename is a stable fetch path, the `version` field
53
+ // is the authority). Tools MUST NOT redefine the ruleset in-tool.
54
+ // - `required_status_checks` contexts are TREATED AS A SUPERSET: if the
30
55
  // repo already requires MORE contexts than the canonical list, we leave
31
56
  // them alone (a repo that runs additional CI gates legitimately needs
32
57
  // them in the required set). We only ADD the canonical contexts that
33
58
  // are missing.
34
- // - `required_approving_review_count`: canonical floor is 1. If the repo
35
- // already requires MORE than 1, we leave it alone (only raise to floor).
59
+ // - `required_approving_review_count`: canonical is 0 (the `clud-bug-review`
60
+ // CHECK is the gate). Owners MAY raise it; tools MUST NOT raise it by
61
+ // default and MUST NOT lower an owner-raised floor. We treat it as a
62
+ // floor of the canonical value (only ever raise, never lower).
63
+ // - `bypass_actors`: the Repository-admin bypass (always) is the self-mod
64
+ // escape hatch. Treated as a superset — extra bypass actors the repo
65
+ // already has are preserved.
36
66
  //
37
- // Offline-resilient bundling: the canonical ruleset ships in `data/` at
67
+ // Offline-resilient bundling: the preset rulesets ship in `data/rulesets/` at
38
68
  // the package root (alongside `bin/`, `templates/`, etc., per package.json
39
- // `files`). We read it at runtime via fs.readFile so the build stays out
69
+ // `files`). We read them at runtime via fs.readFile so the build stays out
40
70
  // of TypeScript's `rootDir` constraint, and so callers that re-bundle
41
71
  // clud-bug into a single file can override the ruleset by passing one
42
- // explicitly to `applyCanonicalRuleset({ ruleset })`.
72
+ // explicitly to `applyCanonicalRuleset({ ruleset })` (or a preset name via
73
+ // `{ preset }`).
43
74
 
44
75
  import { readFile } from 'node:fs/promises';
45
76
  import { dirname, join } from 'node:path';
46
77
  import { fileURLToPath } from 'node:url';
47
78
 
79
+ /** A single rule inside the ruleset's `rules` array (GitHub rulesets shape). */
80
+ export interface CanonicalRulesetRule {
81
+ type: string;
82
+ parameters?: Record<string, unknown>;
83
+ }
84
+
85
+ /** Ruleset targeting conditions (which refs the ruleset applies to). */
86
+ export interface CanonicalRulesetConditions {
87
+ ref_name: { include: string[]; exclude: string[] };
88
+ }
89
+
90
+ /** An actor allowed to bypass the ruleset (e.g. the Repository-admin role). */
91
+ export interface CanonicalRulesetBypassActor {
92
+ actor_type: string;
93
+ actor_id: number;
94
+ bypass_mode: string;
95
+ }
96
+
48
97
  /**
49
- * The canonical-v1.json structure. Frozen at v1; major bumps require
50
- * coordinated tool releases per the protocol repo's `$comment` field.
98
+ * The vendored ruleset structure (GitHub v2 rulesets shape). The reporulez
99
+ * presets under `data/rulesets/` are vendored verbatim they carry `name`,
100
+ * `target`, `enforcement`, `conditions`, `bypass_actors`, `rules`, and a
101
+ * `$comment` header (consumer-ignored). `version` / `spec_version` are
102
+ * OPTIONAL: only the legacy metadata-rich `canonical-v1.json` alias carried
103
+ * them; the vendored presets omit them. `$comment` / `$notes` are OPTIONAL
104
+ * and consumer-ignored (not modeled here). Major schema bumps require
105
+ * coordinated tool releases.
51
106
  */
52
107
  export interface CanonicalRuleset {
53
- version: 'v1';
54
- spec_version: string;
55
- branch_protection: {
56
- required_status_checks: {
57
- strict: boolean;
58
- contexts: string[];
59
- };
60
- required_pull_request_reviews: {
61
- required_approving_review_count: number;
62
- dismiss_stale_reviews: boolean;
63
- require_code_owner_reviews: boolean;
64
- };
65
- required_conversation_resolution: boolean;
66
- enforce_admins: boolean;
67
- allow_force_pushes: boolean;
68
- allow_deletions: boolean;
69
- required_linear_history: boolean;
70
- allow_auto_merge: boolean;
71
- delete_branch_on_merge: boolean;
72
- squash_merge_commit_title: string;
73
- squash_merge_commit_message: string;
74
- };
108
+ version?: 'v2';
109
+ spec_version?: string;
110
+ name: string;
111
+ target: string;
112
+ enforcement: string;
113
+ conditions: CanonicalRulesetConditions;
114
+ bypass_actors: CanonicalRulesetBypassActor[];
115
+ rules: CanonicalRulesetRule[];
75
116
  }
76
117
 
77
118
  /**
@@ -81,107 +122,181 @@ export interface CanonicalRuleset {
81
122
  */
82
123
  const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
83
124
 
84
- /** Memoized canonical ruleset — loaded once per process. */
85
- let CANONICAL_V1_CACHE: CanonicalRuleset | null = null;
125
+ /**
126
+ * The purpose-named preset taxonomy, vendored from reporulez (the canonical
127
+ * source) into `data/rulesets/<name>.json`. Ordered least → most strict.
128
+ */
129
+ export const PRESET_NAMES = [
130
+ 'baseline',
131
+ 'clud-bug',
132
+ 'skdd',
133
+ 'public-guard',
134
+ ] as const;
135
+ export type PresetName = (typeof PRESET_NAMES)[number];
136
+
137
+ /**
138
+ * Default preset — `skdd` (the four-check SkDD ruleset). Preserves the prior
139
+ * single-canonical behavior when `--preset` isn't passed.
140
+ */
141
+ export const DEFAULT_PRESET: PresetName = 'skdd';
142
+
143
+ /** Type guard: is `name` one of the known presets? */
144
+ export function isPresetName(name: string): name is PresetName {
145
+ return (PRESET_NAMES as readonly string[]).includes(name);
146
+ }
147
+
148
+ /** Memoized presets — each variant loaded at most once per process. */
149
+ const PRESET_CACHE = new Map<PresetName, CanonicalRuleset>();
86
150
 
87
151
  /**
88
- * Loads the bundled canonical-v1 ruleset from `data/canonical-v1.json`.
89
- * Memoized so repeated calls don't re-hit the filesystem.
152
+ * Loads a vendored ruleset preset from `data/rulesets/<name>.json`. The four
153
+ * variants (baseline · clud-bug · skdd · public-guard) are vendored verbatim
154
+ * from reporulez (the canonical source); consumers ignore the `$comment`
155
+ * header. Memoized per-name so repeated calls don't re-hit the filesystem.
90
156
  *
91
- * Throws a wrapped error if the JSON file is missing — that's an
92
- * install-time defect (package.json `files` excluded `data/`), not a
93
- * runtime concern, and the loud failure surfaces it immediately.
157
+ * Throws a wrapped error if the name is unknown or the JSON is missing —
158
+ * an unknown preset is a caller bug; a missing file is an install-time
159
+ * defect (package.json `files` excluded `data/`). Both surface loudly.
94
160
  */
95
- export async function loadCanonicalV1(): Promise<CanonicalRuleset> {
96
- if (CANONICAL_V1_CACHE) return CANONICAL_V1_CACHE;
97
- const path = join(PKG_ROOT, 'data', 'canonical-v1.json');
161
+ export async function loadPreset(name: PresetName): Promise<CanonicalRuleset> {
162
+ if (!isPresetName(name)) {
163
+ throw new Error(
164
+ `configure-github: unknown preset "${name}" (valid: ${PRESET_NAMES.join(', ')})`,
165
+ );
166
+ }
167
+ const cached = PRESET_CACHE.get(name);
168
+ if (cached) return cached;
169
+ const path = join(PKG_ROOT, 'data', 'rulesets', `${name}.json`);
98
170
  try {
99
171
  const raw = await readFile(path, 'utf-8');
100
- CANONICAL_V1_CACHE = JSON.parse(raw) as CanonicalRuleset;
101
- return CANONICAL_V1_CACHE;
172
+ const parsed = JSON.parse(raw) as CanonicalRuleset;
173
+ PRESET_CACHE.set(name, parsed);
174
+ return parsed;
102
175
  } catch (err) {
103
176
  const msg = err instanceof Error ? err.message : String(err);
104
177
  throw new Error(
105
- `configure-github: failed to load bundled canonical-v1.json at ${path}: ${msg}`,
178
+ `configure-github: failed to load ruleset preset "${name}" at ${path}: ${msg}`,
106
179
  );
107
180
  }
108
181
  }
109
182
 
110
183
  /**
111
- * Minimal structural Octokit interface only the methods we actually call.
184
+ * Back-compat alias: the historical `loadCanonicalV1()` entry point now
185
+ * resolves the DEFAULT (`skdd`) preset. `data/canonical-v1.json` is retained
186
+ * on disk as a copy of the skdd preset for the stable external fetch path,
187
+ * but the loader reads `data/rulesets/skdd.json`.
188
+ */
189
+ export async function loadCanonicalV1(): Promise<CanonicalRuleset> {
190
+ return loadPreset(DEFAULT_PRESET);
191
+ }
192
+
193
+ /**
194
+ * Repo-level "conveniences" — merge hygiene that lives on the REPOSITORY,
195
+ * not the ruleset (v2 rulesets can't express these). Applied for EVERY
196
+ * preset as universal hygiene: squash-only merges, auto-merge + branch
197
+ * cleanup on, PR title/body as the squash commit message.
198
+ */
199
+ export interface RepoConveniences {
200
+ delete_branch_on_merge: boolean;
201
+ allow_auto_merge: boolean;
202
+ allow_squash_merge: boolean;
203
+ allow_merge_commit: boolean;
204
+ allow_rebase_merge: boolean;
205
+ squash_merge_commit_title: string;
206
+ squash_merge_commit_message: string;
207
+ }
208
+
209
+ /** The canonical repo-conveniences every preset converges the repo to. */
210
+ export const CANONICAL_REPO_CONVENIENCES: RepoConveniences = {
211
+ delete_branch_on_merge: true,
212
+ allow_auto_merge: true,
213
+ allow_squash_merge: true,
214
+ allow_merge_commit: false,
215
+ allow_rebase_merge: false,
216
+ squash_merge_commit_title: 'PR_TITLE',
217
+ squash_merge_commit_message: 'PR_BODY',
218
+ };
219
+
220
+ /** A ruleset as returned by GitHub's list endpoint (summary — id + name). */
221
+ export interface RepoRulesetSummary {
222
+ id: number;
223
+ name: string;
224
+ }
225
+
226
+ /** A ruleset as returned by GitHub's get-by-id endpoint (full definition). */
227
+ export interface RepoRulesetFull {
228
+ id: number;
229
+ name: string;
230
+ target?: string;
231
+ enforcement?: string;
232
+ conditions?: { ref_name?: { include?: string[]; exclude?: string[] } } | null;
233
+ bypass_actors?: Array<{
234
+ actor_type?: string;
235
+ actor_id?: number;
236
+ bypass_mode?: string;
237
+ }> | null;
238
+ rules?: CanonicalRulesetRule[] | null;
239
+ }
240
+
241
+ /** The body sent to POST/PUT `…/rulesets` (the metadata-stripped payload). */
242
+ export interface RulesetWritePayload {
243
+ name: string;
244
+ target: string;
245
+ enforcement: string;
246
+ bypass_actors: CanonicalRulesetBypassActor[];
247
+ conditions: CanonicalRulesetConditions;
248
+ rules: CanonicalRulesetRule[];
249
+ }
250
+
251
+ /**
252
+ * Minimal structural Octokit interface — only the rulesets methods we call.
112
253
  *
113
254
  * We intentionally don't import `@octokit/rest` types: that would force
114
255
  * every consumer (App, CLI, future tools) to install Octokit at runtime
115
256
  * even when they pass a `gh`-CLI-backed adapter. Keeping the shape
116
257
  * structural lets the CLI's `gh`-wrapping adapter satisfy it without
117
- * pulling in a 200KB dep.
118
- *
119
- * If `@octokit/rest`'s real method shapes ever drift, the adapter +
120
- * runtime call sites are the only files that need updating; the rule
121
- * table here stays Octokit-version-agnostic.
258
+ * pulling in a 200KB dep. The method names mirror `@octokit/rest`'s
259
+ * `repos.*` rulesets surface so a real Octokit instance satisfies it too.
122
260
  */
123
261
  export interface OctokitLike {
124
262
  repos: {
125
- /** Read branch protection state. Throws on 404 (no protection rule). */
126
- getBranchProtection(params: {
263
+ /** List the repo's rulesets (summaries). `includes_parents:false` scopes to repo-level. */
264
+ getRepoRulesets(params: {
127
265
  owner: string;
128
266
  repo: string;
129
- branch: string;
130
- }): Promise<{
131
- data: {
132
- required_status_checks?:
133
- | { strict?: boolean; contexts?: string[] }
134
- | null;
135
- required_pull_request_reviews?:
136
- | {
137
- required_approving_review_count?: number;
138
- dismiss_stale_reviews?: boolean;
139
- require_code_owner_reviews?: boolean;
140
- }
141
- | null;
142
- required_conversation_resolution?: { enabled?: boolean } | null;
143
- enforce_admins?: { enabled?: boolean } | null;
144
- allow_force_pushes?: { enabled?: boolean } | null;
145
- allow_deletions?: { enabled?: boolean } | null;
146
- required_linear_history?: { enabled?: boolean } | null;
147
- };
148
- }>;
149
- /** PUT branch protection (the REST API does NOT support PATCH). */
150
- updateBranchProtection(params: {
267
+ includes_parents?: boolean;
268
+ }): Promise<{ data: RepoRulesetSummary[] }>;
269
+ /** Fetch a single ruleset by id (full rules/conditions/bypass_actors). */
270
+ getRepoRuleset(params: {
151
271
  owner: string;
152
272
  repo: string;
153
- branch: string;
154
- required_status_checks: { strict: boolean; contexts: string[] } | null;
155
- enforce_admins: boolean | null;
156
- required_pull_request_reviews: {
157
- required_approving_review_count: number;
158
- dismiss_stale_reviews: boolean;
159
- require_code_owner_reviews: boolean;
160
- } | null;
161
- restrictions: null;
162
- required_conversation_resolution?: boolean;
163
- required_linear_history?: boolean;
164
- allow_force_pushes?: boolean;
165
- allow_deletions?: boolean;
166
- }): Promise<unknown>;
167
- /** Read repo-level settings (delete_branch_on_merge, allow_auto_merge, etc). */
168
- get(params: { owner: string; repo: string }): Promise<{
169
- data: {
170
- delete_branch_on_merge?: boolean;
171
- allow_auto_merge?: boolean;
172
- squash_merge_commit_title?: string;
173
- squash_merge_commit_message?: string;
174
- };
175
- }>;
176
- /** PATCH repo-level settings. */
177
- update(params: {
273
+ ruleset_id: number;
274
+ }): Promise<{ data: RepoRulesetFull }>;
275
+ /** Create a ruleset (POST). */
276
+ createRepoRuleset(
277
+ params: { owner: string; repo: string } & RulesetWritePayload,
278
+ ): Promise<{ data: RepoRulesetFull }>;
279
+ /** Update an existing ruleset by id (PUT). */
280
+ updateRepoRuleset(
281
+ params: {
282
+ owner: string;
283
+ repo: string;
284
+ ruleset_id: number;
285
+ } & RulesetWritePayload,
286
+ ): Promise<{ data: RepoRulesetFull }>;
287
+ /**
288
+ * Read repo-level settings (the conveniences: merge methods + squash
289
+ * commit shape + branch-cleanup toggles). Backs the universal-hygiene
290
+ * PATCH; mirrors `@octokit/rest`'s `repos.get`.
291
+ */
292
+ get(params: {
178
293
  owner: string;
179
294
  repo: string;
180
- delete_branch_on_merge?: boolean;
181
- allow_auto_merge?: boolean;
182
- squash_merge_commit_title?: string;
183
- squash_merge_commit_message?: string;
184
- }): Promise<unknown>;
295
+ }): Promise<{ data: Partial<RepoConveniences> }>;
296
+ /** PATCH repo-level settings (the conveniences payload). `repos.update`. */
297
+ update(
298
+ params: { owner: string; repo: string } & Partial<RepoConveniences>,
299
+ ): Promise<unknown>;
185
300
  };
186
301
  }
187
302
 
@@ -189,19 +304,25 @@ export interface ApplyCanonicalRulesetParams {
189
304
  owner: string;
190
305
  repo: string;
191
306
  /**
192
- * Target branch (default: `main`). Most consumers will use `main`; this
193
- * is exposed for downstream tools that converge non-`main` defaults.
307
+ * Target branch (default: `main`). The canonical ruleset targets the repo's
308
+ * default branch via the `~DEFAULT_BRANCH` sentinel; passing an explicit
309
+ * non-`main` branch narrows the ruleset to `refs/heads/<branch>` instead.
194
310
  */
195
311
  branch?: string;
196
312
  /**
197
- * The ruleset to apply. Defaults to the bundled `canonical-v1.json`
198
- * (loaded via `loadCanonicalV1()` if absent). Future v2 callers pass
199
- * an explicit ruleset; the type system pins the shape.
313
+ * The ruleset to apply. When absent, resolved from `preset` (or the
314
+ * DEFAULT `skdd` preset). Callers may pass an explicit ruleset; the type
315
+ * system pins the shape. Takes precedence over `preset`.
200
316
  */
201
317
  ruleset?: CanonicalRuleset;
202
318
  /**
203
- * If true, compute diff only and skip all PATCH calls. The result still
204
- * reports `changes: string[]` so the CLI can render the human-readable
319
+ * Named preset to load when `ruleset` isn't passed explicitly. One of
320
+ * baseline · clud-bug · skdd · public-guard. Defaults to `skdd`.
321
+ */
322
+ preset?: PresetName;
323
+ /**
324
+ * If true, compute diff only and skip all create/update calls. The result
325
+ * still reports `changes: string[]` so the CLI can render the human-readable
205
326
  * diff before applying.
206
327
  */
207
328
  dryRun?: boolean;
@@ -210,288 +331,409 @@ export interface ApplyCanonicalRulesetParams {
210
331
  export interface ApplyResult {
211
332
  /** Human-readable diff lines, one per detected difference. Empty when alreadyCanonical. */
212
333
  changes: string[];
213
- /** True when the live state already matches every canonical setting. */
334
+ /** True when the live ruleset already satisfies every canonical setting. */
214
335
  alreadyCanonical: boolean;
215
- /** Schema version applied (always 'canonical-v1' for this build). */
336
+ /** Schema-family identifier applied (the `canonical-v1.json` fetch path). */
216
337
  ruleset: 'canonical-v1';
217
338
  }
218
339
 
219
340
  /**
220
- * Applies the canonical ruleset to a GitHub repo. Reads current state via
221
- * the Octokit-like instance, diffs against `ruleset` (default
222
- * `CANONICAL_V1`), and PATCHes only what differs. Idempotent: second call
223
- * returns `alreadyCanonical: true` with no PATCH side effects.
341
+ * Applies the selected preset to a GitHub repo: repo conveniences FIRST
342
+ * (universal hygiene for every preset), then the canonical ruleset via the
343
+ * rulesets API.
224
344
  *
225
- * Behavior on partial mismatch:
345
+ * Conveniences: diffs repo-level settings (squash-only merges, auto-merge,
346
+ * delete-branch-on-merge, PR title/body squash message) and PATCHes only
347
+ * what drifted (`repos.update`), before the ruleset write.
226
348
  *
227
- * - `required_status_checks.contexts`: canonical contexts that are
228
- * missing get added; extra contexts on the repo are preserved (superset
229
- * contract a repo that runs more CI gates legitimately needs them).
230
- * - `required_approving_review_count`: canonical floor is 1; if the repo
231
- * already requires more, we don't lower (raise-only contract).
232
- * - All booleans (allow_force_pushes, allow_deletions, etc.): converge
233
- * to the canonical value exactly.
349
+ * Ruleset: lists the repo's rulesets, finds the canonical one by name, and:
350
+ * - creates it (POST) if absent, or
351
+ * - diffs the existing one against canonical and updates it (PUT) only
352
+ * when it doesn't already satisfy the canonical contract.
353
+ * Idempotent: a second call against a converged repo returns
354
+ * `alreadyCanonical: true` with no repo-update / create / update side effects.
234
355
  *
235
- * Throws on Octokit transport failure (auth, network, 403). The CLI
236
- * wraps these with a friendly error message.
356
+ * Behavior on partial mismatch (SPEC §7.4):
357
+ * - `required_status_checks` contexts: canonical contexts that are missing
358
+ * get added; extra contexts on the repo are preserved (superset contract).
359
+ * - `required_approving_review_count`: treated as a floor (only raise, never
360
+ * lower an owner-raised value).
361
+ * - `bypass_actors`: superset — extra actors the repo already has survive.
362
+ * - repo conveniences + everything else converge to the canonical value.
363
+ *
364
+ * Throws on Octokit transport failure (auth, network, 403). The CLI wraps
365
+ * these with a friendly error message.
237
366
  */
238
367
  export async function applyCanonicalRuleset(
239
368
  octokit: OctokitLike,
240
369
  params: ApplyCanonicalRulesetParams,
241
370
  ): Promise<ApplyResult> {
242
- const {
371
+ const { owner, repo, branch = 'main', dryRun = false } = params;
372
+ const ruleset =
373
+ params.ruleset ?? (await loadPreset(params.preset ?? DEFAULT_PRESET));
374
+
375
+ const desired = toRulesetWritePayload(ruleset, branch);
376
+
377
+ // ----- Repo conveniences (universal hygiene — every preset) -----------
378
+ // These live on the REPOSITORY, not the ruleset. Diff first so a repo
379
+ // that already matches is a true no-op (idempotency contract); the PATCH
380
+ // below fires BEFORE the ruleset write.
381
+ const repoResp = await octokit.repos.get({ owner, repo });
382
+ const conveniences = diffRepoConveniences(
383
+ repoResp.data ?? {},
384
+ CANONICAL_REPO_CONVENIENCES,
385
+ );
386
+
387
+ // ----- Read current rulesets ------------------------------------------
388
+ // The list endpoint returns 200 with `[]` for a repo that has no rulesets
389
+ // (no 404 to special-case). Transport errors (403/network) bubble up.
390
+ // `includes_parents:false` keeps us to repo-level rulesets so we don't
391
+ // mistake an inherited org ruleset (which we can't update at repo level)
392
+ // for ours.
393
+ const listResp = await octokit.repos.getRepoRulesets({
243
394
  owner,
244
395
  repo,
245
- branch = 'main',
246
- dryRun = false,
247
- } = params;
248
- const ruleset = params.ruleset ?? (await loadCanonicalV1());
249
-
250
- const target = ruleset.branch_protection;
251
- const changes: string[] = [];
396
+ includes_parents: false,
397
+ });
398
+ const existingSummary =
399
+ (listResp.data ?? []).find((r) => r.name === desired.name) ?? null;
252
400
 
253
- // ----- Read current branch protection state ---------------------------
254
- // 404 no base protection rule. We treat that as "every canonical
255
- // setting differs" so the apply path creates the rule. Other errors
256
- // bubble up callers can't recover from a 403.
257
- let current: Awaited<
258
- ReturnType<OctokitLike['repos']['getBranchProtection']>
259
- >['data'] = {};
260
- let hasBaseProtection = true;
261
- try {
262
- const resp = await octokit.repos.getBranchProtection({
401
+ let rulesetChanges: string[];
402
+ let existingFull: RepoRulesetFull | null = null;
403
+ if (existingSummary) {
404
+ const fullResp = await octokit.repos.getRepoRuleset({
263
405
  owner,
264
406
  repo,
265
- branch,
407
+ ruleset_id: existingSummary.id,
266
408
  });
267
- current = resp.data;
268
- } catch (err) {
269
- if (isBranchNotProtected(err)) {
270
- hasBaseProtection = false;
271
- } else {
272
- throw err;
273
- }
409
+ existingFull = fullResp.data;
410
+ rulesetChanges = diffExistingRuleset(existingFull, desired);
411
+ } else {
412
+ rulesetChanges = describeCreate(desired);
274
413
  }
275
414
 
276
- // ----- Diff: required_status_checks -----------------------------------
277
- // Behavior: contexts are a SUPERSET. Strict mode must match exactly.
278
- const currentChecks = current.required_status_checks ?? null;
279
- const currentContexts = new Set(currentChecks?.contexts ?? []);
280
- const targetContexts = new Set(target.required_status_checks.contexts);
281
- const missingContexts = [...targetContexts].filter(
282
- (c) => !currentContexts.has(c),
283
- );
284
- const strictMismatch =
285
- (currentChecks?.strict ?? false) !== target.required_status_checks.strict;
286
- let needsChecksPatch = !hasBaseProtection;
287
- if (missingContexts.length > 0) {
288
- changes.push(
289
- `required_status_checks.contexts: add ${JSON.stringify(missingContexts)}`,
290
- );
291
- needsChecksPatch = true;
292
- }
293
- if (strictMismatch) {
294
- changes.push(
295
- `required_status_checks.strict: ${currentChecks?.strict ?? false} → ${target.required_status_checks.strict}`,
296
- );
297
- needsChecksPatch = true;
415
+ // Conveniences reported first (they're applied first).
416
+ const changes = [...conveniences.changes, ...rulesetChanges];
417
+ const alreadyCanonical = changes.length === 0;
418
+ if (alreadyCanonical || dryRun) {
419
+ return { changes, alreadyCanonical, ruleset: 'canonical-v1' };
298
420
  }
299
- // The merged context list: keep extras the repo already required.
300
- const mergedContexts = [
301
- ...new Set([
302
- ...(currentChecks?.contexts ?? []),
303
- ...target.required_status_checks.contexts,
304
- ]),
305
- ];
306
421
 
307
- // ----- Diff: required_pull_request_reviews ----------------------------
308
- // Behavior: approving_review_count is a FLOOR (only raise). Booleans
309
- // converge exactly.
310
- const currentReviews = current.required_pull_request_reviews ?? null;
311
- const currentCount = currentReviews?.required_approving_review_count ?? 0;
312
- const targetCount =
313
- target.required_pull_request_reviews.required_approving_review_count;
314
- const effectiveCount = Math.max(currentCount, targetCount);
315
- const targetDismiss =
316
- target.required_pull_request_reviews.dismiss_stale_reviews;
317
- const targetCodeOwner =
318
- target.required_pull_request_reviews.require_code_owner_reviews;
319
- let needsReviewsPatch = !hasBaseProtection;
320
- if (effectiveCount !== currentCount) {
321
- changes.push(
322
- `required_pull_request_reviews.required_approving_review_count: ${currentCount} → ${effectiveCount}`,
323
- );
324
- needsReviewsPatch = true;
325
- }
326
- if ((currentReviews?.dismiss_stale_reviews ?? false) !== targetDismiss) {
327
- changes.push(
328
- `required_pull_request_reviews.dismiss_stale_reviews: ${currentReviews?.dismiss_stale_reviews ?? false} → ${targetDismiss}`,
329
- );
330
- needsReviewsPatch = true;
331
- }
332
- if (
333
- (currentReviews?.require_code_owner_reviews ?? false) !== targetCodeOwner
334
- ) {
335
- changes.push(
336
- `required_pull_request_reviews.require_code_owner_reviews: ${currentReviews?.require_code_owner_reviews ?? false} → ${targetCodeOwner}`,
337
- );
338
- needsReviewsPatch = true;
422
+ // ----- Apply conveniences FIRST (before the ruleset write) ------------
423
+ if (conveniences.needsPatch) {
424
+ await octokit.repos.update({ owner, repo, ...conveniences.patch });
339
425
  }
340
426
 
341
- // ----- Diff: single-flag branch protection booleans -------------------
342
- const conv =
343
- current.required_conversation_resolution?.enabled ?? false;
344
- const enforceAdmins = current.enforce_admins?.enabled ?? false;
345
- const allowFP = current.allow_force_pushes?.enabled ?? false;
346
- const allowDel = current.allow_deletions?.enabled ?? false;
347
- const linearHistory = current.required_linear_history?.enabled ?? false;
348
- if (conv !== target.required_conversation_resolution) {
349
- changes.push(
350
- `required_conversation_resolution: ${conv} → ${target.required_conversation_resolution}`,
351
- );
352
- }
353
- if (enforceAdmins !== target.enforce_admins) {
354
- changes.push(
355
- `enforce_admins: ${enforceAdmins} → ${target.enforce_admins}`,
356
- );
357
- }
358
- if (allowFP !== target.allow_force_pushes) {
359
- changes.push(
360
- `allow_force_pushes: ${allowFP} → ${target.allow_force_pushes}`,
361
- );
362
- }
363
- if (allowDel !== target.allow_deletions) {
364
- changes.push(
365
- `allow_deletions: ${allowDel} → ${target.allow_deletions}`,
366
- );
427
+ // ----- Apply ruleset (only when the ruleset itself drifted) -----------
428
+ // Gate on `rulesetChanges`, not the combined `changes`: a repo whose
429
+ // ruleset is already canonical but whose conveniences drifted must NOT
430
+ // incur a spurious ruleset PUT (idempotency at the ruleset layer).
431
+ if (rulesetChanges.length > 0) {
432
+ if (existingSummary && existingFull) {
433
+ // PUT: merge to preserve the repo's extras (superset contexts / bypass
434
+ // actors / owner-raised approval floor) instead of clobbering them.
435
+ const merged = mergeForPut(existingFull, desired);
436
+ await octokit.repos.updateRepoRuleset({
437
+ owner,
438
+ repo,
439
+ ruleset_id: existingSummary.id,
440
+ ...merged,
441
+ });
442
+ } else {
443
+ await octokit.repos.createRepoRuleset({ owner, repo, ...desired });
444
+ }
367
445
  }
368
- if (linearHistory !== target.required_linear_history) {
369
- changes.push(
370
- `required_linear_history: ${linearHistory} → ${target.required_linear_history}`,
371
- );
446
+
447
+ return { changes, alreadyCanonical: false, ruleset: 'canonical-v1' };
448
+ }
449
+
450
+ /**
451
+ * Diffs the repo's current conveniences against the canonical set. Returns
452
+ * one human-readable line per drifted field plus the minimal PATCH body.
453
+ * `needsPatch` is false when the repo already matches (idempotency contract).
454
+ * Only keys present in `desired` are compared, so extra repo settings GitHub
455
+ * echoes back never produce a spurious diff.
456
+ */
457
+ function diffRepoConveniences(
458
+ current: Partial<RepoConveniences>,
459
+ desired: RepoConveniences,
460
+ ): { changes: string[]; patch: Partial<RepoConveniences>; needsPatch: boolean } {
461
+ const changes: string[] = [];
462
+ const patch: Record<string, unknown> = {};
463
+ for (const key of Object.keys(desired) as (keyof RepoConveniences)[]) {
464
+ const want = desired[key];
465
+ const have = current[key] ?? (typeof want === 'boolean' ? false : '');
466
+ if (have !== want) {
467
+ changes.push(`${key}: ${have === '' ? '(unset)' : have} → ${want}`);
468
+ patch[key] = want;
469
+ }
372
470
  }
471
+ return {
472
+ changes,
473
+ patch: patch as Partial<RepoConveniences>,
474
+ needsPatch: changes.length > 0,
475
+ };
476
+ }
373
477
 
374
- // ----- Diff: repo-level settings --------------------------------------
375
- // delete_branch_on_merge / allow_auto_merge / squash commit shape live
376
- // on the REPO, not the branch protection rule. Fetched separately.
377
- const repoResp = await octokit.repos.get({ owner, repo });
378
- const repoData = repoResp.data;
379
- const repoPatch: Parameters<OctokitLike['repos']['update']>[0] = {
380
- owner,
381
- repo,
478
+ /**
479
+ * Builds the GitHub rulesets write payload from the canonical file, stripping
480
+ * the vendored-only metadata (`$comment`, `$notes`, `version`, `spec_version`)
481
+ * that the rulesets API doesn't accept. When an explicit non-default branch
482
+ * is requested, narrows the ref condition to `refs/heads/<branch>` (the
483
+ * canonical otherwise targets the repo default branch via `~DEFAULT_BRANCH`).
484
+ */
485
+ function toRulesetWritePayload(
486
+ ruleset: CanonicalRuleset,
487
+ branch: string,
488
+ ): RulesetWritePayload {
489
+ const conditions: CanonicalRulesetConditions =
490
+ branch && branch !== 'main'
491
+ ? { ref_name: { include: [`refs/heads/${branch}`], exclude: [] } }
492
+ : ruleset.conditions;
493
+ return {
494
+ name: ruleset.name,
495
+ target: ruleset.target,
496
+ enforcement: ruleset.enforcement,
497
+ bypass_actors: ruleset.bypass_actors,
498
+ conditions,
499
+ rules: ruleset.rules,
382
500
  };
383
- let needsRepoPatch = false;
384
- if (
385
- (repoData.delete_branch_on_merge ?? false) !== target.delete_branch_on_merge
386
- ) {
387
- changes.push(
388
- `delete_branch_on_merge: ${repoData.delete_branch_on_merge ?? false} → ${target.delete_branch_on_merge}`,
389
- );
390
- repoPatch.delete_branch_on_merge = target.delete_branch_on_merge;
391
- needsRepoPatch = true;
501
+ }
502
+
503
+ /** Human-readable change lines for the create (no existing ruleset) path. */
504
+ function describeCreate(desired: RulesetWritePayload): string[] {
505
+ const changes = [
506
+ `ruleset "${desired.name}": create (target=${desired.target}, enforcement=${desired.enforcement})`,
507
+ ];
508
+ for (const rule of desired.rules) changes.push(`+ rule ${summarizeRule(rule)}`);
509
+ return changes;
510
+ }
511
+
512
+ /** One-line summary of a rule for human-readable diff output. */
513
+ function summarizeRule(rule: CanonicalRulesetRule): string {
514
+ const p = rule.parameters ?? {};
515
+ if (rule.type === 'pull_request') {
516
+ return `pull_request (approvals=${p.required_approving_review_count}, thread_resolution=${p.required_review_thread_resolution}, merge=${JSON.stringify(p.allowed_merge_methods)})`;
392
517
  }
393
- if ((repoData.allow_auto_merge ?? false) !== target.allow_auto_merge) {
394
- changes.push(
395
- `allow_auto_merge: ${repoData.allow_auto_merge ?? false} → ${target.allow_auto_merge}`,
396
- );
397
- repoPatch.allow_auto_merge = target.allow_auto_merge;
398
- needsRepoPatch = true;
518
+ if (rule.type === 'required_status_checks') {
519
+ const contexts = statusCheckContexts(p);
520
+ return `required_status_checks (strict=${p.strict_required_status_checks_policy}, contexts=${JSON.stringify(contexts)})`;
399
521
  }
400
- if (
401
- (repoData.squash_merge_commit_title ?? '') !==
402
- target.squash_merge_commit_title
403
- ) {
522
+ return rule.type;
523
+ }
524
+
525
+ /** Extracts the `context` strings from a required_status_checks params blob. */
526
+ function statusCheckContexts(params: Record<string, unknown>): string[] {
527
+ const list = params.required_status_checks;
528
+ if (!Array.isArray(list)) return [];
529
+ return list
530
+ .map((c) => (c && typeof c === 'object' ? (c as { context?: string }).context : undefined))
531
+ .filter((c): c is string => typeof c === 'string');
532
+ }
533
+
534
+ /**
535
+ * Diffs an existing ruleset against the canonical desired payload. Returns
536
+ * one human-readable line per difference the apply path would fix; an empty
537
+ * array means the existing ruleset already satisfies the canonical (no-op).
538
+ */
539
+ function diffExistingRuleset(
540
+ existing: RepoRulesetFull,
541
+ desired: RulesetWritePayload,
542
+ ): string[] {
543
+ const changes: string[] = [];
544
+
545
+ if ((existing.enforcement ?? '') !== desired.enforcement) {
404
546
  changes.push(
405
- `squash_merge_commit_title: ${repoData.squash_merge_commit_title ?? '(unset)'} → ${target.squash_merge_commit_title}`,
547
+ `enforcement: ${existing.enforcement ?? '(unset)'} → ${desired.enforcement}`,
406
548
  );
407
- repoPatch.squash_merge_commit_title = target.squash_merge_commit_title;
408
- needsRepoPatch = true;
409
549
  }
410
- if (
411
- (repoData.squash_merge_commit_message ?? '') !==
412
- target.squash_merge_commit_message
413
- ) {
550
+ if ((existing.target ?? '') !== desired.target) {
551
+ changes.push(`target: ${existing.target ?? '(unset)'} → ${desired.target}`);
552
+ }
553
+
554
+ // conditions: the desired ref-includes must all be present on the existing.
555
+ const existingInclude = new Set(existing.conditions?.ref_name?.include ?? []);
556
+ const missingInclude = desired.conditions.ref_name.include.filter(
557
+ (r) => !existingInclude.has(r),
558
+ );
559
+ if (missingInclude.length > 0) {
414
560
  changes.push(
415
- `squash_merge_commit_message: ${repoData.squash_merge_commit_message ?? '(unset)'} → ${target.squash_merge_commit_message}`,
561
+ `conditions.ref_name.include: add ${JSON.stringify(missingInclude)}`,
416
562
  );
417
- repoPatch.squash_merge_commit_message = target.squash_merge_commit_message;
418
- needsRepoPatch = true;
419
563
  }
420
564
 
421
- const alreadyCanonical = changes.length === 0;
565
+ // bypass_actors: superset report only actors the existing set lacks.
566
+ const missingActors = desired.bypass_actors.filter(
567
+ (d) =>
568
+ !(existing.bypass_actors ?? []).some(
569
+ (e) =>
570
+ e.actor_type === d.actor_type &&
571
+ e.actor_id === d.actor_id &&
572
+ e.bypass_mode === d.bypass_mode,
573
+ ),
574
+ );
575
+ if (missingActors.length > 0) {
576
+ changes.push(`bypass_actors: add ${JSON.stringify(missingActors)}`);
577
+ }
422
578
 
423
- if (alreadyCanonical || dryRun) {
424
- return {
425
- changes,
426
- alreadyCanonical,
427
- ruleset: 'canonical-v1',
428
- };
579
+ // rules: for each desired rule, ensure the existing set has a matching one.
580
+ const existingByType = new Map<string, CanonicalRulesetRule>();
581
+ for (const r of existing.rules ?? []) existingByType.set(r.type, r);
582
+ for (const rule of desired.rules) {
583
+ const ex = existingByType.get(rule.type);
584
+ if (!ex) {
585
+ changes.push(`rules: add "${rule.type}"`);
586
+ continue;
587
+ }
588
+ if (rule.parameters) {
589
+ changes.push(
590
+ ...diffRuleParams(rule.type, ex.parameters ?? {}, rule.parameters),
591
+ );
592
+ }
429
593
  }
594
+ return changes;
595
+ }
430
596
 
431
- // ----- Apply: branch protection ---------------------------------------
432
- // The REST API requires a PUT (not PATCH) with the full settings object.
433
- // We always send a complete envelope; the diff above gates whether we
434
- // call PUT at all.
435
- if (needsChecksPatch || needsReviewsPatch || !hasBaseProtection || hasBranchProtectionLevelDiff(changes)) {
436
- await octokit.repos.updateBranchProtection({
437
- owner,
438
- repo,
439
- branch,
440
- required_status_checks: {
441
- strict: target.required_status_checks.strict,
442
- contexts: mergedContexts,
443
- },
444
- enforce_admins: target.enforce_admins,
445
- required_pull_request_reviews: {
446
- required_approving_review_count: effectiveCount,
447
- dismiss_stale_reviews: targetDismiss,
448
- require_code_owner_reviews: targetCodeOwner,
449
- },
450
- restrictions: null,
451
- required_conversation_resolution: target.required_conversation_resolution,
452
- required_linear_history: target.required_linear_history,
453
- allow_force_pushes: target.allow_force_pushes,
454
- allow_deletions: target.allow_deletions,
455
- });
597
+ /**
598
+ * Diffs a single rule's parameters (existing vs desired). Applies the SPEC
599
+ * §7.4 semantics per parameter:
600
+ * - `required_status_checks` (array of {context}): superset — only report
601
+ * the canonical contexts the existing set is missing.
602
+ * - `required_approving_review_count`: floor — only report when the existing
603
+ * is BELOW the canonical (never lower an owner-raised value).
604
+ * - array params (e.g. `allowed_merge_methods`): set-equality.
605
+ * - scalars/booleans: exact match.
606
+ * Only keys present in `desired` are compared, so GitHub echoing extra
607
+ * default parameters never produces a spurious diff.
608
+ */
609
+ function diffRuleParams(
610
+ type: string,
611
+ existing: Record<string, unknown>,
612
+ desired: Record<string, unknown>,
613
+ ): string[] {
614
+ const changes: string[] = [];
615
+ for (const [key, want] of Object.entries(desired)) {
616
+ if (key === 'required_status_checks') {
617
+ const wantCtx = statusCheckContexts({ required_status_checks: want });
618
+ const haveCtx = new Set(
619
+ statusCheckContexts({ required_status_checks: existing[key] }),
620
+ );
621
+ const missing = wantCtx.filter((c) => !haveCtx.has(c));
622
+ if (missing.length > 0) {
623
+ changes.push(`${type}.required_status_checks: add ${JSON.stringify(missing)}`);
624
+ }
625
+ continue;
626
+ }
627
+ if (key === 'required_approving_review_count') {
628
+ const have = Number(existing[key] ?? 0);
629
+ const eff = Math.max(have, Number(want));
630
+ if (eff !== have) {
631
+ changes.push(`${type}.required_approving_review_count: ${have} → ${eff}`);
632
+ }
633
+ continue;
634
+ }
635
+ if (Array.isArray(want)) {
636
+ if (!arraysEqualAsSet(want, existing[key])) {
637
+ changes.push(
638
+ `${type}.${key}: ${JSON.stringify(existing[key])} → ${JSON.stringify(want)}`,
639
+ );
640
+ }
641
+ continue;
642
+ }
643
+ if (existing[key] !== want) {
644
+ changes.push(`${type}.${key}: ${existing[key] ?? '(unset)'} → ${want}`);
645
+ }
456
646
  }
647
+ return changes;
648
+ }
457
649
 
458
- // ----- Apply: repo-level settings -------------------------------------
459
- if (needsRepoPatch) {
460
- await octokit.repos.update(repoPatch);
650
+ /**
651
+ * Builds the PUT payload for an update, preserving the repo's legitimate
652
+ * extras rather than clobbering them:
653
+ * - union the existing + canonical bypass_actors,
654
+ * - union the existing + canonical required_status_checks contexts,
655
+ * - keep the higher of the existing / canonical approving-review count.
656
+ * Every other parameter converges to the canonical value.
657
+ */
658
+ function mergeForPut(
659
+ existing: RepoRulesetFull,
660
+ desired: RulesetWritePayload,
661
+ ): RulesetWritePayload {
662
+ // Union bypass_actors by (actor_type, actor_id).
663
+ const bypass: CanonicalRulesetBypassActor[] = [...desired.bypass_actors];
664
+ for (const e of existing.bypass_actors ?? []) {
665
+ const already = bypass.some(
666
+ (d) => d.actor_type === e.actor_type && d.actor_id === e.actor_id,
667
+ );
668
+ if (!already && e.actor_type && typeof e.actor_id === 'number' && e.bypass_mode) {
669
+ bypass.push({
670
+ actor_type: e.actor_type,
671
+ actor_id: e.actor_id,
672
+ bypass_mode: e.bypass_mode,
673
+ });
674
+ }
461
675
  }
462
676
 
677
+ const existingByType = new Map<string, CanonicalRulesetRule>();
678
+ for (const r of existing.rules ?? []) existingByType.set(r.type, r);
679
+
680
+ const rules = desired.rules.map((rule) => {
681
+ const ex = existingByType.get(rule.type);
682
+ if (rule.type === 'required_status_checks' && rule.parameters) {
683
+ const merged = unionContexts(
684
+ statusCheckContexts(ex?.parameters ?? {}),
685
+ statusCheckContexts(rule.parameters),
686
+ );
687
+ return {
688
+ ...rule,
689
+ parameters: {
690
+ ...rule.parameters,
691
+ required_status_checks: merged.map((context) => ({ context })),
692
+ },
693
+ };
694
+ }
695
+ if (rule.type === 'pull_request' && rule.parameters) {
696
+ const have = Number(ex?.parameters?.required_approving_review_count ?? 0);
697
+ const want = Number(rule.parameters.required_approving_review_count ?? 0);
698
+ return {
699
+ ...rule,
700
+ parameters: {
701
+ ...rule.parameters,
702
+ required_approving_review_count: Math.max(have, want),
703
+ },
704
+ };
705
+ }
706
+ return rule;
707
+ });
708
+
463
709
  return {
464
- changes,
465
- alreadyCanonical: false,
466
- ruleset: 'canonical-v1',
710
+ name: desired.name,
711
+ target: desired.target,
712
+ enforcement: desired.enforcement,
713
+ bypass_actors: bypass,
714
+ conditions: desired.conditions,
715
+ rules,
467
716
  };
468
717
  }
469
718
 
470
- /**
471
- * Octokit (and gh's wrapping adapter) report a missing branch protection
472
- * rule as a 404. We catch on the structural shape of the error so the
473
- * downstream Octokit version doesn't matter.
474
- */
475
- function isBranchNotProtected(err: unknown): boolean {
476
- if (!err || typeof err !== 'object') return false;
477
- const e = err as { status?: number; message?: string };
478
- if (e.status === 404) return true;
479
- if (typeof e.message === 'string') {
480
- return /Branch not protected|Not Found|404/i.test(e.message);
719
+ /** Union of two context lists, preserving existing order then appending new. */
720
+ function unionContexts(existing: string[], desired: string[]): string[] {
721
+ const seen = new Set<string>();
722
+ const out: string[] = [];
723
+ for (const c of [...existing, ...desired]) {
724
+ if (!seen.has(c)) {
725
+ seen.add(c);
726
+ out.push(c);
727
+ }
481
728
  }
482
- return false;
729
+ return out;
483
730
  }
484
731
 
485
- /**
486
- * Detect whether any of the per-branch protection settings (conversation
487
- * resolution / force-pushes / deletions / linear-history / enforce-admins)
488
- * appeared in the change list. Used to decide whether the branch protection
489
- * PUT needs to fire when status_checks + reviews are both in sync.
490
- */
491
- function hasBranchProtectionLevelDiff(changes: string[]): boolean {
492
- return changes.some((c) =>
493
- /^(required_conversation_resolution|enforce_admins|allow_force_pushes|allow_deletions|required_linear_history):/.test(
494
- c,
495
- ),
496
- );
732
+ /** Compares two values as sets (order-insensitive). Non-arrays fall back to ===. */
733
+ function arraysEqualAsSet(a: unknown, b: unknown): boolean {
734
+ if (!Array.isArray(a) || !Array.isArray(b)) return a === b;
735
+ if (a.length !== b.length) return false;
736
+ const sa = a.map((x) => JSON.stringify(x)).sort();
737
+ const sb = b.map((x) => JSON.stringify(x)).sort();
738
+ return sa.every((v, i) => v === sb[i]);
497
739
  }