@homeflare/alchemy 0.27.4 → 0.29.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/README.md +2 -0
- package/dist/caddy/admin-address.d.ts +27 -0
- package/dist/caddy/admin-address.d.ts.map +1 -0
- package/dist/caddy/admin-calls.d.ts +25 -28
- package/dist/caddy/admin-calls.d.ts.map +1 -1
- package/dist/caddy/admin.d.ts +28 -38
- package/dist/caddy/admin.d.ts.map +1 -1
- package/dist/caddy/caddy-http-client.d.ts +25 -0
- package/dist/caddy/caddy-http-client.d.ts.map +1 -0
- package/dist/caddy/config-errors.d.ts +24 -0
- package/dist/caddy/config-errors.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +20 -49
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config-reconcile.d.ts +45 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +4 -3
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +10 -2
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +3 -3
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +487 -444
- package/dist/caddy/index.js.map +16 -12
- package/dist/caddy/local-admin.d.ts +20 -34
- package/dist/caddy/local-admin.d.ts.map +1 -1
- package/dist/caddy/providers.d.ts +19 -3
- package/dist/caddy/providers.d.ts.map +1 -1
- 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/declare-repo-baseline.d.ts +1 -1
- package/dist/github/declare-repo-baseline.d.ts.map +1 -1
- package/dist/github/index.d.ts +9 -5
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +161 -25
- package/dist/github/index.js.map +15 -13
- package/dist/github/repo-baseline-data.d.ts +10 -1
- package/dist/github/repo-baseline-data.d.ts.map +1 -1
- package/dist/github/repo-policy.d.ts +1 -1
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-errors.d.ts +37 -3
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-form.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +15 -2
- 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-octokit.d.ts +49 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
- package/dist/github/repository-ruleset-probe.d.ts +2 -1
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
- package/dist/github/repository-ruleset-props.d.ts +91 -0
- package/dist/github/repository-ruleset-props.d.ts.map +1 -0
- package/dist/github/repository-ruleset-providers.d.ts +42 -0
- package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
- package/dist/github/repository-ruleset.d.ts +28 -34
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts +27 -16
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/fake-stack.d.ts +9 -4
- package/dist/litellm/fake-stack.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +7 -5
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +53 -123
- package/dist/litellm/index.js.map +8 -8
- package/dist/litellm/operations.d.ts +60 -0
- package/dist/litellm/operations.d.ts.map +1 -0
- package/dist/litellm/pass-through-endpoint.d.ts +10 -28
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
- package/dist/litellm/pass-through-errors.d.ts +37 -0
- package/dist/litellm/pass-through-errors.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +11 -4
- package/dist/litellm/pass-through-form.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +7 -5
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/netbox/prefix.d.ts +4 -4
- package/dist/paperless/custom-field.d.ts +21 -21
- package/dist/paperless/custom-field.d.ts.map +1 -1
- package/dist/paperless/document-type.d.ts +34 -4
- package/dist/paperless/document-type.d.ts.map +1 -1
- package/dist/paperless/fake-paperless.d.ts +36 -19
- package/dist/paperless/fake-paperless.d.ts.map +1 -1
- package/dist/paperless/index.d.ts +7 -2
- package/dist/paperless/index.d.ts.map +1 -1
- package/dist/paperless/index.js +164 -228
- package/dist/paperless/index.js.map +10 -13
- package/dist/paperless/matching-locate.d.ts +42 -20
- package/dist/paperless/matching-locate.d.ts.map +1 -1
- package/dist/paperless/matching-types.d.ts +47 -18
- package/dist/paperless/matching-types.d.ts.map +1 -1
- package/dist/paperless/matching.d.ts +30 -27
- package/dist/paperless/matching.d.ts.map +1 -1
- package/dist/paperless/providers.d.ts +13 -7
- package/dist/paperless/providers.d.ts.map +1 -1
- package/dist/paperless/storage-path.d.ts +34 -4
- package/dist/paperless/storage-path.d.ts.map +1 -1
- package/dist/paperless/tag.d.ts +43 -8
- package/dist/paperless/tag.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts +30 -23
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/distilled-pve.d.ts +89 -0
- package/dist/proxmox/distilled-pve.d.ts.map +1 -0
- package/dist/proxmox/index.js +663 -609
- package/dist/proxmox/index.js.map +12 -11
- package/dist/proxmox/members.d.ts +29 -10
- package/dist/proxmox/members.d.ts.map +1 -1
- package/docs/caddy-admin.md +1 -1
- package/docs/caddy.md +3 -2
- package/docs/credentials.md +16 -0
- package/docs/litellm.md +21 -13
- package/docs/paperless.md +12 -0
- package/docs/peers.md +5 -0
- package/docs/repo-policy.md +16 -1
- package/docs/repository-ruleset.md +53 -13
- package/docs/upstream-conformance.md +126 -10
- package/package.json +9 -2
- package/src/caddy/admin-address.ts +71 -0
- package/src/caddy/admin-calls.ts +48 -100
- package/src/caddy/admin.ts +37 -41
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/caddy-http-client.ts +229 -0
- package/src/caddy/config-adopt-provider.test.ts +139 -0
- package/src/caddy/config-adopt.test.ts +60 -126
- package/src/caddy/config-errors.ts +47 -0
- package/src/caddy/config-lifecycle.test.ts +55 -42
- package/src/caddy/config-lifecycle.ts +72 -162
- package/src/caddy/config-reconcile.ts +138 -0
- package/src/caddy/config.ts +36 -42
- package/src/caddy/fake-caddy.ts +20 -2
- package/src/caddy/index.ts +3 -8
- package/src/caddy/local-admin.test.ts +35 -21
- package/src/caddy/local-admin.ts +60 -167
- package/src/caddy/providers.test.ts +14 -6
- package/src/caddy/providers.ts +20 -4
- package/src/github/declare-repo-baseline.ts +6 -6
- package/src/github/index.ts +10 -4
- package/src/github/repo-baseline-data.test.ts +13 -0
- package/src/github/repo-baseline-data.ts +11 -2
- package/src/github/repo-policy-stack.test.ts +59 -0
- package/src/github/repo-policy.ts +3 -2
- package/src/github/repository-ruleset-errors.ts +51 -4
- package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +184 -0
- package/src/github/repository-ruleset-form.ts +7 -3
- package/src/github/repository-ruleset-guards.ts +11 -4
- 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-octokit.test.ts +141 -0
- package/src/github/repository-ruleset-octokit.ts +121 -30
- package/src/github/repository-ruleset-probe.ts +2 -1
- package/src/github/repository-ruleset-props.ts +95 -0
- package/src/github/repository-ruleset-providers.ts +46 -0
- package/src/github/repository-ruleset-reconcile.ts +35 -0
- package/src/github/repository-ruleset-wire.test.ts +38 -27
- package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
- package/src/github/repository-ruleset.ts +53 -43
- package/src/litellm/fake-litellm.ts +83 -78
- package/src/litellm/fake-stack.ts +13 -5
- package/src/litellm/index.ts +7 -21
- package/src/litellm/operations.ts +149 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
- package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
- package/src/litellm/pass-through-endpoint.test.ts +8 -8
- package/src/litellm/pass-through-endpoint.ts +21 -31
- package/src/litellm/pass-through-errors.ts +37 -0
- package/src/litellm/pass-through-form.ts +20 -10
- package/src/litellm/providers.ts +8 -7
- package/src/paperless/custom-field.test.ts +49 -40
- package/src/paperless/custom-field.ts +57 -33
- package/src/paperless/document-type.test.ts +48 -0
- package/src/paperless/document-type.ts +51 -22
- package/src/paperless/fake-paperless.ts +63 -72
- package/src/paperless/index.ts +7 -13
- package/src/paperless/matching-locate.test.ts +102 -0
- package/src/paperless/matching-locate.ts +80 -86
- package/src/paperless/matching-types.ts +49 -16
- package/src/paperless/matching.ts +77 -90
- package/src/paperless/providers.ts +14 -10
- package/src/paperless/storage-path.test.ts +56 -0
- package/src/paperless/storage-path.ts +53 -24
- package/src/paperless/tag-identity.test.ts +127 -0
- package/src/paperless/tag.test.ts +100 -0
- package/src/paperless/tag.ts +62 -32
- package/src/proxmox/acl.test.ts +145 -0
- package/src/proxmox/acl.ts +128 -150
- package/src/proxmox/distilled-pve.test.ts +231 -0
- package/src/proxmox/distilled-pve.ts +147 -0
- package/src/proxmox/members-timeout.test.ts +161 -0
- package/src/proxmox/members.test.ts +9 -0
- package/src/proxmox/members.ts +36 -2
- package/dist/litellm/client.d.ts +0 -76
- package/dist/litellm/client.d.ts.map +0 -1
- package/dist/litellm/credentials.d.ts +0 -48
- package/dist/litellm/credentials.d.ts.map +0 -1
- package/dist/litellm/generated/pass-through.d.ts +0 -71
- package/dist/litellm/generated/pass-through.d.ts.map +0 -1
- package/dist/paperless/client.d.ts +0 -37
- package/dist/paperless/client.d.ts.map +0 -1
- package/dist/paperless/credentials.d.ts +0 -40
- package/dist/paperless/credentials.d.ts.map +0 -1
- package/dist/paperless/errors.d.ts +0 -49
- package/dist/paperless/errors.d.ts.map +0 -1
- package/src/litellm/client.ts +0 -195
- package/src/litellm/credentials.ts +0 -73
- package/src/litellm/generated/pass-through.ts +0 -73
- package/src/paperless/client.ts +0 -88
- package/src/paperless/credentials.ts +0 -69
- package/src/paperless/errors.ts +0 -62
- package/src/paperless/matching-identity.test.ts +0 -194
- package/src/paperless/matching.test.ts +0 -128
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K2 (2026-09-24): MEASURES `contextReportedSuccess` (repository-ruleset-octokit.ts) against a
|
|
3
|
+
* real `@octokit/rest` instance with a fetch shim (no network) — the same pattern
|
|
4
|
+
* repository-ruleset-wire.test.ts uses for H15, applied here to the "which ref did we check"
|
|
5
|
+
* question instead of "did the wire field survive". The shim routes by URL path, not by call
|
|
6
|
+
* order, so these tests exercise the SAME sequencing the real implementation performs: default
|
|
7
|
+
* branch tip first, then (only if the tip has nothing) recent merged PR heads, bounded.
|
|
8
|
+
*
|
|
9
|
+
* Both sides the guard exists to hold: accept a context that reports only on a recent merged PR
|
|
10
|
+
* head (the case K2 fixes — CI runs on `pull_request` only, never the tip), and still refuse one
|
|
11
|
+
* that reports nowhere, tip or any recent merged head, INCLUDING one whose only success is
|
|
12
|
+
* older than `RECENT_MERGED_PR_LIMIT` merges ago (the bound is real, not decorative).
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import { Octokit } from '@octokit/rest';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import { RECENT_MERGED_PR_LIMIT, contextReportedSuccess } from './repository-ruleset-octokit.ts';
|
|
18
|
+
|
|
19
|
+
interface CheckRun {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly conclusion: string | null;
|
|
22
|
+
}
|
|
23
|
+
interface Status {
|
|
24
|
+
readonly context: string;
|
|
25
|
+
readonly state: string;
|
|
26
|
+
}
|
|
27
|
+
interface MergedPull {
|
|
28
|
+
readonly merged_at: string | null;
|
|
29
|
+
readonly head: { readonly sha: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Fixture {
|
|
33
|
+
readonly defaultBranch: string;
|
|
34
|
+
readonly checkRuns?: Readonly<Record<string, readonly CheckRun[]>>;
|
|
35
|
+
readonly statuses?: Readonly<Record<string, readonly Status[]>>;
|
|
36
|
+
/** Already in the order GitHub's `sort: updated, direction: desc` would return — most
|
|
37
|
+
* recently updated first. */
|
|
38
|
+
readonly closedPulls?: readonly MergedPull[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Routes by URL path, not call order — GET .../pulls, GET .../commits/{ref}/check-runs, GET
|
|
42
|
+
* .../commits/{ref}/statuses, else the bare repo GET (`default_branch`). `captured` records
|
|
43
|
+
* every URL so a test can assert a ref was — or, for boundedness, was NEVER — looked up. */
|
|
44
|
+
function makeServingFetch(fx: Fixture, captured: string[]): typeof fetch {
|
|
45
|
+
const respond = (body: unknown) =>
|
|
46
|
+
new Response(JSON.stringify(body), {
|
|
47
|
+
status: 200,
|
|
48
|
+
headers: { 'content-type': 'application/json' },
|
|
49
|
+
});
|
|
50
|
+
return (async (input: Parameters<typeof fetch>[0]) => {
|
|
51
|
+
const url = String(input);
|
|
52
|
+
captured.push(url);
|
|
53
|
+
if (/\/pulls(\?|$)/.test(url)) return respond(fx.closedPulls ?? []);
|
|
54
|
+
const commits = /\/commits\/([^/?]+)\/(check-runs|statuses)/.exec(url);
|
|
55
|
+
if (commits) {
|
|
56
|
+
const [, ref, kind] = commits;
|
|
57
|
+
if (kind === 'check-runs') {
|
|
58
|
+
const runs = fx.checkRuns?.[ref as string] ?? [];
|
|
59
|
+
return respond({ total_count: runs.length, check_runs: runs });
|
|
60
|
+
}
|
|
61
|
+
return respond(fx.statuses?.[ref as string] ?? []);
|
|
62
|
+
}
|
|
63
|
+
return respond({ default_branch: fx.defaultBranch });
|
|
64
|
+
}) as typeof fetch;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const octokitOver = (fx: Fixture, captured: string[] = []) =>
|
|
68
|
+
new Octokit({
|
|
69
|
+
auth: 'fake-token-not-a-real-credential',
|
|
70
|
+
request: { fetch: makeServingFetch(fx, captured) },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const run = (eff: Effect.Effect<boolean, Error>): Promise<boolean> => Effect.runPromise(eff);
|
|
74
|
+
|
|
75
|
+
describe('contextReportedSuccess', () => {
|
|
76
|
+
test('accepts a context that reports only on a recent merged PR head, not the tip', async () => {
|
|
77
|
+
const octokit = octokitOver({
|
|
78
|
+
defaultBranch: 'main',
|
|
79
|
+
checkRuns: { sha2: [{ name: 'ci', conclusion: 'success' }] },
|
|
80
|
+
closedPulls: [
|
|
81
|
+
{ merged_at: '2026-09-20T00:00:00Z', head: { sha: 'sha3' } },
|
|
82
|
+
{ merged_at: '2026-09-19T00:00:00Z', head: { sha: 'sha2' } },
|
|
83
|
+
{ merged_at: '2026-09-18T00:00:00Z', head: { sha: 'sha1' } },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
const reported = await run(
|
|
87
|
+
contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
|
|
88
|
+
);
|
|
89
|
+
expect(reported).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('refuses a context that reports nowhere — tip or any recent merged head', async () => {
|
|
93
|
+
const octokit = octokitOver({
|
|
94
|
+
defaultBranch: 'main',
|
|
95
|
+
closedPulls: [
|
|
96
|
+
{ merged_at: '2026-09-20T00:00:00Z', head: { sha: 'sha3' } },
|
|
97
|
+
{ merged_at: '2026-09-19T00:00:00Z', head: { sha: 'sha2' } },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
const reported = await run(
|
|
101
|
+
contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
|
|
102
|
+
);
|
|
103
|
+
expect(reported).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a success older than RECENT_MERGED_PR_LIMIT merges ago is still refused — the bound is real', async () => {
|
|
107
|
+
// RECENT_MERGED_PR_LIMIT (10) merged PRs report nothing; only the 11th (oldest returned,
|
|
108
|
+
// outside the bound) reports `ci` — proves the function stops at the limit rather than
|
|
109
|
+
// scanning everything the list call happened to return.
|
|
110
|
+
const closedPulls: MergedPull[] = Array.from({ length: RECENT_MERGED_PR_LIMIT }, (_, i) => ({
|
|
111
|
+
merged_at: `2026-09-${String(20 - i)}T00:00:00Z`,
|
|
112
|
+
head: { sha: `within-bound-${String(i)}` },
|
|
113
|
+
}));
|
|
114
|
+
closedPulls.push({ merged_at: '2026-08-01T00:00:00Z', head: { sha: 'beyond-bound' } });
|
|
115
|
+
const captured: string[] = [];
|
|
116
|
+
const octokit = octokitOver(
|
|
117
|
+
{
|
|
118
|
+
defaultBranch: 'main',
|
|
119
|
+
checkRuns: { 'beyond-bound': [{ name: 'ci', conclusion: 'success' }] },
|
|
120
|
+
closedPulls,
|
|
121
|
+
},
|
|
122
|
+
captured,
|
|
123
|
+
);
|
|
124
|
+
const reported = await run(
|
|
125
|
+
contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
|
|
126
|
+
);
|
|
127
|
+
expect(reported).toBe(false);
|
|
128
|
+
expect(captured.some((url) => url.includes('/commits/beyond-bound/'))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the default branch tip alone is still enough — no fallback needed', async () => {
|
|
132
|
+
const octokit = octokitOver({
|
|
133
|
+
defaultBranch: 'main',
|
|
134
|
+
statuses: { main: [{ context: 'secret scan', state: 'success' }] },
|
|
135
|
+
});
|
|
136
|
+
const reported = await run(
|
|
137
|
+
contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-kit', 'secret scan'),
|
|
138
|
+
);
|
|
139
|
+
expect(reported).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
* should reach the wire. repository-ruleset-wire.test.ts MEASURES this against a real Octokit
|
|
9
9
|
* instance with a fetch shim rather than asserting it from reading the source.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* ★ K2 (2026-09-24): "REPORTED SUCCESS" IS THE DEFAULT BRANCH'S TIP, THEN RECENT MERGED PR
|
|
12
|
+
* HEADS — NOT FULL HISTORY. The rendered CI (`packages/config/src/repo-shape/ci.ts`) triggers
|
|
13
|
+
* on `pull_request` ONLY, deliberately (measured 2026-09-15..22: 613 of 619 main-push runs
|
|
14
|
+
* duplicated an already-green PR run, ~41% of the mini's CI load) — so `ci`/`secret scan`/
|
|
15
|
+
* `CodeQL` NEVER report on the default branch's tip itself, only on PR heads. Checking the tip
|
|
16
|
+
* alone therefore refused adding any of them everywhere, including homeflare-builds' pending
|
|
17
|
+
* first ruleset. `contextReportedSuccess` now falls back to `recentMergedHeadShas` — bounded
|
|
18
|
+
* (`RECENT_MERGED_PR_LIMIT` heads, from one `per_page`-bounded list call, never the repo's
|
|
19
|
+
* full PR history) — so a context that only ever reports on PR heads is no longer refused
|
|
20
|
+
* everywhere, while a context that has never reported ANYWHERE (not the tip, not any recent
|
|
21
|
+
* merged head) still is: `NeverReportedContext`'s whole purpose survives unchanged.
|
|
22
|
+
* ⚠️ Still not full history: a context whose only successful run predates
|
|
23
|
+
* `RECENT_MERGED_PR_LIMIT` merges ago reads as never-reported until it runs again. Widening
|
|
24
|
+
* the bound trades this guard's cost (more vendor calls per newly-required context) against
|
|
25
|
+
* its reach; `RECENT_MERGED_PR_LIMIT` is a deliberate, named number for exactly that reason,
|
|
26
|
+
* not a magic constant.
|
|
19
27
|
*/
|
|
20
28
|
import type { Octokit as OctokitClient } from '@octokit/rest';
|
|
21
29
|
import { GitHubCredentials } from 'alchemy/GitHub';
|
|
@@ -37,6 +45,110 @@ const octokitFor = (
|
|
|
37
45
|
|
|
38
46
|
const is404 = (e: unknown): boolean => (e as { status?: number } | undefined)?.status === 404;
|
|
39
47
|
|
|
48
|
+
/** How many of the most-recently-updated MERGED pull requests' head SHAs to check, once the
|
|
49
|
+
* default branch's own tip has no reported success — see the file header (K2). */
|
|
50
|
+
export const RECENT_MERGED_PR_LIMIT = 10;
|
|
51
|
+
/** How many closed PRs to list (in one bounded call) to find up to `RECENT_MERGED_PR_LIMIT`
|
|
52
|
+
* merged ones among them — larger than the limit because `state: 'closed'` also returns PRs
|
|
53
|
+
* closed without merging, sorted by `updated_at`, not `merged_at` (the REST API has no
|
|
54
|
+
* merged-only list). A GitHub API list call has no `merged` filter, so this is `100` — GitHub's
|
|
55
|
+
* own per-page maximum, not a smaller round number — to keep unmerged churn (e.g. Dependabot
|
|
56
|
+
* PRs closed without merging, which still bump `updated_at`) from crowding out real merges in a
|
|
57
|
+
* repo with heavy PR traffic (2026-09-24 review finding: a smaller page risked under-reaching in
|
|
58
|
+
* exactly the kind of repo this fix targets). Still bounded, still one call, still never the
|
|
59
|
+
* repo's full history — a repo whose last `RECENT_MERGED_PR_LIMIT` merges don't even fit in the
|
|
60
|
+
* last 100 closed PRs is treated the same as one with too little history: fewer heads found,
|
|
61
|
+
* same "still refused" fallback. */
|
|
62
|
+
const RECENT_CLOSED_PR_PAGE_SIZE = 100;
|
|
63
|
+
|
|
64
|
+
/** Whether `context` reported success for a single ref — a branch tip or a PR head SHA, both
|
|
65
|
+
* checked the same way (checks + statuses, exactly as GitHub's own UI does). Takes a plain
|
|
66
|
+
* `OctokitClient`, not an Effect service, so it and its caller below are testable directly
|
|
67
|
+
* against a real `@octokit/rest` instance with a fetch shim (repository-ruleset-octokit.test.ts)
|
|
68
|
+
* — no `GitHubCredentials` layer needed, mirroring `desiredWireRuleset`'s pure-function seam. */
|
|
69
|
+
export const hasRefReportedSuccess = (
|
|
70
|
+
octokit: OctokitClient,
|
|
71
|
+
owner: string,
|
|
72
|
+
repo: string,
|
|
73
|
+
ref: string,
|
|
74
|
+
context: string,
|
|
75
|
+
): Effect.Effect<boolean, Error> =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const [checkRuns, statuses] = yield* Effect.all([
|
|
78
|
+
Effect.tryPromise({
|
|
79
|
+
try: () => octokit.rest.checks.listForRef({ owner, repo, ref, check_name: context }),
|
|
80
|
+
catch: asError,
|
|
81
|
+
}),
|
|
82
|
+
Effect.tryPromise({
|
|
83
|
+
try: () => octokit.rest.repos.listCommitStatusesForRef({ owner, repo, ref }),
|
|
84
|
+
catch: asError,
|
|
85
|
+
}),
|
|
86
|
+
]);
|
|
87
|
+
const checkReported = checkRuns.data.check_runs.some(
|
|
88
|
+
(run) => run.name === context && run.conclusion === 'success',
|
|
89
|
+
);
|
|
90
|
+
const statusReported = statuses.data.some(
|
|
91
|
+
(status) => status.context === context && status.state === 'success',
|
|
92
|
+
);
|
|
93
|
+
return checkReported || statusReported;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** The head SHAs of up to `RECENT_MERGED_PR_LIMIT` most-recently-updated MERGED pull requests
|
|
97
|
+
* against `base` — one bounded list call (`RECENT_CLOSED_PR_PAGE_SIZE` closed PRs), filtered to
|
|
98
|
+
* merged, then capped. Never paginates further: a repo whose last `RECENT_MERGED_PR_LIMIT`
|
|
99
|
+
* merges are all older than its last `RECENT_CLOSED_PR_PAGE_SIZE` closed PRs simply yields fewer
|
|
100
|
+
* than the limit, same as a repo with too little history — both are the "still refused" case. */
|
|
101
|
+
export const recentMergedHeadShas = (
|
|
102
|
+
octokit: OctokitClient,
|
|
103
|
+
owner: string,
|
|
104
|
+
repo: string,
|
|
105
|
+
base: string,
|
|
106
|
+
): Effect.Effect<readonly string[], Error> =>
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const closed = yield* Effect.tryPromise({
|
|
109
|
+
try: () =>
|
|
110
|
+
octokit.rest.pulls.list({
|
|
111
|
+
owner,
|
|
112
|
+
repo,
|
|
113
|
+
state: 'closed',
|
|
114
|
+
base,
|
|
115
|
+
sort: 'updated',
|
|
116
|
+
direction: 'desc',
|
|
117
|
+
per_page: RECENT_CLOSED_PR_PAGE_SIZE,
|
|
118
|
+
}),
|
|
119
|
+
catch: asError,
|
|
120
|
+
});
|
|
121
|
+
return closed.data
|
|
122
|
+
.filter((pr) => pr.merged_at !== null)
|
|
123
|
+
.slice(0, RECENT_MERGED_PR_LIMIT)
|
|
124
|
+
.map((pr) => pr.head.sha);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** The full K2 answer: the default branch's tip, then (only if the tip has no success) up to
|
|
128
|
+
* `RECENT_MERGED_PR_LIMIT` recent merged PR heads, short-circuiting on the first success. `false`
|
|
129
|
+
* only once every one of those has been checked and none reported — the case
|
|
130
|
+
* `NeverReportedContext` exists to refuse. */
|
|
131
|
+
export const contextReportedSuccess = (
|
|
132
|
+
octokit: OctokitClient,
|
|
133
|
+
owner: string,
|
|
134
|
+
repo: string,
|
|
135
|
+
context: string,
|
|
136
|
+
): Effect.Effect<boolean, Error> =>
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const repository = yield* Effect.tryPromise({
|
|
139
|
+
try: () => octokit.rest.repos.get({ owner, repo }),
|
|
140
|
+
catch: asError,
|
|
141
|
+
});
|
|
142
|
+
const ref = repository.data.default_branch;
|
|
143
|
+
if (yield* hasRefReportedSuccess(octokit, owner, repo, ref, context)) return true;
|
|
144
|
+
|
|
145
|
+
const heads = yield* recentMergedHeadShas(octokit, owner, repo, ref);
|
|
146
|
+
for (const sha of heads) {
|
|
147
|
+
if (yield* hasRefReportedSuccess(octokit, owner, repo, sha, context)) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
});
|
|
151
|
+
|
|
40
152
|
export const makeRulesetOctokit = (baseUrl: string | undefined): RealRulesetOctokit => ({
|
|
41
153
|
list: ({ owner, repo }) =>
|
|
42
154
|
Effect.gen(function* () {
|
|
@@ -114,27 +226,6 @@ export const makeRulesetOctokit = (baseUrl: string | undefined): RealRulesetOcto
|
|
|
114
226
|
hasContextReportedSuccess: ({ owner, repo, context }) =>
|
|
115
227
|
Effect.gen(function* () {
|
|
116
228
|
const octokit = yield* octokitFor(baseUrl);
|
|
117
|
-
|
|
118
|
-
try: () => octokit.rest.repos.get({ owner, repo }),
|
|
119
|
-
catch: asError,
|
|
120
|
-
});
|
|
121
|
-
const ref = repository.data.default_branch;
|
|
122
|
-
const [checkRuns, statuses] = yield* Effect.all([
|
|
123
|
-
Effect.tryPromise({
|
|
124
|
-
try: () => octokit.rest.checks.listForRef({ owner, repo, ref, check_name: context }),
|
|
125
|
-
catch: asError,
|
|
126
|
-
}),
|
|
127
|
-
Effect.tryPromise({
|
|
128
|
-
try: () => octokit.rest.repos.listCommitStatusesForRef({ owner, repo, ref }),
|
|
129
|
-
catch: asError,
|
|
130
|
-
}),
|
|
131
|
-
]);
|
|
132
|
-
const checkReported = checkRuns.data.check_runs.some(
|
|
133
|
-
(run) => run.name === context && run.conclusion === 'success',
|
|
134
|
-
);
|
|
135
|
-
const statusReported = statuses.data.some(
|
|
136
|
-
(status) => status.context === context && status.state === 'success',
|
|
137
|
-
);
|
|
138
|
-
return checkReported || statusReported;
|
|
229
|
+
return yield* contextReportedSuccess(octokit, owner, repo, context);
|
|
139
230
|
}),
|
|
140
231
|
});
|
|
@@ -71,7 +71,8 @@ export interface RulesetOctokit<R = never> {
|
|
|
71
71
|
readonly repo: string;
|
|
72
72
|
readonly rulesetId: number;
|
|
73
73
|
}) => Effect.Effect<void, Error, R>;
|
|
74
|
-
/** Whether `context` has
|
|
74
|
+
/** Whether `context` has reported success against the repository's default branch tip OR a
|
|
75
|
+
* recent merged PR head (K2, bounded — see repository-ruleset-octokit.ts's file header) —
|
|
75
76
|
* backs the `NeverReportedContext` guard. */
|
|
76
77
|
readonly hasContextReportedSuccess: (input: {
|
|
77
78
|
readonly owner: string;
|