@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.
Files changed (217) hide show
  1. package/README.md +2 -0
  2. package/dist/caddy/admin-address.d.ts +27 -0
  3. package/dist/caddy/admin-address.d.ts.map +1 -0
  4. package/dist/caddy/admin-calls.d.ts +25 -28
  5. package/dist/caddy/admin-calls.d.ts.map +1 -1
  6. package/dist/caddy/admin.d.ts +28 -38
  7. package/dist/caddy/admin.d.ts.map +1 -1
  8. package/dist/caddy/caddy-http-client.d.ts +25 -0
  9. package/dist/caddy/caddy-http-client.d.ts.map +1 -0
  10. package/dist/caddy/config-errors.d.ts +24 -0
  11. package/dist/caddy/config-errors.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +20 -49
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  14. package/dist/caddy/config-reconcile.d.ts +45 -0
  15. package/dist/caddy/config-reconcile.d.ts.map +1 -0
  16. package/dist/caddy/config.d.ts +4 -3
  17. package/dist/caddy/config.d.ts.map +1 -1
  18. package/dist/caddy/fake-caddy.d.ts +10 -2
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  20. package/dist/caddy/index.d.ts +3 -3
  21. package/dist/caddy/index.d.ts.map +1 -1
  22. package/dist/caddy/index.js +487 -444
  23. package/dist/caddy/index.js.map +16 -12
  24. package/dist/caddy/local-admin.d.ts +20 -34
  25. package/dist/caddy/local-admin.d.ts.map +1 -1
  26. package/dist/caddy/providers.d.ts +19 -3
  27. package/dist/caddy/providers.d.ts.map +1 -1
  28. package/dist/cloudflare/mesh-node-token.d.ts +1 -1
  29. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -1
  30. package/dist/forgejo/branch-protection.d.ts +4 -4
  31. package/dist/forgejo/org-actions-secrets.d.ts +4 -4
  32. package/dist/forgejo/org-label.d.ts +4 -4
  33. package/dist/forgejo/org-team.d.ts +4 -4
  34. package/dist/forgejo/repo-webhook.d.ts +4 -4
  35. package/dist/forgejo/repository.d.ts +4 -4
  36. package/dist/forgejo/team-member.d.ts +4 -4
  37. package/dist/github/declare-repo-baseline.d.ts +1 -1
  38. package/dist/github/declare-repo-baseline.d.ts.map +1 -1
  39. package/dist/github/index.d.ts +9 -5
  40. package/dist/github/index.d.ts.map +1 -1
  41. package/dist/github/index.js +161 -25
  42. package/dist/github/index.js.map +15 -13
  43. package/dist/github/repo-baseline-data.d.ts +10 -1
  44. package/dist/github/repo-baseline-data.d.ts.map +1 -1
  45. package/dist/github/repo-policy.d.ts +1 -1
  46. package/dist/github/repo-policy.d.ts.map +1 -1
  47. package/dist/github/repository-ruleset-errors.d.ts +37 -3
  48. package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
  49. package/dist/github/repository-ruleset-form.d.ts.map +1 -1
  50. package/dist/github/repository-ruleset-guards.d.ts +15 -2
  51. package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
  52. package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
  53. package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
  54. package/dist/github/repository-ruleset-octokit.d.ts +49 -0
  55. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -1
  56. package/dist/github/repository-ruleset-probe.d.ts +2 -1
  57. package/dist/github/repository-ruleset-probe.d.ts.map +1 -1
  58. package/dist/github/repository-ruleset-props.d.ts +91 -0
  59. package/dist/github/repository-ruleset-props.d.ts.map +1 -0
  60. package/dist/github/repository-ruleset-providers.d.ts +42 -0
  61. package/dist/github/repository-ruleset-providers.d.ts.map +1 -0
  62. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
  63. package/dist/github/repository-ruleset.d.ts +28 -34
  64. package/dist/github/repository-ruleset.d.ts.map +1 -1
  65. package/dist/litellm/fake-litellm.d.ts +27 -16
  66. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  67. package/dist/litellm/fake-stack.d.ts +9 -4
  68. package/dist/litellm/fake-stack.d.ts.map +1 -1
  69. package/dist/litellm/index.d.ts +7 -5
  70. package/dist/litellm/index.d.ts.map +1 -1
  71. package/dist/litellm/index.js +53 -123
  72. package/dist/litellm/index.js.map +8 -8
  73. package/dist/litellm/operations.d.ts +60 -0
  74. package/dist/litellm/operations.d.ts.map +1 -0
  75. package/dist/litellm/pass-through-endpoint.d.ts +10 -28
  76. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  77. package/dist/litellm/pass-through-errors.d.ts +37 -0
  78. package/dist/litellm/pass-through-errors.d.ts.map +1 -0
  79. package/dist/litellm/pass-through-form.d.ts +11 -4
  80. package/dist/litellm/pass-through-form.d.ts.map +1 -1
  81. package/dist/litellm/providers.d.ts +7 -5
  82. package/dist/litellm/providers.d.ts.map +1 -1
  83. package/dist/netbox/prefix.d.ts +4 -4
  84. package/dist/paperless/custom-field.d.ts +21 -21
  85. package/dist/paperless/custom-field.d.ts.map +1 -1
  86. package/dist/paperless/document-type.d.ts +34 -4
  87. package/dist/paperless/document-type.d.ts.map +1 -1
  88. package/dist/paperless/fake-paperless.d.ts +36 -19
  89. package/dist/paperless/fake-paperless.d.ts.map +1 -1
  90. package/dist/paperless/index.d.ts +7 -2
  91. package/dist/paperless/index.d.ts.map +1 -1
  92. package/dist/paperless/index.js +164 -228
  93. package/dist/paperless/index.js.map +10 -13
  94. package/dist/paperless/matching-locate.d.ts +42 -20
  95. package/dist/paperless/matching-locate.d.ts.map +1 -1
  96. package/dist/paperless/matching-types.d.ts +47 -18
  97. package/dist/paperless/matching-types.d.ts.map +1 -1
  98. package/dist/paperless/matching.d.ts +30 -27
  99. package/dist/paperless/matching.d.ts.map +1 -1
  100. package/dist/paperless/providers.d.ts +13 -7
  101. package/dist/paperless/providers.d.ts.map +1 -1
  102. package/dist/paperless/storage-path.d.ts +34 -4
  103. package/dist/paperless/storage-path.d.ts.map +1 -1
  104. package/dist/paperless/tag.d.ts +43 -8
  105. package/dist/paperless/tag.d.ts.map +1 -1
  106. package/dist/proxmox/acl.d.ts +30 -23
  107. package/dist/proxmox/acl.d.ts.map +1 -1
  108. package/dist/proxmox/distilled-pve.d.ts +89 -0
  109. package/dist/proxmox/distilled-pve.d.ts.map +1 -0
  110. package/dist/proxmox/index.js +663 -609
  111. package/dist/proxmox/index.js.map +12 -11
  112. package/dist/proxmox/members.d.ts +29 -10
  113. package/dist/proxmox/members.d.ts.map +1 -1
  114. package/docs/caddy-admin.md +1 -1
  115. package/docs/caddy.md +3 -2
  116. package/docs/credentials.md +16 -0
  117. package/docs/litellm.md +21 -13
  118. package/docs/paperless.md +12 -0
  119. package/docs/peers.md +5 -0
  120. package/docs/repo-policy.md +16 -1
  121. package/docs/repository-ruleset.md +53 -13
  122. package/docs/upstream-conformance.md +126 -10
  123. package/package.json +9 -2
  124. package/src/caddy/admin-address.ts +71 -0
  125. package/src/caddy/admin-calls.ts +48 -100
  126. package/src/caddy/admin.ts +37 -41
  127. package/src/caddy/adopt-scope.test.ts +1 -1
  128. package/src/caddy/caddy-http-client.ts +229 -0
  129. package/src/caddy/config-adopt-provider.test.ts +139 -0
  130. package/src/caddy/config-adopt.test.ts +60 -126
  131. package/src/caddy/config-errors.ts +47 -0
  132. package/src/caddy/config-lifecycle.test.ts +55 -42
  133. package/src/caddy/config-lifecycle.ts +72 -162
  134. package/src/caddy/config-reconcile.ts +138 -0
  135. package/src/caddy/config.ts +36 -42
  136. package/src/caddy/fake-caddy.ts +20 -2
  137. package/src/caddy/index.ts +3 -8
  138. package/src/caddy/local-admin.test.ts +35 -21
  139. package/src/caddy/local-admin.ts +60 -167
  140. package/src/caddy/providers.test.ts +14 -6
  141. package/src/caddy/providers.ts +20 -4
  142. package/src/github/declare-repo-baseline.ts +6 -6
  143. package/src/github/index.ts +10 -4
  144. package/src/github/repo-baseline-data.test.ts +13 -0
  145. package/src/github/repo-baseline-data.ts +11 -2
  146. package/src/github/repo-policy-stack.test.ts +59 -0
  147. package/src/github/repo-policy.ts +3 -2
  148. package/src/github/repository-ruleset-errors.ts +51 -4
  149. package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
  150. package/src/github/repository-ruleset-exact-adopt.test.ts +184 -0
  151. package/src/github/repository-ruleset-form.ts +7 -3
  152. package/src/github/repository-ruleset-guards.ts +11 -4
  153. package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
  154. package/src/github/repository-ruleset-narrowing.test.ts +210 -0
  155. package/src/github/repository-ruleset-octokit.test.ts +141 -0
  156. package/src/github/repository-ruleset-octokit.ts +121 -30
  157. package/src/github/repository-ruleset-probe.ts +2 -1
  158. package/src/github/repository-ruleset-props.ts +95 -0
  159. package/src/github/repository-ruleset-providers.ts +46 -0
  160. package/src/github/repository-ruleset-reconcile.ts +35 -0
  161. package/src/github/repository-ruleset-wire.test.ts +38 -27
  162. package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
  163. package/src/github/repository-ruleset.ts +53 -43
  164. package/src/litellm/fake-litellm.ts +83 -78
  165. package/src/litellm/fake-stack.ts +13 -5
  166. package/src/litellm/index.ts +7 -21
  167. package/src/litellm/operations.ts +149 -0
  168. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +17 -20
  169. package/src/litellm/pass-through-endpoint-replace.test.ts +3 -4
  170. package/src/litellm/pass-through-endpoint.test.ts +8 -8
  171. package/src/litellm/pass-through-endpoint.ts +21 -31
  172. package/src/litellm/pass-through-errors.ts +37 -0
  173. package/src/litellm/pass-through-form.ts +20 -10
  174. package/src/litellm/providers.ts +8 -7
  175. package/src/paperless/custom-field.test.ts +49 -40
  176. package/src/paperless/custom-field.ts +57 -33
  177. package/src/paperless/document-type.test.ts +48 -0
  178. package/src/paperless/document-type.ts +51 -22
  179. package/src/paperless/fake-paperless.ts +63 -72
  180. package/src/paperless/index.ts +7 -13
  181. package/src/paperless/matching-locate.test.ts +102 -0
  182. package/src/paperless/matching-locate.ts +80 -86
  183. package/src/paperless/matching-types.ts +49 -16
  184. package/src/paperless/matching.ts +77 -90
  185. package/src/paperless/providers.ts +14 -10
  186. package/src/paperless/storage-path.test.ts +56 -0
  187. package/src/paperless/storage-path.ts +53 -24
  188. package/src/paperless/tag-identity.test.ts +127 -0
  189. package/src/paperless/tag.test.ts +100 -0
  190. package/src/paperless/tag.ts +62 -32
  191. package/src/proxmox/acl.test.ts +145 -0
  192. package/src/proxmox/acl.ts +128 -150
  193. package/src/proxmox/distilled-pve.test.ts +231 -0
  194. package/src/proxmox/distilled-pve.ts +147 -0
  195. package/src/proxmox/members-timeout.test.ts +161 -0
  196. package/src/proxmox/members.test.ts +9 -0
  197. package/src/proxmox/members.ts +36 -2
  198. package/dist/litellm/client.d.ts +0 -76
  199. package/dist/litellm/client.d.ts.map +0 -1
  200. package/dist/litellm/credentials.d.ts +0 -48
  201. package/dist/litellm/credentials.d.ts.map +0 -1
  202. package/dist/litellm/generated/pass-through.d.ts +0 -71
  203. package/dist/litellm/generated/pass-through.d.ts.map +0 -1
  204. package/dist/paperless/client.d.ts +0 -37
  205. package/dist/paperless/client.d.ts.map +0 -1
  206. package/dist/paperless/credentials.d.ts +0 -40
  207. package/dist/paperless/credentials.d.ts.map +0 -1
  208. package/dist/paperless/errors.d.ts +0 -49
  209. package/dist/paperless/errors.d.ts.map +0 -1
  210. package/src/litellm/client.ts +0 -195
  211. package/src/litellm/credentials.ts +0 -73
  212. package/src/litellm/generated/pass-through.ts +0 -73
  213. package/src/paperless/client.ts +0 -88
  214. package/src/paperless/credentials.ts +0 -69
  215. package/src/paperless/errors.ts +0 -62
  216. package/src/paperless/matching-identity.test.ts +0 -194
  217. 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
- * ⚠️ "Reported success" is checked at the default branch's CURRENT TIP only, not its full
12
- * history — a context that reported success three merges ago and has since started failing
13
- * reads as reported (true), and one that has only ever run on a branch nobody has merged
14
- * since reads as never-reported (false) until the next merge updates the tip. A full-history
15
- * search is disproportionate to what this guard protects against (adding a context that
16
- * plainly cannot report), and GitHub's check-runs/statuses APIs are themselves scoped to a
17
- * single ref, not "ever, anywhere" — there is no cheaper vendor call that answers the wider
18
- * question.
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
- const repository = yield* Effect.tryPromise({
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 ever reported success against the repository's default branch —
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;