@homeflare/alchemy 0.28.0 → 0.29.1
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 +8 -4
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +83 -24
- package/dist/github/index.js.map +14 -12
- package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
- package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
- package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
- package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
- package/dist/github/repo-auto-merge-gate.d.ts +54 -0
- package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
- 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 +3 -2
- 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 +3 -2
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- 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 +7 -4
- package/dist/github/repository-ruleset-props.d.ts.map +1 -1
- 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.d.ts +26 -4
- 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 +5 -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-apply-order.test.ts +86 -0
- package/src/github/declare-repo-baseline.ts +40 -7
- package/src/github/index.ts +8 -4
- package/src/github/repo-apply-order-fake-providers.ts +119 -0
- package/src/github/repo-apply-order-fake-stack.ts +74 -0
- package/src/github/repo-auto-merge-gate.ts +57 -0
- package/src/github/repo-baseline-data.test.ts +13 -0
- package/src/github/repo-baseline-data.ts +11 -2
- package/src/github/repo-policy-apply-order.test.ts +72 -0
- package/src/github/repo-policy-stack.test.ts +59 -0
- package/src/github/repo-policy.ts +31 -3
- package/src/github/repository-ruleset-errors.ts +6 -4
- package/src/github/repository-ruleset-exact-adopt-k1.test.ts +109 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +14 -9
- package/src/github/repository-ruleset-form.ts +7 -3
- package/src/github/repository-ruleset-guards.ts +3 -2
- 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 +7 -4
- package/src/github/repository-ruleset-providers.ts +46 -0
- package/src/github/repository-ruleset-wire.test.ts +38 -27
- package/src/github/repository-ruleset.ts +27 -3
- 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
|
@@ -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
|
|
@@ -24,11 +25,27 @@
|
|
|
24
25
|
* ⛔ BOTH RESOURCES RETAIN. `retain` is already the vendor default for each of them; it
|
|
25
26
|
* is piped anyway so that a vendor change cannot quietly turn "drop this call from the
|
|
26
27
|
* stack" into "delete the repository" or "unprotect the default branch".
|
|
28
|
+
*
|
|
29
|
+
* ⛔ APPLY ORDER: `allowAutoMerge` NEVER GOES LIVE BEFORE THE RULESET IT WAITS ON — see
|
|
30
|
+
* repo-auto-merge-gate.ts's header for the mechanism (Alchemy orders by Output references in
|
|
31
|
+
* props, not declaration order), the K5 hazard it closes, and why checks/approvals being
|
|
32
|
+
* REMOVED needs no matching reverse edge. `assertAutoMergeWaits` (repo-policy-guards.ts)
|
|
33
|
+
* already guarantees `policy.repository.allowAutoMerge` is only ever `true` when the ruleset
|
|
34
|
+
* carries something outstanding (checks, approvals, or both), so that one flag is the gate.
|
|
35
|
+
* ⚠️ A REPOSITORY THAT DOES NOT YET EXIST: gating on the ruleset only matters once there are open
|
|
36
|
+
* pull requests to prematurely merge, and `GitHub.Repository`'s own `reconcile` sets
|
|
37
|
+
* `allowAutoMerge` atomically in the SAME create call that makes the repo — no window where a
|
|
38
|
+
* PR could exploit it. This helper's documented use (`builds`, above) is always a repo that
|
|
39
|
+
* already converged before its ruleset's first deploy; a caller declaring both fresh in one
|
|
40
|
+
* deploy, with checks/approvals from day one, must create the bare repository first (a prior
|
|
41
|
+
* deploy, or a separate `GitHub.Repository` call) — GitHub's ruleset API 404s on a repo that
|
|
42
|
+
* does not exist yet, and this gate now makes the ruleset apply before the repository.
|
|
27
43
|
*/
|
|
28
44
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
29
45
|
import * as GitHub from 'alchemy/GitHub';
|
|
30
46
|
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
31
47
|
import * as Effect from 'effect/Effect';
|
|
48
|
+
import { gateAutoMergeOnRuleset } from './repo-auto-merge-gate.ts';
|
|
32
49
|
import { type RepoPolicyOptions, repoPolicy } from './repo-policy-form.ts';
|
|
33
50
|
import { RepositoryRuleset } from './repository-ruleset.ts';
|
|
34
51
|
|
|
@@ -57,9 +74,20 @@ export const declareRepoPolicy = (id: string, options: DeclareRepoPolicyOptions)
|
|
|
57
74
|
const kept = resource.pipe(RemovalPolicy.retain());
|
|
58
75
|
return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
|
|
59
76
|
};
|
|
60
|
-
const repository = yield* owned(GitHub.Repository(id, policy.repository));
|
|
61
77
|
// `policy.ruleset` is a plain `RulesetProps` — every field `RepositoryRulesetProps` adds
|
|
62
78
|
// over it is optional, so it needs no translation to satisfy the bridge's props.
|
|
79
|
+
|
|
80
|
+
// Auto-merge is turning on (or staying on): declare and apply the ruleset FIRST, so its
|
|
81
|
+
// rulesetId is available to gate the repository's allowAutoMerge — see the file header.
|
|
82
|
+
if (policy.repository.allowAutoMerge) {
|
|
83
|
+
const ruleset = yield* owned(RepositoryRuleset(`${id}-ruleset`, policy.ruleset));
|
|
84
|
+
const repository = yield* owned(
|
|
85
|
+
GitHub.Repository(id, gateAutoMergeOnRuleset(policy.repository, ruleset)),
|
|
86
|
+
);
|
|
87
|
+
return { policy, repository, ruleset };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const repository = yield* owned(GitHub.Repository(id, policy.repository));
|
|
63
91
|
const ruleset = yield* owned(RepositoryRuleset(`${id}-ruleset`, policy.ruleset));
|
|
64
92
|
return { policy, repository, ruleset };
|
|
65
93
|
});
|
|
@@ -123,8 +123,9 @@ export class RequiredChecksOmitted extends Data.TaggedError('RequiredChecksOmitt
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/** A context being ADDED to `requiredStatusChecks` has never reported success
|
|
127
|
-
* 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. */
|
|
128
129
|
export class NeverReportedContext extends Data.TaggedError('NeverReportedContext')<{
|
|
129
130
|
readonly owner: string;
|
|
130
131
|
readonly repository: string;
|
|
@@ -133,8 +134,9 @@ export class NeverReportedContext extends Data.TaggedError('NeverReportedContext
|
|
|
133
134
|
override get message(): string {
|
|
134
135
|
return (
|
|
135
136
|
`GitHub.RepositoryRuleset: "${this.context}" has never reported success on ` +
|
|
136
|
-
`${this.owner}/${this.repository}'s default branch
|
|
137
|
-
'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.'
|
|
138
140
|
);
|
|
139
141
|
}
|
|
140
142
|
}
|
|
@@ -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
|
+
});
|
|
@@ -14,15 +14,20 @@
|
|
|
14
14
|
* Integration 29110 is `always`, not the reverse) — see repository-ruleset-narrowing-guards.ts
|
|
15
15
|
* for the guard these fixtures exercise.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
* true zero-write adopt
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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.
|
|
26
31
|
*/
|
|
27
32
|
import { describe, expect, test } from 'bun:test';
|
|
28
33
|
import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
|
|
@@ -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.
|
|
@@ -182,8 +182,9 @@ export const newlyRequiredContexts = (
|
|
|
182
182
|
.filter((c) => !liveContexts.has(c));
|
|
183
183
|
};
|
|
184
184
|
|
|
185
|
-
/** Fails on the first newly-required context that has never reported success
|
|
186
|
-
*
|
|
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. */
|
|
187
188
|
export const refuseUnreportedContexts = <R = never>(
|
|
188
189
|
octokit: RulesetOctokit<R>,
|
|
189
190
|
owner: string,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K2 (2026-09-24): MEASURES `contextReportedSuccess` (repository-ruleset-octokit.ts) against a
|
|
3
|
+
* real `@octokit/rest` instance with a fetch shim (no network) — the same pattern
|
|
4
|
+
* repository-ruleset-wire.test.ts uses for H15, applied here to the "which ref did we check"
|
|
5
|
+
* question instead of "did the wire field survive". The shim routes by URL path, not by call
|
|
6
|
+
* order, so these tests exercise the SAME sequencing the real implementation performs: default
|
|
7
|
+
* branch tip first, then (only if the tip has nothing) recent merged PR heads, bounded.
|
|
8
|
+
*
|
|
9
|
+
* Both sides the guard exists to hold: accept a context that reports only on a recent merged PR
|
|
10
|
+
* head (the case K2 fixes — CI runs on `pull_request` only, never the tip), and still refuse one
|
|
11
|
+
* that reports nowhere, tip or any recent merged head, INCLUDING one whose only success is
|
|
12
|
+
* older than `RECENT_MERGED_PR_LIMIT` merges ago (the bound is real, not decorative).
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import { Octokit } from '@octokit/rest';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import { RECENT_MERGED_PR_LIMIT, contextReportedSuccess } from './repository-ruleset-octokit.ts';
|
|
18
|
+
|
|
19
|
+
interface CheckRun {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly conclusion: string | null;
|
|
22
|
+
}
|
|
23
|
+
interface Status {
|
|
24
|
+
readonly context: string;
|
|
25
|
+
readonly state: string;
|
|
26
|
+
}
|
|
27
|
+
interface MergedPull {
|
|
28
|
+
readonly merged_at: string | null;
|
|
29
|
+
readonly head: { readonly sha: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Fixture {
|
|
33
|
+
readonly defaultBranch: string;
|
|
34
|
+
readonly checkRuns?: Readonly<Record<string, readonly CheckRun[]>>;
|
|
35
|
+
readonly statuses?: Readonly<Record<string, readonly Status[]>>;
|
|
36
|
+
/** Already in the order GitHub's `sort: updated, direction: desc` would return — most
|
|
37
|
+
* recently updated first. */
|
|
38
|
+
readonly closedPulls?: readonly MergedPull[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Routes by URL path, not call order — GET .../pulls, GET .../commits/{ref}/check-runs, GET
|
|
42
|
+
* .../commits/{ref}/statuses, else the bare repo GET (`default_branch`). `captured` records
|
|
43
|
+
* every URL so a test can assert a ref was — or, for boundedness, was NEVER — looked up. */
|
|
44
|
+
function makeServingFetch(fx: Fixture, captured: string[]): typeof fetch {
|
|
45
|
+
const respond = (body: unknown) =>
|
|
46
|
+
new Response(JSON.stringify(body), {
|
|
47
|
+
status: 200,
|
|
48
|
+
headers: { 'content-type': 'application/json' },
|
|
49
|
+
});
|
|
50
|
+
return (async (input: Parameters<typeof fetch>[0]) => {
|
|
51
|
+
const url = String(input);
|
|
52
|
+
captured.push(url);
|
|
53
|
+
if (/\/pulls(\?|$)/.test(url)) return respond(fx.closedPulls ?? []);
|
|
54
|
+
const commits = /\/commits\/([^/?]+)\/(check-runs|statuses)/.exec(url);
|
|
55
|
+
if (commits) {
|
|
56
|
+
const [, ref, kind] = commits;
|
|
57
|
+
if (kind === 'check-runs') {
|
|
58
|
+
const runs = fx.checkRuns?.[ref as string] ?? [];
|
|
59
|
+
return respond({ total_count: runs.length, check_runs: runs });
|
|
60
|
+
}
|
|
61
|
+
return respond(fx.statuses?.[ref as string] ?? []);
|
|
62
|
+
}
|
|
63
|
+
return respond({ default_branch: fx.defaultBranch });
|
|
64
|
+
}) as typeof fetch;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const octokitOver = (fx: Fixture, captured: string[] = []) =>
|
|
68
|
+
new Octokit({
|
|
69
|
+
auth: 'fake-token-not-a-real-credential',
|
|
70
|
+
request: { fetch: makeServingFetch(fx, captured) },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const run = (eff: Effect.Effect<boolean, Error>): Promise<boolean> => Effect.runPromise(eff);
|
|
74
|
+
|
|
75
|
+
describe('contextReportedSuccess', () => {
|
|
76
|
+
test('accepts a context that reports only on a recent merged PR head, not the tip', async () => {
|
|
77
|
+
const octokit = octokitOver({
|
|
78
|
+
defaultBranch: 'main',
|
|
79
|
+
checkRuns: { sha2: [{ name: 'ci', conclusion: 'success' }] },
|
|
80
|
+
closedPulls: [
|
|
81
|
+
{ merged_at: '2026-09-20T00:00:00Z', head: { sha: 'sha3' } },
|
|
82
|
+
{ merged_at: '2026-09-19T00:00:00Z', head: { sha: 'sha2' } },
|
|
83
|
+
{ merged_at: '2026-09-18T00:00:00Z', head: { sha: 'sha1' } },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
const reported = await run(
|
|
87
|
+
contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
|
|
88
|
+
);
|
|
89
|
+
expect(reported).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('refuses a context that reports nowhere — tip or any recent merged head', async () => {
|
|
93
|
+
const octokit = octokitOver({
|
|
94
|
+
defaultBranch: 'main',
|
|
95
|
+
closedPulls: [
|
|
96
|
+
{ merged_at: '2026-09-20T00:00:00Z', head: { sha: 'sha3' } },
|
|
97
|
+
{ merged_at: '2026-09-19T00:00:00Z', head: { sha: 'sha2' } },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
const reported = await run(
|
|
101
|
+
contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
|
|
102
|
+
);
|
|
103
|
+
expect(reported).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a success older than RECENT_MERGED_PR_LIMIT merges ago is still refused — the bound is real', async () => {
|
|
107
|
+
// RECENT_MERGED_PR_LIMIT (10) merged PRs report nothing; only the 11th (oldest returned,
|
|
108
|
+
// outside the bound) reports `ci` — proves the function stops at the limit rather than
|
|
109
|
+
// scanning everything the list call happened to return.
|
|
110
|
+
const closedPulls: MergedPull[] = Array.from({ length: RECENT_MERGED_PR_LIMIT }, (_, i) => ({
|
|
111
|
+
merged_at: `2026-09-${String(20 - i)}T00:00:00Z`,
|
|
112
|
+
head: { sha: `within-bound-${String(i)}` },
|
|
113
|
+
}));
|
|
114
|
+
closedPulls.push({ merged_at: '2026-08-01T00:00:00Z', head: { sha: 'beyond-bound' } });
|
|
115
|
+
const captured: string[] = [];
|
|
116
|
+
const octokit = octokitOver(
|
|
117
|
+
{
|
|
118
|
+
defaultBranch: 'main',
|
|
119
|
+
checkRuns: { 'beyond-bound': [{ name: 'ci', conclusion: 'success' }] },
|
|
120
|
+
closedPulls,
|
|
121
|
+
},
|
|
122
|
+
captured,
|
|
123
|
+
);
|
|
124
|
+
const reported = await run(
|
|
125
|
+
contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-builds', 'ci'),
|
|
126
|
+
);
|
|
127
|
+
expect(reported).toBe(false);
|
|
128
|
+
expect(captured.some((url) => url.includes('/commits/beyond-bound/'))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the default branch tip alone is still enough — no fallback needed', async () => {
|
|
132
|
+
const octokit = octokitOver({
|
|
133
|
+
defaultBranch: 'main',
|
|
134
|
+
statuses: { main: [{ context: 'secret scan', state: 'success' }] },
|
|
135
|
+
});
|
|
136
|
+
const reported = await run(
|
|
137
|
+
contextReportedSuccess(octokit, 'taslabs-net', 'homeflare-kit', 'secret scan'),
|
|
138
|
+
);
|
|
139
|
+
expect(reported).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
* should reach the wire. repository-ruleset-wire.test.ts MEASURES this against a real Octokit
|
|
9
9
|
* instance with a fetch shim rather than asserting it from reading the source.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* ★ K2 (2026-09-24): "REPORTED SUCCESS" IS THE DEFAULT BRANCH'S TIP, THEN RECENT MERGED PR
|
|
12
|
+
* HEADS — NOT FULL HISTORY. The rendered CI (`packages/config/src/repo-shape/ci.ts`) triggers
|
|
13
|
+
* on `pull_request` ONLY, deliberately (measured 2026-09-15..22: 613 of 619 main-push runs
|
|
14
|
+
* duplicated an already-green PR run, ~41% of the mini's CI load) — so `ci`/`secret scan`/
|
|
15
|
+
* `CodeQL` NEVER report on the default branch's tip itself, only on PR heads. Checking the tip
|
|
16
|
+
* alone therefore refused adding any of them everywhere, including homeflare-builds' pending
|
|
17
|
+
* first ruleset. `contextReportedSuccess` now falls back to `recentMergedHeadShas` — bounded
|
|
18
|
+
* (`RECENT_MERGED_PR_LIMIT` heads, from one `per_page`-bounded list call, never the repo's
|
|
19
|
+
* full PR history) — so a context that only ever reports on PR heads is no longer refused
|
|
20
|
+
* everywhere, while a context that has never reported ANYWHERE (not the tip, not any recent
|
|
21
|
+
* merged head) still is: `NeverReportedContext`'s whole purpose survives unchanged.
|
|
22
|
+
* ⚠️ Still not full history: a context whose only successful run predates
|
|
23
|
+
* `RECENT_MERGED_PR_LIMIT` merges ago reads as never-reported until it runs again. Widening
|
|
24
|
+
* the bound trades this guard's cost (more vendor calls per newly-required context) against
|
|
25
|
+
* its reach; `RECENT_MERGED_PR_LIMIT` is a deliberate, named number for exactly that reason,
|
|
26
|
+
* not a magic constant.
|
|
19
27
|
*/
|
|
20
28
|
import type { Octokit as OctokitClient } from '@octokit/rest';
|
|
21
29
|
import { GitHubCredentials } from 'alchemy/GitHub';
|
|
@@ -37,6 +45,110 @@ const octokitFor = (
|
|
|
37
45
|
|
|
38
46
|
const is404 = (e: unknown): boolean => (e as { status?: number } | undefined)?.status === 404;
|
|
39
47
|
|
|
48
|
+
/** How many of the most-recently-updated MERGED pull requests' head SHAs to check, once the
|
|
49
|
+
* default branch's own tip has no reported success — see the file header (K2). */
|
|
50
|
+
export const RECENT_MERGED_PR_LIMIT = 10;
|
|
51
|
+
/** How many closed PRs to list (in one bounded call) to find up to `RECENT_MERGED_PR_LIMIT`
|
|
52
|
+
* merged ones among them — larger than the limit because `state: 'closed'` also returns PRs
|
|
53
|
+
* closed without merging, sorted by `updated_at`, not `merged_at` (the REST API has no
|
|
54
|
+
* merged-only list). A GitHub API list call has no `merged` filter, so this is `100` — GitHub's
|
|
55
|
+
* own per-page maximum, not a smaller round number — to keep unmerged churn (e.g. Dependabot
|
|
56
|
+
* PRs closed without merging, which still bump `updated_at`) from crowding out real merges in a
|
|
57
|
+
* repo with heavy PR traffic (2026-09-24 review finding: a smaller page risked under-reaching in
|
|
58
|
+
* exactly the kind of repo this fix targets). Still bounded, still one call, still never the
|
|
59
|
+
* repo's full history — a repo whose last `RECENT_MERGED_PR_LIMIT` merges don't even fit in the
|
|
60
|
+
* last 100 closed PRs is treated the same as one with too little history: fewer heads found,
|
|
61
|
+
* same "still refused" fallback. */
|
|
62
|
+
const RECENT_CLOSED_PR_PAGE_SIZE = 100;
|
|
63
|
+
|
|
64
|
+
/** Whether `context` reported success for a single ref — a branch tip or a PR head SHA, both
|
|
65
|
+
* checked the same way (checks + statuses, exactly as GitHub's own UI does). Takes a plain
|
|
66
|
+
* `OctokitClient`, not an Effect service, so it and its caller below are testable directly
|
|
67
|
+
* against a real `@octokit/rest` instance with a fetch shim (repository-ruleset-octokit.test.ts)
|
|
68
|
+
* — no `GitHubCredentials` layer needed, mirroring `desiredWireRuleset`'s pure-function seam. */
|
|
69
|
+
export const hasRefReportedSuccess = (
|
|
70
|
+
octokit: OctokitClient,
|
|
71
|
+
owner: string,
|
|
72
|
+
repo: string,
|
|
73
|
+
ref: string,
|
|
74
|
+
context: string,
|
|
75
|
+
): Effect.Effect<boolean, Error> =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const [checkRuns, statuses] = yield* Effect.all([
|
|
78
|
+
Effect.tryPromise({
|
|
79
|
+
try: () => octokit.rest.checks.listForRef({ owner, repo, ref, check_name: context }),
|
|
80
|
+
catch: asError,
|
|
81
|
+
}),
|
|
82
|
+
Effect.tryPromise({
|
|
83
|
+
try: () => octokit.rest.repos.listCommitStatusesForRef({ owner, repo, ref }),
|
|
84
|
+
catch: asError,
|
|
85
|
+
}),
|
|
86
|
+
]);
|
|
87
|
+
const checkReported = checkRuns.data.check_runs.some(
|
|
88
|
+
(run) => run.name === context && run.conclusion === 'success',
|
|
89
|
+
);
|
|
90
|
+
const statusReported = statuses.data.some(
|
|
91
|
+
(status) => status.context === context && status.state === 'success',
|
|
92
|
+
);
|
|
93
|
+
return checkReported || statusReported;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** The head SHAs of up to `RECENT_MERGED_PR_LIMIT` most-recently-updated MERGED pull requests
|
|
97
|
+
* against `base` — one bounded list call (`RECENT_CLOSED_PR_PAGE_SIZE` closed PRs), filtered to
|
|
98
|
+
* merged, then capped. Never paginates further: a repo whose last `RECENT_MERGED_PR_LIMIT`
|
|
99
|
+
* merges are all older than its last `RECENT_CLOSED_PR_PAGE_SIZE` closed PRs simply yields fewer
|
|
100
|
+
* than the limit, same as a repo with too little history — both are the "still refused" case. */
|
|
101
|
+
export const recentMergedHeadShas = (
|
|
102
|
+
octokit: OctokitClient,
|
|
103
|
+
owner: string,
|
|
104
|
+
repo: string,
|
|
105
|
+
base: string,
|
|
106
|
+
): Effect.Effect<readonly string[], Error> =>
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const closed = yield* Effect.tryPromise({
|
|
109
|
+
try: () =>
|
|
110
|
+
octokit.rest.pulls.list({
|
|
111
|
+
owner,
|
|
112
|
+
repo,
|
|
113
|
+
state: 'closed',
|
|
114
|
+
base,
|
|
115
|
+
sort: 'updated',
|
|
116
|
+
direction: 'desc',
|
|
117
|
+
per_page: RECENT_CLOSED_PR_PAGE_SIZE,
|
|
118
|
+
}),
|
|
119
|
+
catch: asError,
|
|
120
|
+
});
|
|
121
|
+
return closed.data
|
|
122
|
+
.filter((pr) => pr.merged_at !== null)
|
|
123
|
+
.slice(0, RECENT_MERGED_PR_LIMIT)
|
|
124
|
+
.map((pr) => pr.head.sha);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** The full K2 answer: the default branch's tip, then (only if the tip has no success) up to
|
|
128
|
+
* `RECENT_MERGED_PR_LIMIT` recent merged PR heads, short-circuiting on the first success. `false`
|
|
129
|
+
* only once every one of those has been checked and none reported — the case
|
|
130
|
+
* `NeverReportedContext` exists to refuse. */
|
|
131
|
+
export const contextReportedSuccess = (
|
|
132
|
+
octokit: OctokitClient,
|
|
133
|
+
owner: string,
|
|
134
|
+
repo: string,
|
|
135
|
+
context: string,
|
|
136
|
+
): Effect.Effect<boolean, Error> =>
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
const repository = yield* Effect.tryPromise({
|
|
139
|
+
try: () => octokit.rest.repos.get({ owner, repo }),
|
|
140
|
+
catch: asError,
|
|
141
|
+
});
|
|
142
|
+
const ref = repository.data.default_branch;
|
|
143
|
+
if (yield* hasRefReportedSuccess(octokit, owner, repo, ref, context)) return true;
|
|
144
|
+
|
|
145
|
+
const heads = yield* recentMergedHeadShas(octokit, owner, repo, ref);
|
|
146
|
+
for (const sha of heads) {
|
|
147
|
+
if (yield* hasRefReportedSuccess(octokit, owner, repo, sha, context)) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
});
|
|
151
|
+
|
|
40
152
|
export const makeRulesetOctokit = (baseUrl: string | undefined): RealRulesetOctokit => ({
|
|
41
153
|
list: ({ owner, repo }) =>
|
|
42
154
|
Effect.gen(function* () {
|
|
@@ -114,27 +226,6 @@ export const makeRulesetOctokit = (baseUrl: string | undefined): RealRulesetOcto
|
|
|
114
226
|
hasContextReportedSuccess: ({ owner, repo, context }) =>
|
|
115
227
|
Effect.gen(function* () {
|
|
116
228
|
const octokit = yield* octokitFor(baseUrl);
|
|
117
|
-
|
|
118
|
-
try: () => octokit.rest.repos.get({ owner, repo }),
|
|
119
|
-
catch: asError,
|
|
120
|
-
});
|
|
121
|
-
const ref = repository.data.default_branch;
|
|
122
|
-
const [checkRuns, statuses] = yield* Effect.all([
|
|
123
|
-
Effect.tryPromise({
|
|
124
|
-
try: () => octokit.rest.checks.listForRef({ owner, repo, ref, check_name: context }),
|
|
125
|
-
catch: asError,
|
|
126
|
-
}),
|
|
127
|
-
Effect.tryPromise({
|
|
128
|
-
try: () => octokit.rest.repos.listCommitStatusesForRef({ owner, repo, ref }),
|
|
129
|
-
catch: asError,
|
|
130
|
-
}),
|
|
131
|
-
]);
|
|
132
|
-
const checkReported = checkRuns.data.check_runs.some(
|
|
133
|
-
(run) => run.name === context && run.conclusion === 'success',
|
|
134
|
-
);
|
|
135
|
-
const statusReported = statuses.data.some(
|
|
136
|
-
(status) => status.context === context && status.state === 'success',
|
|
137
|
-
);
|
|
138
|
-
return checkReported || statusReported;
|
|
229
|
+
return yield* contextReportedSuccess(octokit, owner, repo, context);
|
|
139
230
|
}),
|
|
140
231
|
});
|
|
@@ -71,7 +71,8 @@ export interface RulesetOctokit<R = never> {
|
|
|
71
71
|
readonly repo: string;
|
|
72
72
|
readonly rulesetId: number;
|
|
73
73
|
}) => Effect.Effect<void, Error, R>;
|
|
74
|
-
/** Whether `context` has
|
|
74
|
+
/** Whether `context` has reported success against the repository's default branch tip OR a
|
|
75
|
+
* recent merged PR head (K2, bounded — see repository-ruleset-octokit.ts's file header) —
|
|
75
76
|
* backs the `NeverReportedContext` guard. */
|
|
76
77
|
readonly hasContextReportedSuccess: (input: {
|
|
77
78
|
readonly owner: string;
|
|
@@ -18,10 +18,13 @@ export interface RepositoryRulesetPullRequestRule extends NonNullable<
|
|
|
18
18
|
/** @default every method GitHub allows — the house baseline always pins `['squash']`. */
|
|
19
19
|
readonly allowedMergeMethods?: readonly ('merge' | 'squash' | 'rebase')[];
|
|
20
20
|
readonly requiredReviewers?: readonly RepositoryRulesetReviewerRule[];
|
|
21
|
-
/** `require_extra_approval_for_unattributed_changes` — see repository-ruleset.ts's H15.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
21
|
+
/** `require_extra_approval_for_unattributed_changes` — see repository-ruleset.ts's H15.
|
|
22
|
+
* Both values are modeled (K1, 2026-09-23): 8 live rulesets carry `true` (aop, cloudflareforms,
|
|
23
|
+
* doesthishelp-workeropen, homeflare-anyauth, homeflare-desktop, loggarr, magictransit,
|
|
24
|
+
* proxmox-tb4 — re-read live via `gh api repos/taslabs-net/<repo>/rulesets/<id>`,
|
|
25
|
+
* 2026-09-23), and this resource declares exactly what is live or it cannot adopt them —
|
|
26
|
+
* `undefined` stays "no opinion" (GitHub defaults an absent key to `true`). */
|
|
27
|
+
readonly extraApprovalForUnattributedChanges?: boolean;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export interface RepositoryRulesetStatusChecksRule extends NonNullable<
|