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,27 +43,34 @@
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
  import { readFile } from 'node:fs/promises';
44
75
  import { dirname, join } from 'node:path';
45
76
  import { fileURLToPath } from 'node:url';
@@ -49,241 +80,408 @@ import { fileURLToPath } from 'node:url';
49
80
  * `<pkg>`, parallel to the package.json `files` map entry for `data`.
50
81
  */
51
82
  const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
52
- /** Memoized canonical ruleset — loaded once per process. */
53
- let CANONICAL_V1_CACHE = null;
54
83
  /**
55
- * Loads the bundled canonical-v1 ruleset from `data/canonical-v1.json`.
56
- * Memoized so repeated calls don't re-hit the filesystem.
84
+ * The purpose-named preset taxonomy, vendored from reporulez (the canonical
85
+ * source) into `data/rulesets/<name>.json`. Ordered least most strict.
86
+ */
87
+ export const PRESET_NAMES = [
88
+ 'baseline',
89
+ 'clud-bug',
90
+ 'skdd',
91
+ 'public-guard',
92
+ ];
93
+ /**
94
+ * Default preset — `skdd` (the four-check SkDD ruleset). Preserves the prior
95
+ * single-canonical behavior when `--preset` isn't passed.
96
+ */
97
+ export const DEFAULT_PRESET = 'skdd';
98
+ /** Type guard: is `name` one of the known presets? */
99
+ export function isPresetName(name) {
100
+ return PRESET_NAMES.includes(name);
101
+ }
102
+ /** Memoized presets — each variant loaded at most once per process. */
103
+ const PRESET_CACHE = new Map();
104
+ /**
105
+ * Loads a vendored ruleset preset from `data/rulesets/<name>.json`. The four
106
+ * variants (baseline · clud-bug · skdd · public-guard) are vendored verbatim
107
+ * from reporulez (the canonical source); consumers ignore the `$comment`
108
+ * header. Memoized per-name so repeated calls don't re-hit the filesystem.
57
109
  *
58
- * Throws a wrapped error if the JSON file is missing — that's an
59
- * install-time defect (package.json `files` excluded `data/`), not a
60
- * runtime concern, and the loud failure surfaces it immediately.
110
+ * Throws a wrapped error if the name is unknown or the JSON is missing —
111
+ * an unknown preset is a caller bug; a missing file is an install-time
112
+ * defect (package.json `files` excluded `data/`). Both surface loudly.
61
113
  */
