@homeflare/alchemy 0.12.0 → 0.14.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 +7 -0
- package/dist/github/index.d.ts +18 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/github/index.js +161 -0
- package/dist/github/index.js.map +12 -0
- package/dist/github/repo-policy-form.d.ts +86 -0
- package/dist/github/repo-policy-form.d.ts.map +1 -0
- package/dist/github/repo-policy-guards.d.ts +72 -0
- package/dist/github/repo-policy-guards.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +21 -0
- package/dist/github/repo-policy.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +278 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +57 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +8 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +681 -183
- package/dist/proxmox/index.js.map +14 -4
- package/dist/proxmox/notification-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +4 -3
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopted-deploys.md +8 -0
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/proxmox.md +3 -0
- package/docs/repo-policy-ruleset-hazards.md +65 -0
- package/docs/repo-policy.md +155 -0
- package/package.json +5 -1
- package/src/github/index.ts +23 -0
- package/src/github/repo-policy-form.test.ts +239 -0
- package/src/github/repo-policy-form.ts +173 -0
- package/src/github/repo-policy-guards.ts +179 -0
- package/src/github/repo-policy.test.ts +58 -0
- package/src/github/repo-policy.ts +65 -0
- package/src/index.ts +1 -0
- package/src/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/generated/pbs.ts +329 -0
- package/src/proxmox/generated/pve.ts +62 -0
- package/src/proxmox/index.test.ts +3 -0
- package/src/proxmox/index.ts +18 -0
- package/src/proxmox/notification-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- package/src/proxmox/pbs-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- package/src/proxmox/resource.ts +6 -6
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/verify/fake-engine.ts +8 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { type RepoPolicyRepositorySettings, repoPolicy } from './repo-policy-form.ts';
|
|
3
|
+
|
|
4
|
+
const base = { checks: ['ci', 'secret scan'], owner: 'acme', repository: 'widget' } as const;
|
|
5
|
+
|
|
6
|
+
describe('repoPolicy — the repository half', () => {
|
|
7
|
+
test('squash only, auto-merge on, head branches deleted', () => {
|
|
8
|
+
const { repository } = repoPolicy(base);
|
|
9
|
+
|
|
10
|
+
expect(repository).toMatchObject({
|
|
11
|
+
allowAutoMerge: true,
|
|
12
|
+
allowMergeCommit: false,
|
|
13
|
+
allowRebaseMerge: false,
|
|
14
|
+
allowSquashMerge: true,
|
|
15
|
+
deleteBranchOnMerge: true,
|
|
16
|
+
name: 'widget',
|
|
17
|
+
owner: 'acme',
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('caller settings pass through but cannot re-open a merge method', () => {
|
|
22
|
+
// ⛔ The policy fields are spread AFTER `settings` for exactly this reason. A caller
|
|
23
|
+
// who could turn merge commits back on here would defeat the helper silently.
|
|
24
|
+
// ★ `RepoPolicyRepositorySettings` already OMITS the merge keys, so this cast is the
|
|
25
|
+
// only way to write the call at all — the type is the first guard, this is the
|
|
26
|
+
// second, for a JavaScript consumer the compiler never sees.
|
|
27
|
+
const settings = { allowMergeCommit: true, description: 'a widget' } as unknown;
|
|
28
|
+
const { repository } = repoPolicy({
|
|
29
|
+
...base,
|
|
30
|
+
settings: settings as RepoPolicyRepositorySettings,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(repository.description).toBe('a widget');
|
|
34
|
+
expect(repository.allowMergeCommit).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('repoPolicy — the ruleset half', () => {
|
|
39
|
+
test('covers the default branch, blocks deletion and force pushes, strict off', () => {
|
|
40
|
+
const { ruleset } = repoPolicy(base);
|
|
41
|
+
|
|
42
|
+
expect(ruleset).toMatchObject({
|
|
43
|
+
enforcement: 'active',
|
|
44
|
+
name: 'main',
|
|
45
|
+
owner: 'acme',
|
|
46
|
+
repository: 'widget',
|
|
47
|
+
target: 'branch',
|
|
48
|
+
});
|
|
49
|
+
expect(ruleset.conditions).toEqual({ exclude: [], include: ['~DEFAULT_BRANCH'] });
|
|
50
|
+
expect(ruleset.rules?.deletion).toBe(true);
|
|
51
|
+
expect(ruleset.rules?.nonFastForward).toBe(true);
|
|
52
|
+
expect(ruleset.rules?.requiredStatusChecks?.strictRequiredStatusChecksPolicy).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('nobody bypasses unless the caller names an actor', () => {
|
|
56
|
+
expect(repoPolicy(base).ruleset.bypassActors).toEqual([]);
|
|
57
|
+
expect(
|
|
58
|
+
repoPolicy({ ...base, bypassActors: [{ actorId: 5, actorType: 'RepositoryRole' }] }).ruleset
|
|
59
|
+
.bypassActors,
|
|
60
|
+
).toEqual([{ actorId: 5, actorType: 'RepositoryRole' }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('checks are trimmed, de-duplicated and sorted, so re-ordering is not a diff', () => {
|
|
64
|
+
// ⚠️ THE NO-OP PROPERTY. GitHub stores the contexts in the order they were sent, so
|
|
65
|
+
// an unsorted list would diff against itself the first time someone wrote the same
|
|
66
|
+
// two contexts the other way round — a deploy that changes nothing and says it did.
|
|
67
|
+
const one = repoPolicy({ ...base, checks: ['ci', 'secret scan'] });
|
|
68
|
+
const other = repoPolicy({ ...base, checks: [' secret scan ', 'ci', 'ci'] });
|
|
69
|
+
|
|
70
|
+
expect(other).toEqual(one);
|
|
71
|
+
expect(one.ruleset.rules?.requiredStatusChecks?.checks).toEqual([
|
|
72
|
+
{ context: 'ci' },
|
|
73
|
+
{ context: 'secret scan' },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the ruleset name and the refs it covers are the callers', () => {
|
|
78
|
+
const { ruleset } = repoPolicy({
|
|
79
|
+
...base,
|
|
80
|
+
exclude: ['refs/heads/dev/*'],
|
|
81
|
+
include: ['refs/heads/release/*'],
|
|
82
|
+
rulesetName: 'release',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(ruleset.name).toBe('release');
|
|
86
|
+
expect(ruleset.conditions).toEqual({
|
|
87
|
+
exclude: ['refs/heads/dev/*'],
|
|
88
|
+
include: ['refs/heads/release/*'],
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('repoPolicy — required reviews are opt-in', () => {
|
|
94
|
+
test('no pull_request rule unless approvals were asked for', () => {
|
|
95
|
+
expect(repoPolicy(base).ruleset.rules?.pullRequest).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('asking for approvals declares the rule and dismisses stale reviews', () => {
|
|
99
|
+
expect(repoPolicy({ ...base, requiredApprovals: 2 }).ruleset.rules?.pullRequest).toEqual({
|
|
100
|
+
dismissStaleReviewsOnPush: true,
|
|
101
|
+
requiredApprovingReviewCount: 2,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('zero approvals is refused, naming the flag Alchemy cannot send', () => {
|
|
106
|
+
// ⛔ Not pedantry: 0 is the solo-maintainer shape, and it needs a field the vendor
|
|
107
|
+
// resource has no property for. Accepting it would ship a rule that blocks the
|
|
108
|
+
// pull requests it was written to allow.
|
|
109
|
+
expect(() => repoPolicy({ ...base, requiredApprovals: 0 })).toThrow(
|
|
110
|
+
/require_extra_approval_for_unattributed_changes/,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a fractional or negative count is refused too', () => {
|
|
115
|
+
expect(() => repoPolicy({ ...base, requiredApprovals: 1.5 })).toThrow(/whole number/);
|
|
116
|
+
expect(() => repoPolicy({ ...base, requiredApprovals: -1 })).toThrow(/whole number/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('repoPolicy — the auto-merge trap', () => {
|
|
121
|
+
test('auto-merge with nothing required at all is refused', () => {
|
|
122
|
+
// ⛔ THE TRAP THIS HELPER EXISTS FOR. `gh pr merge --auto` is a queue only while
|
|
123
|
+
// something is outstanding; with nothing required GitHub merges on the spot.
|
|
124
|
+
expect(() => repoPolicy({ ...base, checks: [] })).toThrow(/immediately/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a required review is something outstanding, so an empty checks list is fine', () => {
|
|
128
|
+
// ⚠️ The guard is not "no checks": auto-merge waits on a required approval too, and
|
|
129
|
+
// refusing that combination would block a legitimate review-gated repository.
|
|
130
|
+
const { repository, ruleset } = repoPolicy({ ...base, checks: [], requiredApprovals: 1 });
|
|
131
|
+
|
|
132
|
+
expect(repository.allowAutoMerge).toBe(true);
|
|
133
|
+
expect(ruleset.rules?.requiredStatusChecks).toBeUndefined();
|
|
134
|
+
expect(ruleset.rules?.pullRequest?.requiredApprovingReviewCount).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('turning auto-merge off is the only way to declare an empty checks list', () => {
|
|
138
|
+
const { repository, ruleset } = repoPolicy({ ...base, autoMerge: false, checks: [] });
|
|
139
|
+
|
|
140
|
+
expect(repository.allowAutoMerge).toBe(false);
|
|
141
|
+
expect(ruleset.rules?.requiredStatusChecks).toBeUndefined();
|
|
142
|
+
// The branch is still protected — that is the point of the bootstrap state.
|
|
143
|
+
expect(ruleset.rules?.deletion).toBe(true);
|
|
144
|
+
expect(ruleset.rules?.nonFastForward).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a blank context is refused rather than stored as a check nothing reports', () => {
|
|
148
|
+
expect(() => repoPolicy({ ...base, checks: ['ci', ' '] })).toThrow(/blank/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('auto-merge over a ruleset that is not enforcing is refused', () => {
|
|
152
|
+
// ⚠️ THE QUIET DOOR. The checks are listed on the ruleset and none of them block
|
|
153
|
+
// anything, so auto-merge lands the pull request while the plan looks correct.
|
|
154
|
+
expect(() => repoPolicy({ ...base, enforcement: 'evaluate' })).toThrow(/enforcement/);
|
|
155
|
+
expect(() => repoPolicy({ ...base, enforcement: 'disabled' })).toThrow(/immediately/);
|
|
156
|
+
expect(
|
|
157
|
+
repoPolicy({ ...base, autoMerge: false, enforcement: 'evaluate' }).ruleset.enforcement,
|
|
158
|
+
).toBe('evaluate');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('an explicitly empty include is refused — it would match no ref at all', () => {
|
|
162
|
+
// ⚠️ THE QUIETEST DOOR. GitHub still shows the ruleset as active; it just covers
|
|
163
|
+
// nothing, so every rule on it is decorative.
|
|
164
|
+
expect(() => repoPolicy({ ...base, include: [] })).toThrow(/matches no ref/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a blank ref pattern is refused — length is not coverage', () => {
|
|
168
|
+
// ⚠️ THE DOOR BESIDE THE GUARD. `[' ']` has length 1, so a guard that counts the
|
|
169
|
+
// array passes it; GitHub then stores a condition that matches nothing. Identical
|
|
170
|
+
// end state to `include: []`, reached past the check that exists to stop it.
|
|
171
|
+
expect(() => repoPolicy({ ...base, include: [' '] })).toThrow(/blank/);
|
|
172
|
+
expect(() => repoPolicy({ ...base, exclude: [''] })).toThrow(/blank/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('an exclude that cancels every include is refused', () => {
|
|
176
|
+
// ⚠️ THE DOOR THAT READS AS A NARROWING. Exclusions win in a GitHub ruleset, so this
|
|
177
|
+
// is a ruleset over nothing — but it looks like scoping, not like switching off.
|
|
178
|
+
expect(() => repoPolicy({ ...base, exclude: ['~DEFAULT_BRANCH'] })).toThrow(
|
|
179
|
+
/cancels every include/,
|
|
180
|
+
);
|
|
181
|
+
expect(() =>
|
|
182
|
+
repoPolicy({
|
|
183
|
+
...base,
|
|
184
|
+
exclude: ['refs/heads/release/*', 'refs/heads/main'],
|
|
185
|
+
include: ['refs/heads/main', 'refs/heads/release/*'],
|
|
186
|
+
}),
|
|
187
|
+
).toThrow(/cancels every include/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('an exclude that leaves an include standing is a narrowing, and allowed', () => {
|
|
191
|
+
// ★ EXACT CANCELLATION ONLY. Deciding glob overlap in general means reimplementing
|
|
192
|
+
// GitHub's matcher; a narrow guard that is always right beats a broad one that
|
|
193
|
+
// false-refuses a legitimate scope.
|
|
194
|
+
const { ruleset } = repoPolicy({
|
|
195
|
+
...base,
|
|
196
|
+
exclude: ['refs/heads/release/*'],
|
|
197
|
+
include: ['~DEFAULT_BRANCH', 'refs/heads/release/*'],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ★ Sorted, so `refs/…` precedes the `~` tokens — the ordering is normalization, not
|
|
201
|
+
// a preference, and it is what keeps a second deploy from diffing against itself.
|
|
202
|
+
expect(ruleset.conditions).toEqual({
|
|
203
|
+
exclude: ['refs/heads/release/*'],
|
|
204
|
+
include: ['refs/heads/release/*', '~DEFAULT_BRANCH'],
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('ref patterns are trimmed, de-duplicated and sorted, so re-ordering is not a diff', () => {
|
|
209
|
+
const one = repoPolicy({ ...base, include: ['refs/heads/main', 'refs/heads/release/*'] });
|
|
210
|
+
const other = repoPolicy({
|
|
211
|
+
...base,
|
|
212
|
+
include: [' refs/heads/release/* ', 'refs/heads/main', 'refs/heads/main'],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(other).toEqual(one);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('repoPolicy — one host for both halves', () => {
|
|
220
|
+
test('baseUrl reaches the repository and the ruleset, or neither', () => {
|
|
221
|
+
// ⛔ Setting it on one would point the repository at an Enterprise host and its
|
|
222
|
+
// ruleset at github.com — two halves of one policy on two different instances.
|
|
223
|
+
const { repository, ruleset } = repoPolicy({ ...base, baseUrl: 'github.example.com' });
|
|
224
|
+
|
|
225
|
+
expect(repository.baseUrl).toBe('github.example.com');
|
|
226
|
+
expect(ruleset.baseUrl).toBe('github.example.com');
|
|
227
|
+
|
|
228
|
+
const plain = repoPolicy(base);
|
|
229
|
+
expect('baseUrl' in plain.repository).toBe(false);
|
|
230
|
+
expect('baseUrl' in plain.ruleset).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('repoPolicy — required inputs', () => {
|
|
235
|
+
test('an empty owner or repository is refused', () => {
|
|
236
|
+
expect(() => repoPolicy({ ...base, owner: ' ' })).toThrow(/owner is required/);
|
|
237
|
+
expect(() => repoPolicy({ ...base, repository: '' })).toThrow(/repository is required/);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `repoPolicy` — one repository's house merge policy, as plain Alchemy props.
|
|
3
|
+
*
|
|
4
|
+
* ★ PURE ON PURPOSE, AND TYPE-ONLY IMPORTS. The exact shape a stack will deploy can be
|
|
5
|
+
* asserted in a unit test with no credentials, no network and no Effect runtime.
|
|
6
|
+
* `repo-policy.ts` is the thin half that turns these props into resources, and
|
|
7
|
+
* `repo-policy-guards.ts` holds everything this refuses and why.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ AUTO-MERGE WITH NOTHING TO WAIT FOR MERGES IMMEDIATELY. `gh pr merge --auto` is a
|
|
10
|
+
* queue only while something is outstanding. Five different inputs produce "nothing
|
|
11
|
+
* outstanding" — no `checks` and no `requiredApprovals`, an `enforcement` that is not
|
|
12
|
+
* `active`, and three ways to land a ruleset that matches no ref (an empty `include`, a
|
|
13
|
+
* blank ref pattern, an `exclude` that cancels every `include`). All five are refused.
|
|
14
|
+
* See the guards module.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ EVERY CONTEXT IN `checks` MUST ALWAYS REPORT. A required context that never reports
|
|
17
|
+
* is not "passed", it is "pending", and the pull request waits on it forever —
|
|
18
|
+
* auto-merge included. Point this at an aggregate job that runs `if: always()` and
|
|
19
|
+
* fails when any upstream job did, never at a job behind a `paths:` filter, a matrix
|
|
20
|
+
* leg, or anything a skipped workflow can silence.
|
|
21
|
+
*/
|
|
22
|
+
import type { RepositoryProps, RulesetProps } from 'alchemy/GitHub';
|
|
23
|
+
import {
|
|
24
|
+
assertAutoMergeWaits,
|
|
25
|
+
normalizeChecks,
|
|
26
|
+
normalizeExclude,
|
|
27
|
+
normalizeInclude,
|
|
28
|
+
requireName,
|
|
29
|
+
resolveApprovals,
|
|
30
|
+
} from './repo-policy-guards.ts';
|
|
31
|
+
|
|
32
|
+
/** Alchemy's own bypass-actor shape, re-exported so a caller imports one module, not two. */
|
|
33
|
+
export type RepoPolicyBypassActor = NonNullable<RulesetProps['bypassActors']>[number];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Repository fields the policy does NOT own — description, topics, visibility, …
|
|
37
|
+
* ⛔ Merged UNDERNEATH the policy, so a caller can describe their repo but cannot quietly
|
|
38
|
+
* re-enable merge commits through this door. The omitted keys are the policy itself.
|
|
39
|
+
* ⛔ `baseUrl` IS OMITTED TOO, and is a top-level option instead. Setting it on only one
|
|
40
|
+
* of the two resources would point the repository at a GitHub Enterprise host and its
|
|
41
|
+
* ruleset at github.com — two halves of one policy on two different instances.
|
|
42
|
+
*/
|
|
43
|
+
export type RepoPolicyRepositorySettings = Omit<
|
|
44
|
+
RepositoryProps,
|
|
45
|
+
| 'allowAutoMerge'
|
|
46
|
+
| 'allowMergeCommit'
|
|
47
|
+
| 'allowRebaseMerge'
|
|
48
|
+
| 'allowSquashMerge'
|
|
49
|
+
| 'baseUrl'
|
|
50
|
+
| 'deleteBranchOnMerge'
|
|
51
|
+
| 'name'
|
|
52
|
+
| 'owner'
|
|
53
|
+
>;
|
|
54
|
+
|
|
55
|
+
export interface RepoPolicyOptions {
|
|
56
|
+
/** Repository owner — a user or organization login. */
|
|
57
|
+
readonly owner: string;
|
|
58
|
+
/** Repository name. */
|
|
59
|
+
readonly repository: string;
|
|
60
|
+
/**
|
|
61
|
+
* Status-check contexts a pull request must pass, named exactly as the job reports
|
|
62
|
+
* them. Trimmed, de-duplicated and sorted, so re-ordering the list is not a diff.
|
|
63
|
+
*/
|
|
64
|
+
readonly checks: readonly string[];
|
|
65
|
+
/** @default true — with nothing required it is refused, not quietly downgraded. */
|
|
66
|
+
readonly autoMerge?: boolean;
|
|
67
|
+
/** @default 'main' */
|
|
68
|
+
readonly rulesetName?: string;
|
|
69
|
+
/** Ref patterns the ruleset covers. @default ['~DEFAULT_BRANCH'] */
|
|
70
|
+
readonly include?: readonly string[];
|
|
71
|
+
/**
|
|
72
|
+
* Ref patterns exempted from it. @default []
|
|
73
|
+
* ⛔ Exclusions win over inclusions, so one that cancels every `include` is refused —
|
|
74
|
+
* it reads as a narrowing and acts as an off switch.
|
|
75
|
+
*/
|
|
76
|
+
readonly exclude?: readonly string[];
|
|
77
|
+
/** Who may bypass the ruleset. @default [] — nobody, administrators included. */
|
|
78
|
+
readonly bypassActors?: readonly RepoPolicyBypassActor[];
|
|
79
|
+
/**
|
|
80
|
+
* Approving reviews a pull request needs. Omit for none — `0` is refused rather than
|
|
81
|
+
* read as "no reviews"; `resolveApprovals` in the guards module says why.
|
|
82
|
+
*/
|
|
83
|
+
readonly requiredApprovals?: number;
|
|
84
|
+
/** @default 'active' — anything else is refused alongside `autoMerge`. */
|
|
85
|
+
readonly enforcement?: 'active' | 'disabled' | 'evaluate';
|
|
86
|
+
/** GitHub Enterprise host, applied to BOTH resources or to neither. */
|
|
87
|
+
readonly baseUrl?: string;
|
|
88
|
+
/** Everything about the repository that is not merge policy. */
|
|
89
|
+
readonly settings?: RepoPolicyRepositorySettings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RepoPolicy {
|
|
93
|
+
readonly repository: RepositoryProps;
|
|
94
|
+
readonly ruleset: RulesetProps;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_RULESET_NAME = 'main';
|
|
98
|
+
/**
|
|
99
|
+
* ★ `~DEFAULT_BRANCH`, NOT `refs/heads/main`. GitHub resolves it per repository, so one
|
|
100
|
+
* policy covers a repo whose default branch is `master` or `trunk`, and renaming the
|
|
101
|
+
* default branch later does not silently leave the ruleset pointing at nothing.
|
|
102
|
+
*/
|
|
103
|
+
const DEFAULT_INCLUDE: readonly string[] = ['~DEFAULT_BRANCH'];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The house policy for one repository: squash-only merges, auto-merge on, head branches
|
|
107
|
+
* deleted, and a ruleset over the default branch that blocks deletion and force pushes
|
|
108
|
+
* and requires `checks`.
|
|
109
|
+
*
|
|
110
|
+
* ★ `strictRequiredStatusChecksPolicy` IS ALWAYS FALSE, AND NOT A PARAMETER. "Require
|
|
111
|
+
* branches to be up to date" means every merge to the default branch invalidates every
|
|
112
|
+
* other open pull request, which with auto-merge on is a re-run storm that converges
|
|
113
|
+
* only when the queue empties. The checks still have to pass; they just do not have to
|
|
114
|
+
* have passed against the very tip.
|
|
115
|
+
*/
|
|
116
|
+
export function repoPolicy(options: RepoPolicyOptions): RepoPolicy {
|
|
117
|
+
const owner = requireName('owner', options.owner);
|
|
118
|
+
const repository = requireName('repository', options.repository);
|
|
119
|
+
const checks = normalizeChecks(options.checks);
|
|
120
|
+
const include = normalizeInclude(options.include, DEFAULT_INCLUDE);
|
|
121
|
+
const exclude = normalizeExclude(options.exclude, include);
|
|
122
|
+
const autoMerge = options.autoMerge ?? true;
|
|
123
|
+
const enforcement = options.enforcement ?? 'active';
|
|
124
|
+
const approvals = resolveApprovals(options.requiredApprovals);
|
|
125
|
+
assertAutoMergeWaits({ approvals, autoMerge, checkCount: checks.length, enforcement });
|
|
126
|
+
const host = options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl };
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
repository: {
|
|
130
|
+
...options.settings,
|
|
131
|
+
...host,
|
|
132
|
+
// ⛔ Squash only. The ruleset's own `allowed_merge_methods` is not reachable through
|
|
133
|
+
// Alchemy's `pullRequest` rule, so the repository is the only place this holds.
|
|
134
|
+
allowAutoMerge: autoMerge,
|
|
135
|
+
allowMergeCommit: false,
|
|
136
|
+
allowRebaseMerge: false,
|
|
137
|
+
allowSquashMerge: true,
|
|
138
|
+
deleteBranchOnMerge: true,
|
|
139
|
+
name: repository,
|
|
140
|
+
owner,
|
|
141
|
+
},
|
|
142
|
+
ruleset: {
|
|
143
|
+
...host,
|
|
144
|
+
bypassActors: [...(options.bypassActors ?? [])],
|
|
145
|
+
conditions: { exclude, include },
|
|
146
|
+
enforcement,
|
|
147
|
+
name: options.rulesetName ?? DEFAULT_RULESET_NAME,
|
|
148
|
+
owner,
|
|
149
|
+
repository,
|
|
150
|
+
rules: {
|
|
151
|
+
deletion: true,
|
|
152
|
+
nonFastForward: true,
|
|
153
|
+
...(approvals === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: {
|
|
156
|
+
pullRequest: {
|
|
157
|
+
dismissStaleReviewsOnPush: true,
|
|
158
|
+
requiredApprovingReviewCount: approvals,
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
...(checks.length === 0
|
|
162
|
+
? {}
|
|
163
|
+
: {
|
|
164
|
+
requiredStatusChecks: {
|
|
165
|
+
checks: checks.map((context) => ({ context })),
|
|
166
|
+
strictRequiredStatusChecksPolicy: false,
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
target: 'branch',
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything `repoPolicy` refuses, and why. Extracted from repo-policy-form.ts so the
|
|
3
|
+
* shape stays readable and the reasoning stays whole.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ FIVE DIFFERENT DOORS LEAD TO THE SAME FAILURE — auto-merge with nothing to wait for.
|
|
6
|
+
* `gh pr merge --auto` is a queue only while something is outstanding; with nothing
|
|
7
|
+
* outstanding GitHub merges the pull request on the spot. An empty `checks` is the
|
|
8
|
+
* obvious door. `enforcement: 'disabled'` or `'evaluate'` is the quiet one: the ruleset
|
|
9
|
+
* exists, the required checks are listed on it, and none of them block anything. Then
|
|
10
|
+
* three ways to end up with a ruleset that matches NO REF — which enforces on no ref
|
|
11
|
+
* while the GitHub UI still shows it as `active`: an empty `include`, a blank ref
|
|
12
|
+
* pattern, and an `exclude` that cancels every `include`. All five are refused.
|
|
13
|
+
*
|
|
14
|
+
* ★ THE REF DOORS WERE FOUND BY ADVERSARIAL REVIEW, NOT BY DESIGN. The first cut of this
|
|
15
|
+
* module guarded `include.length === 0` and spread `exclude` raw, so
|
|
16
|
+
* `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` and `include: [' ']`
|
|
17
|
+
* both sailed through with `allowAutoMerge: true` — the exact end state the empty-array
|
|
18
|
+
* guard exists to prevent, reached by a door beside it. Checked by execution, not by
|
|
19
|
+
* reading: see the `matches no ref` tests in repo-policy-form.test.ts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A non-empty, trimmed identifier, or an error naming the field. */
|
|
23
|
+
export function requireName(field: string, value: string): string {
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
if (trimmed === '') throw new Error(`repoPolicy: ${field} is required`);
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Trim, de-duplicate, sort. ★ The sort is what makes a second deploy a no-op: GitHub
|
|
31
|
+
* stores the contexts in the order they were sent, so an unsorted list would diff against
|
|
32
|
+
* itself the first time a caller wrote the same contexts in a different order.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeChecks(checks: readonly string[]): string[] {
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
for (const raw of checks) {
|
|
37
|
+
const context = raw.trim();
|
|
38
|
+
// ⚠️ A blank context is a typo that reads as coverage: GitHub happily stores it and
|
|
39
|
+
// no job ever reports it, so every pull request waits on a check that cannot come.
|
|
40
|
+
if (context === '') {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'repoPolicy: a required check context is blank — name it exactly as the job reports it',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
seen.add(context);
|
|
46
|
+
}
|
|
47
|
+
return [...seen].sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Trim, de-duplicate, sort one list of ref patterns, refusing a blank one.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ A BLANK PATTERN IS NOT AN EMPTY LIST, AND THAT IS THE WHOLE TRAP. `[' ']` has
|
|
54
|
+
* length 1, so a guard that counts the array waves it through; GitHub then stores a ref
|
|
55
|
+
* condition that matches nothing. Same end state as `[]`, reached past the guard.
|
|
56
|
+
* ★ Sorted for the same reason `normalizeChecks` is: GitHub echoes the patterns back in
|
|
57
|
+
* the order they were sent, so an unsorted list diffs against itself the first time a
|
|
58
|
+
* caller writes the same patterns in a different order.
|
|
59
|
+
*/
|
|
60
|
+
function normalizeRefs(field: string, patterns: readonly string[]): string[] {
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
for (const raw of patterns) {
|
|
63
|
+
const pattern = raw.trim();
|
|
64
|
+
if (pattern === '') {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`repoPolicy: a ${field} ref pattern is blank — a ruleset that matches no ref enforces nothing.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
seen.add(pattern);
|
|
70
|
+
}
|
|
71
|
+
return [...seen].sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ⛔ AN EMPTY `include` IS REFUSED. Alchemy defaults `conditions.include` to `['~ALL']`
|
|
76
|
+
* when it is absent, but an explicitly empty array is sent as an empty array, and a
|
|
77
|
+
* ruleset that matches no ref protects nothing while still reading as `active` in the
|
|
78
|
+
* GitHub UI — the worst possible combination, because it looks like it worked.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeInclude(
|
|
81
|
+
include: readonly string[] | undefined,
|
|
82
|
+
fallback: readonly string[],
|
|
83
|
+
): string[] {
|
|
84
|
+
if (include === undefined) return [...fallback];
|
|
85
|
+
if (include.length === 0) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'repoPolicy: include is empty — a ruleset that matches no ref enforces nothing. Omit it for the default branch.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return normalizeRefs('include', include);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ⛔ AN `exclude` THAT CANCELS EVERY `include` IS REFUSED. In a GitHub ruleset the
|
|
95
|
+
* exclusions win, so `include: ['~DEFAULT_BRANCH'], exclude: ['~DEFAULT_BRANCH']` is a
|
|
96
|
+
* ruleset over nothing — and it reads as a narrowing, which is what makes it worse than
|
|
97
|
+
* the empty array: the caller believes they scoped the policy, not switched it off.
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ EXACT CANCELLATION ONLY, DELIBERATELY. GitHub's patterns are globs, so deciding in
|
|
100
|
+
* general whether some exclude swallows some include means implementing GitHub's
|
|
101
|
+
* matcher and being wrong about it quietly. This refuses the case that is decidable —
|
|
102
|
+
* every include pattern also appears verbatim in `exclude` — and leaves
|
|
103
|
+
* `exclude: ['refs/heads/*']` against `include: ['~DEFAULT_BRANCH']` to the operator.
|
|
104
|
+
* A narrower guard that is always right beats a broad one that false-refuses.
|
|
105
|
+
*/
|
|
106
|
+
export function normalizeExclude(
|
|
107
|
+
exclude: readonly string[] | undefined,
|
|
108
|
+
include: readonly string[],
|
|
109
|
+
): string[] {
|
|
110
|
+
const patterns = exclude === undefined ? [] : normalizeRefs('exclude', exclude);
|
|
111
|
+
if (patterns.length === 0) return patterns;
|
|
112
|
+
const excluded = new Set(patterns);
|
|
113
|
+
if (include.every((pattern) => excluded.has(pattern))) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
[
|
|
116
|
+
'repoPolicy: exclude cancels every include pattern, so the ruleset matches no ref and enforces nothing.',
|
|
117
|
+
`include ${JSON.stringify(include)} is fully covered by exclude ${JSON.stringify(patterns)}.`,
|
|
118
|
+
].join(' '),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return patterns;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* ⛔ `0` IS REFUSED, NOT TREATED AS "NO REVIEWS". A `pull_request` rule with zero
|
|
126
|
+
* approvals is the solo-maintainer shape — every change through a PR, no second person
|
|
127
|
+
* — and it only works with `require_extra_approval_for_unattributed_changes: false`.
|
|
128
|
+
* alchemy@2.0.0-beta.79's `Ruleset` cannot express that field, and GitHub defaults it
|
|
129
|
+
* to `true` when it is omitted (measured 2026-09-17), which blocks the very PRs the
|
|
130
|
+
* zero was meant to let through. Omitting `requiredApprovals` declares no rule at all,
|
|
131
|
+
* which is a different and honest thing.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveApprovals(requested: number | undefined): number | undefined {
|
|
134
|
+
if (requested === undefined) return undefined;
|
|
135
|
+
if (!Number.isInteger(requested) || requested < 1) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
[
|
|
138
|
+
'repoPolicy: requiredApprovals must be a whole number of 1 or more.',
|
|
139
|
+
'Omit it to declare no pull_request rule.',
|
|
140
|
+
'0 is not that: it needs require_extra_approval_for_unattributed_changes: false,',
|
|
141
|
+
'which alchemy@2.0.0-beta.79 cannot send and GitHub defaults to true.',
|
|
142
|
+
].join(' '),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return requested;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* ⚠️ THE TEST IS "SOMETHING OUTSTANDING", NOT "SOME CHECKS". A required review holds a
|
|
150
|
+
* pull request open just as a required check does, so `requiredApprovals` with an empty
|
|
151
|
+
* `checks` is a legitimate policy. What is refused is auto-merge over a ruleset that
|
|
152
|
+
* will not hold anything back.
|
|
153
|
+
*/
|
|
154
|
+
export function assertAutoMergeWaits(input: {
|
|
155
|
+
readonly autoMerge: boolean;
|
|
156
|
+
readonly checkCount: number;
|
|
157
|
+
readonly approvals: number | undefined;
|
|
158
|
+
readonly enforcement: 'active' | 'disabled' | 'evaluate';
|
|
159
|
+
}): void {
|
|
160
|
+
if (!input.autoMerge) return;
|
|
161
|
+
if (input.enforcement !== 'active') {
|
|
162
|
+
throw new Error(
|
|
163
|
+
[
|
|
164
|
+
`repoPolicy: autoMerge with enforcement '${input.enforcement}' merges every pull request immediately.`,
|
|
165
|
+
'A ruleset that is not active enforces none of its rules, so auto-merge has nothing to wait for.',
|
|
166
|
+
'Pass autoMerge: false while the ruleset is being evaluated.',
|
|
167
|
+
].join(' '),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (input.checkCount === 0 && input.approvals === undefined) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
[
|
|
173
|
+
'repoPolicy: autoMerge with nothing required merges every pull request immediately.',
|
|
174
|
+
'Name at least one always-reporting check context, or ask for requiredApprovals,',
|
|
175
|
+
'or pass autoMerge: false for a repository that has no green run to require yet.',
|
|
176
|
+
].join(' '),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declaring half, and the two docs that carry its traps.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY TEST A DOC. Both files ship in the tarball and are the only place a consumer is
|
|
5
|
+
* told that the vendor ruleset adopts nothing and that auto-merge with nothing required
|
|
6
|
+
* merges on the spot. A silent edit that drops either warning would fail nothing else
|
|
7
|
+
* in this repo, and would cost the consumer a duplicate ruleset or a PR merged on red.
|
|
8
|
+
* ⛔ THE HAZARDS LIVE IN A SIBLING FILE, AND THE POINTER IS TESTED TOO. Extracting them
|
|
9
|
+
* is only safe while `repo-policy.md` still sends the reader there; a dangling pointer
|
|
10
|
+
* would read as "no hazard" rather than as a broken link.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { declareRepoPolicy } from './index.ts';
|
|
14
|
+
|
|
15
|
+
const doc = await Bun.file(new URL('../../docs/repo-policy.md', import.meta.url)).text();
|
|
16
|
+
const hazards = await Bun.file(
|
|
17
|
+
new URL('../../docs/repo-policy-ruleset-hazards.md', import.meta.url),
|
|
18
|
+
).text();
|
|
19
|
+
|
|
20
|
+
describe('declareRepoPolicy', () => {
|
|
21
|
+
test('is a function, not a factory that reaches GitHub at import time', () => {
|
|
22
|
+
// ⚠️ Importing this module must not construct a resource or an Octokit client: the
|
|
23
|
+
// barrel is loaded by the consumer smoke test with no credentials in the process.
|
|
24
|
+
expect(typeof declareRepoPolicy).toBe('function');
|
|
25
|
+
expect(declareRepoPolicy.length).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('docs/repo-policy.md', () => {
|
|
30
|
+
test('stays under the 200-line cap', () => {
|
|
31
|
+
expect(doc.split('\n').length).toBeLessThanOrEqual(200);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('warns that auto-merge with nothing required merges immediately', () => {
|
|
35
|
+
expect(doc).toContain('auto-merge');
|
|
36
|
+
expect(doc).toMatch(/merges? (the pull request )?immediately/i);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('sends the reader to the ruleset hazards, which is where they now live', () => {
|
|
40
|
+
expect(doc).toContain('repo-policy-ruleset-hazards.md');
|
|
41
|
+
expect(doc).toMatch(/cannot be adopted/i);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('docs/repo-policy-ruleset-hazards.md', () => {
|
|
46
|
+
test('stays under the 200-line cap', () => {
|
|
47
|
+
expect(hazards.split('\n').length).toBeLessThanOrEqual(200);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('warns that the vendor ruleset cannot be adopted and duplicates instead', () => {
|
|
51
|
+
expect(hazards).toContain('createRepoRuleset');
|
|
52
|
+
expect(hazards).toContain('rulesets');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('carries the preflight that keeps a first deploy from duplicating', () => {
|
|
56
|
+
expect(hazards).toContain('gh api repos/<owner>/<repo>/rulesets');
|
|
57
|
+
});
|
|
58
|
+
});
|