@homeflare/alchemy 0.27.3 → 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/cloudflare/mesh-node-token.d.ts +1 -1
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +4 -4
- package/dist/forgejo/org-actions-secrets.d.ts +4 -4
- package/dist/forgejo/org-label.d.ts +4 -4
- package/dist/forgejo/org-team.d.ts +4 -4
- package/dist/forgejo/repo-webhook.d.ts +4 -4
- package/dist/forgejo/repository.d.ts +4 -4
- package/dist/forgejo/team-member.d.ts +4 -4
- 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/dist/netbox/fake-netbox.d.ts +43 -0
- package/dist/netbox/fake-netbox.d.ts.map +1 -0
- package/dist/netbox/index.d.ts +1 -2
- package/dist/netbox/index.d.ts.map +1 -1
- package/dist/netbox/index.js +71 -119
- package/dist/netbox/index.js.map +5 -6
- package/dist/netbox/prefix.d.ts +33 -2
- package/dist/netbox/prefix.d.ts.map +1 -1
- package/dist/netbox/resource.d.ts +73 -48
- package/dist/netbox/resource.d.ts.map +1 -1
- package/docs/credentials.md +10 -0
- package/docs/peers.md +11 -0
- package/docs/upstream-conformance.md +17 -2
- package/package.json +7 -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
- package/src/netbox/fake-netbox.ts +56 -0
- package/src/netbox/index.ts +8 -2
- package/src/netbox/prefix.test.ts +223 -0
- package/src/netbox/prefix.ts +68 -32
- package/src/netbox/resource.test.ts +67 -0
- package/src/netbox/resource.ts +133 -112
- package/dist/netbox/client.d.ts +0 -52
- package/dist/netbox/client.d.ts.map +0 -1
- package/src/netbox/client.test.ts +0 -67
- package/src/netbox/client.ts +0 -146
|
@@ -43,6 +43,49 @@ export class BypassActorWidened extends Data.TaggedError('BypassActorWidened')<{
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** A declared `bypassActors` drops an actor the live ruleset has, with no
|
|
47
|
+
* `acknowledgeBypassNarrowing` on the declaration. Unlike widening, narrowing is not always
|
|
48
|
+
* wrong — an exact-adopt declaration that matches live exactly narrows nothing — so this is
|
|
49
|
+
* refused only when live genuinely has more than the declaration keeps, and it is escapable
|
|
50
|
+
* (`BypassActorWidened` is not): see `bypassNarrowingRefusal` in
|
|
51
|
+
* repository-ruleset-narrowing-guards.ts. */
|
|
52
|
+
export class BypassActorNarrowed extends Data.TaggedError('BypassActorNarrowed')<{
|
|
53
|
+
readonly owner: string;
|
|
54
|
+
readonly repository: string;
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly narrowed: readonly string[];
|
|
57
|
+
}> {
|
|
58
|
+
override get message(): string {
|
|
59
|
+
return (
|
|
60
|
+
`GitHub.RepositoryRuleset "${this.name}" on ${this.owner}/${this.repository}: the ` +
|
|
61
|
+
`declaration drops a bypass actor the live ruleset has (${this.narrowed.join(', ')}), and ` +
|
|
62
|
+
'the declaration carries no `acknowledgeBypassNarrowing`. Add one with a reason once the ' +
|
|
63
|
+
'removal is deliberate, or restore the actor to match live.'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A rule type the declaration explicitly marks absent (`false`) while the live ruleset still
|
|
69
|
+
* has it, with no `acknowledgeRuleNarrowing` on the declaration — see `ruleNarrowingRefusal` in
|
|
70
|
+
* repository-ruleset-narrowing-guards.ts. Distinct from `UndeclaredLiveRule`: that one fires on
|
|
71
|
+
* SILENCE (the declaration says nothing); this one fires on an EXPLICIT, but unacknowledged,
|
|
72
|
+
* removal. */
|
|
73
|
+
export class RuleNarrowed extends Data.TaggedError('RuleNarrowed')<{
|
|
74
|
+
readonly owner: string;
|
|
75
|
+
readonly repository: string;
|
|
76
|
+
readonly name: string;
|
|
77
|
+
readonly ruleType: string;
|
|
78
|
+
}> {
|
|
79
|
+
override get message(): string {
|
|
80
|
+
return (
|
|
81
|
+
`GitHub.RepositoryRuleset "${this.name}" on ${this.owner}/${this.repository}: the ` +
|
|
82
|
+
`declaration drops a live "${this.ruleType}" rule, and carries no ` +
|
|
83
|
+
'`acknowledgeRuleNarrowing`. Add one with a reason once the removal is deliberate, or ' +
|
|
84
|
+
'declare the rule to match live.'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
46
89
|
/** A live rule's `type` is neither modeled nor explicitly refused by RULE_TYPE_COVERAGE, or is
|
|
47
90
|
* refused and present live anyway. The PUT replaces `rules` wholesale, so silently omitting a
|
|
48
91
|
* live rule type would drop it — this fails the plan by name instead. */
|
|
@@ -129,7 +172,9 @@ export class ReadbackMismatch extends Data.TaggedError('ReadbackMismatch')<{
|
|
|
129
172
|
export type RepositoryRulesetError =
|
|
130
173
|
| DuplicateRuleset
|
|
131
174
|
| BypassActorWidened
|
|
175
|
+
| BypassActorNarrowed
|
|
132
176
|
| UndeclaredLiveRule
|
|
177
|
+
| RuleNarrowed
|
|
133
178
|
| RequiredChecksOmitted
|
|
134
179
|
| NeverReportedContext
|
|
135
180
|
| RulesetConstraintRefused
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact-adopt pass-through, proven against the 5 live rulesets the red team found this resource
|
|
3
|
+
* could not express before 2026-09-23 (design critique, "strongest_failure": `repoBaselineRuleset()`
|
|
4
|
+
* hardcodes `bypassActors: []` and an unconditional `pullRequest` rule; every one of these 5
|
|
5
|
+
* repos' live ruleset has a non-empty `bypass_actors`, and one — `taslabs-net/taslabs-net` — has
|
|
6
|
+
* no live `pull_request` rule at all). This file proves the underlying `RepositoryRuleset`
|
|
7
|
+
* resource (not the convenience `repoBaselineRuleset()`, which intentionally keeps its baseline
|
|
8
|
+
* defaults — see repo-baseline-data.ts) can express all 5 shapes, and that declaring one exactly
|
|
9
|
+
* is never treated as narrowing.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ RE-READ LIVE 2026-09-23 via `gh api repos/taslabs-net/<repo>/rulesets/<id>`, not copied
|
|
12
|
+
* from the design doc's own paraphrase — `bypass_mode` differs from the design critique's
|
|
13
|
+
* prose for `doesthishelp-workeropen` (measured: RepositoryRole 5 is `pull_request` mode and
|
|
14
|
+
* Integration 29110 is `always`, not the reverse) — see repository-ruleset-narrowing-guards.ts
|
|
15
|
+
* for the guard these fixtures exercise.
|
|
16
|
+
*
|
|
17
|
+
* `taslabs-net` is the only one of the 5 whose FULL ruleset (bypass AND rules) is provable as a
|
|
18
|
+
* true zero-write adopt today: its live `pull_request` rules (aop/magictransit/loggarr/
|
|
19
|
+
* doesthishelp-workeropen) all carry `require_extra_approval_for_unattributed_changes: true`,
|
|
20
|
+
* which this resource cannot yet declare (K1 — a separate, later PR; H15's comment on
|
|
21
|
+
* `extraApprovalForUnattributedChanges` explains why only `false` is modeled today). The other
|
|
22
|
+
* 4 fixtures below prove the narrower claim K1 does not gate: that this resource's `bypassActors`
|
|
23
|
+
* round-trips an arbitrary `actor_id`/`actor_type`/`bypass_mode` combination — single-actor by
|
|
24
|
+
* role, single-actor by integration, and multi-actor with mixed modes — onto the wire exactly as
|
|
25
|
+
* declared, with neither `BypassActorWidened` nor `BypassActorNarrowed` firing on a match.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, expect, test } from 'bun:test';
|
|
28
|
+
import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
|
|
29
|
+
import {
|
|
30
|
+
BASE_PULL_REQUEST,
|
|
31
|
+
props,
|
|
32
|
+
reconcileWithOctokit,
|
|
33
|
+
run,
|
|
34
|
+
} from './repository-ruleset-test-helpers.ts';
|
|
35
|
+
|
|
36
|
+
describe('exact-adopt pass-through — live shapes measured 2026-09-23', () => {
|
|
37
|
+
// The critical case: a non-empty bypass_actors AND an absent pull_request rule, both at once.
|
|
38
|
+
// `gh api repos/taslabs-net/taslabs-net/rulesets/12206080`.
|
|
39
|
+
test('taslabs-net/taslabs-net: non-empty bypass, no pull_request rule — zero-write adopt', async () => {
|
|
40
|
+
const live = {
|
|
41
|
+
id: 12206080,
|
|
42
|
+
name: 'main',
|
|
43
|
+
target: 'branch',
|
|
44
|
+
enforcement: 'active',
|
|
45
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
46
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
47
|
+
rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }],
|
|
48
|
+
};
|
|
49
|
+
const fake = makeGetOnlyFake({ 'taslabs-net': live });
|
|
50
|
+
const declaration = props({
|
|
51
|
+
repository: 'taslabs-net',
|
|
52
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
53
|
+
rules: { deletion: true, nonFastForward: true },
|
|
54
|
+
});
|
|
55
|
+
const attrs = await run(reconcileWithOctokit(fake, declaration, { rulesetId: 12206080 }));
|
|
56
|
+
expect(attrs.rulesetId).toBe(12206080);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// `gh api repos/taslabs-net/aop/rulesets/14572279` — single RepositoryRole actor, mode
|
|
60
|
+
// "always". Live `pull_request` also carries the K1 flag, so the write below is an `update`,
|
|
61
|
+
// not a noop — the point here is that `bypassActors` alone sends back exactly what was
|
|
62
|
+
// declared, and neither narrowing nor widening guard fires on a matching bypass list.
|
|
63
|
+
test('aop: single RepositoryRole actor round-trips exactly', async () => {
|
|
64
|
+
const live = {
|
|
65
|
+
id: 14572279,
|
|
66
|
+
name: 'main',
|
|
67
|
+
target: 'branch',
|
|
68
|
+
enforcement: 'active',
|
|
69
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
70
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
71
|
+
rules: [
|
|
72
|
+
{ type: 'deletion' },
|
|
73
|
+
{ type: 'non_fast_forward' },
|
|
74
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
const fake = makeRecordingFake({ aop: live });
|
|
78
|
+
const declaration = props({
|
|
79
|
+
repository: 'aop',
|
|
80
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
81
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
82
|
+
});
|
|
83
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 14572279 }));
|
|
84
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
85
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// `gh api repos/taslabs-net/loggarr/rulesets/12323114` — a single Integration (Dependabot)
|
|
90
|
+
// actor with `bypass_mode: "pull_request"`, not `"always"`.
|
|
91
|
+
test('loggarr: single Integration actor, pull_request bypass mode, round-trips exactly', async () => {
|
|
92
|
+
const live = {
|
|
93
|
+
id: 12323114,
|
|
94
|
+
name: 'main',
|
|
95
|
+
target: 'branch',
|
|
96
|
+
enforcement: 'active',
|
|
97
|
+
bypass_actors: [{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'pull_request' }],
|
|
98
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
99
|
+
rules: [
|
|
100
|
+
{ type: 'deletion' },
|
|
101
|
+
{ type: 'non_fast_forward' },
|
|
102
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const fake = makeRecordingFake({ loggarr: live });
|
|
106
|
+
const declaration = props({
|
|
107
|
+
repository: 'loggarr',
|
|
108
|
+
bypassActors: [{ actorType: 'Integration', actorId: 29110, bypassMode: 'pull_request' }],
|
|
109
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
110
|
+
});
|
|
111
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 12323114 }));
|
|
112
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
113
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'pull_request' },
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// `gh api repos/taslabs-net/doesthishelp-workeropen/rulesets/12504824` — TWO actors with
|
|
118
|
+
// DIFFERENT modes each (the design critique's own "(both)" shorthand for this repo).
|
|
119
|
+
test('doesthishelp-workeropen: two actors, mixed bypass modes, round-trip exactly', async () => {
|
|
120
|
+
const live = {
|
|
121
|
+
id: 12504824,
|
|
122
|
+
name: 'main',
|
|
123
|
+
target: 'branch',
|
|
124
|
+
enforcement: 'active',
|
|
125
|
+
bypass_actors: [
|
|
126
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
|
|
127
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'always' },
|
|
128
|
+
],
|
|
129
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
130
|
+
rules: [
|
|
131
|
+
{ type: 'deletion' },
|
|
132
|
+
{ type: 'non_fast_forward' },
|
|
133
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
const fake = makeRecordingFake({ 'doesthishelp-workeropen': live });
|
|
137
|
+
const declaration = props({
|
|
138
|
+
repository: 'doesthishelp-workeropen',
|
|
139
|
+
bypassActors: [
|
|
140
|
+
{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'pull_request' },
|
|
141
|
+
{ actorType: 'Integration', actorId: 29110, bypassMode: 'always' },
|
|
142
|
+
],
|
|
143
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
144
|
+
});
|
|
145
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 12504824 }));
|
|
146
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
147
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
|
|
148
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'always' },
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// `gh api repos/taslabs-net/magictransit/rulesets/10470003` — same single-actor shape as
|
|
153
|
+
// `aop`, kept as its own case because it is one of the 5 the design's own census named.
|
|
154
|
+
test('magictransit: single RepositoryRole actor round-trips exactly', async () => {
|
|
155
|
+
const live = {
|
|
156
|
+
id: 10470003,
|
|
157
|
+
name: 'main',
|
|
158
|
+
target: 'branch',
|
|
159
|
+
enforcement: 'active',
|
|
160
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
161
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
162
|
+
rules: [
|
|
163
|
+
{ type: 'deletion' },
|
|
164
|
+
{ type: 'non_fast_forward' },
|
|
165
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
const fake = makeRecordingFake({ magictransit: live });
|
|
169
|
+
const declaration = props({
|
|
170
|
+
repository: 'magictransit',
|
|
171
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
172
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
173
|
+
});
|
|
174
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 10470003 }));
|
|
175
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
176
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
} from './repository-ruleset-errors.ts';
|
|
15
15
|
import type { RulesetOctokit, RulesetRecord } from './repository-ruleset-probe.ts';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** Exported: repository-ruleset-narrowing-guards.ts reuses the identical key so a widened and a
|
|
18
|
+
* narrowed actor are never computed by two independently-drifting definitions of "the same
|
|
19
|
+
* actor". */
|
|
20
|
+
export const bypassKey = (a: { actor_type: unknown; actor_id: unknown; bypass_mode?: unknown }) =>
|
|
18
21
|
`${String(a.actor_type)}:${String(a.actor_id ?? '')}:${String(a.bypass_mode ?? 'always')}`;
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -56,7 +59,10 @@ export function bypassWideningRefusal(input: {
|
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
|
|
62
|
+
/** Exported for the same reason as `bypassKey` — repository-ruleset-narrowing-guards.ts's
|
|
63
|
+
* `ruleNarrowingRefusal` needs the identical live-type set `undeclaredLiveRuleRefusal` computes,
|
|
64
|
+
* not a second reading of `live.rules` that could disagree with this one. */
|
|
65
|
+
export const liveRuleTypes = (live: RulesetRecord | undefined): Set<string> =>
|
|
60
66
|
new Set((live?.rules ?? []).map((r) => String(r.type)));
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrowing refusals — the counterpart to `bypassWideningRefusal`/`undeclaredLiveRuleRefusal` in
|
|
3
|
+
* repository-ruleset-guards.ts, split into its own file purely to keep both under the 250-line
|
|
4
|
+
* cap (same reason repository-ruleset-props.ts exists).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ WIDENING STAYS UNCONDITIONAL; NARROWING GETS AN ESCAPE HATCH. Widening bypass (granting a
|
|
7
|
+
* NEW actor) has no legitimate reason to happen through a deploy — "grant it by hand and adopt
|
|
8
|
+
* the change" (repository-ruleset-errors.ts's `BypassActorWidened` message) already covers it,
|
|
9
|
+
* and this file does not touch that guard. NARROWING (removing a live bypass actor, or
|
|
10
|
+
* dropping a live rule) is different: it is exactly what an EXACT-ADOPT declaration of a repo
|
|
11
|
+
* whose live ruleset is narrower than the house baseline needs to express on its very first
|
|
12
|
+
* deploy — a caller declaring `taslabs-net/taslabs-net`'s live shape (measured live via `gh
|
|
13
|
+
* api repos/taslabs-net/taslabs-net/rulesets/12206080`, 2026-09-23: `bypass_actors:
|
|
14
|
+
* [{actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always"}]`, `rules: [deletion,
|
|
15
|
+
* non_fast_forward]`, no `pull_request` rule) is not narrowing anything by declaring exactly
|
|
16
|
+
* that shape — it is matching live exactly, and `reconcileRuleset`'s own noop check (comparing
|
|
17
|
+
* canonicalized wire forms) makes zero writes for it, same as any other exact match. What
|
|
18
|
+
* these guards refuse is a LATER declaration that removes something the CURRENT live state
|
|
19
|
+
* still has, without saying why. `acknowledgeBypassNarrowing`/`acknowledgeRuleNarrowing` are
|
|
20
|
+
* the "why": a reasoned, deliberate sign-off, not a rubber stamp — see their doc comments on
|
|
21
|
+
* `RepositoryRulesetProps` (repository-ruleset-props.ts).
|
|
22
|
+
*
|
|
23
|
+
* ⛔ THE TWO ACKNOWLEDGEMENTS ARE DELIBERATELY SEPARATE PROPS, NOT ONE SHARED FLAG (2026-09-23
|
|
24
|
+
* review finding). A single `acknowledgeNarrowing` computed once and handed to both guards let
|
|
25
|
+
* a reason written for one kind of narrowing silently also excuse the other — a declaration
|
|
26
|
+
* dropping BOTH a live rule and a live bypass actor at once needed only one acknowledgement,
|
|
27
|
+
* however narrowly worded, to pass both checks. Each guard below now reads its OWN prop.
|
|
28
|
+
*/
|
|
29
|
+
import { bypassKey, liveRuleTypes } from './repository-ruleset-guards.ts';
|
|
30
|
+
import { BypassActorNarrowed, RuleNarrowed } from './repository-ruleset-errors.ts';
|
|
31
|
+
import type { RepositoryRulesetProps, RepositoryRulesetRules } from './repository-ruleset-props.ts';
|
|
32
|
+
import type { RulesetRecord } from './repository-ruleset-probe.ts';
|
|
33
|
+
|
|
34
|
+
/** `true` only for a non-empty, non-whitespace `reason` on `acknowledgeBypassNarrowing` — a bare
|
|
35
|
+
* `{}` or a blank string is not an acknowledgement (repository-ruleset.ts's `constraintRefusal`
|
|
36
|
+
* refuses the latter at plan time; this is the read `bypassNarrowingRefusal` shares). Deliberately
|
|
37
|
+
* ignorant of `acknowledgeRuleNarrowing` — see the file header. */
|
|
38
|
+
export const isBypassNarrowingAcknowledged = (props: RepositoryRulesetProps): boolean =>
|
|
39
|
+
props.acknowledgeBypassNarrowing !== undefined &&
|
|
40
|
+
props.acknowledgeBypassNarrowing.reason.trim().length > 0;
|
|
41
|
+
|
|
42
|
+
/** The `rules`-side counterpart to `isBypassNarrowingAcknowledged`, reading only
|
|
43
|
+
* `acknowledgeRuleNarrowing` — see the file header. */
|
|
44
|
+
export const isRuleNarrowingAcknowledged = (props: RepositoryRulesetProps): boolean =>
|
|
45
|
+
props.acknowledgeRuleNarrowing !== undefined &&
|
|
46
|
+
props.acknowledgeRuleNarrowing.reason.trim().length > 0;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A live bypass actor the declaration drops, unacknowledged. `undefined` `declared` never
|
|
50
|
+
* reaches here — same rule as `bypassWideningRefusal`: unmanaged means the live value is
|
|
51
|
+
* preserved unchanged (repository-ruleset-reconcile.ts), so there is nothing to narrow.
|
|
52
|
+
*/
|
|
53
|
+
export function bypassNarrowingRefusal(input: {
|
|
54
|
+
readonly owner: string;
|
|
55
|
+
readonly repository: string;
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly declared: RepositoryRulesetProps['bypassActors'];
|
|
58
|
+
readonly live: RulesetRecord | undefined;
|
|
59
|
+
readonly acknowledged: boolean;
|
|
60
|
+
}): BypassActorNarrowed | undefined {
|
|
61
|
+
if (input.acknowledged) return undefined;
|
|
62
|
+
if (input.live === undefined) return undefined;
|
|
63
|
+
const liveActors = (input.live.bypass_actors ?? []) as {
|
|
64
|
+
actor_type: unknown;
|
|
65
|
+
actor_id: unknown;
|
|
66
|
+
bypass_mode?: unknown;
|
|
67
|
+
}[];
|
|
68
|
+
const declaredKeys = new Set(
|
|
69
|
+
(input.declared ?? []).map((a) =>
|
|
70
|
+
bypassKey({
|
|
71
|
+
actor_type: a.actorType,
|
|
72
|
+
actor_id: a.actorId,
|
|
73
|
+
bypass_mode: a.bypassMode ?? 'always',
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
const narrowed = liveActors.map(bypassKey).filter((k) => !declaredKeys.has(k));
|
|
78
|
+
if (narrowed.length === 0) return undefined;
|
|
79
|
+
return new BypassActorNarrowed({
|
|
80
|
+
owner: input.owner,
|
|
81
|
+
repository: input.repository,
|
|
82
|
+
name: input.name,
|
|
83
|
+
narrowed,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The live rule `type` for the first boolean/`pullRequest` rule the declaration marks `false`
|
|
88
|
+
* while live still has it — `undefined` when none does. `requiredStatusChecks: false` is
|
|
89
|
+
* checked by the caller directly (its live-side read needs `required_status_checks`'s own
|
|
90
|
+
* parameters shape, not just presence, elsewhere in this family — kept out of this helper so it
|
|
91
|
+
* stays a plain type-to-type comparison). */
|
|
92
|
+
function droppedBooleanOrPullRequestType(
|
|
93
|
+
rules: RepositoryRulesetRules,
|
|
94
|
+
live: ReadonlySet<string>,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
const declaredFalse: readonly [boolean | undefined, string][] = [
|
|
97
|
+
[rules.creation, 'creation'],
|
|
98
|
+
[rules.update, 'update'],
|
|
99
|
+
[rules.deletion, 'deletion'],
|
|
100
|
+
[rules.requiredLinearHistory, 'required_linear_history'],
|
|
101
|
+
[rules.requiredSignatures, 'required_signatures'],
|
|
102
|
+
[rules.nonFastForward, 'non_fast_forward'],
|
|
103
|
+
];
|
|
104
|
+
for (const [declared, type] of declaredFalse) {
|
|
105
|
+
if (declared === false && live.has(type)) return type;
|
|
106
|
+
}
|
|
107
|
+
if (rules.pullRequest === false && live.has('pull_request')) return 'pull_request';
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A rule the declaration explicitly marks absent (a boolean rule or `pullRequest` declared
|
|
113
|
+
* `false`, or `requiredStatusChecks: false`) while the LIVE ruleset still has it, unacknowledged
|
|
114
|
+
* — the drop `undeclaredLiveRuleRefusal` (repository-ruleset-guards.ts) never sees, because an
|
|
115
|
+
* explicit `false` IS "declared" there (see its own `declaredRuleTypes`).
|
|
116
|
+
*/
|
|
117
|
+
export function ruleNarrowingRefusal(input: {
|
|
118
|
+
readonly owner: string;
|
|
119
|
+
readonly repository: string;
|
|
120
|
+
readonly name: string;
|
|
121
|
+
readonly declared: RepositoryRulesetProps['rules'];
|
|
122
|
+
readonly live: RulesetRecord | undefined;
|
|
123
|
+
readonly acknowledged: boolean;
|
|
124
|
+
}): RuleNarrowed | undefined {
|
|
125
|
+
if (input.acknowledged) return undefined;
|
|
126
|
+
const rules = input.declared;
|
|
127
|
+
if (rules === undefined) return undefined;
|
|
128
|
+
const live = liveRuleTypes(input.live);
|
|
129
|
+
const ruleType =
|
|
130
|
+
droppedBooleanOrPullRequestType(rules, live) ??
|
|
131
|
+
(rules.requiredStatusChecks === false && live.has('required_status_checks')
|
|
132
|
+
? 'required_status_checks'
|
|
133
|
+
: undefined);
|
|
134
|
+
if (ruleType === undefined) return undefined;
|
|
135
|
+
return new RuleNarrowed({
|
|
136
|
+
owner: input.owner,
|
|
137
|
+
repository: input.repository,
|
|
138
|
+
name: input.name,
|
|
139
|
+
ruleType,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -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
|
+
});
|