@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
package/src/caddy/providers.ts
CHANGED
|
@@ -6,12 +6,28 @@
|
|
|
6
6
|
* ★ launchdProviders() TOO when the stack uses caddyWithFile(): the file is a launchd-subpath
|
|
7
7
|
* HostFile, written through that subpath's HostRunner.
|
|
8
8
|
* ⚠️ The default is localCaddyAdmin(): `http://127.0.0.1:2019`, Caddy's default admin listener.
|
|
9
|
-
* Pass another
|
|
9
|
+
* Pass another CaddyTransport for a unix socket, a narrowed `admin { origins }`, or an SSH
|
|
10
|
+
* forward — `localCaddyAdmin({ address, hostHeader })` builds one; a consumer can provide any
|
|
11
|
+
* other `{ endpoint, listener, layer }` reaching `@distilled.cloud/caddy`'s own `Credentials`
|
|
12
|
+
* and `HttpClient.HttpClient`.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ `Layer.provideMerge`, NEVER PLAIN `Layer.provide`, for `caddyAdminLayer`. `CaddyConfigProvider()`'s
|
|
15
|
+
* `read`/`diff`/`reconcile` handlers each still need `CaddyAdminService` and
|
|
16
|
+
* `@distilled.cloud/caddy`'s `Credentials`/`HttpClient` — Alchemy's own `Provider.effect` types
|
|
17
|
+
* those as part of the LAYER's overall requirement (`ProviderService`'s per-handler `Req` type
|
|
18
|
+
* parameters, `alchemy/Provider.ts`), for exactly this reason. Plain `Layer.provide` SEALS a
|
|
19
|
+
* layer — it satisfies `CaddyConfigProvider()`'s requirement only for BUILDING the `Provider`
|
|
20
|
+
* value (the `admin` in its outer `Effect.gen`), then hides those services from anything that
|
|
21
|
+
* runs LATER, when the returned `read`/`diff`/`reconcile` FUNCTIONS actually execute. MEASURED
|
|
22
|
+
* 2026-09-23: with plain `Layer.provide`, `caddyProviders()`'s own `reconcile` handler dies with
|
|
23
|
+
* "Service not found: homeflare/caddy/CaddyAdmin" the moment the ENGINE calls it. `provideMerge`
|
|
24
|
+
* feeds `caddyAdminLayer`'s output into `CaddyConfigProvider()`'s requirement AND keeps that same
|
|
25
|
+
* output live in the result, so every later handler call still runs with it in context.
|
|
10
26
|
*/
|
|
11
27
|
import * as Layer from 'effect/Layer';
|
|
12
|
-
import { type
|
|
28
|
+
import { type CaddyTransport, caddyAdminLayer } from './admin.ts';
|
|
13
29
|
import { CaddyConfigProvider } from './config.ts';
|
|
14
30
|
import { localCaddyAdmin } from './local-admin.ts';
|
|
15
31
|
|
|
16
|
-
export const caddyProviders = (
|
|
17
|
-
CaddyConfigProvider().pipe(Layer.
|
|
32
|
+
export const caddyProviders = (transport: CaddyTransport = localCaddyAdmin()) =>
|
|
33
|
+
CaddyConfigProvider().pipe(Layer.provideMerge(caddyAdminLayer(transport)));
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* visibility: 'private',
|
|
9
9
|
* });
|
|
10
10
|
*
|
|
11
|
-
* Provide `
|
|
12
|
-
* provider of its own, the same as `declareRepoPolicy`. Both resources
|
|
13
|
-
* `adopt(true)` ALWAYS, unlike `declareRepoPolicy`'s optional `adopt` — a
|
|
14
|
-
* shared is meant for a checkout adopting the estate's existing repository
|
|
15
|
-
* never for creating one out from under a caller who forgot the flag (H1: a
|
|
16
|
-
* ours until adopted).
|
|
11
|
+
* Provide `repoPolicyProviders()` (repository-ruleset-providers.ts) — this subpath ships no
|
|
12
|
+
* provider of its own beyond the ruleset bridge, the same as `declareRepoPolicy`. Both resources
|
|
13
|
+
* are `retain` and `adopt(true)` ALWAYS, unlike `declareRepoPolicy`'s optional `adopt` — a
|
|
14
|
+
* baseline this widely shared is meant for a checkout adopting the estate's existing repository
|
|
15
|
+
* and ruleset by name, never for creating one out from under a caller who forgot the flag (H1: a
|
|
16
|
+
* name match is not ours until adopted).
|
|
17
17
|
*
|
|
18
18
|
* ⛔ NO IMPORT FROM `@homeflare/config`, either direction — see `renderRepoShape`'s own header
|
|
19
19
|
* in `packages/config/src/repo-shape/render.ts`. A caller spreads `renderRepoShape(shape)
|
package/src/github/index.ts
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* one bridge resource this house builds atop upstream GitHub: `GitHub.RepositoryRuleset`.
|
|
4
4
|
*
|
|
5
5
|
* ⚠️ `declareRepoPolicy`/`declareRepoBaseline` COMPOSE UPSTREAM `GitHub.Repository` PLUS THIS
|
|
6
|
-
* SUBPATH'S OWN `RepositoryRulesetProvider()` — provide
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* SUBPATH'S OWN `RepositoryRulesetProvider()` — provide `repoPolicyProviders()`
|
|
7
|
+
* (repository-ruleset-providers.ts), the composed `GitHub.providers()` +
|
|
8
|
+
* `RepositoryRulesetProvider()` layer that actually typechecks (2026-09-24: writing the two
|
|
9
|
+
* side by side leaves `RepositoryRulesetProvider()`'s own `GitHubCredentials` requirement
|
|
10
|
+
* unfed). Before 2026-09-23 this subpath shipped no provider at all, composing only vendor
|
|
11
|
+
* resources; `RepositoryRuleset` is the one exception, and repository-ruleset.ts's header
|
|
12
|
+
* says why upstream's own `Ruleset` cannot fill the gap.
|
|
10
13
|
* ★ WHY THE POLICY HELPERS EXIST. "Open a pull request, let auto-merge land it" is safe only
|
|
11
14
|
* when five repository properties and four ruleset rules all agree, and every way of getting
|
|
12
15
|
* one of them wrong is silent — an auto-merge that lands on red, a force push that rewrites
|
|
@@ -42,13 +45,16 @@ export {
|
|
|
42
45
|
type RepositoryRulesetRules,
|
|
43
46
|
type RepositoryRulesetStatusChecksRule,
|
|
44
47
|
} from './repository-ruleset.ts';
|
|
48
|
+
export { repoPolicyProviders } from './repository-ruleset-providers.ts';
|
|
45
49
|
export { RULE_TYPE_COVERAGE, type RepositoryRuleType } from './repository-ruleset-constraints.ts';
|
|
46
50
|
export {
|
|
51
|
+
BypassActorNarrowed,
|
|
47
52
|
BypassActorWidened,
|
|
48
53
|
DuplicateRuleset,
|
|
49
54
|
NeverReportedContext,
|
|
50
55
|
ReadbackMismatch,
|
|
51
56
|
RequiredChecksOmitted,
|
|
57
|
+
RuleNarrowed,
|
|
52
58
|
RulesetConstraintRefused,
|
|
53
59
|
UndeclaredLiveRule,
|
|
54
60
|
type RepositoryRulesetError,
|
|
@@ -36,6 +36,19 @@ describe('repoBaselineSettings', () => {
|
|
|
36
36
|
visibility: undefined,
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
// K5 (2026-09-24): zero required checks means `gh pr merge --auto` has nothing to wait for —
|
|
41
|
+
// GitHub merges a CLEAN pull request immediately, with no review and no green run. Regression
|
|
42
|
+
// for repoBaselineSettings hardcoding `allowAutoMerge: true` regardless of `checks`.
|
|
43
|
+
test('zero checks turns auto-merge off — nothing would ever hold a merge back', () => {
|
|
44
|
+
const settings = repoBaselineSettings({ ...input, checks: [] });
|
|
45
|
+
expect(settings.allowAutoMerge).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('at least one check keeps auto-merge on', () => {
|
|
49
|
+
const settings = repoBaselineSettings({ ...input, checks: ['ci'] });
|
|
50
|
+
expect(settings.allowAutoMerge).toBe(true);
|
|
51
|
+
});
|
|
39
52
|
});
|
|
40
53
|
|
|
41
54
|
describe('repoBaselineRuleset', () => {
|
|
@@ -21,7 +21,16 @@ export interface RepoBaselineInput {
|
|
|
21
21
|
|
|
22
22
|
/** `GitHub.Repository` fields this baseline owns. Every merge-automation caller across the 17
|
|
23
23
|
* tray checkouts uses `--squash` (measured 2026-09-23; the only non-squash mentions are
|
|
24
|
-
* GitLab-era comments in cliff.toml), so squash-only is not a per-repo choice.
|
|
24
|
+
* GitLab-era comments in cliff.toml), so squash-only is not a per-repo choice.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ K5 (2026-09-24): `allowAutoMerge` FOLLOWS `checks.length`, NOT A HARDCODED `true`. `gh pr
|
|
27
|
+
* merge --auto` (and the ruleset's own auto-merge) is a queue only while something is
|
|
28
|
+
* outstanding — with zero required status checks there is nothing to wait for, so GitHub
|
|
29
|
+
* merges a CLEAN pull request on the spot, with no review and no green run. This is the same
|
|
30
|
+
* failure `repo-policy-guards.ts`'s `assertAutoMergeWaits` refuses on the sibling `repoPolicy`
|
|
31
|
+
* path (five doors to the same "nothing outstanding" state); this baseline has no `autoMerge`
|
|
32
|
+
* opt-out prop to refuse WITH, so the fix here is unconditional rather than a thrown refusal —
|
|
33
|
+
* a repo with an empty `checks` list simply never gets auto-merge turned on by this baseline. */
|
|
25
34
|
export function repoBaselineSettings(input: RepoBaselineInput) {
|
|
26
35
|
return {
|
|
27
36
|
owner: input.owner,
|
|
@@ -31,7 +40,7 @@ export function repoBaselineSettings(input: RepoBaselineInput) {
|
|
|
31
40
|
allowSquashMerge: true,
|
|
32
41
|
allowMergeCommit: false,
|
|
33
42
|
allowRebaseMerge: false,
|
|
34
|
-
allowAutoMerge:
|
|
43
|
+
allowAutoMerge: input.checks.length > 0,
|
|
35
44
|
deleteBranchOnMerge: true,
|
|
36
45
|
hasWiki: false,
|
|
37
46
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TYPE-LEVEL test — a real `Alchemy.Stack(...)` whose body calls `declareRepoPolicy` AND
|
|
3
|
+
* `declareRepoBaseline`, wired with the documented `repoPolicyProviders()`. This is the test
|
|
4
|
+
* that would have caught 2026-09-24's bug (homeflare-builds bump PR 6, tsc TS2345):
|
|
5
|
+
* `RepositoryRuleset`'s `Resource<>` declaration put `GitHubCredentials` — not
|
|
6
|
+
* `Provider<RepositoryRuleset>` — in its 5th type parameter, so a stack BODY that only ever
|
|
7
|
+
* calls `declareRepoPolicy`/`declareRepoBaseline` failed `tsc`, and nothing in this repo's own
|
|
8
|
+
* `bun run check` exercised that path before now: every existing test in this family drives the
|
|
9
|
+
* resource's CRUD handlers directly (repository-ruleset-*.test.ts) or the pure form functions
|
|
10
|
+
* (repo-policy-form.test.ts), never a real `Alchemy.Stack(...)` body.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ BUILT, NEVER RUN. `Alchemy.Stack(...)` returns an unexecuted `Effect` — every combinator
|
|
13
|
+
* between here and a runtime (`.pipe`, `Effect.gen`, `Effect.flatMap`, `Object.assign`) is
|
|
14
|
+
* itself lazy (MEASURED by reading `alchemy/src/Stack.ts`'s own `Stack`/`make`: no
|
|
15
|
+
* `Effect.runSync`/`runPromise` anywhere in the construction path). Building the value is
|
|
16
|
+
* enough to make `tsc` check the body's inferred requirement type against `Stack`'s own
|
|
17
|
+
* `Req extends StackServices | ProviderServices` constraint — see repository-ruleset.ts's
|
|
18
|
+
* file header for what that constraint is and why `GitHubCredentials` used to fail it. This
|
|
19
|
+
* test never calls `Effect.runPromise`/`runSync`: no network, no `GitHubCredentials`
|
|
20
|
+
* resolved, no state written.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ `.pipe(Effect.orDie)` ON `declareRepoPolicy` IS UNRELATED TO WHAT THIS TEST CHECKS.
|
|
23
|
+
* `declareRepoPolicy` refuses a bad policy through `Effect.try`'s `catch`, so its error
|
|
24
|
+
* channel is `Error`, not `never` — a separate, pre-existing type surface (present since its
|
|
25
|
+
* original PR #98) that `Alchemy.Stack`'s own `eff: Effect.Effect<A, ConfigError, Req>` does
|
|
26
|
+
* not accept regardless of the `Req`/`Providers` bug this test targets. Dying on it here keeps
|
|
27
|
+
* that surface out of the way without papering over it — a real caller still has to decide how
|
|
28
|
+
* to handle a refused policy, which this test deliberately does not answer.
|
|
29
|
+
*/
|
|
30
|
+
import { expect, test } from 'bun:test';
|
|
31
|
+
import * as Alchemy from 'alchemy';
|
|
32
|
+
import * as Effect from 'effect/Effect';
|
|
33
|
+
import { declareRepoBaseline } from './declare-repo-baseline.ts';
|
|
34
|
+
import { declareRepoPolicy } from './repo-policy.ts';
|
|
35
|
+
import { repoPolicyProviders } from './repository-ruleset-providers.ts';
|
|
36
|
+
|
|
37
|
+
test('declareRepoPolicy and declareRepoBaseline typecheck as one Alchemy.Stack body', () => {
|
|
38
|
+
const stack = Alchemy.Stack(
|
|
39
|
+
'repo-policy-typecheck',
|
|
40
|
+
{ providers: repoPolicyProviders(), state: Alchemy.inMemoryState() },
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const policy = yield* declareRepoPolicy('api', {
|
|
43
|
+
owner: 'taslabs-net',
|
|
44
|
+
repository: 'api',
|
|
45
|
+
checks: ['ci', 'secret scan'],
|
|
46
|
+
}).pipe(Effect.orDie);
|
|
47
|
+
const baseline = yield* declareRepoBaseline('widgets', {
|
|
48
|
+
owner: 'taslabs-net',
|
|
49
|
+
repository: 'widgets',
|
|
50
|
+
checks: ['ci'],
|
|
51
|
+
visibility: 'private',
|
|
52
|
+
});
|
|
53
|
+
return { policy, baseline };
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
// Not run — see file header. This only proves construction didn't throw synchronously; the
|
|
57
|
+
// real assertion is `tsc`'s, over the whole expression above.
|
|
58
|
+
expect(stack).toBeDefined();
|
|
59
|
+
});
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* checks: ['ci', 'secret scan'],
|
|
8
8
|
* });
|
|
9
9
|
*
|
|
10
|
-
* Provide `
|
|
11
|
-
*
|
|
10
|
+
* Provide `repoPolicyProviders()` (repository-ruleset-providers.ts) — the composed
|
|
11
|
+
* `GitHub.providers()` + `RepositoryRulesetProvider()` layer; this subpath adds no GitHub
|
|
12
|
+
* provider of its own beyond the ruleset bridge.
|
|
12
13
|
*
|
|
13
14
|
* ★ REWIRED 2026-09-23 onto `GitHub.RepositoryRuleset`, not upstream `GitHub.Ruleset` — the
|
|
14
15
|
* ADOPT-SAFE bridge documented in repository-ruleset.ts. Only `builds` calls this, and its
|
|
@@ -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. */
|
|
@@ -80,8 +123,9 @@ export class RequiredChecksOmitted extends Data.TaggedError('RequiredChecksOmitt
|
|
|
80
123
|
}
|
|
81
124
|
}
|
|
82
125
|
|
|
83
|
-
/** A context being ADDED to `requiredStatusChecks` has never reported success
|
|
84
|
-
* branch
|
|
126
|
+
/** A context being ADDED to `requiredStatusChecks` has never reported success anywhere this
|
|
127
|
+
* family looks (the default branch's tip, then recent merged PR heads — K2, bounded, not full
|
|
128
|
+
* history). Requiring it would leave every future pull request pending forever. */
|
|
85
129
|
export class NeverReportedContext extends Data.TaggedError('NeverReportedContext')<{
|
|
86
130
|
readonly owner: string;
|
|
87
131
|
readonly repository: string;
|
|
@@ -90,8 +134,9 @@ export class NeverReportedContext extends Data.TaggedError('NeverReportedContext
|
|
|
90
134
|
override get message(): string {
|
|
91
135
|
return (
|
|
92
136
|
`GitHub.RepositoryRuleset: "${this.context}" has never reported success on ` +
|
|
93
|
-
`${this.owner}/${this.repository}'s default branch
|
|
94
|
-
'pull request waiting on a check that has
|
|
137
|
+
`${this.owner}/${this.repository}'s default branch tip or any of its recent merged pull ` +
|
|
138
|
+
'requests. Requiring it now would leave every pull request waiting on a check that has ' +
|
|
139
|
+
'never once reported — refused before any write.'
|
|
95
140
|
);
|
|
96
141
|
}
|
|
97
142
|
}
|
|
@@ -129,7 +174,9 @@ export class ReadbackMismatch extends Data.TaggedError('ReadbackMismatch')<{
|
|
|
129
174
|
export type RepositoryRulesetError =
|
|
130
175
|
| DuplicateRuleset
|
|
131
176
|
| BypassActorWidened
|
|
177
|
+
| BypassActorNarrowed
|
|
132
178
|
| UndeclaredLiveRule
|
|
179
|
+
| RuleNarrowed
|
|
133
180
|
| RequiredChecksOmitted
|
|
134
181
|
| NeverReportedContext
|
|
135
182
|
| RulesetConstraintRefused
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K1 (2026-09-23): `extraApprovalForUnattributedChanges` now declares `true`, not only `false`
|
|
3
|
+
* — split out of repository-ruleset-exact-adopt.test.ts purely to keep that file under the
|
|
4
|
+
* 250-line cap, not because the case is unrelated: this is the exact gap that file's own header
|
|
5
|
+
* named as blocking a true zero-write adopt for aop/magictransit/loggarr/
|
|
6
|
+
* doesthishelp-workeropen (all four carry `require_extra_approval_for_unattributed_changes:
|
|
7
|
+
* true` live).
|
|
8
|
+
*
|
|
9
|
+
* `gh api repos/taslabs-net/aop/rulesets/14572279`, re-read 2026-09-23 for this PR — the FULL
|
|
10
|
+
* record (not H15's own caution about paraphrasing was heeded the hard way: an earlier revision
|
|
11
|
+
* of this fixture hand-typed only the fields the design doc had quoted, which omitted
|
|
12
|
+
* `dismiss_stale_reviews_on_push: true` and `required_reviewers: []` — both of which
|
|
13
|
+
* `buildPullRequestRule` always sends, so a fixture missing them can never truly noop; the "zero
|
|
14
|
+
* writes" assertion below caught it): `bypass_actors: [{actor_id: 5, actor_type:
|
|
15
|
+
* "RepositoryRole", bypass_mode: "always"}]`, `rules: [deletion, non_fast_forward, pull_request]`,
|
|
16
|
+
* and the full `pull_request` parameters are `required_approving_review_count: 0`,
|
|
17
|
+
* `dismiss_stale_reviews_on_push: true`, `required_reviewers: []`, `require_code_owner_review:
|
|
18
|
+
* false`, `require_last_push_approval: false`, `required_review_thread_resolution: false`,
|
|
19
|
+
* `require_extra_approval_for_unattributed_changes: true`,
|
|
20
|
+
* `allowed_merge_methods: [merge, squash, rebase]`.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, test } from 'bun:test';
|
|
23
|
+
import { makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
|
|
24
|
+
import {
|
|
25
|
+
BASE_PULL_REQUEST,
|
|
26
|
+
props,
|
|
27
|
+
reconcileWithOctokit,
|
|
28
|
+
run,
|
|
29
|
+
} from './repository-ruleset-test-helpers.ts';
|
|
30
|
+
|
|
31
|
+
const AOP_LIVE = {
|
|
32
|
+
id: 14572279,
|
|
33
|
+
name: 'main',
|
|
34
|
+
target: 'branch',
|
|
35
|
+
enforcement: 'active',
|
|
36
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
37
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
38
|
+
rules: [
|
|
39
|
+
{ type: 'deletion' },
|
|
40
|
+
{ type: 'non_fast_forward' },
|
|
41
|
+
{
|
|
42
|
+
type: 'pull_request',
|
|
43
|
+
parameters: {
|
|
44
|
+
required_approving_review_count: 0,
|
|
45
|
+
dismiss_stale_reviews_on_push: true,
|
|
46
|
+
required_reviewers: [],
|
|
47
|
+
require_code_owner_review: false,
|
|
48
|
+
require_last_push_approval: false,
|
|
49
|
+
required_review_thread_resolution: false,
|
|
50
|
+
require_extra_approval_for_unattributed_changes: true,
|
|
51
|
+
allowed_merge_methods: ['merge', 'squash', 'rebase'],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('K1: extraApprovalForUnattributedChanges completes exact-adopt', () => {
|
|
58
|
+
// Unlike repository-ruleset-exact-adopt.test.ts's bypass-only fixture (which deliberately
|
|
59
|
+
// keeps `BASE_PULL_REQUEST`'s `false`/`[squash]` to prove bypass round-trips on its own), this
|
|
60
|
+
// declares aop's FULL live `pull_request` shape — methods AND the extra-approval flag — so
|
|
61
|
+
// the whole ruleset, not just `bypassActors`, is now a genuine zero-write adopt.
|
|
62
|
+
test('aop: full live shape, extra-approval flag included, makes zero writes', async () => {
|
|
63
|
+
const fake = makeRecordingFake({ aop: AOP_LIVE });
|
|
64
|
+
const declaration = props({
|
|
65
|
+
repository: 'aop',
|
|
66
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
67
|
+
rules: {
|
|
68
|
+
deletion: true,
|
|
69
|
+
nonFastForward: true,
|
|
70
|
+
pullRequest: {
|
|
71
|
+
...BASE_PULL_REQUEST,
|
|
72
|
+
dismissStaleReviewsOnPush: true,
|
|
73
|
+
allowedMergeMethods: ['merge', 'squash', 'rebase'],
|
|
74
|
+
extraApprovalForUnattributedChanges: true,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 14572279 }));
|
|
79
|
+
expect(fake.writes).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// The counterpart failure mode: declaring `false` against a live `true` is real drift (not a
|
|
83
|
+
// narrowing/widening question — that guard family is about `bypassActors` and rule presence,
|
|
84
|
+
// not this field's value) and must still produce exactly one `update`, never a silent noop.
|
|
85
|
+
test('aop: declaring false against a live true is drift, not a noop', async () => {
|
|
86
|
+
const fake = makeRecordingFake({ aop: AOP_LIVE });
|
|
87
|
+
const declaration = props({
|
|
88
|
+
repository: 'aop',
|
|
89
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
90
|
+
rules: {
|
|
91
|
+
deletion: true,
|
|
92
|
+
nonFastForward: true,
|
|
93
|
+
pullRequest: {
|
|
94
|
+
...BASE_PULL_REQUEST,
|
|
95
|
+
dismissStaleReviewsOnPush: true,
|
|
96
|
+
allowedMergeMethods: ['merge', 'squash', 'rebase'],
|
|
97
|
+
extraApprovalForUnattributedChanges: false,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 14572279 }));
|
|
102
|
+
expect(fake.writes).toHaveLength(1);
|
|
103
|
+
const pr = fake.writes[0]?.body?.rules?.find((r) => r.type === 'pull_request');
|
|
104
|
+
expect(
|
|
105
|
+
(pr as { parameters: Record<string, unknown> } | undefined)?.parameters
|
|
106
|
+
.require_extra_approval_for_unattributed_changes,
|
|
107
|
+
).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
* ★ K1 LANDED 2026-09-23: `extraApprovalForUnattributedChanges` now declares `true`, not only
|
|
18
|
+
* `false` — the gap this file's earlier revision named as blocking a true zero-write adopt for
|
|
19
|
+
* aop/magictransit/loggarr/doesthishelp-workeropen (all four carry
|
|
20
|
+
* `require_extra_approval_for_unattributed_changes: true` live). The 4 fixtures below still
|
|
21
|
+
* prove the narrower claim: that `bypassActors` round-trips an arbitrary
|
|
22
|
+
* `actor_id`/`actor_type`/`bypass_mode` combination — single-actor by role, single-actor by
|
|
23
|
+
* integration, and multi-actor with mixed modes — onto the wire exactly as declared, with
|
|
24
|
+
* neither `BypassActorWidened` nor `BypassActorNarrowed` firing on a match; their declarations
|
|
25
|
+
* still use `BASE_PULL_REQUEST` (`extraApprovalForUnattributedChanges: false`), so each is
|
|
26
|
+
* still a genuine `update`, not a noop — that keeps this file's original point (bypass alone
|
|
27
|
+
* round-trips) legible on its own. The case K1 unblocks — `aop`'s FULL live shape, extra-
|
|
28
|
+
* approval flag included, as a true zero-write adopt — is proven in
|
|
29
|
+
* repository-ruleset-exact-adopt-k1.test.ts, split out purely to keep this file under the
|
|
30
|
+
* 250-line cap.
|
|
31
|
+
*/
|
|
32
|
+
import { describe, expect, test } from 'bun:test';
|
|
33
|
+
import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
|
|
34
|
+
import {
|
|
35
|
+
BASE_PULL_REQUEST,
|
|
36
|
+
props,
|
|
37
|
+
reconcileWithOctokit,
|
|
38
|
+
run,
|
|
39
|
+
} from './repository-ruleset-test-helpers.ts';
|
|
40
|
+
|
|
41
|
+
describe('exact-adopt pass-through — live shapes measured 2026-09-23', () => {
|
|
42
|
+
// The critical case: a non-empty bypass_actors AND an absent pull_request rule, both at once.
|
|
43
|
+
// `gh api repos/taslabs-net/taslabs-net/rulesets/12206080`.
|
|
44
|
+
test('taslabs-net/taslabs-net: non-empty bypass, no pull_request rule — zero-write adopt', async () => {
|
|
45
|
+
const live = {
|
|
46
|
+
id: 12206080,
|
|
47
|
+
name: 'main',
|
|
48
|
+
target: 'branch',
|
|
49
|
+
enforcement: 'active',
|
|
50
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
51
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
52
|
+
rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }],
|
|
53
|
+
};
|
|
54
|
+
const fake = makeGetOnlyFake({ 'taslabs-net': live });
|
|
55
|
+
const declaration = props({
|
|
56
|
+
repository: 'taslabs-net',
|
|
57
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
58
|
+
rules: { deletion: true, nonFastForward: true },
|
|
59
|
+
});
|
|
60
|
+
const attrs = await run(reconcileWithOctokit(fake, declaration, { rulesetId: 12206080 }));
|
|
61
|
+
expect(attrs.rulesetId).toBe(12206080);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// `gh api repos/taslabs-net/aop/rulesets/14572279` — single RepositoryRole actor, mode
|
|
65
|
+
// "always". Live `pull_request` also carries the K1 flag, so the write below is an `update`,
|
|
66
|
+
// not a noop — the point here is that `bypassActors` alone sends back exactly what was
|
|
67
|
+
// declared, and neither narrowing nor widening guard fires on a matching bypass list.
|
|
68
|
+
test('aop: single RepositoryRole actor round-trips exactly', async () => {
|
|
69
|
+
const live = {
|
|
70
|
+
id: 14572279,
|
|
71
|
+
name: 'main',
|
|
72
|
+
target: 'branch',
|
|
73
|
+
enforcement: 'active',
|
|
74
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
75
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
76
|
+
rules: [
|
|
77
|
+
{ type: 'deletion' },
|
|
78
|
+
{ type: 'non_fast_forward' },
|
|
79
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
const fake = makeRecordingFake({ aop: live });
|
|
83
|
+
const declaration = props({
|
|
84
|
+
repository: 'aop',
|
|
85
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
86
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
87
|
+
});
|
|
88
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 14572279 }));
|
|
89
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
90
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// `gh api repos/taslabs-net/loggarr/rulesets/12323114` — a single Integration (Dependabot)
|
|
95
|
+
// actor with `bypass_mode: "pull_request"`, not `"always"`.
|
|
96
|
+
test('loggarr: single Integration actor, pull_request bypass mode, round-trips exactly', async () => {
|
|
97
|
+
const live = {
|
|
98
|
+
id: 12323114,
|
|
99
|
+
name: 'main',
|
|
100
|
+
target: 'branch',
|
|
101
|
+
enforcement: 'active',
|
|
102
|
+
bypass_actors: [{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'pull_request' }],
|
|
103
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
104
|
+
rules: [
|
|
105
|
+
{ type: 'deletion' },
|
|
106
|
+
{ type: 'non_fast_forward' },
|
|
107
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
const fake = makeRecordingFake({ loggarr: live });
|
|
111
|
+
const declaration = props({
|
|
112
|
+
repository: 'loggarr',
|
|
113
|
+
bypassActors: [{ actorType: 'Integration', actorId: 29110, bypassMode: 'pull_request' }],
|
|
114
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
115
|
+
});
|
|
116
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 12323114 }));
|
|
117
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
118
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'pull_request' },
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// `gh api repos/taslabs-net/doesthishelp-workeropen/rulesets/12504824` — TWO actors with
|
|
123
|
+
// DIFFERENT modes each (the design critique's own "(both)" shorthand for this repo).
|
|
124
|
+
test('doesthishelp-workeropen: two actors, mixed bypass modes, round-trip exactly', async () => {
|
|
125
|
+
const live = {
|
|
126
|
+
id: 12504824,
|
|
127
|
+
name: 'main',
|
|
128
|
+
target: 'branch',
|
|
129
|
+
enforcement: 'active',
|
|
130
|
+
bypass_actors: [
|
|
131
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
|
|
132
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'always' },
|
|
133
|
+
],
|
|
134
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
135
|
+
rules: [
|
|
136
|
+
{ type: 'deletion' },
|
|
137
|
+
{ type: 'non_fast_forward' },
|
|
138
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
const fake = makeRecordingFake({ 'doesthishelp-workeropen': live });
|
|
142
|
+
const declaration = props({
|
|
143
|
+
repository: 'doesthishelp-workeropen',
|
|
144
|
+
bypassActors: [
|
|
145
|
+
{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'pull_request' },
|
|
146
|
+
{ actorType: 'Integration', actorId: 29110, bypassMode: 'always' },
|
|
147
|
+
],
|
|
148
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
149
|
+
});
|
|
150
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 12504824 }));
|
|
151
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
152
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
|
|
153
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'always' },
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// `gh api repos/taslabs-net/magictransit/rulesets/10470003` — same single-actor shape as
|
|
158
|
+
// `aop`, kept as its own case because it is one of the 5 the design's own census named.
|
|
159
|
+
test('magictransit: single RepositoryRole actor round-trips exactly', async () => {
|
|
160
|
+
const live = {
|
|
161
|
+
id: 10470003,
|
|
162
|
+
name: 'main',
|
|
163
|
+
target: 'branch',
|
|
164
|
+
enforcement: 'active',
|
|
165
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
166
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
167
|
+
rules: [
|
|
168
|
+
{ type: 'deletion' },
|
|
169
|
+
{ type: 'non_fast_forward' },
|
|
170
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
const fake = makeRecordingFake({ magictransit: live });
|
|
174
|
+
const declaration = props({
|
|
175
|
+
repository: 'magictransit',
|
|
176
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
177
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
178
|
+
});
|
|
179
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 10470003 }));
|
|
180
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
181
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -50,11 +50,15 @@ function buildPullRequestRule(rule: RepositoryRulesetPullRequestRule): WireRule
|
|
|
50
50
|
minimum_approvals: r.minimumApprovals,
|
|
51
51
|
reviewer: { id: r.reviewer.actorId, type: r.reviewer.actorType },
|
|
52
52
|
})),
|
|
53
|
-
//
|
|
54
|
-
//
|
|
53
|
+
// Sent as declared, `true` or `false` (K1, 2026-09-23) — see repository-ruleset.ts's H15
|
|
54
|
+
// for why this field exists here at all (Octokit, not distilled) and
|
|
55
|
+
// repository-ruleset-wire.test.ts for whether Octokit forwards it untyped for both values.
|
|
55
56
|
...(rule.extraApprovalForUnattributedChanges === undefined
|
|
56
57
|
? {}
|
|
57
|
-
: {
|
|
58
|
+
: {
|
|
59
|
+
require_extra_approval_for_unattributed_changes:
|
|
60
|
+
rule.extraApprovalForUnattributedChanges,
|
|
61
|
+
}),
|
|
58
62
|
},
|
|
59
63
|
// The extension field above has no home in Octokit's own rule union (H15) — the cast is
|
|
60
64
|
// the only place that gap is bridged.
|
|
@@ -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
|
/**
|
|
@@ -176,8 +182,9 @@ export const newlyRequiredContexts = (
|
|
|
176
182
|
.filter((c) => !liveContexts.has(c));
|
|
177
183
|
};
|
|
178
184
|
|
|
179
|
-
/** Fails on the first newly-required context that has never reported success
|
|
180
|
-
*
|
|
185
|
+
/** Fails on the first newly-required context that has never reported success anywhere this
|
|
186
|
+
* family looks (the default branch's tip, then recent merged PR heads — K2) — checked once here
|
|
187
|
+
* (reconcile's observe step) and again by the standalone checker. */
|
|
181
188
|
export const refuseUnreportedContexts = <R = never>(
|
|
182
189
|
octokit: RulesetOctokit<R>,
|
|
183
190
|
owner: string,
|