@homeflare/alchemy 0.27.4 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/github/index.d.ts +1 -1
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +99 -3
- package/dist/github/index.js.map +8 -7
- package/dist/github/repository-ruleset-errors.d.ts +34 -1
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -1
- package/dist/github/repository-ruleset-guards.d.ts +12 -0
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -1
- package/dist/github/repository-ruleset-narrowing-guards.d.ts +39 -0
- package/dist/github/repository-ruleset-narrowing-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-props.d.ts +88 -0
- package/dist/github/repository-ruleset-props.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -1
- package/dist/github/repository-ruleset.d.ts +3 -31
- package/dist/github/repository-ruleset.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/github/index.ts +2 -0
- package/src/github/repository-ruleset-errors.ts +45 -0
- package/src/github/repository-ruleset-exact-adopt.test.ts +179 -0
- package/src/github/repository-ruleset-guards.ts +8 -2
- package/src/github/repository-ruleset-narrowing-guards.ts +141 -0
- package/src/github/repository-ruleset-narrowing.test.ts +210 -0
- package/src/github/repository-ruleset-props.ts +92 -0
- package/src/github/repository-ruleset-reconcile.ts +35 -0
- package/src/github/repository-ruleset-write-refusals.test.ts +6 -26
- package/src/github/repository-ruleset.ts +27 -41
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GitHub.RepositoryRuleset`'s declared shape — split out of repository-ruleset.ts purely to
|
|
3
|
+
* keep that file (the provider wiring) under the 250-line cap; nothing here is provider logic.
|
|
4
|
+
*/
|
|
5
|
+
import type { RulesetProps } from 'alchemy/GitHub';
|
|
6
|
+
type UpstreamRules = NonNullable<RulesetProps['rules']>;
|
|
7
|
+
export interface RepositoryRulesetReviewerRule {
|
|
8
|
+
readonly filePatterns: readonly string[];
|
|
9
|
+
readonly minimumApprovals: number;
|
|
10
|
+
readonly reviewer: {
|
|
11
|
+
readonly actorId: number;
|
|
12
|
+
readonly actorType: 'Team';
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface RepositoryRulesetPullRequestRule extends NonNullable<UpstreamRules['pullRequest']> {
|
|
16
|
+
/** @default every method GitHub allows — the house baseline always pins `['squash']`. */
|
|
17
|
+
readonly allowedMergeMethods?: readonly ('merge' | 'squash' | 'rebase')[];
|
|
18
|
+
readonly requiredReviewers?: readonly RepositoryRulesetReviewerRule[];
|
|
19
|
+
/** `require_extra_approval_for_unattributed_changes` — see repository-ruleset.ts's H15. Only
|
|
20
|
+
* `false` is modeled: sending `true` has no house use, and GitHub already defaults absent to
|
|
21
|
+
* `true`. */
|
|
22
|
+
readonly extraApprovalForUnattributedChanges?: false;
|
|
23
|
+
}
|
|
24
|
+
export interface RepositoryRulesetStatusChecksRule extends NonNullable<UpstreamRules['requiredStatusChecks']> {
|
|
25
|
+
readonly doNotEnforceOnCreate?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface RepositoryRulesetRules extends Omit<UpstreamRules, 'pullRequest' | 'requiredStatusChecks'> {
|
|
28
|
+
/** `undefined`: no opinion (refused if the live ruleset already has one and this is not an
|
|
29
|
+
* exact-adopt pass-through — see repository-ruleset-guards.ts's `undeclaredLiveRuleRefusal`).
|
|
30
|
+
* `false`: declare its ABSENCE — either "this repo never had one" (a create, or an adopt
|
|
31
|
+
* where live already has none, both no-ops) or a deliberate removal of a live rule, which
|
|
32
|
+
* `ruleNarrowingRefusal` gates behind `acknowledgeRuleNarrowing` (see below). Added 2026-09-23 for
|
|
33
|
+
* exact-adopt pass-through: `requiredStatusChecks` already had this `false` escape; `deletion`/
|
|
34
|
+
* `nonFastForward`/etc already have it implicitly (plain `boolean`, so `false` is already a
|
|
35
|
+
* value); `pullRequest` was the one modeled rule with NO way to express "not present at all" —
|
|
36
|
+
* a repo like `taslabs-net/taslabs-net` (measured live 2026-09-23: `rules = [deletion,
|
|
37
|
+
* non_fast_forward]`, no `pull_request`) could not be adopted through this resource without
|
|
38
|
+
* either refusing to omit the key (a type error) or silently declaring a brand-new rule. */
|
|
39
|
+
readonly pullRequest?: RepositoryRulesetPullRequestRule | false;
|
|
40
|
+
/** `undefined`: no opinion (refused if the live ruleset already has one — see
|
|
41
|
+
* repository-ruleset-guards.ts). `false`: declare its ABSENCE — removes a live one. */
|
|
42
|
+
readonly requiredStatusChecks?: RepositoryRulesetStatusChecksRule | false;
|
|
43
|
+
}
|
|
44
|
+
export interface RepositoryRulesetProps extends Omit<RulesetProps, 'rules'> {
|
|
45
|
+
readonly rules?: RepositoryRulesetRules;
|
|
46
|
+
/**
|
|
47
|
+
* An explicit, reasoned sign-off that THIS declaration deliberately drops a live bypass actor
|
|
48
|
+
* (`bypassActors` is a strict subset of what is live). Without this, `reconcile` refuses the
|
|
49
|
+
* write rather than silently loosen a ruleset that already protects something — see
|
|
50
|
+
* `bypassNarrowingRefusal` in repository-ruleset-narrowing-guards.ts. `reason` is not logged or
|
|
51
|
+
* sent to GitHub; it exists so the declaration's own diff carries WHY, for the next reader
|
|
52
|
+
* (S21: a refusal names the fact it checked — this is the human-authored counterpart for the
|
|
53
|
+
* one case a fact alone cannot settle: "is this narrowing intentional?").
|
|
54
|
+
*
|
|
55
|
+
* ⛔ SEPARATE FROM `acknowledgeRuleNarrowing` ON PURPOSE (2026-09-23 review finding: a single
|
|
56
|
+
* shared `acknowledgeNarrowing` let one reason worded for a rule drop silently also rubber-
|
|
57
|
+
* stamp an unrelated bypass-actor drop in the SAME declaration — a `bypassActors: []` left
|
|
58
|
+
* over from an unrelated edit slipped through under a reason that never mentioned bypass at
|
|
59
|
+
* all). Each prop authorizes only the narrowing its own guard checks; a declaration that
|
|
60
|
+
* narrows both needs both, each with its own reason.
|
|
61
|
+
*
|
|
62
|
+
* ⛔ ALSO A STANDING PROP, NOT A ONE-TIME TOKEN — remove it from the declaration once the
|
|
63
|
+
* narrowing it describes has landed. Left in place, it keeps disabling the bypass-narrowing
|
|
64
|
+
* guard for every future `reconcile` of this resource, not only the change it was written
|
|
65
|
+
* for — the same way `rules: { deletion: false }` stays a standing "this rule stays absent"
|
|
66
|
+
* fact rather than a one-time action, only here the standing fact is "bypass narrowing needs
|
|
67
|
+
* no review", which is a fact you want true for as short a window as possible. A reviewer
|
|
68
|
+
* reading a diff that adds a SECOND, unrelated bypass drop under an OLD
|
|
69
|
+
* `acknowledgeBypassNarrowing` left over from a prior change is the failure mode this note
|
|
70
|
+
* exists to head off.
|
|
71
|
+
*/
|
|
72
|
+
readonly acknowledgeBypassNarrowing?: {
|
|
73
|
+
readonly reason: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* The `rules`-side counterpart to `acknowledgeBypassNarrowing` above — an explicit, reasoned
|
|
77
|
+
* sign-off that THIS declaration deliberately drops a live rule (a modeled rule type declared
|
|
78
|
+
* `false`/boolean-`false` while the live ruleset still has it). Without this, `reconcile`
|
|
79
|
+
* refuses the write — see `ruleNarrowingRefusal` in repository-ruleset-narrowing-guards.ts.
|
|
80
|
+
* Same standing-prop caution as `acknowledgeBypassNarrowing`: remove it once the rule drop it
|
|
81
|
+
* describes has landed, or it keeps excusing every later rule drop on this resource too.
|
|
82
|
+
*/
|
|
83
|
+
readonly acknowledgeRuleNarrowing?: {
|
|
84
|
+
readonly reason: string;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
88
|
+
//# sourceMappingURL=repository-ruleset-props.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository-ruleset-props.d.ts","sourceRoot":"","sources":["../../src/github/repository-ruleset-props.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,KAAK,aAAa,GAAG,WAAW,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC;AAExD,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,QAAQ,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7E;AAED,MAAM,WAAW,gCAAiC,SAAQ,WAAW,CACnE,aAAa,CAAC,aAAa,CAAC,CAC7B;IACC,yFAAyF;IACzF,QAAQ,CAAC,mBAAmB,CAAC,EAAE,SAAS,CAAC,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC,EAAE,CAAC;IAC1E,QAAQ,CAAC,iBAAiB,CAAC,EAAE,SAAS,6BAA6B,EAAE,CAAC;IACtE;;iBAEa;IACb,QAAQ,CAAC,mCAAmC,CAAC,EAAE,KAAK,CAAC;CACtD;AAED,MAAM,WAAW,iCAAkC,SAAQ,WAAW,CACpE,aAAa,CAAC,sBAAsB,CAAC,CACtC;IACC,QAAQ,CAAC,oBAAoB,CAAC,EAAE,OAAO,CAAC;CACzC;AAED,MAAM,WAAW,sBAAuB,SAAQ,IAAI,CAClD,aAAa,EACb,aAAa,GAAG,sBAAsB,CACvC;IACC;;;;;;;;;;gGAU4F;IAC5F,QAAQ,CAAC,WAAW,CAAC,EAAE,gCAAgC,GAAG,KAAK,CAAC;IAChE;2FACuF;IACvF,QAAQ,CAAC,oBAAoB,CAAC,EAAE,iCAAiC,GAAG,KAAK,CAAC;CAC3E;AAED,MAAM,WAAW,sBAAuB,SAAQ,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC;IACzE,QAAQ,CAAC,KAAK,CAAC,EAAE,sBAAsB,CAAC;IACxC;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,QAAQ,CAAC,0BAA0B,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAClE;;;;;;;OAOG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACjE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repository-ruleset-reconcile.d.ts","sourceRoot":"","sources":["../../src/github/repository-ruleset-reconcile.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,2BAA2B,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAEnG,OAAO,EAEL,KAAK,sBAAsB,EAC3B,KAAK,wBAAwB,EAC9B,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"repository-ruleset-reconcile.d.ts","sourceRoot":"","sources":["../../src/github/repository-ruleset-reconcile.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,2BAA2B,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAEnG,OAAO,EAEL,KAAK,sBAAsB,EAC3B,KAAK,wBAAwB,EAC9B,MAAM,gCAAgC,CAAC;AAgBxC,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,aAAa,EAEnB,MAAM,+BAA+B,CAAC;AA+CvC;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,GAAG,KAAK,SAAS;IACjD,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,sBAAsB,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,2BAA2B,GAAG,SAAS,CAAC;IACzD,QAAQ,CAAC,iBAAiB,EAAE,CAC1B,KAAK,EAAE,sBAAsB,KAC1B,wBAAwB,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,2BAA2B,CAAC;CACrE,KAAG,MAAM,CAAC,MAAM,CAAC,2BAA2B,EAAE,sBAAsB,GAAG,KAAK,EAAE,CAAC,CAoH5E,CAAC"}
|
|
@@ -40,38 +40,11 @@
|
|
|
40
40
|
* is not hand-rolled — only the choice of Octokit over distilled is a recorded divergence.
|
|
41
41
|
*/
|
|
42
42
|
import { Resource } from 'alchemy';
|
|
43
|
-
import type { GitHubCredentials
|
|
43
|
+
import type { GitHubCredentials } from 'alchemy/GitHub';
|
|
44
44
|
import * as Provider from 'alchemy/Provider';
|
|
45
45
|
import { type RulesetRecord } from './repository-ruleset-probe.ts';
|
|
46
|
-
type
|
|
47
|
-
export
|
|
48
|
-
readonly filePatterns: readonly string[];
|
|
49
|
-
readonly minimumApprovals: number;
|
|
50
|
-
readonly reviewer: {
|
|
51
|
-
readonly actorId: number;
|
|
52
|
-
readonly actorType: 'Team';
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
export interface RepositoryRulesetPullRequestRule extends NonNullable<UpstreamRules['pullRequest']> {
|
|
56
|
-
/** @default every method GitHub allows — the house baseline always pins `['squash']`. */
|
|
57
|
-
readonly allowedMergeMethods?: readonly ('merge' | 'squash' | 'rebase')[];
|
|
58
|
-
readonly requiredReviewers?: readonly RepositoryRulesetReviewerRule[];
|
|
59
|
-
/** `require_extra_approval_for_unattributed_changes` — see H15 above. Only `false` is
|
|
60
|
-
* modeled: sending `true` has no house use, and GitHub already defaults absent to `true`. */
|
|
61
|
-
readonly extraApprovalForUnattributedChanges?: false;
|
|
62
|
-
}
|
|
63
|
-
export interface RepositoryRulesetStatusChecksRule extends NonNullable<UpstreamRules['requiredStatusChecks']> {
|
|
64
|
-
readonly doNotEnforceOnCreate?: boolean;
|
|
65
|
-
}
|
|
66
|
-
export interface RepositoryRulesetRules extends Omit<UpstreamRules, 'pullRequest' | 'requiredStatusChecks'> {
|
|
67
|
-
readonly pullRequest?: RepositoryRulesetPullRequestRule;
|
|
68
|
-
/** `undefined`: no opinion (refused if the live ruleset already has one — see
|
|
69
|
-
* repository-ruleset-guards.ts). `false`: declare its ABSENCE — removes a live one. */
|
|
70
|
-
readonly requiredStatusChecks?: RepositoryRulesetStatusChecksRule | false;
|
|
71
|
-
}
|
|
72
|
-
export interface RepositoryRulesetProps extends Omit<RulesetProps, 'rules'> {
|
|
73
|
-
readonly rules?: RepositoryRulesetRules;
|
|
74
|
-
}
|
|
46
|
+
import type { RepositoryRulesetProps } from './repository-ruleset-props.ts';
|
|
47
|
+
export type { RepositoryRulesetProps, RepositoryRulesetPullRequestRule, RepositoryRulesetReviewerRule, RepositoryRulesetRules, RepositoryRulesetStatusChecksRule, } from './repository-ruleset-props.ts';
|
|
75
48
|
export interface RepositoryRulesetAttributes {
|
|
76
49
|
readonly rulesetId: number;
|
|
77
50
|
readonly nodeId: string | undefined;
|
|
@@ -85,5 +58,4 @@ export declare const RepositoryRuleset: import("alchemy").ResourceClass<Reposito
|
|
|
85
58
|
export declare const attrsOf: (r: RulesetRecord) => RepositoryRulesetAttributes;
|
|
86
59
|
export declare const repositoryRulesetHandlers: Provider.ProviderService<RepositoryRuleset, GitHubCredentials, never, never, GitHubCredentials, GitHubCredentials, never, never, never>;
|
|
87
60
|
export declare const RepositoryRulesetProvider: () => import("effect/Layer").Layer<Provider.Provider<RepositoryRuleset>, never, GitHubCredentials>;
|
|
88
|
-
export {};
|
|
89
61
|
//# sourceMappingURL=repository-ruleset.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repository-ruleset.d.ts","sourceRoot":"","sources":["../../src/github/repository-ruleset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAGnC,OAAO,KAAK,EAAE,iBAAiB,EAAE,
|
|
1
|
+
{"version":3,"file":"repository-ruleset.d.ts","sourceRoot":"","sources":["../../src/github/repository-ruleset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAGnC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAQ7C,OAAO,EAAE,KAAK,aAAa,EAAe,MAAM,+BAA+B,CAAC;AAChF,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AAG5E,YAAY,EACV,sBAAsB,EACtB,gCAAgC,EAChC,6BAA6B,EAC7B,sBAAsB,EACtB,iCAAiC,GAClC,MAAM,+BAA+B,CAAC;AAEvC,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC;AAED,MAAM,WAAW,iBAAkB,SAAQ,QAAQ,CACjD,0BAA0B,EAC1B,sBAAsB,EACtB,2BAA2B,EAC3B,KAAK,EACL,iBAAiB,CAClB;CAAG;AAEJ,eAAO,MAAM,iBAAiB,oDAE5B,CAAC;AAEH,eAAO,MAAM,OAAO,MAAO,aAAa,KAAG,2BAMzC,CAAC;AA4CH,eAAO,MAAM,yBAAyB,yIAkEpC,CAAC;AAEH,eAAO,MAAM,yBAAyB,oGAC0B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -94,8 +94,12 @@
|
|
|
94
94
|
"access": "public"
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
|
+
"@distilled.cloud/caddy": "npm:@homeflare/distilled-caddy@0.2.0",
|
|
98
|
+
"@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
|
|
97
99
|
"@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
|
|
98
|
-
"@distilled.cloud/
|
|
100
|
+
"@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
|
|
101
|
+
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.2.0",
|
|
102
|
+
"@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.2.0"
|
|
99
103
|
},
|
|
100
104
|
"peerDependencies": {
|
|
101
105
|
"@distilled.cloud/cloudflare": "1.0.0-rc.12",
|
package/src/github/index.ts
CHANGED
|
@@ -44,11 +44,13 @@ export {
|
|
|
44
44
|
} from './repository-ruleset.ts';
|
|
45
45
|
export { RULE_TYPE_COVERAGE, type RepositoryRuleType } from './repository-ruleset-constraints.ts';
|
|
46
46
|
export {
|
|
47
|
+
BypassActorNarrowed,
|
|
47
48
|
BypassActorWidened,
|
|
48
49
|
DuplicateRuleset,
|
|
49
50
|
NeverReportedContext,
|
|
50
51
|
ReadbackMismatch,
|
|
51
52
|
RequiredChecksOmitted,
|
|
53
|
+
RuleNarrowed,
|
|
52
54
|
RulesetConstraintRefused,
|
|
53
55
|
UndeclaredLiveRule,
|
|
54
56
|
type RepositoryRulesetError,
|
|
@@ -43,6 +43,49 @@ export class BypassActorWidened extends Data.TaggedError('BypassActorWidened')<{
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** A declared `bypassActors` drops an actor the live ruleset has, with no
|
|
47
|
+
* `acknowledgeBypassNarrowing` on the declaration. Unlike widening, narrowing is not always
|
|
48
|
+
* wrong — an exact-adopt declaration that matches live exactly narrows nothing — so this is
|
|
49
|
+
* refused only when live genuinely has more than the declaration keeps, and it is escapable
|
|
50
|
+
* (`BypassActorWidened` is not): see `bypassNarrowingRefusal` in
|
|
51
|
+
* repository-ruleset-narrowing-guards.ts. */
|
|
52
|
+
export class BypassActorNarrowed extends Data.TaggedError('BypassActorNarrowed')<{
|
|
53
|
+
readonly owner: string;
|
|
54
|
+
readonly repository: string;
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly narrowed: readonly string[];
|
|
57
|
+
}> {
|
|
58
|
+
override get message(): string {
|
|
59
|
+
return (
|
|
60
|
+
`GitHub.RepositoryRuleset "${this.name}" on ${this.owner}/${this.repository}: the ` +
|
|
61
|
+
`declaration drops a bypass actor the live ruleset has (${this.narrowed.join(', ')}), and ` +
|
|
62
|
+
'the declaration carries no `acknowledgeBypassNarrowing`. Add one with a reason once the ' +
|
|
63
|
+
'removal is deliberate, or restore the actor to match live.'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A rule type the declaration explicitly marks absent (`false`) while the live ruleset still
|
|
69
|
+
* has it, with no `acknowledgeRuleNarrowing` on the declaration — see `ruleNarrowingRefusal` in
|
|
70
|
+
* repository-ruleset-narrowing-guards.ts. Distinct from `UndeclaredLiveRule`: that one fires on
|
|
71
|
+
* SILENCE (the declaration says nothing); this one fires on an EXPLICIT, but unacknowledged,
|
|
72
|
+
* removal. */
|
|
73
|
+
export class RuleNarrowed extends Data.TaggedError('RuleNarrowed')<{
|
|
74
|
+
readonly owner: string;
|
|
75
|
+
readonly repository: string;
|
|
76
|
+
readonly name: string;
|
|
77
|
+
readonly ruleType: string;
|
|
78
|
+
}> {
|
|
79
|
+
override get message(): string {
|
|
80
|
+
return (
|
|
81
|
+
`GitHub.RepositoryRuleset "${this.name}" on ${this.owner}/${this.repository}: the ` +
|
|
82
|
+
`declaration drops a live "${this.ruleType}" rule, and carries no ` +
|
|
83
|
+
'`acknowledgeRuleNarrowing`. Add one with a reason once the removal is deliberate, or ' +
|
|
84
|
+
'declare the rule to match live.'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
46
89
|
/** A live rule's `type` is neither modeled nor explicitly refused by RULE_TYPE_COVERAGE, or is
|
|
47
90
|
* refused and present live anyway. The PUT replaces `rules` wholesale, so silently omitting a
|
|
48
91
|
* live rule type would drop it — this fails the plan by name instead. */
|
|
@@ -129,7 +172,9 @@ export class ReadbackMismatch extends Data.TaggedError('ReadbackMismatch')<{
|
|
|
129
172
|
export type RepositoryRulesetError =
|
|
130
173
|
| DuplicateRuleset
|
|
131
174
|
| BypassActorWidened
|
|
175
|
+
| BypassActorNarrowed
|
|
132
176
|
| UndeclaredLiveRule
|
|
177
|
+
| RuleNarrowed
|
|
133
178
|
| RequiredChecksOmitted
|
|
134
179
|
| NeverReportedContext
|
|
135
180
|
| RulesetConstraintRefused
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact-adopt pass-through, proven against the 5 live rulesets the red team found this resource
|
|
3
|
+
* could not express before 2026-09-23 (design critique, "strongest_failure": `repoBaselineRuleset()`
|
|
4
|
+
* hardcodes `bypassActors: []` and an unconditional `pullRequest` rule; every one of these 5
|
|
5
|
+
* repos' live ruleset has a non-empty `bypass_actors`, and one — `taslabs-net/taslabs-net` — has
|
|
6
|
+
* no live `pull_request` rule at all). This file proves the underlying `RepositoryRuleset`
|
|
7
|
+
* resource (not the convenience `repoBaselineRuleset()`, which intentionally keeps its baseline
|
|
8
|
+
* defaults — see repo-baseline-data.ts) can express all 5 shapes, and that declaring one exactly
|
|
9
|
+
* is never treated as narrowing.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ RE-READ LIVE 2026-09-23 via `gh api repos/taslabs-net/<repo>/rulesets/<id>`, not copied
|
|
12
|
+
* from the design doc's own paraphrase — `bypass_mode` differs from the design critique's
|
|
13
|
+
* prose for `doesthishelp-workeropen` (measured: RepositoryRole 5 is `pull_request` mode and
|
|
14
|
+
* Integration 29110 is `always`, not the reverse) — see repository-ruleset-narrowing-guards.ts
|
|
15
|
+
* for the guard these fixtures exercise.
|
|
16
|
+
*
|
|
17
|
+
* `taslabs-net` is the only one of the 5 whose FULL ruleset (bypass AND rules) is provable as a
|
|
18
|
+
* true zero-write adopt today: its live `pull_request` rules (aop/magictransit/loggarr/
|
|
19
|
+
* doesthishelp-workeropen) all carry `require_extra_approval_for_unattributed_changes: true`,
|
|
20
|
+
* which this resource cannot yet declare (K1 — a separate, later PR; H15's comment on
|
|
21
|
+
* `extraApprovalForUnattributedChanges` explains why only `false` is modeled today). The other
|
|
22
|
+
* 4 fixtures below prove the narrower claim K1 does not gate: that this resource's `bypassActors`
|
|
23
|
+
* round-trips an arbitrary `actor_id`/`actor_type`/`bypass_mode` combination — single-actor by
|
|
24
|
+
* role, single-actor by integration, and multi-actor with mixed modes — onto the wire exactly as
|
|
25
|
+
* declared, with neither `BypassActorWidened` nor `BypassActorNarrowed` firing on a match.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, expect, test } from 'bun:test';
|
|
28
|
+
import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
|
|
29
|
+
import {
|
|
30
|
+
BASE_PULL_REQUEST,
|
|
31
|
+
props,
|
|
32
|
+
reconcileWithOctokit,
|
|
33
|
+
run,
|
|
34
|
+
} from './repository-ruleset-test-helpers.ts';
|
|
35
|
+
|
|
36
|
+
describe('exact-adopt pass-through — live shapes measured 2026-09-23', () => {
|
|
37
|
+
// The critical case: a non-empty bypass_actors AND an absent pull_request rule, both at once.
|
|
38
|
+
// `gh api repos/taslabs-net/taslabs-net/rulesets/12206080`.
|
|
39
|
+
test('taslabs-net/taslabs-net: non-empty bypass, no pull_request rule — zero-write adopt', async () => {
|
|
40
|
+
const live = {
|
|
41
|
+
id: 12206080,
|
|
42
|
+
name: 'main',
|
|
43
|
+
target: 'branch',
|
|
44
|
+
enforcement: 'active',
|
|
45
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
46
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
47
|
+
rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }],
|
|
48
|
+
};
|
|
49
|
+
const fake = makeGetOnlyFake({ 'taslabs-net': live });
|
|
50
|
+
const declaration = props({
|
|
51
|
+
repository: 'taslabs-net',
|
|
52
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
53
|
+
rules: { deletion: true, nonFastForward: true },
|
|
54
|
+
});
|
|
55
|
+
const attrs = await run(reconcileWithOctokit(fake, declaration, { rulesetId: 12206080 }));
|
|
56
|
+
expect(attrs.rulesetId).toBe(12206080);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// `gh api repos/taslabs-net/aop/rulesets/14572279` — single RepositoryRole actor, mode
|
|
60
|
+
// "always". Live `pull_request` also carries the K1 flag, so the write below is an `update`,
|
|
61
|
+
// not a noop — the point here is that `bypassActors` alone sends back exactly what was
|
|
62
|
+
// declared, and neither narrowing nor widening guard fires on a matching bypass list.
|
|
63
|
+
test('aop: single RepositoryRole actor round-trips exactly', async () => {
|
|
64
|
+
const live = {
|
|
65
|
+
id: 14572279,
|
|
66
|
+
name: 'main',
|
|
67
|
+
target: 'branch',
|
|
68
|
+
enforcement: 'active',
|
|
69
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
70
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
71
|
+
rules: [
|
|
72
|
+
{ type: 'deletion' },
|
|
73
|
+
{ type: 'non_fast_forward' },
|
|
74
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
const fake = makeRecordingFake({ aop: live });
|
|
78
|
+
const declaration = props({
|
|
79
|
+
repository: 'aop',
|
|
80
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
81
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
82
|
+
});
|
|
83
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 14572279 }));
|
|
84
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
85
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// `gh api repos/taslabs-net/loggarr/rulesets/12323114` — a single Integration (Dependabot)
|
|
90
|
+
// actor with `bypass_mode: "pull_request"`, not `"always"`.
|
|
91
|
+
test('loggarr: single Integration actor, pull_request bypass mode, round-trips exactly', async () => {
|
|
92
|
+
const live = {
|
|
93
|
+
id: 12323114,
|
|
94
|
+
name: 'main',
|
|
95
|
+
target: 'branch',
|
|
96
|
+
enforcement: 'active',
|
|
97
|
+
bypass_actors: [{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'pull_request' }],
|
|
98
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
99
|
+
rules: [
|
|
100
|
+
{ type: 'deletion' },
|
|
101
|
+
{ type: 'non_fast_forward' },
|
|
102
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const fake = makeRecordingFake({ loggarr: live });
|
|
106
|
+
const declaration = props({
|
|
107
|
+
repository: 'loggarr',
|
|
108
|
+
bypassActors: [{ actorType: 'Integration', actorId: 29110, bypassMode: 'pull_request' }],
|
|
109
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
110
|
+
});
|
|
111
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 12323114 }));
|
|
112
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
113
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'pull_request' },
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// `gh api repos/taslabs-net/doesthishelp-workeropen/rulesets/12504824` — TWO actors with
|
|
118
|
+
// DIFFERENT modes each (the design critique's own "(both)" shorthand for this repo).
|
|
119
|
+
test('doesthishelp-workeropen: two actors, mixed bypass modes, round-trip exactly', async () => {
|
|
120
|
+
const live = {
|
|
121
|
+
id: 12504824,
|
|
122
|
+
name: 'main',
|
|
123
|
+
target: 'branch',
|
|
124
|
+
enforcement: 'active',
|
|
125
|
+
bypass_actors: [
|
|
126
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
|
|
127
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'always' },
|
|
128
|
+
],
|
|
129
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
130
|
+
rules: [
|
|
131
|
+
{ type: 'deletion' },
|
|
132
|
+
{ type: 'non_fast_forward' },
|
|
133
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
const fake = makeRecordingFake({ 'doesthishelp-workeropen': live });
|
|
137
|
+
const declaration = props({
|
|
138
|
+
repository: 'doesthishelp-workeropen',
|
|
139
|
+
bypassActors: [
|
|
140
|
+
{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'pull_request' },
|
|
141
|
+
{ actorType: 'Integration', actorId: 29110, bypassMode: 'always' },
|
|
142
|
+
],
|
|
143
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
144
|
+
});
|
|
145
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 12504824 }));
|
|
146
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
147
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'pull_request' },
|
|
148
|
+
{ actor_id: 29110, actor_type: 'Integration', bypass_mode: 'always' },
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// `gh api repos/taslabs-net/magictransit/rulesets/10470003` — same single-actor shape as
|
|
153
|
+
// `aop`, kept as its own case because it is one of the 5 the design's own census named.
|
|
154
|
+
test('magictransit: single RepositoryRole actor round-trips exactly', async () => {
|
|
155
|
+
const live = {
|
|
156
|
+
id: 10470003,
|
|
157
|
+
name: 'main',
|
|
158
|
+
target: 'branch',
|
|
159
|
+
enforcement: 'active',
|
|
160
|
+
bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }],
|
|
161
|
+
conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } },
|
|
162
|
+
rules: [
|
|
163
|
+
{ type: 'deletion' },
|
|
164
|
+
{ type: 'non_fast_forward' },
|
|
165
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
const fake = makeRecordingFake({ magictransit: live });
|
|
169
|
+
const declaration = props({
|
|
170
|
+
repository: 'magictransit',
|
|
171
|
+
bypassActors: [{ actorType: 'RepositoryRole', actorId: 5, bypassMode: 'always' }],
|
|
172
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
173
|
+
});
|
|
174
|
+
await run(reconcileWithOctokit(fake.octokit, declaration, { rulesetId: 10470003 }));
|
|
175
|
+
expect(fake.writes[0]?.body?.bypass_actors).toEqual([
|
|
176
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
} from './repository-ruleset-errors.ts';
|
|
15
15
|
import type { RulesetOctokit, RulesetRecord } from './repository-ruleset-probe.ts';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** Exported: repository-ruleset-narrowing-guards.ts reuses the identical key so a widened and a
|
|
18
|
+
* narrowed actor are never computed by two independently-drifting definitions of "the same
|
|
19
|
+
* actor". */
|
|
20
|
+
export const bypassKey = (a: { actor_type: unknown; actor_id: unknown; bypass_mode?: unknown }) =>
|
|
18
21
|
`${String(a.actor_type)}:${String(a.actor_id ?? '')}:${String(a.bypass_mode ?? 'always')}`;
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -56,7 +59,10 @@ export function bypassWideningRefusal(input: {
|
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
|
|
62
|
+
/** Exported for the same reason as `bypassKey` — repository-ruleset-narrowing-guards.ts's
|
|
63
|
+
* `ruleNarrowingRefusal` needs the identical live-type set `undeclaredLiveRuleRefusal` computes,
|
|
64
|
+
* not a second reading of `live.rules` that could disagree with this one. */
|
|
65
|
+
export const liveRuleTypes = (live: RulesetRecord | undefined): Set<string> =>
|
|
60
66
|
new Set((live?.rules ?? []).map((r) => String(r.type)));
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrowing refusals — the counterpart to `bypassWideningRefusal`/`undeclaredLiveRuleRefusal` in
|
|
3
|
+
* repository-ruleset-guards.ts, split into its own file purely to keep both under the 250-line
|
|
4
|
+
* cap (same reason repository-ruleset-props.ts exists).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ WIDENING STAYS UNCONDITIONAL; NARROWING GETS AN ESCAPE HATCH. Widening bypass (granting a
|
|
7
|
+
* NEW actor) has no legitimate reason to happen through a deploy — "grant it by hand and adopt
|
|
8
|
+
* the change" (repository-ruleset-errors.ts's `BypassActorWidened` message) already covers it,
|
|
9
|
+
* and this file does not touch that guard. NARROWING (removing a live bypass actor, or
|
|
10
|
+
* dropping a live rule) is different: it is exactly what an EXACT-ADOPT declaration of a repo
|
|
11
|
+
* whose live ruleset is narrower than the house baseline needs to express on its very first
|
|
12
|
+
* deploy — a caller declaring `taslabs-net/taslabs-net`'s live shape (measured live via `gh
|
|
13
|
+
* api repos/taslabs-net/taslabs-net/rulesets/12206080`, 2026-09-23: `bypass_actors:
|
|
14
|
+
* [{actor_id: 5, actor_type: "RepositoryRole", bypass_mode: "always"}]`, `rules: [deletion,
|
|
15
|
+
* non_fast_forward]`, no `pull_request` rule) is not narrowing anything by declaring exactly
|
|
16
|
+
* that shape — it is matching live exactly, and `reconcileRuleset`'s own noop check (comparing
|
|
17
|
+
* canonicalized wire forms) makes zero writes for it, same as any other exact match. What
|
|
18
|
+
* these guards refuse is a LATER declaration that removes something the CURRENT live state
|
|
19
|
+
* still has, without saying why. `acknowledgeBypassNarrowing`/`acknowledgeRuleNarrowing` are
|
|
20
|
+
* the "why": a reasoned, deliberate sign-off, not a rubber stamp — see their doc comments on
|
|
21
|
+
* `RepositoryRulesetProps` (repository-ruleset-props.ts).
|
|
22
|
+
*
|
|
23
|
+
* ⛔ THE TWO ACKNOWLEDGEMENTS ARE DELIBERATELY SEPARATE PROPS, NOT ONE SHARED FLAG (2026-09-23
|
|
24
|
+
* review finding). A single `acknowledgeNarrowing` computed once and handed to both guards let
|
|
25
|
+
* a reason written for one kind of narrowing silently also excuse the other — a declaration
|
|
26
|
+
* dropping BOTH a live rule and a live bypass actor at once needed only one acknowledgement,
|
|
27
|
+
* however narrowly worded, to pass both checks. Each guard below now reads its OWN prop.
|
|
28
|
+
*/
|
|
29
|
+
import { bypassKey, liveRuleTypes } from './repository-ruleset-guards.ts';
|
|
30
|
+
import { BypassActorNarrowed, RuleNarrowed } from './repository-ruleset-errors.ts';
|
|
31
|
+
import type { RepositoryRulesetProps, RepositoryRulesetRules } from './repository-ruleset-props.ts';
|
|
32
|
+
import type { RulesetRecord } from './repository-ruleset-probe.ts';
|
|
33
|
+
|
|
34
|
+
/** `true` only for a non-empty, non-whitespace `reason` on `acknowledgeBypassNarrowing` — a bare
|
|
35
|
+
* `{}` or a blank string is not an acknowledgement (repository-ruleset.ts's `constraintRefusal`
|
|
36
|
+
* refuses the latter at plan time; this is the read `bypassNarrowingRefusal` shares). Deliberately
|
|
37
|
+
* ignorant of `acknowledgeRuleNarrowing` — see the file header. */
|
|
38
|
+
export const isBypassNarrowingAcknowledged = (props: RepositoryRulesetProps): boolean =>
|
|
39
|
+
props.acknowledgeBypassNarrowing !== undefined &&
|
|
40
|
+
props.acknowledgeBypassNarrowing.reason.trim().length > 0;
|
|
41
|
+
|
|
42
|
+
/** The `rules`-side counterpart to `isBypassNarrowingAcknowledged`, reading only
|
|
43
|
+
* `acknowledgeRuleNarrowing` — see the file header. */
|
|
44
|
+
export const isRuleNarrowingAcknowledged = (props: RepositoryRulesetProps): boolean =>
|
|
45
|
+
props.acknowledgeRuleNarrowing !== undefined &&
|
|
46
|
+
props.acknowledgeRuleNarrowing.reason.trim().length > 0;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A live bypass actor the declaration drops, unacknowledged. `undefined` `declared` never
|
|
50
|
+
* reaches here — same rule as `bypassWideningRefusal`: unmanaged means the live value is
|
|
51
|
+
* preserved unchanged (repository-ruleset-reconcile.ts), so there is nothing to narrow.
|
|
52
|
+
*/
|
|
53
|
+
export function bypassNarrowingRefusal(input: {
|
|
54
|
+
readonly owner: string;
|
|
55
|
+
readonly repository: string;
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly declared: RepositoryRulesetProps['bypassActors'];
|
|
58
|
+
readonly live: RulesetRecord | undefined;
|
|
59
|
+
readonly acknowledged: boolean;
|
|
60
|
+
}): BypassActorNarrowed | undefined {
|
|
61
|
+
if (input.acknowledged) return undefined;
|
|
62
|
+
if (input.live === undefined) return undefined;
|
|
63
|
+
const liveActors = (input.live.bypass_actors ?? []) as {
|
|
64
|
+
actor_type: unknown;
|
|
65
|
+
actor_id: unknown;
|
|
66
|
+
bypass_mode?: unknown;
|
|
67
|
+
}[];
|
|
68
|
+
const declaredKeys = new Set(
|
|
69
|
+
(input.declared ?? []).map((a) =>
|
|
70
|
+
bypassKey({
|
|
71
|
+
actor_type: a.actorType,
|
|
72
|
+
actor_id: a.actorId,
|
|
73
|
+
bypass_mode: a.bypassMode ?? 'always',
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
const narrowed = liveActors.map(bypassKey).filter((k) => !declaredKeys.has(k));
|
|
78
|
+
if (narrowed.length === 0) return undefined;
|
|
79
|
+
return new BypassActorNarrowed({
|
|
80
|
+
owner: input.owner,
|
|
81
|
+
repository: input.repository,
|
|
82
|
+
name: input.name,
|
|
83
|
+
narrowed,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The live rule `type` for the first boolean/`pullRequest` rule the declaration marks `false`
|
|
88
|
+
* while live still has it — `undefined` when none does. `requiredStatusChecks: false` is
|
|
89
|
+
* checked by the caller directly (its live-side read needs `required_status_checks`'s own
|
|
90
|
+
* parameters shape, not just presence, elsewhere in this family — kept out of this helper so it
|
|
91
|
+
* stays a plain type-to-type comparison). */
|
|
92
|
+
function droppedBooleanOrPullRequestType(
|
|
93
|
+
rules: RepositoryRulesetRules,
|
|
94
|
+
live: ReadonlySet<string>,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
const declaredFalse: readonly [boolean | undefined, string][] = [
|
|
97
|
+
[rules.creation, 'creation'],
|
|
98
|
+
[rules.update, 'update'],
|
|
99
|
+
[rules.deletion, 'deletion'],
|
|
100
|
+
[rules.requiredLinearHistory, 'required_linear_history'],
|
|
101
|
+
[rules.requiredSignatures, 'required_signatures'],
|
|
102
|
+
[rules.nonFastForward, 'non_fast_forward'],
|
|
103
|
+
];
|
|
104
|
+
for (const [declared, type] of declaredFalse) {
|
|
105
|
+
if (declared === false && live.has(type)) return type;
|
|
106
|
+
}
|
|
107
|
+
if (rules.pullRequest === false && live.has('pull_request')) return 'pull_request';
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A rule the declaration explicitly marks absent (a boolean rule or `pullRequest` declared
|
|
113
|
+
* `false`, or `requiredStatusChecks: false`) while the LIVE ruleset still has it, unacknowledged
|
|
114
|
+
* — the drop `undeclaredLiveRuleRefusal` (repository-ruleset-guards.ts) never sees, because an
|
|
115
|
+
* explicit `false` IS "declared" there (see its own `declaredRuleTypes`).
|
|
116
|
+
*/
|
|
117
|
+
export function ruleNarrowingRefusal(input: {
|
|
118
|
+
readonly owner: string;
|
|
119
|
+
readonly repository: string;
|
|
120
|
+
readonly name: string;
|
|
121
|
+
readonly declared: RepositoryRulesetProps['rules'];
|
|
122
|
+
readonly live: RulesetRecord | undefined;
|
|
123
|
+
readonly acknowledged: boolean;
|
|
124
|
+
}): RuleNarrowed | undefined {
|
|
125
|
+
if (input.acknowledged) return undefined;
|
|
126
|
+
const rules = input.declared;
|
|
127
|
+
if (rules === undefined) return undefined;
|
|
128
|
+
const live = liveRuleTypes(input.live);
|
|
129
|
+
const ruleType =
|
|
130
|
+
droppedBooleanOrPullRequestType(rules, live) ??
|
|
131
|
+
(rules.requiredStatusChecks === false && live.has('required_status_checks')
|
|
132
|
+
? 'required_status_checks'
|
|
133
|
+
: undefined);
|
|
134
|
+
if (ruleType === undefined) return undefined;
|
|
135
|
+
return new RuleNarrowed({
|
|
136
|
+
owner: input.owner,
|
|
137
|
+
repository: input.repository,
|
|
138
|
+
name: input.name,
|
|
139
|
+
ruleType,
|
|
140
|
+
});
|
|
141
|
+
}
|