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,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
|
|
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
|
|
|
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
|
|
50
|
-
*
|
|
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
|
|
54
|
-
spec_version
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
/**
|
|
85
|
-
|
|
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
|
|
89
|
-
*
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
96
|
-
if (
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
178
|
+
`configure-github: failed to load ruleset preset "${name}" at ${path}: ${msg}`,
|
|
106
179
|
);
|
|
107
180
|
}
|
|
108
181
|
}
|
|
109
182
|
|
|
110
183
|
/**
|
|
111
|
-
*
|
|
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
|
-
/**
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
}): Promise<{
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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`).
|
|
193
|
-
*
|
|
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.
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
-
*
|
|
204
|
-
*
|
|
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
|
|
334
|
+
/** True when the live ruleset already satisfies every canonical setting. */
|
|
214
335
|
alreadyCanonical: boolean;
|
|
215
|
-
/** Schema
|
|
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
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
-
*
|
|
236
|
-
*
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
407
|
+
ruleset_id: existingSummary.id,
|
|
266
408
|
});
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
//
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
// -----
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
// -----
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 (
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
`
|
|
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
|
-
(
|
|
412
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
729
|
+
return out;
|
|
483
730
|
}
|
|
484
731
|
|
|
485
|
-
/**
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
}
|