@homeflare/alchemy 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/github/declare-repo-baseline.d.ts +11 -0
  2. package/dist/github/declare-repo-baseline.d.ts.map +1 -0
  3. package/dist/github/index.d.ts +20 -11
  4. package/dist/github/index.d.ts.map +1 -1
  5. package/dist/github/index.js +715 -4
  6. package/dist/github/index.js.map +14 -4
  7. package/dist/github/repo-baseline-data.d.ts +75 -0
  8. package/dist/github/repo-baseline-data.d.ts.map +1 -0
  9. package/dist/github/repo-policy.d.ts +4 -4
  10. package/dist/github/repo-policy.d.ts.map +1 -1
  11. package/dist/github/repository-ruleset-constraints.d.ts +105 -0
  12. package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
  13. package/dist/github/repository-ruleset-errors.d.ts +97 -0
  14. package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
  15. package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
  16. package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
  17. package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
  18. package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
  19. package/dist/github/repository-ruleset-form.d.ts +30 -0
  20. package/dist/github/repository-ruleset-form.d.ts.map +1 -0
  21. package/dist/github/repository-ruleset-guards.d.ts +70 -0
  22. package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
  23. package/dist/github/repository-ruleset-octokit.d.ts +5 -0
  24. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
  25. package/dist/github/repository-ruleset-probe.d.ts +87 -0
  26. package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
  27. package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
  28. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
  29. package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
  30. package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
  31. package/dist/github/repository-ruleset.d.ts +89 -0
  32. package/dist/github/repository-ruleset.d.ts.map +1 -0
  33. package/docs/repo-policy-ruleset-hazards.md +8 -0
  34. package/docs/repo-policy.md +27 -12
  35. package/docs/repository-ruleset.md +114 -0
  36. package/docs/upstream-conformance.md +12 -1
  37. package/docs/upstream-pr-ruleset-fixes.md +77 -0
  38. package/package.json +1 -1
  39. package/src/github/declare-repo-baseline.ts +42 -0
  40. package/src/github/index.ts +43 -11
  41. package/src/github/repo-baseline-data.test.ts +76 -0
  42. package/src/github/repo-baseline-data.ts +86 -0
  43. package/src/github/repo-policy.ts +17 -17
  44. package/src/github/repository-ruleset-constraints.ts +136 -0
  45. package/src/github/repository-ruleset-coverage.test.ts +82 -0
  46. package/src/github/repository-ruleset-errors.ts +136 -0
  47. package/src/github/repository-ruleset-fake-octokit.ts +131 -0
  48. package/src/github/repository-ruleset-fixtures.ts +183 -0
  49. package/src/github/repository-ruleset-form.ts +212 -0
  50. package/src/github/repository-ruleset-guards.ts +194 -0
  51. package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
  52. package/src/github/repository-ruleset-octokit.ts +140 -0
  53. package/src/github/repository-ruleset-probe.ts +116 -0
  54. package/src/github/repository-ruleset-reconcile.ts +176 -0
  55. package/src/github/repository-ruleset-test-helpers.ts +102 -0
  56. package/src/github/repository-ruleset-wire.test.ts +66 -0
  57. package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
  58. package/src/github/repository-ruleset-write.test.ts +83 -0
  59. package/src/github/repository-ruleset.ts +219 -0
@@ -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
+ });