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.
- package/data/canonical-v1.json +39 -34
- package/data/rulesets/baseline.json +29 -0
- package/data/rulesets/clud-bug.json +39 -0
- package/data/rulesets/public-guard.json +29 -0
- package/data/rulesets/skdd.json +42 -0
- package/dist/cli/configure-github.d.ts +13 -4
- package/dist/cli/configure-github.d.ts.map +1 -1
- package/dist/cli/configure-github.js +73 -27
- package/dist/cli/configure-github.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +5 -1
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +33 -10
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/post-check-run.d.ts +2 -0
- package/dist/cli/post-check-run.d.ts.map +1 -1
- package/dist/cli/post-check-run.js +124 -5
- package/dist/cli/post-check-run.js.map +1 -1
- package/dist/cli/review-prompt.d.ts +11 -1
- package/dist/cli/review-prompt.d.ts.map +1 -1
- package/dist/cli/review-prompt.js +141 -37
- package/dist/cli/review-prompt.js.map +1 -1
- package/dist/core/check-verdict.d.ts +1 -1
- package/dist/core/check-verdict.d.ts.map +1 -1
- package/dist/core/check-verdict.js +14 -1
- package/dist/core/check-verdict.js.map +1 -1
- package/dist/core/configure-github.d.ts +184 -119
- package/dist/core/configure-github.d.ts.map +1 -1
- package/dist/core/configure-github.js +407 -209
- package/dist/core/configure-github.js.map +1 -1
- package/dist/core/index.d.ts +4 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +12 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/invariants.d.ts +46 -0
- package/dist/core/invariants.d.ts.map +1 -0
- package/dist/core/invariants.js +96 -0
- package/dist/core/invariants.js.map +1 -0
- package/dist/core/notary-bundle.d.ts +95 -0
- package/dist/core/notary-bundle.d.ts.map +1 -0
- package/dist/core/notary-bundle.js +129 -0
- package/dist/core/notary-bundle.js.map +1 -0
- package/dist/core/notary-validate.d.ts +86 -0
- package/dist/core/notary-validate.d.ts.map +1 -0
- package/dist/core/notary-validate.js +270 -0
- package/dist/core/notary-validate.js.map +1 -0
- package/dist/core/prompt-builder.js +2 -2
- package/dist/core/review-schema-zod.d.ts +48 -0
- package/dist/core/review-schema-zod.d.ts.map +1 -1
- package/dist/core/review-schema-zod.js +5 -0
- package/dist/core/review-schema-zod.js.map +1 -1
- package/dist/core/review-schema.d.ts +3 -0
- package/dist/core/review-schema.d.ts.map +1 -1
- package/dist/core/review-schema.js +9 -0
- package/dist/core/review-schema.js.map +1 -1
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/package.json +1 -1
- package/src/cli/configure-github.ts +108 -31
- package/src/cli/hooks.ts +5 -1
- package/src/cli/main.ts +32 -10
- package/src/cli/post-check-run.ts +149 -5
- package/src/cli/review-prompt.ts +162 -36
- package/src/core/check-verdict.ts +14 -2
- package/src/core/configure-github.ts +591 -349
- package/src/core/index.ts +46 -0
- package/src/core/invariants.ts +123 -0
- package/src/core/notary-bundle.ts +196 -0
- package/src/core/notary-validate.ts +343 -0
- package/src/core/prompt-builder.ts +2 -2
- package/src/core/review-schema-zod.ts +5 -0
- package/src/core/review-schema.ts +12 -0
- package/src/core/version.ts +1 -1
- package/templates/skills/design/designing-elite-ui.md +57 -0
- package/templates/workflow-py.yml.tmpl +1 -1
- package/templates/workflow-ts.yml.tmpl +1 -1
- 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
|
|
6
|
-
//
|
|
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
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
|
35
|
-
//
|
|
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
|
|
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
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
130
|
+
throw new Error(`configure-github: failed to load ruleset preset "${name}" at ${path}: ${msg}`);
|
|
74
131
|
}
|
|
75
132
|
}
|
|
76
133
|
/**
|
|
77
|
-
*
|
|
78
|
-
* the
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
*
|
|
93
|
-
*
|
|
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
|
|
97
|
-
const ruleset = params.ruleset ?? (await
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
207
|
+
ruleset_id: existingSummary.id,
|
|
111
208
|
});
|
|
112
|
-
|
|
209
|
+
existingFull = fullResp.data;
|
|
210
|
+
rulesetChanges = diffExistingRuleset(existingFull, desired);
|
|
113
211
|
}
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
180
|
-
|
|
221
|
+
// ----- Apply conveniences FIRST (before the ruleset write) ------------
|
|
222
|
+
if (conveniences.needsPatch) {
|
|
223
|
+
await octokit.repos.update({ owner, repo, ...conveniences.patch });
|
|
181
224
|
}
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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 (
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 ((
|
|
215
|
-
target.
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
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
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
405
|
+
return changes;
|
|
279
406
|
}
|
|
280
407
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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
|
|
287
|
-
|
|
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
|