@homeflare/alchemy 0.27.4 → 0.28.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.
- package/dist/github/index.d.ts +1 -1
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +99 -3
- package/dist/github/index.js.map +8 -7
- package/dist/github/repository-ruleset-errors.d.ts +34 -1
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +12 -0
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
- package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-props.d.ts +88 -0
- package/dist/github/repository-ruleset-props.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
- package/dist/github/repository-ruleset.d.ts +3 -31
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/github/index.ts +2 -0
- package/src/github/repository-ruleset-errors.ts +45 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +179 -0
- package/src/github/repository-ruleset-guards.ts +8 -2
- package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
- package/src/github/repository-ruleset-narrowing.test.ts +210 -0
- package/src/github/repository-ruleset-props.ts +92 -0
- package/src/github/repository-ruleset-reconcile.ts +35 -0
- package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
- package/src/github/repository-ruleset.ts +27 -41
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrowing refusals (repository-ruleset-narrowing-guards.ts), split out of
|
|
3
|
+
* repository-ruleset-write-refusals.test.ts purely to keep both files under the 250-line cap.
|
|
4
|
+
* `acknowledgeRuleNarrowing`/`acknowledgeBypassNarrowing` are the escape hatches: without the
|
|
5
|
+
* matching one, dropping a live rule or a live bypass actor is refused; with a non-empty reason
|
|
6
|
+
* on the RIGHT prop, it writes exactly what was declared. The two are intentionally separate
|
|
7
|
+
* props, not one shared flag — see the 'mixed narrowing' describe block below and the file
|
|
8
|
+
* header on repository-ruleset-narrowing-guards.ts for the 2026-09-23 finding this fixes. The
|
|
9
|
+
* dedicated exact-adopt fixtures (all 5 live non-empty-bypass shapes, none of which are
|
|
10
|
+
* "narrowing" — they match live exactly) are repository-ruleset-exact-adopt.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
|
|
15
|
+
import {
|
|
16
|
+
BASE_PULL_REQUEST,
|
|
17
|
+
fails,
|
|
18
|
+
props,
|
|
19
|
+
reconcileAgainst,
|
|
20
|
+
reconcileWithOctokit,
|
|
21
|
+
tagOf,
|
|
22
|
+
} from './repository-ruleset-test-helpers.ts';
|
|
23
|
+
|
|
24
|
+
describe('rule narrowing', () => {
|
|
25
|
+
// Regression for the gap the exact-adopt pass-through work closed 2026-09-23: an explicit
|
|
26
|
+
// `false` alone used to be enough to drop a live rule, with no record of WHY. It is now also
|
|
27
|
+
// gated on `acknowledgeRuleNarrowing` — see repository-ruleset-narrowing-guards.ts.
|
|
28
|
+
test('dropping a live rule (deletion declared false) without acknowledgeRuleNarrowing is refused', async () => {
|
|
29
|
+
const live = {
|
|
30
|
+
id: 1,
|
|
31
|
+
name: 'main',
|
|
32
|
+
enforcement: 'active',
|
|
33
|
+
bypass_actors: [],
|
|
34
|
+
conditions: {},
|
|
35
|
+
rules: [
|
|
36
|
+
{ type: 'deletion' },
|
|
37
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
41
|
+
const declaration = props({
|
|
42
|
+
rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
|
|
43
|
+
});
|
|
44
|
+
const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
|
|
45
|
+
expect(tagOf(error)).toBe('RuleNarrowed');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the SAME drop, acknowledged with a reason, is a deliberate removal and writes once', async () => {
|
|
49
|
+
const live = {
|
|
50
|
+
id: 1,
|
|
51
|
+
name: 'main',
|
|
52
|
+
enforcement: 'active',
|
|
53
|
+
bypass_actors: [],
|
|
54
|
+
conditions: {},
|
|
55
|
+
rules: [
|
|
56
|
+
{ type: 'deletion' },
|
|
57
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
const fake = makeRecordingFake({ widgets: live });
|
|
61
|
+
fake.reportedContexts.add('ci');
|
|
62
|
+
const declaration = props({
|
|
63
|
+
rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
|
|
64
|
+
acknowledgeRuleNarrowing: { reason: 'Tim approved dropping delete-protection 2026-09-23' },
|
|
65
|
+
});
|
|
66
|
+
await reconcileAgainst(fake, declaration, { rulesetId: 1 }).pipe(Effect.runPromise);
|
|
67
|
+
expect(fake.writes).toHaveLength(1);
|
|
68
|
+
expect(fake.writes[0]?.op).toBe('update');
|
|
69
|
+
const sentTypes = fake.writes[0]?.body?.rules?.map((r) => r.type);
|
|
70
|
+
expect(sentTypes).not.toContain('deletion');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// A blank (or whitespace-only) reason is not an acknowledgement —
|
|
74
|
+
// `isRuleNarrowingAcknowledged` (repository-ruleset-narrowing-guards.ts) requires a
|
|
75
|
+
// non-empty, TRIMMED reason, so this is indistinguishable from omitting
|
|
76
|
+
// `acknowledgeRuleNarrowing` entirely and gets the same refusal.
|
|
77
|
+
test('acknowledgeRuleNarrowing with a blank reason does not count as acknowledged', async () => {
|
|
78
|
+
const live = {
|
|
79
|
+
id: 1,
|
|
80
|
+
name: 'main',
|
|
81
|
+
enforcement: 'active',
|
|
82
|
+
bypass_actors: [],
|
|
83
|
+
conditions: {},
|
|
84
|
+
rules: [
|
|
85
|
+
{ type: 'deletion' },
|
|
86
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
90
|
+
const declaration = props({
|
|
91
|
+
rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
|
|
92
|
+
acknowledgeRuleNarrowing: { reason: ' ' },
|
|
93
|
+
});
|
|
94
|
+
const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
|
|
95
|
+
expect(tagOf(error)).toBe('RuleNarrowed');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Scoping regression (2026-09-23 review finding): an acknowledgement reasoned for the RULE
|
|
99
|
+
// drop must not also excuse a bypass-actor drop declared in the SAME declaration.
|
|
100
|
+
test('acknowledgeRuleNarrowing does not excuse an unrelated bypass-actor drop', async () => {
|
|
101
|
+
const live = {
|
|
102
|
+
id: 1,
|
|
103
|
+
name: 'main',
|
|
104
|
+
enforcement: 'active',
|
|
105
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
106
|
+
conditions: {},
|
|
107
|
+
rules: [
|
|
108
|
+
{ type: 'deletion' },
|
|
109
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
113
|
+
const declaration = props({
|
|
114
|
+
bypassActors: [],
|
|
115
|
+
rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
|
|
116
|
+
acknowledgeRuleNarrowing: { reason: 'Tim approved dropping delete-protection 2026-09-23' },
|
|
117
|
+
});
|
|
118
|
+
const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
|
|
119
|
+
expect(tagOf(error)).toBe('BypassActorNarrowed');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('bypass narrowing', () => {
|
|
124
|
+
test('dropping a live bypass actor without acknowledgeBypassNarrowing is refused', async () => {
|
|
125
|
+
const live = {
|
|
126
|
+
id: 1,
|
|
127
|
+
name: 'main',
|
|
128
|
+
enforcement: 'active',
|
|
129
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
130
|
+
conditions: {},
|
|
131
|
+
rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }],
|
|
132
|
+
};
|
|
133
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
134
|
+
const declaration = props({
|
|
135
|
+
bypassActors: [],
|
|
136
|
+
rules: { deletion: true, nonFastForward: true },
|
|
137
|
+
});
|
|
138
|
+
const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
|
|
139
|
+
expect(tagOf(error)).toBe('BypassActorNarrowed');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('the SAME drop, acknowledged with a reason, writes the narrowed list', async () => {
|
|
143
|
+
const live = {
|
|
144
|
+
id: 1,
|
|
145
|
+
name: 'main',
|
|
146
|
+
enforcement: 'active',
|
|
147
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
148
|
+
conditions: {},
|
|
149
|
+
rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }],
|
|
150
|
+
};
|
|
151
|
+
const fake = makeRecordingFake({ widgets: live });
|
|
152
|
+
const declaration = props({
|
|
153
|
+
bypassActors: [],
|
|
154
|
+
rules: { deletion: true, nonFastForward: true },
|
|
155
|
+
acknowledgeBypassNarrowing: { reason: 'Tim revoked admin bypass 2026-09-23' },
|
|
156
|
+
});
|
|
157
|
+
await reconcileAgainst(fake, declaration, { rulesetId: 1 }).pipe(Effect.runPromise);
|
|
158
|
+
expect(fake.writes).toHaveLength(1);
|
|
159
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Widening stays refused even alongside an acknowledgement — `acknowledgeBypassNarrowing`
|
|
163
|
+
// gates ONLY narrowing (repository-ruleset-narrowing-guards.ts's file header explains why).
|
|
164
|
+
test('acknowledgeBypassNarrowing does not excuse widening', async () => {
|
|
165
|
+
const live = {
|
|
166
|
+
id: 1,
|
|
167
|
+
name: 'main',
|
|
168
|
+
enforcement: 'active',
|
|
169
|
+
bypass_actors: [],
|
|
170
|
+
conditions: {},
|
|
171
|
+
rules: [],
|
|
172
|
+
};
|
|
173
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
174
|
+
const declaration = props({
|
|
175
|
+
bypassActors: [{ actorType: 'OrganizationAdmin' }],
|
|
176
|
+
acknowledgeBypassNarrowing: { reason: 'irrelevant to widening' },
|
|
177
|
+
});
|
|
178
|
+
const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
|
|
179
|
+
expect(tagOf(error)).toBe('BypassActorWidened');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Scoping regression (2026-09-23 review finding), the mirror of the rule-narrowing case
|
|
183
|
+
// above: an acknowledgement reasoned for the BYPASS drop must not also excuse an unrelated
|
|
184
|
+
// rule drop declared in the SAME declaration.
|
|
185
|
+
test('acknowledgeBypassNarrowing does not excuse an unrelated rule drop', async () => {
|
|
186
|
+
const live = {
|
|
187
|
+
id: 1,
|
|
188
|
+
name: 'main',
|
|
189
|
+
enforcement: 'active',
|
|
190
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
191
|
+
conditions: {},
|
|
192
|
+
rules: [
|
|
193
|
+
{ type: 'deletion' },
|
|
194
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
198
|
+
const declaration = props({
|
|
199
|
+
bypassActors: [],
|
|
200
|
+
rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
|
|
201
|
+
acknowledgeBypassNarrowing: { reason: 'Tim revoked admin bypass 2026-09-23' },
|
|
202
|
+
});
|
|
203
|
+
// The bypass drop is acknowledged (so `bypassNarrowingRefusal` passes), but the rule drop
|
|
204
|
+
// is not — `reconcileRuleset` checks bypass narrowing before rule narrowing, so a REFUSED
|
|
205
|
+
// rule drop under a bypass-only acknowledgement surfaces as `RuleNarrowed`, proving the
|
|
206
|
+
// bypass acknowledgement did not also cover it.
|
|
207
|
+
const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
|
|
208
|
+
expect(tagOf(error)).toBe('RuleNarrowed');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GitHub.RepositoryRuleset`'s declared shape — split out of repository-ruleset.ts purely to
|
|
3
|
+
* keep that file (the provider wiring) under the 250-line cap; nothing here is provider logic.
|
|
4
|
+
*/
|
|
5
|
+
import type { RulesetProps } from 'alchemy/GitHub';
|
|
6
|
+
|
|
7
|
+
type UpstreamRules = NonNullable<RulesetProps['rules']>;
|
|
8
|
+
|
|
9
|
+
export interface RepositoryRulesetReviewerRule {
|
|
10
|
+
readonly filePatterns: readonly string[];
|
|
11
|
+
readonly minimumApprovals: number;
|
|
12
|
+
readonly reviewer: { readonly actorId: number; readonly actorType: 'Team' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RepositoryRulesetPullRequestRule extends NonNullable<
|
|
16
|
+
UpstreamRules['pullRequest']
|
|
17
|
+
> {
|
|
18
|
+
/** @default every method GitHub allows — the house baseline always pins `['squash']`. */
|
|
19
|
+
readonly allowedMergeMethods?: readonly ('merge' | 'squash' | 'rebase')[];
|
|
20
|
+
readonly requiredReviewers?: readonly RepositoryRulesetReviewerRule[];
|
|
21
|
+
/** `require_extra_approval_for_unattributed_changes` — see repository-ruleset.ts's H15. Only
|
|
22
|
+
* `false` is modeled: sending `true` has no house use, and GitHub already defaults absent to
|
|
23
|
+
* `true`. */
|
|
24
|
+
readonly extraApprovalForUnattributedChanges?: false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RepositoryRulesetStatusChecksRule extends NonNullable<
|
|
28
|
+
UpstreamRules['requiredStatusChecks']
|
|
29
|
+
> {
|
|
30
|
+
readonly doNotEnforceOnCreate?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RepositoryRulesetRules extends Omit<
|
|
34
|
+
UpstreamRules,
|
|
35
|
+
'pullRequest' | 'requiredStatusChecks'
|
|
36
|
+
> {
|
|
37
|
+
/** `undefined`: no opinion (refused if the live ruleset already has one and this is not an
|
|
38
|
+
* exact-adopt pass-through — see repository-ruleset-guards.ts's `undeclaredLiveRuleRefusal`).
|
|
39
|
+
* `false`: declare its ABSENCE — either "this repo never had one" (a create, or an adopt
|
|
40
|
+
* where live already has none, both no-ops) or a deliberate removal of a live rule, which
|
|
41
|
+
* `ruleNarrowingRefusal` gates behind `acknowledgeRuleNarrowing` (see below). Added 2026-09-23 for
|
|
42
|
+
* exact-adopt pass-through: `requiredStatusChecks` already had this `false` escape; `deletion`/
|
|
43
|
+
* `nonFastForward`/etc already have it implicitly (plain `boolean`, so `false` is already a
|
|
44
|
+
* value); `pullRequest` was the one modeled rule with NO way to express "not present at all" —
|
|
45
|
+
* a repo like `taslabs-net/taslabs-net` (measured live 2026-09-23: `rules = [deletion,
|
|
46
|
+
* non_fast_forward]`, no `pull_request`) could not be adopted through this resource without
|
|
47
|
+
* either refusing to omit the key (a type error) or silently declaring a brand-new rule. */
|
|
48
|
+
readonly pullRequest?: RepositoryRulesetPullRequestRule | false;
|
|
49
|
+
/** `undefined`: no opinion (refused if the live ruleset already has one — see
|
|
50
|
+
* repository-ruleset-guards.ts). `false`: declare its ABSENCE — removes a live one. */
|
|
51
|
+
readonly requiredStatusChecks?: RepositoryRulesetStatusChecksRule | false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RepositoryRulesetProps extends Omit<RulesetProps, 'rules'> {
|
|
55
|
+
readonly rules?: RepositoryRulesetRules;
|
|
56
|
+
/**
|
|
57
|
+
* An explicit, reasoned sign-off that THIS declaration deliberately drops a live bypass actor
|
|
58
|
+
* (`bypassActors` is a strict subset of what is live). Without this, `reconcile` refuses the
|
|
59
|
+
* write rather than silently loosen a ruleset that already protects something — see
|
|
60
|
+
* `bypassNarrowingRefusal` in repository-ruleset-narrowing-guards.ts. `reason` is not logged or
|
|
61
|
+
* sent to GitHub; it exists so the declaration's own diff carries WHY, for the next reader
|
|
62
|
+
* (S21: a refusal names the fact it checked — this is the human-authored counterpart for the
|
|
63
|
+
* one case a fact alone cannot settle: "is this narrowing intentional?").
|
|
64
|
+
*
|
|
65
|
+
* ⛔ SEPARATE FROM `acknowledgeRuleNarrowing` ON PURPOSE (2026-09-23 review finding: a single
|
|
66
|
+
* shared `acknowledgeNarrowing` let one reason worded for a rule drop silently also rubber-
|
|
67
|
+
* stamp an unrelated bypass-actor drop in the SAME declaration — a `bypassActors: []` left
|
|
68
|
+
* over from an unrelated edit slipped through under a reason that never mentioned bypass at
|
|
69
|
+
* all). Each prop authorizes only the narrowing its own guard checks; a declaration that
|
|
70
|
+
* narrows both needs both, each with its own reason.
|
|
71
|
+
*
|
|
72
|
+
* ⛔ ALSO A STANDING PROP, NOT A ONE-TIME TOKEN — remove it from the declaration once the
|
|
73
|
+
* narrowing it describes has landed. Left in place, it keeps disabling the bypass-narrowing
|
|
74
|
+
* guard for every future `reconcile` of this resource, not only the change it was written
|
|
75
|
+
* for — the same way `rules: { deletion: false }` stays a standing "this rule stays absent"
|
|
76
|
+
* fact rather than a one-time action, only here the standing fact is "bypass narrowing needs
|
|
77
|
+
* no review", which is a fact you want true for as short a window as possible. A reviewer
|
|
78
|
+
* reading a diff that adds a SECOND, unrelated bypass drop under an OLD
|
|
79
|
+
* `acknowledgeBypassNarrowing` left over from a prior change is the failure mode this note
|
|
80
|
+
* exists to head off.
|
|
81
|
+
*/
|
|
82
|
+
readonly acknowledgeBypassNarrowing?: { readonly reason: string };
|
|
83
|
+
/**
|
|
84
|
+
* The `rules`-side counterpart to `acknowledgeBypassNarrowing` above — an explicit, reasoned
|
|
85
|
+
* sign-off that THIS declaration deliberately drops a live rule (a modeled rule type declared
|
|
86
|
+
* `false`/boolean-`false` while the live ruleset still has it). Without this, `reconcile`
|
|
87
|
+
* refuses the write — see `ruleNarrowingRefusal` in repository-ruleset-narrowing-guards.ts.
|
|
88
|
+
* Same standing-prop caution as `acknowledgeBypassNarrowing`: remove it once the rule drop it
|
|
89
|
+
* describes has landed, or it keeps excusing every later rule drop on this resource too.
|
|
90
|
+
*/
|
|
91
|
+
readonly acknowledgeRuleNarrowing?: { readonly reason: string };
|
|
92
|
+
}
|
|
@@ -20,6 +20,12 @@ import {
|
|
|
20
20
|
requiredChecksOmissionRefusal,
|
|
21
21
|
undeclaredLiveRuleRefusal,
|
|
22
22
|
} from './repository-ruleset-guards.ts';
|
|
23
|
+
import {
|
|
24
|
+
bypassNarrowingRefusal,
|
|
25
|
+
isBypassNarrowingAcknowledged,
|
|
26
|
+
isRuleNarrowingAcknowledged,
|
|
27
|
+
ruleNarrowingRefusal,
|
|
28
|
+
} from './repository-ruleset-narrowing-guards.ts';
|
|
23
29
|
import {
|
|
24
30
|
type RulesetOctokit,
|
|
25
31
|
type RulesetRecord,
|
|
@@ -104,6 +110,12 @@ export const reconcileRuleset = <R = never>(input: {
|
|
|
104
110
|
observed = yield* probeByName(octokit, { owner, repo, name: news.name, target });
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
// Deliberately TWO separate reads, not one shared flag — each guard below is authorized
|
|
114
|
+
// only by the acknowledgement scoped to what it checks (see the file header on
|
|
115
|
+
// repository-ruleset-narrowing-guards.ts for the 2026-09-23 finding this fixes).
|
|
116
|
+
const bypassAcknowledged = isBypassNarrowingAcknowledged(news);
|
|
117
|
+
const ruleAcknowledged = isRuleNarrowingAcknowledged(news);
|
|
118
|
+
|
|
107
119
|
const bypassRefusal =
|
|
108
120
|
news.bypassActors === undefined
|
|
109
121
|
? undefined
|
|
@@ -116,6 +128,19 @@ export const reconcileRuleset = <R = never>(input: {
|
|
|
116
128
|
});
|
|
117
129
|
if (bypassRefusal !== undefined) return yield* Effect.fail(bypassRefusal);
|
|
118
130
|
|
|
131
|
+
const bypassNarrowRefusal =
|
|
132
|
+
news.bypassActors === undefined
|
|
133
|
+
? undefined
|
|
134
|
+
: bypassNarrowingRefusal({
|
|
135
|
+
owner,
|
|
136
|
+
repository: repo,
|
|
137
|
+
name: news.name,
|
|
138
|
+
declared: news.bypassActors,
|
|
139
|
+
live: observed,
|
|
140
|
+
acknowledged: bypassAcknowledged,
|
|
141
|
+
});
|
|
142
|
+
if (bypassNarrowRefusal !== undefined) return yield* Effect.fail(bypassNarrowRefusal);
|
|
143
|
+
|
|
119
144
|
const ruleRefusal = undeclaredLiveRuleRefusal({
|
|
120
145
|
owner,
|
|
121
146
|
repository: repo,
|
|
@@ -125,6 +150,16 @@ export const reconcileRuleset = <R = never>(input: {
|
|
|
125
150
|
});
|
|
126
151
|
if (ruleRefusal !== undefined) return yield* Effect.fail(ruleRefusal);
|
|
127
152
|
|
|
153
|
+
const ruleNarrowRefusal = ruleNarrowingRefusal({
|
|
154
|
+
owner,
|
|
155
|
+
repository: repo,
|
|
156
|
+
name: news.name,
|
|
157
|
+
declared: news.rules,
|
|
158
|
+
live: observed,
|
|
159
|
+
acknowledged: ruleAcknowledged,
|
|
160
|
+
});
|
|
161
|
+
if (ruleNarrowRefusal !== undefined) return yield* Effect.fail(ruleNarrowRefusal);
|
|
162
|
+
|
|
128
163
|
const checksRefusal = requiredChecksOmissionRefusal({
|
|
129
164
|
owner,
|
|
130
165
|
repository: repo,
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* (ii) The write-recording fake, refusals: duplicate names, bypass widening, an undeclared live
|
|
3
3
|
* rule, required checks omitted, and a never-reported context each make ZERO writes; and a
|
|
4
|
-
* readback mismatch fails even though the write itself "succeeded" (S10).
|
|
5
|
-
*
|
|
4
|
+
* readback mismatch fails even though the write itself "succeeded" (S10). Bypass/rule narrowing
|
|
5
|
+
* without the matching `acknowledge{Bypass,Rule}Narrowing` are their own file,
|
|
6
|
+
* repository-ruleset-narrowing.test.ts (split out purely for the 250-line cap). The happy paths
|
|
7
|
+
* (create, noop, update) are repository-ruleset-write.test.ts; exact-adopt pass-through's own
|
|
8
|
+
* dedicated fixtures (all 5 live non-empty-bypass shapes) are
|
|
9
|
+
* repository-ruleset-exact-adopt.test.ts.
|
|
6
10
|
*/
|
|
7
11
|
import { describe, expect, test } from 'bun:test';
|
|
8
12
|
import * as Effect from 'effect/Effect';
|
|
@@ -94,30 +98,6 @@ describe('refusals make zero writes', () => {
|
|
|
94
98
|
expect(tagOf(error)).toBe('UndeclaredLiveRule');
|
|
95
99
|
});
|
|
96
100
|
|
|
97
|
-
test('the SAME live ruleset with deletion explicitly declared false is a deliberate removal, not refused', async () => {
|
|
98
|
-
const live = {
|
|
99
|
-
id: 1,
|
|
100
|
-
name: 'main',
|
|
101
|
-
enforcement: 'active',
|
|
102
|
-
bypass_actors: [],
|
|
103
|
-
conditions: {},
|
|
104
|
-
rules: [
|
|
105
|
-
{ type: 'deletion' },
|
|
106
|
-
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
107
|
-
],
|
|
108
|
-
};
|
|
109
|
-
const fake = makeRecordingFake({ widgets: live });
|
|
110
|
-
fake.reportedContexts.add('ci');
|
|
111
|
-
const declaration = props({
|
|
112
|
-
rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
|
|
113
|
-
});
|
|
114
|
-
await reconcileAgainst(fake, declaration, { rulesetId: 1 }).pipe(Effect.runPromise);
|
|
115
|
-
expect(fake.writes).toHaveLength(1);
|
|
116
|
-
expect(fake.writes[0]?.op).toBe('update');
|
|
117
|
-
const sentTypes = fake.writes[0]?.body?.rules?.map((r) => r.type);
|
|
118
|
-
expect(sentTypes).not.toContain('deletion');
|
|
119
|
-
});
|
|
120
|
-
|
|
121
101
|
test('required checks omitted while the live ruleset has them', async () => {
|
|
122
102
|
const live = {
|
|
123
103
|
id: 1,
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
import { Resource } from 'alchemy';
|
|
43
43
|
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
44
44
|
import { isResolved } from 'alchemy/Diff';
|
|
45
|
-
import type { GitHubCredentials
|
|
45
|
+
import type { GitHubCredentials } from 'alchemy/GitHub';
|
|
46
46
|
import * as Provider from 'alchemy/Provider';
|
|
47
47
|
import * as Effect from 'effect/Effect';
|
|
48
48
|
import {
|
|
@@ -52,46 +52,16 @@ import {
|
|
|
52
52
|
import { RulesetConstraintRefused } from './repository-ruleset-errors.ts';
|
|
53
53
|
import { makeRulesetOctokit } from './repository-ruleset-octokit.ts';
|
|
54
54
|
import { type RulesetRecord, probeByName } from './repository-ruleset-probe.ts';
|
|
55
|
+
import type { RepositoryRulesetProps } from './repository-ruleset-props.ts';
|
|
55
56
|
import { reconcileRuleset } from './repository-ruleset-reconcile.ts';
|
|
56
57
|
|
|
57
|
-
type
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface RepositoryRulesetPullRequestRule extends NonNullable<
|
|
66
|
-
UpstreamRules['pullRequest']
|
|
67
|
-
> {
|
|
68
|
-
/** @default every method GitHub allows — the house baseline always pins `['squash']`. */
|
|
69
|
-
readonly allowedMergeMethods?: readonly ('merge' | 'squash' | 'rebase')[];
|
|
70
|
-
readonly requiredReviewers?: readonly RepositoryRulesetReviewerRule[];
|
|
71
|
-
/** `require_extra_approval_for_unattributed_changes` — see H15 above. Only `false` is
|
|
72
|
-
* modeled: sending `true` has no house use, and GitHub already defaults absent to `true`. */
|
|
73
|
-
readonly extraApprovalForUnattributedChanges?: false;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface RepositoryRulesetStatusChecksRule extends NonNullable<
|
|
77
|
-
UpstreamRules['requiredStatusChecks']
|
|
78
|
-
> {
|
|
79
|
-
readonly doNotEnforceOnCreate?: boolean;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface RepositoryRulesetRules extends Omit<
|
|
83
|
-
UpstreamRules,
|
|
84
|
-
'pullRequest' | 'requiredStatusChecks'
|
|
85
|
-
> {
|
|
86
|
-
readonly pullRequest?: RepositoryRulesetPullRequestRule;
|
|
87
|
-
/** `undefined`: no opinion (refused if the live ruleset already has one — see
|
|
88
|
-
* repository-ruleset-guards.ts). `false`: declare its ABSENCE — removes a live one. */
|
|
89
|
-
readonly requiredStatusChecks?: RepositoryRulesetStatusChecksRule | false;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface RepositoryRulesetProps extends Omit<RulesetProps, 'rules'> {
|
|
93
|
-
readonly rules?: RepositoryRulesetRules;
|
|
94
|
-
}
|
|
58
|
+
export type {
|
|
59
|
+
RepositoryRulesetProps,
|
|
60
|
+
RepositoryRulesetPullRequestRule,
|
|
61
|
+
RepositoryRulesetReviewerRule,
|
|
62
|
+
RepositoryRulesetRules,
|
|
63
|
+
RepositoryRulesetStatusChecksRule,
|
|
64
|
+
} from './repository-ruleset-props.ts';
|
|
95
65
|
|
|
96
66
|
export interface RepositoryRulesetAttributes {
|
|
97
67
|
readonly rulesetId: number;
|
|
@@ -124,7 +94,11 @@ export const attrsOf = (r: RulesetRecord): RepositoryRulesetAttributes => ({
|
|
|
124
94
|
/** Plan-time-only refusals — no live data needed, so these run before any read (schema-codegen's
|
|
125
95
|
* rule: check the form before the "no previous state" branch, never after it). */
|
|
126
96
|
function constraintRefusal(props: RepositoryRulesetProps): RulesetConstraintRefused | undefined {
|
|
127
|
-
|
|
97
|
+
// `=== false` narrows `pullRequest` out of the `| false` half added for exact-adopt
|
|
98
|
+
// pass-through (see `RepositoryRulesetRules` above) — a declared absence has no fields to
|
|
99
|
+
// constrain.
|
|
100
|
+
const pr = props.rules?.pullRequest === false ? undefined : props.rules?.pullRequest;
|
|
101
|
+
const approvals = pr?.requiredApprovingReviewCount;
|
|
128
102
|
if (approvals !== undefined) {
|
|
129
103
|
const reason = approvalCountRefusal(approvals);
|
|
130
104
|
if (reason !== undefined) {
|
|
@@ -134,7 +108,7 @@ function constraintRefusal(props: RepositoryRulesetProps): RulesetConstraintRefu
|
|
|
134
108
|
});
|
|
135
109
|
}
|
|
136
110
|
}
|
|
137
|
-
const methods =
|
|
111
|
+
const methods = pr?.allowedMergeMethods;
|
|
138
112
|
if (methods !== undefined) {
|
|
139
113
|
const reason = allowedMergeMethodsRefusal(methods);
|
|
140
114
|
if (reason !== undefined) {
|
|
@@ -144,6 +118,18 @@ function constraintRefusal(props: RepositoryRulesetProps): RulesetConstraintRefu
|
|
|
144
118
|
});
|
|
145
119
|
}
|
|
146
120
|
}
|
|
121
|
+
// ⚠️ NO SEPARATE PLAN-TIME CHECK FOR A BLANK `acknowledge{Bypass,Rule}Narrowing.reason` HERE.
|
|
122
|
+
// A blank reason is already indistinguishable from "not acknowledged at all" —
|
|
123
|
+
// `isBypassNarrowingAcknowledged`/`isRuleNarrowingAcknowledged`
|
|
124
|
+
// (repository-ruleset-narrowing-guards.ts) each require a non-empty, trimmed `reason` on
|
|
125
|
+
// their OWN prop, and the matching narrowing guard reads that, not presence alone. Adding a
|
|
126
|
+
// SECOND check here would refuse with `RulesetConstraintRefused` instead of the more
|
|
127
|
+
// specific `BypassActorNarrowed`/`RuleNarrowed` the caller actually needs to act on — and,
|
|
128
|
+
// unlike those two (exercised directly through `reconcileRuleset`, injected-octokit fakes
|
|
129
|
+
// included), this function is wired only through the real `RepositoryRuleset.Provider`
|
|
130
|
+
// (`makeRulesetOctokit` needs live `GitHubCredentials`), which this family's own test
|
|
131
|
+
// helpers do not exercise (see `repository-ruleset-test-helpers.ts`'s stub
|
|
132
|
+
// `constraintRefusal`) — a check that lives only here would ship untested.
|
|
147
133
|
return undefined;
|
|
148
134
|
}
|
|
149
135
|
|