62
- export async function loadCanonicalV1() {
63
- if (CANONICAL_V1_CACHE)
64
- return CANONICAL_V1_CACHE;
65
- const path = join(PKG_ROOT, 'data', 'canonical-v1.json');
114
+ export async function loadPreset(name) {
115
+ if (!isPresetName(name)) {
116
+ throw new Error(`configure-github: unknown preset "${name}" (valid: ${PRESET_NAMES.join(', ')})`);
117
+ }
118
+ const cached = PRESET_CACHE.get(name);
119
+ if (cached)
120
+ return cached;
121
+ const path = join(PKG_ROOT, 'data', 'rulesets', `${name}.json`);
66
122
  try {
67
123
  const raw = await readFile(path, 'utf-8');
68
- CANONICAL_V1_CACHE = JSON.parse(raw);
69
- return CANONICAL_V1_CACHE;
124
+ const parsed = JSON.parse(raw);
125
+ PRESET_CACHE.set(name, parsed);
126
+ return parsed;
70
127
  }
71
128
  catch (err) {
72
129
  const msg = err instanceof Error ? err.message : String(err);
73
- throw new Error(`configure-github: failed to load bundled canonical-v1.json at ${path}: ${msg}`);
130
+ throw new Error(`configure-github: failed to load ruleset preset "${name}" at ${path}: ${msg}`);
74
131
  }
75
132
  }
76
133
  /**
77
- * Applies the canonical ruleset to a GitHub repo. Reads current state via
78
- * the Octokit-like instance, diffs against `ruleset` (default
79
- * `CANONICAL_V1`), and PATCHes only what differs. Idempotent: second call
80
- * returns `alreadyCanonical: true` with no PATCH side effects.
134
+ * Back-compat alias: the historical `loadCanonicalV1()` entry point now
135
+ * resolves the DEFAULT (`skdd`) preset. `data/canonical-v1.json` is retained
136
+ * on disk as a copy of the skdd preset for the stable external fetch path,
137
+ * but the loader reads `data/rulesets/skdd.json`.
138
+ */
139
+ export async function loadCanonicalV1() {
140
+ return loadPreset(DEFAULT_PRESET);
141
+ }
142
+ /** The canonical repo-conveniences every preset converges the repo to. */
143
+ export const CANONICAL_REPO_CONVENIENCES = {
144
+ delete_branch_on_merge: true,
145
+ allow_auto_merge: true,
146
+ allow_squash_merge: true,
147
+ allow_merge_commit: false,
148
+ allow_rebase_merge: false,
149
+ squash_merge_commit_title: 'PR_TITLE',
150
+ squash_merge_commit_message: 'PR_BODY',
151
+ };
152
+ /**
153
+ * Applies the selected preset to a GitHub repo: repo conveniences FIRST
154
+ * (universal hygiene for every preset), then the canonical ruleset via the
155
+ * rulesets API.
81
156
  *
82
- * Behavior on partial mismatch:
157
+ * Conveniences: diffs repo-level settings (squash-only merges, auto-merge,
158
+ * delete-branch-on-merge, PR title/body squash message) and PATCHes only
159
+ * what drifted (`repos.update`), before the ruleset write.
83
160
  *
84
- * - `required_status_checks.contexts`: canonical contexts that are
85
- * missing get added; extra contexts on the repo are preserved (superset
86
- * contract a repo that runs more CI gates legitimately needs them).
87
- * - `required_approving_review_count`: canonical floor is 1; if the repo
88
- * already requires more, we don't lower (raise-only contract).
89
- * - All booleans (allow_force_pushes, allow_deletions, etc.): converge
90
- * to the canonical value exactly.
161
+ * Ruleset: lists the repo's rulesets, finds the canonical one by name, and:
162
+ * - creates it (POST) if absent, or
163
+ * - diffs the existing one against canonical and updates it (PUT) only
164
+ * when it doesn't already satisfy the canonical contract.
165
+ * Idempotent: a second call against a converged repo returns
166
+ * `alreadyCanonical: true` with no repo-update / create / update side effects.
91
167
  *
92
- * Throws on Octokit transport failure (auth, network, 403). The CLI
93
- * wraps these with a friendly error message.
168
+ * Behavior on partial mismatch (SPEC §7.4):
169
+ * - `required_status_checks` contexts: canonical contexts that are missing
170
+ * get added; extra contexts on the repo are preserved (superset contract).
171
+ * - `required_approving_review_count`: treated as a floor (only raise, never
172
+ * lower an owner-raised value).
173
+ * - `bypass_actors`: superset — extra actors the repo already has survive.
174
+ * - repo conveniences + everything else converge to the canonical value.
175
+ *
176
+ * Throws on Octokit transport failure (auth, network, 403). The CLI wraps
177
+ * these with a friendly error message.
94
178
  */
95
179
  export async function applyCanonicalRuleset(octokit, params) {
96
- const { owner, repo, branch = 'main', dryRun = false, } = params;
97
- const ruleset = params.ruleset ?? (await loadCanonicalV1());
98
- const target = ruleset.branch_protection;
99
- const changes = [];
100
- // ----- Read current branch protection state ---------------------------
101
- // 404 no base protection rule. We treat that as "every canonical
102
- // setting differs" so the apply path creates the rule. Other errors
103
- // bubble up callers can't recover from a 403.
104
- let current = {};
105
- let hasBaseProtection = true;
106
- try {
107
- const resp = await octokit.repos.getBranchProtection({
180
+ const { owner, repo, branch = 'main', dryRun = false } = params;
181
+ const ruleset = params.ruleset ?? (await loadPreset(params.preset ?? DEFAULT_PRESET));
182
+ const desired = toRulesetWritePayload(ruleset, branch);
183
+ // ----- Repo conveniences (universal hygiene — every preset) -----------
184
+ // These live on the REPOSITORY, not the ruleset. Diff first so a repo
185
+ // that already matches is a true no-op (idempotency contract); the PATCH
186
+ // below fires BEFORE the ruleset write.
187
+ const repoResp = await octokit.repos.get({ owner, repo });
188
+ const conveniences = diffRepoConveniences(repoResp.data ?? {}, CANONICAL_REPO_CONVENIENCES);
189
+ // ----- Read current rulesets ------------------------------------------
190
+ // The list endpoint returns 200 with `[]` for a repo that has no rulesets
191
+ // (no 404 to special-case). Transport errors (403/network) bubble up.
192
+ // `includes_parents:false` keeps us to repo-level rulesets so we don't
193
+ // mistake an inherited org ruleset (which we can't update at repo level)
194
+ // for ours.
195
+ const listResp = await octokit.repos.getRepoRulesets({
196
+ owner,
197
+ repo,
198
+ includes_parents: false,
199
+ });
200
+ const existingSummary = (listResp.data ?? []).find((r) => r.name === desired.name) ?? null;
201
+ let rulesetChanges;
202
+ let existingFull = null;
203
+ if (existingSummary) {
204
+ const fullResp = await octokit.repos.getRepoRuleset({
108
205
  owner,
109
206
  repo,
110
- branch,
207
+ ruleset_id: existingSummary.id,
111
208
  });
112
- current = resp.data;
209
+ existingFull = fullResp.data;
210
+ rulesetChanges = diffExistingRuleset(existingFull, desired);
113
211
  }
114
- catch (err) {
115
- if (isBranchNotProtected(err)) {
116
- hasBaseProtection = false;
117
- }
118
- else {
119
- throw err;
120
- }
121
- }
122
- // ----- Diff: required_status_checks -----------------------------------
123
- // Behavior: contexts are a SUPERSET. Strict mode must match exactly.
124
- const currentChecks = current.required_status_checks ?? null;
125
- const currentContexts = new Set(currentChecks?.contexts ?? []);
126
- const targetContexts = new Set(target.required_status_checks.contexts);
127
- const missingContexts = [...targetContexts].filter((c) => !currentContexts.has(c));
128
- const strictMismatch = (currentChecks?.strict ?? false) !== target.required_status_checks.strict;
129
- let needsChecksPatch = !hasBaseProtection;
130
- if (missingContexts.length > 0) {
131
- changes.push(`required_status_checks.contexts: add ${JSON.stringify(missingContexts)}`);
132
- needsChecksPatch = true;
133
- }
134
- if (strictMismatch) {
135
- changes.push(`required_status_checks.strict: ${currentChecks?.strict ?? false} → ${target.required_status_checks.strict}`);
136
- needsChecksPatch = true;
137
- }
138
- // The merged context list: keep extras the repo already required.
139
- const mergedContexts = [
140
- ...new Set([
141
- ...(currentChecks?.contexts ?? []),
142
- ...target.required_status_checks.contexts,
143
- ]),
144
- ];
145
- // ----- Diff: required_pull_request_reviews ----------------------------
146
- // Behavior: approving_review_count is a FLOOR (only raise). Booleans
147
- // converge exactly.
148
- const currentReviews = current.required_pull_request_reviews ?? null;
149
- const currentCount = currentReviews?.required_approving_review_count ?? 0;
150
- const targetCount = target.required_pull_request_reviews.required_approving_review_count;
151
- const effectiveCount = Math.max(currentCount, targetCount);
152
- const targetDismiss = target.required_pull_request_reviews.dismiss_stale_reviews;
153
- const targetCodeOwner = target.required_pull_request_reviews.require_code_owner_reviews;
154
- let needsReviewsPatch = !hasBaseProtection;
155
- if (effectiveCount !== currentCount) {
156
- changes.push(`required_pull_request_reviews.required_approving_review_count: ${currentCount} → ${effectiveCount}`);
157
- needsReviewsPatch = true;
212
+ else {
213
+ rulesetChanges = describeCreate(desired);
158
214
  }
159
- if ((currentReviews?.dismiss_stale_reviews ?? false) !== targetDismiss) {
160
- changes.push(`required_pull_request_reviews.dismiss_stale_reviews: ${currentReviews?.dismiss_stale_reviews ?? false} → ${targetDismiss}`);
161
- needsReviewsPatch = true;
162
- }
163
- if ((currentReviews?.require_code_owner_reviews ?? false) !== targetCodeOwner) {
164
- changes.push(`required_pull_request_reviews.require_code_owner_reviews: ${currentReviews?.require_code_owner_reviews ?? false} → ${targetCodeOwner}`);
165
- needsReviewsPatch = true;
166
- }
167
- // ----- Diff: single-flag branch protection booleans -------------------
168
- const conv = current.required_conversation_resolution?.enabled ?? false;
169
- const enforceAdmins = current.enforce_admins?.enabled ?? false;
170
- const allowFP = current.allow_force_pushes?.enabled ?? false;
171
- const allowDel = current.allow_deletions?.enabled ?? false;
172
- const linearHistory = current.required_linear_history?.enabled ?? false;
173
- if (conv !== target.required_conversation_resolution) {
174
- changes.push(`required_conversation_resolution: ${conv} → ${target.required_conversation_resolution}`);
175
- }
176
- if (enforceAdmins !== target.enforce_admins) {
177
- changes.push(`enforce_admins: ${enforceAdmins} → ${target.enforce_admins}`);
215
+ // Conveniences reported first (they're applied first).
216
+ const changes = [...conveniences.changes, ...rulesetChanges];
217
+ const alreadyCanonical = changes.length === 0;
218
+ if (alreadyCanonical || dryRun) {
219
+ return { changes, alreadyCanonical, ruleset: 'canonical-v1' };
178
220
  }
179
- if (allowFP !== target.allow_force_pushes) {
180
- changes.push(`allow_force_pushes: ${allowFP} → ${target.allow_force_pushes}`);
221
+ // ----- Apply conveniences FIRST (before the ruleset write) ------------
222
+ if (conveniences.needsPatch) {
223
+ await octokit.repos.update({ owner, repo, ...conveniences.patch });
181
224
  }
182
- if (allowDel !== target.allow_deletions) {
183
- changes.push(`allow_deletions: ${allowDel} ${target.allow_deletions}`);
225
+ // ----- Apply ruleset (only when the ruleset itself drifted) -----------
226
+ // Gate on `rulesetChanges`, not the combined `changes`: a repo whose
227
+ // ruleset is already canonical but whose conveniences drifted must NOT
228
+ // incur a spurious ruleset PUT (idempotency at the ruleset layer).
229
+ if (rulesetChanges.length > 0) {
230
+ if (existingSummary && existingFull) {
231
+ // PUT: merge to preserve the repo's extras (superset contexts / bypass
232
+ // actors / owner-raised approval floor) instead of clobbering them.
233
+ const merged = mergeForPut(existingFull, desired);
234
+ await octokit.repos.updateRepoRuleset({
235
+ owner,
236
+ repo,
237
+ ruleset_id: existingSummary.id,
238
+ ...merged,
239
+ });
240
+ }
241
+ else {
242
+ await octokit.repos.createRepoRuleset({ owner, repo, ...desired });
243
+ }
184
244
  }
185
- if (linearHistory !== target.required_linear_history) {
186
- changes.push(`required_linear_history: ${linearHistory} → ${target.required_linear_history}`);
245
+ return { changes, alreadyCanonical: false, ruleset: 'canonical-v1' };
246
+ }
247
+ /**
248
+ * Diffs the repo's current conveniences against the canonical set. Returns
249
+ * one human-readable line per drifted field plus the minimal PATCH body.
250
+ * `needsPatch` is false when the repo already matches (idempotency contract).
251
+ * Only keys present in `desired` are compared, so extra repo settings GitHub
252
+ * echoes back never produce a spurious diff.
253
+ */
254
+ function diffRepoConveniences(current, desired) {
255
+ const changes = [];
256
+ const patch = {};
257
+ for (const key of Object.keys(desired)) {
258
+ const want = desired[key];
259
+ const have = current[key] ?? (typeof want === 'boolean' ? false : '');
260
+ if (have !== want) {
261
+ changes.push(`${key}: ${have === '' ? '(unset)' : have} → ${want}`);
262
+ patch[key] = want;
263
+ }
187
264
  }
188
- // ----- Diff: repo-level settings --------------------------------------
189
- // delete_branch_on_merge / allow_auto_merge / squash commit shape live
190
- // on the REPO, not the branch protection rule. Fetched separately.
191
- const repoResp = await octokit.repos.get({ owner, repo });
192
- const repoData = repoResp.data;
193
- const repoPatch = {
194
- owner,
195
- repo,
265
+ return {
266
+ changes,
267
+ patch: patch,
268
+ needsPatch: changes.length > 0,
196
269
  };
197
- let needsRepoPatch = false;
198
- if ((repoData.delete_branch_on_merge ?? false) !== target.delete_branch_on_merge) {
199
- changes.push(`delete_branch_on_merge: ${repoData.delete_branch_on_merge ?? false} ${target.delete_branch_on_merge}`);
200
- repoPatch.delete_branch_on_merge = target.delete_branch_on_merge;
201
- needsRepoPatch = true;
270
+ }
271
+ /**
272
+ * Builds the GitHub rulesets write payload from the canonical file, stripping
273
+ * the vendored-only metadata (`$comment`, `$notes`, `version`, `spec_version`)
274
+ * that the rulesets API doesn't accept. When an explicit non-default branch
275
+ * is requested, narrows the ref condition to `refs/heads/<branch>` (the
276
+ * canonical otherwise targets the repo default branch via `~DEFAULT_BRANCH`).
277
+ */
278
+ function toRulesetWritePayload(ruleset, branch) {
279
+ const conditions = branch && branch !== 'main'
280
+ ? { ref_name: { include: [`refs/heads/${branch}`], exclude: [] } }
281
+ : ruleset.conditions;
282
+ return {
283
+ name: ruleset.name,
284
+ target: ruleset.target,
285
+ enforcement: ruleset.enforcement,
286
+ bypass_actors: ruleset.bypass_actors,
287
+ conditions,
288
+ rules: ruleset.rules,
289
+ };
290
+ }
291
+ /** Human-readable change lines for the create (no existing ruleset) path. */
292
+ function describeCreate(desired) {
293
+ const changes = [
294
+ `ruleset "${desired.name}": create (target=${desired.target}, enforcement=${desired.enforcement})`,
295
+ ];
296
+ for (const rule of desired.rules)
297
+ changes.push(`+ rule ${summarizeRule(rule)}`);
298
+ return changes;
299
+ }
300
+ /** One-line summary of a rule for human-readable diff output. */
301
+ function summarizeRule(rule) {
302
+ const p = rule.parameters ?? {};
303
+ if (rule.type === 'pull_request') {
304
+ return `pull_request (approvals=${p.required_approving_review_count}, thread_resolution=${p.required_review_thread_resolution}, merge=${JSON.stringify(p.allowed_merge_methods)})`;
202
305
  }
203
- if ((repoData.allow_auto_merge ?? false) !== target.allow_auto_merge) {
204
- changes.push(`allow_auto_merge: ${repoData.allow_auto_merge ?? false} → ${target.allow_auto_merge}`);
205
- repoPatch.allow_auto_merge = target.allow_auto_merge;
206
- needsRepoPatch = true;
306
+ if (rule.type === 'required_status_checks') {
307
+ const contexts = statusCheckContexts(p);
308
+ return `required_status_checks (strict=${p.strict_required_status_checks_policy}, contexts=${JSON.stringify(contexts)})`;
207
309
  }
208
- if ((repoData.squash_merge_commit_title ?? '') !==
209
- target.squash_merge_commit_title) {
210
- changes.push(`squash_merge_commit_title: ${repoData.squash_merge_commit_title ?? '(unset)'} ${target.squash_merge_commit_title}`);
211
- repoPatch.squash_merge_commit_title = target.squash_merge_commit_title;
212
- needsRepoPatch = true;
310
+ return rule.type;
311
+ }
312
+ /** Extracts the `context` strings from a required_status_checks params blob. */
313
+ function statusCheckContexts(params) {
314
+ const list = params.required_status_checks;
315
+ if (!Array.isArray(list))
316
+ return [];
317
+ return list
318
+ .map((c) => (c && typeof c === 'object' ? c.context : undefined))
319
+ .filter((c) => typeof c === 'string');
320
+ }
321
+ /**
322
+ * Diffs an existing ruleset against the canonical desired payload. Returns
323
+ * one human-readable line per difference the apply path would fix; an empty
324
+ * array means the existing ruleset already satisfies the canonical (no-op).
325
+ */
326
+ function diffExistingRuleset(existing, desired) {
327
+ const changes = [];
328
+ if ((existing.enforcement ?? '') !== desired.enforcement) {
329
+ changes.push(`enforcement: ${existing.enforcement ?? '(unset)'} → ${desired.enforcement}`);
213
330
  }
214
- if ((repoData.squash_merge_commit_message ?? '') !==
215
- target.squash_merge_commit_message) {
216
- changes.push(`squash_merge_commit_message: ${repoData.squash_merge_commit_message ?? '(unset)'} → ${target.squash_merge_commit_message}`);
217
- repoPatch.squash_merge_commit_message = target.squash_merge_commit_message;
218
- needsRepoPatch = true;
331
+ if ((existing.target ?? '') !== desired.target) {
332
+ changes.push(`target: ${existing.target ?? '(unset)'} → ${desired.target}`);
219
333
  }
220
- const alreadyCanonical = changes.length === 0;
221
- if (alreadyCanonical || dryRun) {
222
- return {
223
- changes,
224
- alreadyCanonical,
225
- ruleset: 'canonical-v1',
226
- };
334
+ // conditions: the desired ref-includes must all be present on the existing.
335
+ const existingInclude = new Set(existing.conditions?.ref_name?.include ?? []);
336
+ const missingInclude = desired.conditions.ref_name.include.filter((r) => !existingInclude.has(r));
337
+ if (missingInclude.length > 0) {
338
+ changes.push(`conditions.ref_name.include: add ${JSON.stringify(missingInclude)}`);
227
339
  }
228
- // ----- Apply: branch protection ---------------------------------------
229
- // The REST API requires a PUT (not PATCH) with the full settings object.
230
- // We always send a complete envelope; the diff above gates whether we
231
- // call PUT at all.
232
- if (needsChecksPatch || needsReviewsPatch || !hasBaseProtection || hasBranchProtectionLevelDiff(changes)) {
233
- await octokit.repos.updateBranchProtection({
234
- owner,
235
- repo,
236
- branch,
237
- required_status_checks: {
238
- strict: target.required_status_checks.strict,
239
- contexts: mergedContexts,
240
- },
241
- enforce_admins: target.enforce_admins,
242
- required_pull_request_reviews: {
243
- required_approving_review_count: effectiveCount,
244
- dismiss_stale_reviews: targetDismiss,
245
- require_code_owner_reviews: targetCodeOwner,
246
- },
247
- restrictions: null,
248
- required_conversation_resolution: target.required_conversation_resolution,
249
- required_linear_history: target.required_linear_history,
250
- allow_force_pushes: target.allow_force_pushes,
251
- allow_deletions: target.allow_deletions,
252
- });
340
+ // bypass_actors: superset report only actors the existing set lacks.
341
+ const missingActors = desired.bypass_actors.filter((d) => !(existing.bypass_actors ?? []).some((e) => e.actor_type === d.actor_type &&
342
+ e.actor_id === d.actor_id &&
343
+ e.bypass_mode === d.bypass_mode));
344
+ if (missingActors.length > 0) {
345
+ changes.push(`bypass_actors: add ${JSON.stringify(missingActors)}`);
253
346
  }
254
- // ----- Apply: repo-level settings -------------------------------------
255
- if (needsRepoPatch) {
256
- await octokit.repos.update(repoPatch);
347
+ // rules: for each desired rule, ensure the existing set has a matching one.
348
+ const existingByType = new Map();
349
+ for (const r of existing.rules ?? [])
350
+ existingByType.set(r.type, r);
351
+ for (const rule of desired.rules) {
352
+ const ex = existingByType.get(rule.type);
353
+ if (!ex) {
354
+ changes.push(`rules: add "${rule.type}"`);
355
+ continue;
356
+ }
357
+ if (rule.parameters) {
358
+ changes.push(...diffRuleParams(rule.type, ex.parameters ?? {}, rule.parameters));
359
+ }
257
360
  }
258
- return {
259
- changes,
260
- alreadyCanonical: false,
261
- ruleset: 'canonical-v1',
262
- };
361
+ return changes;
263
362
  }
264
363
  /**
265
- * Octokit (and gh's wrapping adapter) report a missing branch protection
266
- * rule as a 404. We catch on the structural shape of the error so the
267
- * downstream Octokit version doesn't matter.
364
+ * Diffs a single rule's parameters (existing vs desired). Applies the SPEC
365
+ * §7.4 semantics per parameter:
366
+ * - `required_status_checks` (array of {context}): superset — only report
367
+ * the canonical contexts the existing set is missing.
368
+ * - `required_approving_review_count`: floor — only report when the existing
369
+ * is BELOW the canonical (never lower an owner-raised value).
370
+ * - array params (e.g. `allowed_merge_methods`): set-equality.
371
+ * - scalars/booleans: exact match.
372
+ * Only keys present in `desired` are compared, so GitHub echoing extra
373
+ * default parameters never produces a spurious diff.
268
374
  */
269
- function isBranchNotProtected(err) {
270
- if (!err || typeof err !== 'object')
271
- return false;
272
- const e = err;
273
- if (e.status === 404)
274
- return true;
275
- if (typeof e.message === 'string') {
276
- return /Branch not protected|Not Found|404/i.test(e.message);
375
+ function diffRuleParams(type, existing, desired) {
376
+ const changes = [];
377
+ for (const [key, want] of Object.entries(desired)) {
378
+ if (key === 'required_status_checks') {
379
+ const wantCtx = statusCheckContexts({ required_status_checks: want });
380
+ const haveCtx = new Set(statusCheckContexts({ required_status_checks: existing[key] }));
381
+ const missing = wantCtx.filter((c) => !haveCtx.has(c));
382
+ if (missing.length > 0) {
383
+ changes.push(`${type}.required_status_checks: add ${JSON.stringify(missing)}`);
384
+ }
385
+ continue;
386
+ }
387
+ if (key === 'required_approving_review_count') {
388
+ const have = Number(existing[key] ?? 0);
389
+ const eff = Math.max(have, Number(want));
390
+ if (eff !== have) {
391
+ changes.push(`${type}.required_approving_review_count: ${have} → ${eff}`);
392
+ }
393
+ continue;
394
+ }
395
+ if (Array.isArray(want)) {
396
+ if (!arraysEqualAsSet(want, existing[key])) {
397
+ changes.push(`${type}.${key}: ${JSON.stringify(existing[key])} → ${JSON.stringify(want)}`);
398
+ }
399
+ continue;
400
+ }
401
+ if (existing[key] !== want) {
402
+ changes.push(`${type}.${key}: ${existing[key] ?? '(unset)'} → ${want}`);
403
+ }
277
404
  }
278
- return false;
405
+ return changes;
279
406
  }
280
407
  /**
281
- * Detect whether any of the per-branch protection settings (conversation
282
- * resolution / force-pushes / deletions / linear-history / enforce-admins)
283
- * appeared in the change list. Used to decide whether the branch protection
284
- * PUT needs to fire when status_checks + reviews are both in sync.
408
+ * Builds the PUT payload for an update, preserving the repo's legitimate
409
+ * extras rather than clobbering them:
410
+ * - union the existing + canonical bypass_actors,
411
+ * - union the existing + canonical required_status_checks contexts,
412
+ * - keep the higher of the existing / canonical approving-review count.
413
+ * Every other parameter converges to the canonical value.
285
414
  */
286
- function hasBranchProtectionLevelDiff(changes) {
287
- return changes.some((c) => /^(required_conversation_resolution|enforce_admins|allow_force_pushes|allow_deletions|required_linear_history):/.test(c));
415
+ function mergeForPut(existing, desired) {
416
+ // Union bypass_actors by (actor_type, actor_id).
417
+ const bypass = [...desired.bypass_actors];
418
+ for (const e of existing.bypass_actors ?? []) {
419
+ const already = bypass.some((d) => d.actor_type === e.actor_type && d.actor_id === e.actor_id);
420
+ if (!already && e.actor_type && typeof e.actor_id === 'number' && e.bypass_mode) {
421
+ bypass.push({
422
+ actor_type: e.actor_type,
423
+ actor_id: e.actor_id,
424
+ bypass_mode: e.bypass_mode,
425
+ });
426
+ }
427
+ }
428
+ const existingByType = new Map();
429
+ for (const r of existing.rules ?? [])
430
+ existingByType.set(r.type, r);
431
+ const rules = desired.rules.map((rule) => {
432
+ const ex = existingByType.get(rule.type);
433
+ if (rule.type === 'required_status_checks' && rule.parameters) {
434
+ const merged = unionContexts(statusCheckContexts(ex?.parameters ?? {}), statusCheckContexts(rule.parameters));
435
+ return {
436
+ ...rule,
437
+ parameters: {
438
+ ...rule.parameters,
439
+ required_status_checks: merged.map((context) => ({ context })),
440
+ },
441
+ };
442
+ }
443
+ if (rule.type === 'pull_request' && rule.parameters) {
444
+ const have = Number(ex?.parameters?.required_approving_review_count ?? 0);
445
+ const want = Number(rule.parameters.required_approving_review_count ?? 0);
446
+ return {
447
+ ...rule,
448
+ parameters: {
449
+ ...rule.parameters,
450
+ required_approving_review_count: Math.max(have, want),
451
+ },
452
+ };
453
+ }
454
+ return rule;
455
+ });
456
+ return {
457
+ name: desired.name,
458
+ target: desired.target,
459
+ enforcement: desired.enforcement,
460
+ bypass_actors: bypass,
461
+ conditions: desired.conditions,
462
+ rules,
463
+ };
464
+ }
465
+ /** Union of two context lists, preserving existing order then appending new. */
466
+ function unionContexts(existing, desired) {
467
+ const seen = new Set();
468
+ const out = [];
469
+ for (const c of [...existing, ...desired]) {
470
+ if (!seen.has(c)) {
471
+ seen.add(c);
472
+ out.push(c);
473
+ }
474
+ }
475
+ return out;
476
+ }
477
+ /** Compares two values as sets (order-insensitive). Non-arrays fall back to ===. */
478
+ function arraysEqualAsSet(a, b) {
479
+ if (!Array.isArray(a) || !Array.isArray(b))
480
+ return a === b;
481
+ if (a.length !== b.length)
482
+ return false;
483
+ const sa = a.map((x) => JSON.stringify(x)).sort();
484
+ const sb = b.map((x) => JSON.stringify(x)).sort();
485
+ return sa.every((v, i) => v === sb[i]);
288
486
  }
289
487
  //# sourceMappingURL=configure-github.js.map