@homeflare/alchemy 0.26.0 → 0.27.1
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/dist/github/declare-repo-baseline.d.ts +11 -0
- package/dist/github/declare-repo-baseline.d.ts.map +1 -0
- package/dist/github/index.d.ts +20 -11
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +715 -4
- package/dist/github/index.js.map +14 -4
- package/dist/github/repo-baseline-data.d.ts +75 -0
- package/dist/github/repo-baseline-data.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +4 -4
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-constraints.d.ts +105 -0
- package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
- package/dist/github/repository-ruleset-errors.d.ts +97 -0
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
- package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
- package/dist/github/repository-ruleset-form.d.ts +30 -0
- package/dist/github/repository-ruleset-form.d.ts.map +1 -0
- package/dist/github/repository-ruleset-guards.d.ts +70 -0
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +5 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-probe.d.ts +87 -0
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +89 -0
- package/dist/github/repository-ruleset.d.ts.map +1 -0
- package/dist/launchd/index.js.map +2 -2
- package/dist/launchd/port-claims.d.ts +2 -2
- package/dist/launchd/port-claims.d.ts.map +1 -1
- package/docs/launchd-ports.md +14 -17
- package/docs/repo-policy-ruleset-hazards.md +8 -0
- package/docs/repo-policy.md +27 -12
- package/docs/repository-ruleset.md +114 -0
- package/docs/upstream-conformance.md +12 -1
- package/docs/upstream-pr-ruleset-fixes.md +77 -0
- package/package.json +1 -1
- package/src/github/declare-repo-baseline.ts +42 -0
- package/src/github/index.ts +43 -11
- package/src/github/repo-baseline-data.test.ts +76 -0
- package/src/github/repo-baseline-data.ts +86 -0
- package/src/github/repo-policy.ts +17 -17
- package/src/github/repository-ruleset-constraints.ts +136 -0
- package/src/github/repository-ruleset-coverage.test.ts +82 -0
- package/src/github/repository-ruleset-errors.ts +136 -0
- package/src/github/repository-ruleset-fake-octokit.ts +131 -0
- package/src/github/repository-ruleset-fixtures.ts +183 -0
- package/src/github/repository-ruleset-form.ts +212 -0
- package/src/github/repository-ruleset-guards.ts +194 -0
- package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
- package/src/github/repository-ruleset-octokit.ts +140 -0
- package/src/github/repository-ruleset-probe.ts +116 -0
- package/src/github/repository-ruleset-reconcile.ts +176 -0
- package/src/github/repository-ruleset-test-helpers.ts +102 -0
- package/src/github/repository-ruleset-wire.test.ts +66 -0
- package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
- package/src/github/repository-ruleset-write.test.ts +83 -0
- package/src/github/repository-ruleset.ts +219 -0
- package/src/launchd/port-claims.ts +8 -9
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props → wire form, and a canonical form for comparing two wire rulesets.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE CANONICALIZATION IS THE NEVER-NOOP FIX. Upstream `GitHub.Ruleset#reconcile` compares
|
|
5
|
+
* `desired` against the live GET with a raw `deepEqual` (see repository-ruleset.ts's header).
|
|
6
|
+
* GitHub does not promise to echo `rules`, `allowed_merge_methods` or `checks` back in
|
|
7
|
+
* request order, and a live `required_status_checks` check carries `integration_id: null`
|
|
8
|
+
* where the request never sent the key at all — so an unsorted, un-normalized comparison
|
|
9
|
+
* drifts against itself on every single plan. `canonicalizeWireRuleset` sorts every list that
|
|
10
|
+
* has no meaningful order and drops nullish `integration_id` before two wire rulesets are
|
|
11
|
+
* ever compared.
|
|
12
|
+
*/
|
|
13
|
+
import type {
|
|
14
|
+
RepositoryRulesetProps,
|
|
15
|
+
RepositoryRulesetPullRequestRule,
|
|
16
|
+
RepositoryRulesetRules,
|
|
17
|
+
RepositoryRulesetStatusChecksRule,
|
|
18
|
+
} from './repository-ruleset.ts';
|
|
19
|
+
import type { CreateRulesetBody, WireRule } from './repository-ruleset-constraints.ts';
|
|
20
|
+
|
|
21
|
+
/** Fixed, deterministic order — independent of the order keys were set on `props.rules` — so
|
|
22
|
+
* two declarations of the same policy always produce the same wire array. */
|
|
23
|
+
const RULE_BUILD_ORDER = [
|
|
24
|
+
'creation',
|
|
25
|
+
'update',
|
|
26
|
+
'deletion',
|
|
27
|
+
'required_linear_history',
|
|
28
|
+
'required_signatures',
|
|
29
|
+
'non_fast_forward',
|
|
30
|
+
'pull_request',
|
|
31
|
+
'required_status_checks',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
function buildPullRequestRule(rule: RepositoryRulesetPullRequestRule): WireRule {
|
|
35
|
+
return {
|
|
36
|
+
type: 'pull_request',
|
|
37
|
+
parameters: {
|
|
38
|
+
required_approving_review_count: rule.requiredApprovingReviewCount ?? 0,
|
|
39
|
+
dismiss_stale_reviews_on_push: rule.dismissStaleReviewsOnPush ?? false,
|
|
40
|
+
require_code_owner_review: rule.requireCodeOwnerReview ?? false,
|
|
41
|
+
require_last_push_approval: rule.requireLastPushApproval ?? false,
|
|
42
|
+
required_review_thread_resolution: rule.requiredReviewThreadResolution ?? false,
|
|
43
|
+
...(rule.allowedMergeMethods === undefined
|
|
44
|
+
? {}
|
|
45
|
+
: { allowed_merge_methods: [...rule.allowedMergeMethods] }),
|
|
46
|
+
// Defaulted to `[]`, not omitted — GitHub's own GET always carries this key (empty when
|
|
47
|
+
// unset), so omitting it here would make every live ruleset diff against itself forever.
|
|
48
|
+
required_reviewers: (rule.requiredReviewers ?? []).map((r) => ({
|
|
49
|
+
file_patterns: [...r.filePatterns],
|
|
50
|
+
minimum_approvals: r.minimumApprovals,
|
|
51
|
+
reviewer: { id: r.reviewer.actorId, type: r.reviewer.actorType },
|
|
52
|
+
})),
|
|
53
|
+
// ⛔ Only ever sent as `false` (H15) — see repository-ruleset.ts for why this exists at
|
|
54
|
+
// all and repository-ruleset-wire.test.ts for whether Octokit forwards it untyped.
|
|
55
|
+
...(rule.extraApprovalForUnattributedChanges === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { require_extra_approval_for_unattributed_changes: false }),
|
|
58
|
+
},
|
|
59
|
+
// The extension field above has no home in Octokit's own rule union (H15) — the cast is
|
|
60
|
+
// the only place that gap is bridged.
|
|
61
|
+
} as WireRule;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildStatusChecksRule(rule: RepositoryRulesetStatusChecksRule): WireRule {
|
|
65
|
+
return {
|
|
66
|
+
type: 'required_status_checks',
|
|
67
|
+
parameters: {
|
|
68
|
+
required_status_checks: rule.checks.map((c) => ({
|
|
69
|
+
context: c.context,
|
|
70
|
+
...(c.integrationId === undefined ? {} : { integration_id: c.integrationId }),
|
|
71
|
+
})),
|
|
72
|
+
strict_required_status_checks_policy: rule.strictRequiredStatusChecksPolicy ?? false,
|
|
73
|
+
do_not_enforce_on_create: rule.doNotEnforceOnCreate ?? false,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildWireRules(rules: RepositoryRulesetRules | undefined): WireRule[] {
|
|
79
|
+
const wire: WireRule[] = [];
|
|
80
|
+
for (const type of RULE_BUILD_ORDER) {
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'creation':
|
|
83
|
+
if (rules?.creation) wire.push({ type: 'creation' });
|
|
84
|
+
break;
|
|
85
|
+
case 'update':
|
|
86
|
+
if (rules?.update) {
|
|
87
|
+
wire.push({ type: 'update', parameters: { update_allows_fetch_and_merge: false } });
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
case 'deletion':
|
|
91
|
+
if (rules?.deletion) wire.push({ type: 'deletion' });
|
|
92
|
+
break;
|
|
93
|
+
case 'required_linear_history':
|
|
94
|
+
if (rules?.requiredLinearHistory) wire.push({ type: 'required_linear_history' });
|
|
95
|
+
break;
|
|
96
|
+
case 'required_signatures':
|
|
97
|
+
if (rules?.requiredSignatures) wire.push({ type: 'required_signatures' });
|
|
98
|
+
break;
|
|
99
|
+
case 'non_fast_forward':
|
|
100
|
+
if (rules?.nonFastForward) wire.push({ type: 'non_fast_forward' });
|
|
101
|
+
break;
|
|
102
|
+
case 'pull_request':
|
|
103
|
+
if (rules?.pullRequest) wire.push(buildPullRequestRule(rules.pullRequest));
|
|
104
|
+
break;
|
|
105
|
+
case 'required_status_checks':
|
|
106
|
+
if (rules?.requiredStatusChecks) {
|
|
107
|
+
wire.push(buildStatusChecksRule(rules.requiredStatusChecks));
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return wire;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The full desired wire body, minus `owner`/`repo` — sent verbatim to `createRepoRuleset` or
|
|
116
|
+
* `updateRepoRuleset`. */
|
|
117
|
+
export function desiredWireRuleset(props: RepositoryRulesetProps): CreateRulesetBody {
|
|
118
|
+
return {
|
|
119
|
+
name: props.name,
|
|
120
|
+
target: props.target ?? 'branch',
|
|
121
|
+
enforcement: props.enforcement ?? 'active',
|
|
122
|
+
bypass_actors: (props.bypassActors ?? []).map((a) => ({
|
|
123
|
+
actor_type: a.actorType,
|
|
124
|
+
// `exactOptionalPropertyTypes`: an always-present `actor_id: number | undefined` is not
|
|
125
|
+
// assignable to the wire's `actor_id?: number | null` — the key must be ABSENT, not
|
|
126
|
+
// present-with-`undefined`, when there is no id.
|
|
127
|
+
...(a.actorId === undefined ? {} : { actor_id: a.actorId }),
|
|
128
|
+
bypass_mode: a.bypassMode ?? 'always',
|
|
129
|
+
})),
|
|
130
|
+
conditions: {
|
|
131
|
+
ref_name: {
|
|
132
|
+
include: [...(props.conditions?.include ?? ['~ALL'])].sort(),
|
|
133
|
+
exclude: [...(props.conditions?.exclude ?? [])].sort(),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
rules: buildWireRules(props.rules),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sortByJson = <T>(items: readonly T[]): T[] =>
|
|
141
|
+
[...items].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
142
|
+
|
|
143
|
+
/** Drop keys whose value is `null` or `undefined`, recursively — GitHub's GET fills
|
|
144
|
+
* `integration_id: null` where the request never sent the key; this makes both read the same. */
|
|
145
|
+
/**
|
|
146
|
+
* ⚠️ SORTS OBJECT KEYS TOO, NOT ONLY ARRAY ELEMENTS. `JSON.stringify` preserves insertion
|
|
147
|
+
* order, so `{a:1,b:2}` and `{b:2,a:1}` stringify to different text despite being the same
|
|
148
|
+
* value — measured directly by this file's own lifecycle test: `desiredWireRuleset` and a
|
|
149
|
+
* fixture built by spreading `{...liveRule, ...patch}` produced `parameters` keys in two
|
|
150
|
+
* different orders and every downstream `JSON.stringify` equality check failed although the
|
|
151
|
+
* values were identical. Sorting keys here is what makes the `JSON.stringify(canonicalize(x))
|
|
152
|
+
* === JSON.stringify(canonicalize(y))` comparisons the rest of this family relies on
|
|
153
|
+
* (repository-ruleset-reconcile.ts, and every canonicalize-based test) actually compare
|
|
154
|
+
* VALUES rather than incidental key order.
|
|
155
|
+
*/
|
|
156
|
+
function stripNullish(value: unknown): unknown {
|
|
157
|
+
if (Array.isArray(value)) return value.map(stripNullish);
|
|
158
|
+
if (value !== null && typeof value === 'object') {
|
|
159
|
+
const out: Record<string, unknown> = {};
|
|
160
|
+
for (const k of Object.keys(value).sort()) {
|
|
161
|
+
const v = (value as Record<string, unknown>)[k];
|
|
162
|
+
if (v !== null && v !== undefined) out[k] = stripNullish(v);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function canonicalizeRule(rule: Record<string, unknown>): Record<string, unknown> {
|
|
170
|
+
const stripped = stripNullish(rule) as Record<string, unknown>;
|
|
171
|
+
const params = stripped.parameters as Record<string, unknown> | undefined;
|
|
172
|
+
if (params === undefined) return stripped;
|
|
173
|
+
const next = { ...params };
|
|
174
|
+
if (Array.isArray(next.allowed_merge_methods)) {
|
|
175
|
+
next.allowed_merge_methods = [...(next.allowed_merge_methods as string[])].sort();
|
|
176
|
+
}
|
|
177
|
+
if (Array.isArray(next.required_status_checks)) {
|
|
178
|
+
next.required_status_checks = sortByJson(
|
|
179
|
+
next.required_status_checks as Record<string, unknown>[],
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return { ...stripped, parameters: next };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Normalize a wire ruleset (desired OR observed) so two representations of the same policy
|
|
187
|
+
* compare equal regardless of the order GitHub happens to echo lists back in.
|
|
188
|
+
*
|
|
189
|
+
* ⚠️ `input: unknown`, DELIBERATELY, NOT `CreateRulesetBody | RulesetRecord`. The two real
|
|
190
|
+
* callers (our own request body, and GitHub's JSON response) are independently typed and
|
|
191
|
+
* under `exactOptionalPropertyTypes` a shared structural interface fights both of them at
|
|
192
|
+
* once for no safety this function actually uses — every field below is read defensively,
|
|
193
|
+
* the same way a live GET already has to be, so a typed façade here would be decorative.
|
|
194
|
+
*/
|
|
195
|
+
export function canonicalizeWireRuleset(input: unknown): unknown {
|
|
196
|
+
const r = (input ?? {}) as {
|
|
197
|
+
name?: unknown;
|
|
198
|
+
target?: unknown;
|
|
199
|
+
enforcement?: unknown;
|
|
200
|
+
bypass_actors?: readonly Record<string, unknown>[];
|
|
201
|
+
conditions?: unknown;
|
|
202
|
+
rules?: readonly Record<string, unknown>[];
|
|
203
|
+
};
|
|
204
|
+
return stripNullish({
|
|
205
|
+
name: r.name,
|
|
206
|
+
target: r.target ?? 'branch',
|
|
207
|
+
enforcement: r.enforcement,
|
|
208
|
+
bypass_actors: sortByJson(r.bypass_actors ?? []),
|
|
209
|
+
conditions: r.conditions,
|
|
210
|
+
rules: sortByJson((r.rules ?? []).map(canonicalizeRule)),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything `reconcile` refuses before it writes, and why. Extracted so
|
|
3
|
+
* repository-ruleset.ts stays the wiring, and the reasoning stays whole — the same split
|
|
4
|
+
* `repo-policy-guards.ts` uses for `declareRepoPolicy`.
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import type { RepositoryRulesetProps } from './repository-ruleset.ts';
|
|
8
|
+
import { RULE_TYPE_COVERAGE, type RepositoryRuleType } from './repository-ruleset-constraints.ts';
|
|
9
|
+
import {
|
|
10
|
+
BypassActorWidened,
|
|
11
|
+
NeverReportedContext,
|
|
12
|
+
RequiredChecksOmitted,
|
|
13
|
+
UndeclaredLiveRule,
|
|
14
|
+
} from './repository-ruleset-errors.ts';
|
|
15
|
+
import type { RulesetOctokit, RulesetRecord } from './repository-ruleset-probe.ts';
|
|
16
|
+
|
|
17
|
+
const bypassKey = (a: { actor_type: unknown; actor_id: unknown; bypass_mode?: unknown }) =>
|
|
18
|
+
`${String(a.actor_type)}:${String(a.actor_id ?? '')}:${String(a.bypass_mode ?? 'always')}`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ⛔ A CREATE REQUIRES `[]`; `undefined` on a create is the same thing (nothing live to widen
|
|
22
|
+
* from). On an update/adopt, `undefined` means unmanaged — the caller never sees this guard
|
|
23
|
+
* because repository-ruleset.ts sends the LIVE value back unchanged in that case, and this
|
|
24
|
+
* function is only called when `props.bypassActors` is explicitly declared.
|
|
25
|
+
*/
|
|
26
|
+
export function bypassWideningRefusal(input: {
|
|
27
|
+
readonly owner: string;
|
|
28
|
+
readonly repository: string;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly declared: RepositoryRulesetProps['bypassActors'];
|
|
31
|
+
readonly live: RulesetRecord | undefined;
|
|
32
|
+
}): BypassActorWidened | undefined {
|
|
33
|
+
// A create may declare any bypass list — nothing live to widen against.
|
|
34
|
+
if (input.live === undefined) return undefined;
|
|
35
|
+
const liveActors = (input.live.bypass_actors ?? []) as {
|
|
36
|
+
actor_type: unknown;
|
|
37
|
+
actor_id: unknown;
|
|
38
|
+
bypass_mode?: unknown;
|
|
39
|
+
}[];
|
|
40
|
+
const liveKeys = new Set(liveActors.map(bypassKey));
|
|
41
|
+
const widened = (input.declared ?? [])
|
|
42
|
+
.map((a) =>
|
|
43
|
+
bypassKey({
|
|
44
|
+
actor_type: a.actorType,
|
|
45
|
+
actor_id: a.actorId,
|
|
46
|
+
bypass_mode: a.bypassMode ?? 'always',
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
.filter((k) => !liveKeys.has(k));
|
|
50
|
+
if (widened.length === 0) return undefined;
|
|
51
|
+
return new BypassActorWidened({
|
|
52
|
+
owner: input.owner,
|
|
53
|
+
repository: input.repository,
|
|
54
|
+
name: input.name,
|
|
55
|
+
widened,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const liveRuleTypes = (live: RulesetRecord | undefined): Set<string> =>
|
|
60
|
+
new Set((live?.rules ?? []).map((r) => String(r.type)));
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Refuses (a) any live rule of a REFUSED type — this resource can never represent it, and the
|
|
64
|
+
* PUT replaces `rules` wholesale, so silence would drop it; and (b) ANY live rule of a MODELED
|
|
65
|
+
* type the declaration is silent about — same hazard, for every rule this resource owns.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ `declaredTypes` MUST BE PRESENCE, NOT TRUTHINESS — this is the fix for a real bug this
|
|
68
|
+
* family's own design first shipped with. A plain `rules?.deletion` check cannot distinguish
|
|
69
|
+
* "omitted" from "declared `false`", so `undeclaredLiveRuleRefusal` originally exempted every
|
|
70
|
+
* boolean rule (`creation`/`update`/`deletion`/`requiredLinearHistory`/`requiredSignatures`/
|
|
71
|
+
* `nonFastForward`) as "fully owned — omission IS the removal". Concretely, that meant: a
|
|
72
|
+
* live ruleset has `deletion: true` (blocks branch deletion); a caller declares this resource
|
|
73
|
+
* with `rules: { pullRequest: {...}, requiredStatusChecks: {...} }`, simply forgetting
|
|
74
|
+
* `deletion: true` — an easy mistake, since omitting an optional boolean is not a type error.
|
|
75
|
+
* The wholesale PUT would have sent `rules` without a `deletion` entry, silently REMOVING
|
|
76
|
+
* branch-deletion protection on the very next `reconcile` — exactly the hazard this whole
|
|
77
|
+
* guard exists to prevent, reached through the one door it left open. Found by a self-review
|
|
78
|
+
* pass (no subagent available in this harness), not by a test; there is now one:
|
|
79
|
+
* repository-ruleset-write-refusals.test.ts's "an omitted boolean rule" case.
|
|
80
|
+
* `required_status_checks` still gets its own, better-worded `RequiredChecksOmitted` from
|
|
81
|
+
* {@link requiredChecksOmissionRefusal} and is excluded here so it is not refused twice.
|
|
82
|
+
*/
|
|
83
|
+
export function undeclaredLiveRuleRefusal(input: {
|
|
84
|
+
readonly owner: string;
|
|
85
|
+
readonly repository: string;
|
|
86
|
+
readonly name: string;
|
|
87
|
+
readonly live: RulesetRecord | undefined;
|
|
88
|
+
readonly declaredTypes: ReadonlySet<string>;
|
|
89
|
+
}): UndeclaredLiveRule | undefined {
|
|
90
|
+
for (const type of liveRuleTypes(input.live)) {
|
|
91
|
+
const coverage = RULE_TYPE_COVERAGE[type as RepositoryRuleType];
|
|
92
|
+
if (coverage === 'refused') {
|
|
93
|
+
return new UndeclaredLiveRule({
|
|
94
|
+
owner: input.owner,
|
|
95
|
+
repository: input.repository,
|
|
96
|
+
name: input.name,
|
|
97
|
+
ruleType: type,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
coverage === 'modeled' &&
|
|
102
|
+
type !== 'required_status_checks' &&
|
|
103
|
+
!input.declaredTypes.has(type)
|
|
104
|
+
) {
|
|
105
|
+
return new UndeclaredLiveRule({
|
|
106
|
+
owner: input.owner,
|
|
107
|
+
repository: input.repository,
|
|
108
|
+
name: input.name,
|
|
109
|
+
ruleType: type,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The wire `type` for every rule key present ON THE DECLARATION OBJECT ITSELF — `Object.hasOwn`,
|
|
117
|
+
* not truthiness, so `{ deletion: false }` (an explicit, deliberate removal) counts as declared
|
|
118
|
+
* and a bare omission does not. Feeds `undeclaredLiveRuleRefusal`'s `declaredTypes`. */
|
|
119
|
+
export function declaredRuleTypes(rules: RepositoryRulesetProps['rules']): ReadonlySet<string> {
|
|
120
|
+
if (rules === undefined) return new Set();
|
|
121
|
+
const BOOLEAN_KEY_TO_TYPE: Record<string, string> = {
|
|
122
|
+
creation: 'creation',
|
|
123
|
+
update: 'update',
|
|
124
|
+
deletion: 'deletion',
|
|
125
|
+
requiredLinearHistory: 'required_linear_history',
|
|
126
|
+
requiredSignatures: 'required_signatures',
|
|
127
|
+
nonFastForward: 'non_fast_forward',
|
|
128
|
+
};
|
|
129
|
+
const types = new Set<string>();
|
|
130
|
+
for (const [key, type] of Object.entries(BOOLEAN_KEY_TO_TYPE)) {
|
|
131
|
+
if (Object.hasOwn(rules, key)) types.add(type);
|
|
132
|
+
}
|
|
133
|
+
if (Object.hasOwn(rules, 'pullRequest')) types.add('pull_request');
|
|
134
|
+
return types;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `requiredStatusChecks: undefined` while the live ruleset HAS one → refused, naming the live
|
|
138
|
+
* contexts. `requiredStatusChecks: false` is the declared removal and never reaches here. */
|
|
139
|
+
export function requiredChecksOmissionRefusal(input: {
|
|
140
|
+
readonly owner: string;
|
|
141
|
+
readonly repository: string;
|
|
142
|
+
readonly name: string;
|
|
143
|
+
readonly declared: RepositoryRulesetProps['rules'];
|
|
144
|
+
readonly live: RulesetRecord | undefined;
|
|
145
|
+
}): RequiredChecksOmitted | undefined {
|
|
146
|
+
if (input.declared?.requiredStatusChecks !== undefined) return undefined;
|
|
147
|
+
const liveRule = (input.live?.rules ?? []).find((r) => r.type === 'required_status_checks');
|
|
148
|
+
if (liveRule === undefined) return undefined;
|
|
149
|
+
const params = liveRule.parameters as
|
|
150
|
+
| { required_status_checks?: { context: string }[] }
|
|
151
|
+
| undefined;
|
|
152
|
+
const liveContexts = (params?.required_status_checks ?? []).map((c) => c.context);
|
|
153
|
+
return new RequiredChecksOmitted({
|
|
154
|
+
owner: input.owner,
|
|
155
|
+
repository: input.repository,
|
|
156
|
+
name: input.name,
|
|
157
|
+
liveContexts,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Every context in the declared `requiredStatusChecks` that the LIVE ruleset does not already
|
|
162
|
+
* require — i.e. being added by this plan, the only case a never-reported context can matter. */
|
|
163
|
+
export const newlyRequiredContexts = (
|
|
164
|
+
declared: RepositoryRulesetProps['rules'],
|
|
165
|
+
live: RulesetRecord | undefined,
|
|
166
|
+
): readonly string[] => {
|
|
167
|
+
if (declared?.requiredStatusChecks === false || declared?.requiredStatusChecks === undefined)
|
|
168
|
+
return [];
|
|
169
|
+
const liveRule = (live?.rules ?? []).find((r) => r.type === 'required_status_checks');
|
|
170
|
+
const liveParams = liveRule?.parameters as
|
|
171
|
+
| { required_status_checks?: { context: string }[] }
|
|
172
|
+
| undefined;
|
|
173
|
+
const liveContexts = new Set((liveParams?.required_status_checks ?? []).map((c) => c.context));
|
|
174
|
+
return declared.requiredStatusChecks.checks
|
|
175
|
+
.map((c) => c.context)
|
|
176
|
+
.filter((c) => !liveContexts.has(c));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Fails on the first newly-required context that has never reported success on the default
|
|
180
|
+
* branch — checked once here (reconcile's observe step) and again by the standalone checker. */
|
|
181
|
+
export const refuseUnreportedContexts = <R = never>(
|
|
182
|
+
octokit: RulesetOctokit<R>,
|
|
183
|
+
owner: string,
|
|
184
|
+
repo: string,
|
|
185
|
+
contexts: readonly string[],
|
|
186
|
+
): Effect.Effect<void, NeverReportedContext | Error, R> =>
|
|
187
|
+
Effect.gen(function* () {
|
|
188
|
+
for (const context of contexts) {
|
|
189
|
+
const reported = yield* octokit.hasContextReportedSuccess({ owner, repo, context });
|
|
190
|
+
if (!reported) {
|
|
191
|
+
return yield* Effect.fail(new NeverReportedContext({ owner, repository: repo, context }));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (i) GET-only loopback fake serving the 16 captured rulesets: cold adoption never creates a
|
|
3
|
+
* duplicate, the house baseline plans real drift against every one of them (none has
|
|
4
|
+
* `allowed_merge_methods: [squash]` yet), and the canonicalization fix (repository-ruleset-
|
|
5
|
+
* form.ts) reads a reordered-but-equal live ruleset as a noop while still catching real drift —
|
|
6
|
+
* the mutation check.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { LIVE_RULESET_FIXTURES } from './repository-ruleset-fixtures.ts';
|
|
11
|
+
import { makeGetOnlyFake } from './repository-ruleset-fake-octokit.ts';
|
|
12
|
+
import { canonicalizeWireRuleset, desiredWireRuleset } from './repository-ruleset-form.ts';
|
|
13
|
+
import { probeByName } from './repository-ruleset-probe.ts';
|
|
14
|
+
import type { RepositoryRulesetProps } from './repository-ruleset.ts';
|
|
15
|
+
|
|
16
|
+
const run = <A, E>(eff: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(eff);
|
|
17
|
+
|
|
18
|
+
const baselineProps = (
|
|
19
|
+
owner: string,
|
|
20
|
+
repository: string,
|
|
21
|
+
checks: readonly string[],
|
|
22
|
+
): RepositoryRulesetProps => ({
|
|
23
|
+
owner,
|
|
24
|
+
repository,
|
|
25
|
+
name: 'main',
|
|
26
|
+
target: 'branch',
|
|
27
|
+
enforcement: 'active',
|
|
28
|
+
bypassActors: [],
|
|
29
|
+
conditions: { include: ['~DEFAULT_BRANCH'], exclude: [] },
|
|
30
|
+
rules: {
|
|
31
|
+
deletion: true,
|
|
32
|
+
nonFastForward: true,
|
|
33
|
+
pullRequest: {
|
|
34
|
+
requiredApprovingReviewCount: 0,
|
|
35
|
+
dismissStaleReviewsOnPush: true,
|
|
36
|
+
requireCodeOwnerReview: false,
|
|
37
|
+
requireLastPushApproval: false,
|
|
38
|
+
requiredReviewThreadResolution: false,
|
|
39
|
+
allowedMergeMethods: ['squash'],
|
|
40
|
+
extraApprovalForUnattributedChanges: false,
|
|
41
|
+
},
|
|
42
|
+
...(checks.length === 0
|
|
43
|
+
? {}
|
|
44
|
+
: { requiredStatusChecks: { checks: checks.map((context) => ({ context })) } }),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const STANDARD_REPOS = Object.entries(LIVE_RULESET_FIXTURES).filter(
|
|
49
|
+
([repo]) => !['homeflare', 'anyauth', 'desktop'].includes(repo),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
describe('probeByName over the 16 live fixtures', () => {
|
|
53
|
+
test('finds exactly the one ruleset each repo has, never a duplicate', async () => {
|
|
54
|
+
const fake = makeGetOnlyFake(LIVE_RULESET_FIXTURES);
|
|
55
|
+
for (const [repo, live] of Object.entries(LIVE_RULESET_FIXTURES)) {
|
|
56
|
+
const found = await run(
|
|
57
|
+
probeByName(fake, { owner: 'taslabs-net', repo, name: 'main', target: 'branch' }),
|
|
58
|
+
);
|
|
59
|
+
expect(found?.id).toBe(live.id);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a repo with no ruleset probes to undefined, not a create-blocking error', async () => {
|
|
64
|
+
const fake = makeGetOnlyFake(LIVE_RULESET_FIXTURES);
|
|
65
|
+
const found = await run(
|
|
66
|
+
probeByName(fake, { owner: 'taslabs-net', repo: 'builds', name: 'main', target: 'branch' }),
|
|
67
|
+
);
|
|
68
|
+
expect(found).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('house baseline vs every live fixture', () => {
|
|
73
|
+
test('all 16 show real drift today: none has allowed_merge_methods narrowed to squash', () => {
|
|
74
|
+
for (const [repo, live] of Object.entries(LIVE_RULESET_FIXTURES)) {
|
|
75
|
+
const checks =
|
|
76
|
+
repo === 'homeflare' || repo === 'anyauth' || repo === 'desktop'
|
|
77
|
+
? []
|
|
78
|
+
: ['ci', 'secret scan'];
|
|
79
|
+
const desired = desiredWireRuleset(baselineProps('taslabs-net', repo, checks));
|
|
80
|
+
const same =
|
|
81
|
+
JSON.stringify(canonicalizeWireRuleset(desired)) ===
|
|
82
|
+
JSON.stringify(canonicalizeWireRuleset(live));
|
|
83
|
+
expect(same).toBe(false);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test(`${String(STANDARD_REPOS.length)} standard repos differ ONLY in allowed_merge_methods and the extension flag`, () => {
|
|
88
|
+
for (const [repo, live] of STANDARD_REPOS) {
|
|
89
|
+
const desired = desiredWireRuleset(baselineProps('taslabs-net', repo, ['ci', 'secret scan']));
|
|
90
|
+
// Patch just those two fields onto the live record and expect a match — proves the
|
|
91
|
+
// 13 "already matching" repos truly match everywhere else the survey claims they do.
|
|
92
|
+
const patchedRules = (live.rules ?? []).map((r) =>
|
|
93
|
+
r.type === 'pull_request'
|
|
94
|
+
? {
|
|
95
|
+
...r,
|
|
96
|
+
parameters: {
|
|
97
|
+
...(r.parameters as Record<string, unknown>),
|
|
98
|
+
allowed_merge_methods: ['squash'],
|
|
99
|
+
require_extra_approval_for_unattributed_changes: false,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
: r,
|
|
103
|
+
);
|
|
104
|
+
const patched = { ...live, rules: patchedRules };
|
|
105
|
+
const same =
|
|
106
|
+
JSON.stringify(canonicalizeWireRuleset(desired)) ===
|
|
107
|
+
JSON.stringify(canonicalizeWireRuleset(patched));
|
|
108
|
+
expect(same).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('canonicalization: reorder is a noop, real drift is not (the never-noop fix)', () => {
|
|
114
|
+
const repo = 'openbao';
|
|
115
|
+
const desired = desiredWireRuleset(baselineProps('taslabs-net', repo, ['ci', 'secret scan']));
|
|
116
|
+
const matching = {
|
|
117
|
+
...desired,
|
|
118
|
+
// Same policy, reordered `rules`, reordered `allowed_merge_methods`/checks, and an
|
|
119
|
+
// `integration_id: null` GitHub adds that the request never sent.
|
|
120
|
+
rules: [...(desired.rules ?? [])].reverse().map((r) =>
|
|
121
|
+
r.type === 'required_status_checks' && r.parameters !== undefined
|
|
122
|
+
? {
|
|
123
|
+
...r,
|
|
124
|
+
parameters: {
|
|
125
|
+
...r.parameters,
|
|
126
|
+
required_status_checks: [...r.parameters.required_status_checks]
|
|
127
|
+
.reverse()
|
|
128
|
+
.map((c) => ({
|
|
129
|
+
...c,
|
|
130
|
+
integration_id: null,
|
|
131
|
+
})),
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
: r,
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
test('a reordered-but-equal live ruleset canonicalizes to a noop', () => {
|
|
139
|
+
expect(JSON.stringify(canonicalizeWireRuleset(desired))).toBe(
|
|
140
|
+
JSON.stringify(canonicalizeWireRuleset(matching)),
|
|
141
|
+
);
|
|
142
|
+
// Sanity: the RAW (un-canonicalized) forms really do differ in order, so this is not a
|
|
143
|
+
// vacuous "already identical" comparison.
|
|
144
|
+
expect(JSON.stringify(desired)).not.toBe(JSON.stringify(matching));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('mutation check: 3 independently changed values give 3 independently non-noop results', () => {
|
|
148
|
+
const mutations: Record<string, unknown> = {
|
|
149
|
+
'a dismissed check dropped': {
|
|
150
|
+
...matching,
|
|
151
|
+
rules: matching.rules.map((r) =>
|
|
152
|
+
r.type === 'required_status_checks'
|
|
153
|
+
? { ...r, parameters: { ...r.parameters, required_status_checks: [{ context: 'ci' }] } }
|
|
154
|
+
: r,
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
'enforcement disabled': { ...matching, enforcement: 'disabled' },
|
|
158
|
+
'a bypass actor added': {
|
|
159
|
+
...matching,
|
|
160
|
+
bypass_actors: [{ actor_type: 'OrganizationAdmin', bypass_mode: 'always' }],
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
let nonNoop = 0;
|
|
164
|
+
for (const value of Object.values(mutations)) {
|
|
165
|
+
const same =
|
|
166
|
+
JSON.stringify(canonicalizeWireRuleset(desired)) ===
|
|
167
|
+
JSON.stringify(canonicalizeWireRuleset(value));
|
|
168
|
+
if (!same) nonNoop++;
|
|
169
|
+
}
|
|
170
|
+
expect(nonNoop).toBe(Object.keys(mutations).length);
|
|
171
|
+
});
|
|
172
|
+
});
|