@homeflare/alchemy 0.26.0 → 0.27.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/dist/github/declare-repo-baseline.d.ts +11 -0
- package/dist/github/declare-repo-baseline.d.ts.map +1 -0
- package/dist/github/index.d.ts +20 -11
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +715 -4
- package/dist/github/index.js.map +14 -4
- package/dist/github/repo-baseline-data.d.ts +75 -0
- package/dist/github/repo-baseline-data.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +4 -4
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-constraints.d.ts +105 -0
- package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
- package/dist/github/repository-ruleset-errors.d.ts +97 -0
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
- package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
- package/dist/github/repository-ruleset-form.d.ts +30 -0
- package/dist/github/repository-ruleset-form.d.ts.map +1 -0
- package/dist/github/repository-ruleset-guards.d.ts +70 -0
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +5 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-probe.d.ts +87 -0
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +89 -0
- package/dist/github/repository-ruleset.d.ts.map +1 -0
- package/dist/launchd/index.js.map +2 -2
- package/dist/launchd/port-claims.d.ts +2 -2
- package/dist/launchd/port-claims.d.ts.map +1 -1
- package/docs/launchd-ports.md +14 -17
- package/docs/repo-policy-ruleset-hazards.md +8 -0
- package/docs/repo-policy.md +27 -12
- package/docs/repository-ruleset.md +114 -0
- package/docs/upstream-conformance.md +12 -1
- package/docs/upstream-pr-ruleset-fixes.md +77 -0
- package/package.json +1 -1
- package/src/github/declare-repo-baseline.ts +42 -0
- package/src/github/index.ts +43 -11
- package/src/github/repo-baseline-data.test.ts +76 -0
- package/src/github/repo-baseline-data.ts +86 -0
- package/src/github/repo-policy.ts +17 -17
- package/src/github/repository-ruleset-constraints.ts +136 -0
- package/src/github/repository-ruleset-coverage.test.ts +82 -0
- package/src/github/repository-ruleset-errors.ts +136 -0
- package/src/github/repository-ruleset-fake-octokit.ts +131 -0
- package/src/github/repository-ruleset-fixtures.ts +183 -0
- package/src/github/repository-ruleset-form.ts +212 -0
- package/src/github/repository-ruleset-guards.ts +194 -0
- package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
- package/src/github/repository-ruleset-octokit.ts +140 -0
- package/src/github/repository-ruleset-probe.ts +116 -0
- package/src/github/repository-ruleset-reconcile.ts +176 -0
- package/src/github/repository-ruleset-test-helpers.ts +102 -0
- package/src/github/repository-ruleset-wire.test.ts +66 -0
- package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
- package/src/github/repository-ruleset-write.test.ts +83 -0
- package/src/github/repository-ruleset.ts +219 -0
- package/src/launchd/port-claims.ts +8 -9
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (ii) The write-recording fake, refusals: duplicate names, bypass widening, an undeclared live
|
|
3
|
+
* rule, required checks omitted, and a never-reported context each make ZERO writes; and a
|
|
4
|
+
* readback mismatch fails even though the write itself "succeeded" (S10). The happy paths
|
|
5
|
+
* (create, noop, update) are repository-ruleset-write.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { makeGetOnlyFake, makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
|
|
10
|
+
import type { RulesetOctokit } from './repository-ruleset-probe.ts';
|
|
11
|
+
import {
|
|
12
|
+
BASE_PULL_REQUEST,
|
|
13
|
+
fails,
|
|
14
|
+
props,
|
|
15
|
+
reconcileAgainst,
|
|
16
|
+
reconcileWithOctokit,
|
|
17
|
+
tagOf,
|
|
18
|
+
} from './repository-ruleset-test-helpers.ts';
|
|
19
|
+
|
|
20
|
+
describe('refusals make zero writes', () => {
|
|
21
|
+
test('duplicate ruleset names', async () => {
|
|
22
|
+
const fake: RulesetOctokit = {
|
|
23
|
+
list: () =>
|
|
24
|
+
Effect.succeed([
|
|
25
|
+
{ id: 1, name: 'main', target: 'branch' },
|
|
26
|
+
{ id: 2, name: 'main', target: 'branch' },
|
|
27
|
+
]),
|
|
28
|
+
get: () => Effect.succeed(undefined),
|
|
29
|
+
create: () => Effect.fail(new Error('must not be called')),
|
|
30
|
+
update: () => Effect.fail(new Error('must not be called')),
|
|
31
|
+
delete: () => Effect.fail(new Error('must not be called')),
|
|
32
|
+
hasContextReportedSuccess: () => Effect.succeed(true),
|
|
33
|
+
};
|
|
34
|
+
const error = await fails(reconcileWithOctokit(fake, props()));
|
|
35
|
+
expect(tagOf(error)).toBe('DuplicateRuleset');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('bypass widening', async () => {
|
|
39
|
+
const live = {
|
|
40
|
+
id: 1,
|
|
41
|
+
name: 'main',
|
|
42
|
+
enforcement: 'active',
|
|
43
|
+
bypass_actors: [],
|
|
44
|
+
conditions: {},
|
|
45
|
+
rules: [],
|
|
46
|
+
};
|
|
47
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
48
|
+
const error = await fails(
|
|
49
|
+
reconcileWithOctokit(fake, props({ bypassActors: [{ actorType: 'OrganizationAdmin' }] }), {
|
|
50
|
+
rulesetId: 1,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
expect(tagOf(error)).toBe('BypassActorWidened');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a refused live rule type (e.g. workflows) blocks any write', async () => {
|
|
57
|
+
const live = {
|
|
58
|
+
id: 1,
|
|
59
|
+
name: 'main',
|
|
60
|
+
enforcement: 'active',
|
|
61
|
+
bypass_actors: [],
|
|
62
|
+
conditions: {},
|
|
63
|
+
rules: [{ type: 'workflows', parameters: {} }],
|
|
64
|
+
};
|
|
65
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
66
|
+
const error = await fails(reconcileWithOctokit(fake, props(), { rulesetId: 1 }));
|
|
67
|
+
expect(tagOf(error)).toBe('UndeclaredLiveRule');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Regression for a bug this family's own first cut shipped with: an omitted boolean rule
|
|
71
|
+
// (as opposed to one explicitly declared `false`) was treated as "fully owned, so absence
|
|
72
|
+
// is the removal" and never refused — meaning forgetting `deletion: true` in a declaration
|
|
73
|
+
// would silently drop branch-deletion protection from a live ruleset on the next update.
|
|
74
|
+
// Found by self-review, not by a failing test; see undeclaredLiveRuleRefusal's own comment.
|
|
75
|
+
test('an omitted boolean rule (not declared false) the live ruleset has blocks any write', async () => {
|
|
76
|
+
const live = {
|
|
77
|
+
id: 1,
|
|
78
|
+
name: 'main',
|
|
79
|
+
enforcement: 'active',
|
|
80
|
+
bypass_actors: [],
|
|
81
|
+
conditions: {},
|
|
82
|
+
rules: [
|
|
83
|
+
{ type: 'deletion' },
|
|
84
|
+
{ type: 'non_fast_forward' },
|
|
85
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
89
|
+
// Declares pullRequest but says nothing at all about `deletion` — not even `false`.
|
|
90
|
+
const declaration = props({
|
|
91
|
+
rules: { nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
92
|
+
});
|
|
93
|
+
const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
|
|
94
|
+
expect(tagOf(error)).toBe('UndeclaredLiveRule');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the SAME live ruleset with deletion explicitly declared false is a deliberate removal, not refused', async () => {
|
|
98
|
+
const live = {
|
|
99
|
+
id: 1,
|
|
100
|
+
name: 'main',
|
|
101
|
+
enforcement: 'active',
|
|
102
|
+
bypass_actors: [],
|
|
103
|
+
conditions: {},
|
|
104
|
+
rules: [
|
|
105
|
+
{ type: 'deletion' },
|
|
106
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: 0 } },
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
const fake = makeRecordingFake({ widgets: live });
|
|
110
|
+
fake.reportedContexts.add('ci');
|
|
111
|
+
const declaration = props({
|
|
112
|
+
rules: { deletion: false, pullRequest: BASE_PULL_REQUEST },
|
|
113
|
+
});
|
|
114
|
+
await reconcileAgainst(fake, declaration, { rulesetId: 1 }).pipe(Effect.runPromise);
|
|
115
|
+
expect(fake.writes).toHaveLength(1);
|
|
116
|
+
expect(fake.writes[0]?.op).toBe('update');
|
|
117
|
+
const sentTypes = fake.writes[0]?.body?.rules?.map((r) => r.type);
|
|
118
|
+
expect(sentTypes).not.toContain('deletion');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('required checks omitted while the live ruleset has them', async () => {
|
|
122
|
+
const live = {
|
|
123
|
+
id: 1,
|
|
124
|
+
name: 'main',
|
|
125
|
+
enforcement: 'active',
|
|
126
|
+
bypass_actors: [],
|
|
127
|
+
conditions: {},
|
|
128
|
+
rules: [
|
|
129
|
+
{
|
|
130
|
+
type: 'required_status_checks',
|
|
131
|
+
parameters: { required_status_checks: [{ context: 'ci' }] },
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
const fake = makeGetOnlyFake({ widgets: live });
|
|
136
|
+
const declaration = props({
|
|
137
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
138
|
+
});
|
|
139
|
+
const error = await fails(reconcileWithOctokit(fake, declaration, { rulesetId: 1 }));
|
|
140
|
+
expect(tagOf(error)).toBe('RequiredChecksOmitted');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a never-reported context refuses before any write', async () => {
|
|
144
|
+
const fake = makeRecordingFake({});
|
|
145
|
+
// `reportedContexts` starts empty — `ci` has never reported.
|
|
146
|
+
const error = await fails(reconcileAgainst(fake, props()));
|
|
147
|
+
expect(tagOf(error)).toBe('NeverReportedContext');
|
|
148
|
+
expect(fake.writes).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('readback', () => {
|
|
153
|
+
test('fails when the independent re-read does not match what was sent', async () => {
|
|
154
|
+
// A hand-built fake, not `makeRecordingFake` — the point here is specifically that `get`
|
|
155
|
+
// (the INDEPENDENT re-read) can disagree with what `create` echoed back, simulating GitHub
|
|
156
|
+
// accepting the write but not persisting it as declared (S10: never trust the write's own
|
|
157
|
+
// report as proof).
|
|
158
|
+
const created = {
|
|
159
|
+
id: 1,
|
|
160
|
+
name: 'main',
|
|
161
|
+
target: 'branch',
|
|
162
|
+
enforcement: 'active',
|
|
163
|
+
bypass_actors: [],
|
|
164
|
+
conditions: {},
|
|
165
|
+
rules: [
|
|
166
|
+
{
|
|
167
|
+
type: 'pull_request',
|
|
168
|
+
parameters: { required_approving_review_count: 0, allowed_merge_methods: ['squash'] },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
const persisted = {
|
|
173
|
+
...created,
|
|
174
|
+
rules: [
|
|
175
|
+
// GitHub silently kept every method instead of narrowing to squash.
|
|
176
|
+
{
|
|
177
|
+
type: 'pull_request',
|
|
178
|
+
parameters: {
|
|
179
|
+
required_approving_review_count: 0,
|
|
180
|
+
allowed_merge_methods: ['merge', 'squash', 'rebase'],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
const fake: RulesetOctokit = {
|
|
186
|
+
list: () => Effect.succeed([]),
|
|
187
|
+
get: () => Effect.succeed(persisted),
|
|
188
|
+
create: () => Effect.succeed(created),
|
|
189
|
+
update: () => Effect.fail(new Error('not exercised')),
|
|
190
|
+
delete: () => Effect.fail(new Error('not exercised')),
|
|
191
|
+
hasContextReportedSuccess: () => Effect.succeed(true),
|
|
192
|
+
};
|
|
193
|
+
const declaration = props({
|
|
194
|
+
rules: { deletion: true, nonFastForward: true, pullRequest: BASE_PULL_REQUEST },
|
|
195
|
+
});
|
|
196
|
+
const error = await fails(reconcileWithOctokit(fake, declaration));
|
|
197
|
+
expect(tagOf(error)).toBe('ReadbackMismatch');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (ii) The write-recording fake, happy paths: a create sends the extension flag and
|
|
3
|
+
* `allowedMergeMethods`; a matching live ruleset makes zero writes (S10); one drifted field
|
|
4
|
+
* makes exactly one `update`, never a create. Refusals (zero writes) and the readback check
|
|
5
|
+
* are repository-ruleset-write-refusals.test.ts, split out to stay under the 250-line cap.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { makeRecordingFake } from './repository-ruleset-fake-octokit.ts';
|
|
9
|
+
import { desiredWireRuleset } from './repository-ruleset-form.ts';
|
|
10
|
+
import { props, reconcileAgainst, run } from './repository-ruleset-test-helpers.ts';
|
|
11
|
+
|
|
12
|
+
describe('create', () => {
|
|
13
|
+
test('sends allowedMergeMethods and the extension flag, and nothing else creates', async () => {
|
|
14
|
+
const fake = makeRecordingFake({});
|
|
15
|
+
fake.reportedContexts.add('ci');
|
|
16
|
+
const attrs = await run(reconcileAgainst(fake, props()));
|
|
17
|
+
expect(fake.writes).toHaveLength(1);
|
|
18
|
+
const [write] = fake.writes;
|
|
19
|
+
expect(write?.op).toBe('create');
|
|
20
|
+
expect(write?.repo).toBe('widgets');
|
|
21
|
+
const pr = write?.body?.rules?.find((r) => r.type === 'pull_request');
|
|
22
|
+
expect(
|
|
23
|
+
(pr as { parameters: Record<string, unknown> }).parameters.allowed_merge_methods,
|
|
24
|
+
).toEqual(['squash']);
|
|
25
|
+
expect(
|
|
26
|
+
(pr as { parameters: Record<string, unknown> }).parameters
|
|
27
|
+
.require_extra_approval_for_unattributed_changes,
|
|
28
|
+
).toBe(false);
|
|
29
|
+
expect(attrs.name).toBe('main');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('noop and update', () => {
|
|
34
|
+
test('a matching live ruleset makes zero writes', async () => {
|
|
35
|
+
const desired = desiredWireRuleset(props());
|
|
36
|
+
const fake = makeRecordingFake({
|
|
37
|
+
widgets: {
|
|
38
|
+
id: 1,
|
|
39
|
+
name: desired.name,
|
|
40
|
+
target: desired.target,
|
|
41
|
+
source_type: 'Repository',
|
|
42
|
+
enforcement: desired.enforcement,
|
|
43
|
+
bypass_actors: desired.bypass_actors as never,
|
|
44
|
+
conditions: desired.conditions,
|
|
45
|
+
rules: desired.rules as never,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
fake.reportedContexts.add('ci');
|
|
49
|
+
await run(reconcileAgainst(fake, props(), { rulesetId: 1 }));
|
|
50
|
+
expect(fake.writes).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('one drifted field makes exactly one update, never a create', async () => {
|
|
54
|
+
const desired = desiredWireRuleset(props());
|
|
55
|
+
const fake = makeRecordingFake({
|
|
56
|
+
widgets: {
|
|
57
|
+
id: 1,
|
|
58
|
+
name: desired.name,
|
|
59
|
+
target: desired.target,
|
|
60
|
+
source_type: 'Repository',
|
|
61
|
+
enforcement: desired.enforcement,
|
|
62
|
+
bypass_actors: desired.bypass_actors as never,
|
|
63
|
+
conditions: desired.conditions,
|
|
64
|
+
rules: (desired.rules ?? []).map((r) =>
|
|
65
|
+
r.type === 'pull_request'
|
|
66
|
+
? {
|
|
67
|
+
...r,
|
|
68
|
+
parameters: {
|
|
69
|
+
...r.parameters,
|
|
70
|
+
allowed_merge_methods: ['merge', 'squash', 'rebase'],
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
: r,
|
|
74
|
+
) as never,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
fake.reportedContexts.add('ci');
|
|
78
|
+
await run(reconcileAgainst(fake, props(), { rulesetId: 1 }));
|
|
79
|
+
expect(fake.writes).toHaveLength(1);
|
|
80
|
+
const [write] = fake.writes;
|
|
81
|
+
expect(write?.op).toBe('update');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GitHub.RepositoryRuleset` — a bridge resource for the one thing upstream `GitHub.Ruleset`
|
|
3
|
+
* cannot do: find a ruleset that already exists before creating a second one with the same
|
|
4
|
+
* name.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THE HAZARD THIS EXISTS TO CLOSE. Read out of `alchemy@2.0.0-beta.79`'s own source
|
|
7
|
+
* (`node_modules/alchemy/src/GitHub/Ruleset.ts`, `reconcile`): `let observed = output ===
|
|
8
|
+
* undefined ? undefined : yield* getRuleset(news, output.rulesetId)`. With no prior state,
|
|
9
|
+
* `observed` stays `undefined` and `createRepoRuleset` runs UNCONDITIONALLY — there is no
|
|
10
|
+
* probe by name. GitHub permits several rulesets with one name on a repository, so nothing
|
|
11
|
+
* errors; the repo quietly ends up with two, both enforcing. `declareRepoPolicy` in this same
|
|
12
|
+
* package (repo-policy.ts) documents the same hazard and tells a caller to check `gh api
|
|
13
|
+
* repos/<owner>/<repo>/rulesets` by hand before every first deploy. This resource makes that
|
|
14
|
+
* check the engine's own job: `read`/`reconcile` list-and-filter by name+target before ever
|
|
15
|
+
* creating (see repository-ruleset-probe.ts).
|
|
16
|
+
*
|
|
17
|
+
* ★ WHY A NEW TYPE STRING, NOT A PATCHED `GitHub.Ruleset`. Upstream's `Ruleset` cannot be
|
|
18
|
+
* edited from here — it ships inside the `alchemy` package. `GitHub.RepositoryRuleset` is a
|
|
19
|
+
* distinct `Provider` (its own `Context.Service` keyed by this type string — see
|
|
20
|
+
* `alchemy/src/Provider.ts#Provider`), so it is declared and merged into a stack's provider
|
|
21
|
+
* layer independently of `GitHub.providers()` — the same way `postgres/database.ts` merges
|
|
22
|
+
* `PostgresDatabaseProvider()` beside it. Nothing about upstream's `GitHub.Providers`
|
|
23
|
+
* collection needs touching for the two to coexist in one stack. Props are a strict superset
|
|
24
|
+
* of upstream's `RulesetProps` (H14: the type string sits inside upstream's own `GitHub`
|
|
25
|
+
* namespace, recorded as house difference H15 below for the client choice).
|
|
26
|
+
*
|
|
27
|
+
* ⛔ H15: CLIENT IS OCTOKIT, NOT DISTILLED — A NAMED DIVERGENCE FROM S23. The house's default is
|
|
28
|
+
* `@distilled.cloud/*` when a vendor SDK exists there, and `@distilled.cloud/github@1.0.0-rc.12`
|
|
29
|
+
* does ship `createRepoRuleset`/`updateRepoRuleset`. But it has NO field for
|
|
30
|
+
* `require_extra_approval_for_unattributed_changes` (measured 2026-09-22: grep of its
|
|
31
|
+
* generated types), and its request bodies are plain `effect/Schema` `S.Struct`s, which strip
|
|
32
|
+
* properties a schema does not declare on encode — REASONED, not measured against distilled
|
|
33
|
+
* itself; repository-ruleset-wire.test.ts measures the Octokit side of the same question
|
|
34
|
+
* instead, because Octokit is the client this file actually uses. That flag is the entire
|
|
35
|
+
* reason this resource exists for the 0-approval, solo-maintainer shape (see
|
|
36
|
+
* repo-policy-guards.ts's `resolveApprovals` for the same story on the upstream-only path):
|
|
37
|
+
* GitHub defaults it to `true` when absent, which blocks the very pull requests a `0` was
|
|
38
|
+
* meant to allow. `octokitFor`/`GitHubCredentials` are upstream's own EXPORTED building
|
|
39
|
+
* blocks (S23's second clause: reuse Alchemy's own client construction), so the client itself
|
|
40
|
+
* is not hand-rolled — only the choice of Octokit over distilled is a recorded divergence.
|
|
41
|
+
*/
|
|
42
|
+
import { Resource } from 'alchemy';
|
|
43
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
44
|
+
import { isResolved } from 'alchemy/Diff';
|
|
45
|
+
import type { GitHubCredentials, RulesetProps } from 'alchemy/GitHub';
|
|
46
|
+
import * as Provider from 'alchemy/Provider';
|
|
47
|
+
import * as Effect from 'effect/Effect';
|
|
48
|
+
import {
|
|
49
|
+
allowedMergeMethodsRefusal,
|
|
50
|
+
approvalCountRefusal,
|
|
51
|
+
} from './repository-ruleset-constraints.ts';
|
|
52
|
+
import { RulesetConstraintRefused } from './repository-ruleset-errors.ts';
|
|
53
|
+
import { makeRulesetOctokit } from './repository-ruleset-octokit.ts';
|
|
54
|
+
import { type RulesetRecord, probeByName } from './repository-ruleset-probe.ts';
|
|
55
|
+
import { reconcileRuleset } from './repository-ruleset-reconcile.ts';
|
|
56
|
+
|
|
57
|
+
type UpstreamRules = NonNullable<RulesetProps['rules']>;
|
|
58
|
+
|
|
59
|
+
export interface RepositoryRulesetReviewerRule {
|
|
60
|
+
readonly filePatterns: readonly string[];
|
|
61
|
+
readonly minimumApprovals: number;
|
|
62
|
+
readonly reviewer: { readonly actorId: number; readonly actorType: 'Team' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RepositoryRulesetPullRequestRule extends NonNullable<
|
|
66
|
+
UpstreamRules['pullRequest']
|
|
67
|
+
> {
|
|
68
|
+
/** @default every method GitHub allows — the house baseline always pins `['squash']`. */
|
|
69
|
+
readonly allowedMergeMethods?: readonly ('merge' | 'squash' | 'rebase')[];
|
|
70
|
+
readonly requiredReviewers?: readonly RepositoryRulesetReviewerRule[];
|
|
71
|
+
/** `require_extra_approval_for_unattributed_changes` — see H15 above. Only `false` is
|
|
72
|
+
* modeled: sending `true` has no house use, and GitHub already defaults absent to `true`. */
|
|
73
|
+
readonly extraApprovalForUnattributedChanges?: false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RepositoryRulesetStatusChecksRule extends NonNullable<
|
|
77
|
+
UpstreamRules['requiredStatusChecks']
|
|
78
|
+
> {
|
|
79
|
+
readonly doNotEnforceOnCreate?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RepositoryRulesetRules extends Omit<
|
|
83
|
+
UpstreamRules,
|
|
84
|
+
'pullRequest' | 'requiredStatusChecks'
|
|
85
|
+
> {
|
|
86
|
+
readonly pullRequest?: RepositoryRulesetPullRequestRule;
|
|
87
|
+
/** `undefined`: no opinion (refused if the live ruleset already has one — see
|
|
88
|
+
* repository-ruleset-guards.ts). `false`: declare its ABSENCE — removes a live one. */
|
|
89
|
+
readonly requiredStatusChecks?: RepositoryRulesetStatusChecksRule | false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RepositoryRulesetProps extends Omit<RulesetProps, 'rules'> {
|
|
93
|
+
readonly rules?: RepositoryRulesetRules;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface RepositoryRulesetAttributes {
|
|
97
|
+
readonly rulesetId: number;
|
|
98
|
+
readonly nodeId: string | undefined;
|
|
99
|
+
readonly name: string;
|
|
100
|
+
readonly createdAt: string | undefined;
|
|
101
|
+
readonly updatedAt: string | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface RepositoryRuleset extends Resource<
|
|
105
|
+
'GitHub.RepositoryRuleset',
|
|
106
|
+
RepositoryRulesetProps,
|
|
107
|
+
RepositoryRulesetAttributes,
|
|
108
|
+
never,
|
|
109
|
+
GitHubCredentials
|
|
110
|
+
> {}
|
|
111
|
+
|
|
112
|
+
export const RepositoryRuleset = Resource<RepositoryRuleset>('GitHub.RepositoryRuleset', {
|
|
113
|
+
defaultRemovalPolicy: 'retain',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const attrsOf = (r: RulesetRecord): RepositoryRulesetAttributes => ({
|
|
117
|
+
rulesetId: r.id,
|
|
118
|
+
nodeId: r.node_id,
|
|
119
|
+
name: r.name,
|
|
120
|
+
createdAt: r.created_at,
|
|
121
|
+
updatedAt: r.updated_at,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Plan-time-only refusals — no live data needed, so these run before any read (schema-codegen's
|
|
125
|
+
* rule: check the form before the "no previous state" branch, never after it). */
|
|
126
|
+
function constraintRefusal(props: RepositoryRulesetProps): RulesetConstraintRefused | undefined {
|
|
127
|
+
const approvals = props.rules?.pullRequest?.requiredApprovingReviewCount;
|
|
128
|
+
if (approvals !== undefined) {
|
|
129
|
+
const reason = approvalCountRefusal(approvals);
|
|
130
|
+
if (reason !== undefined) {
|
|
131
|
+
return new RulesetConstraintRefused({
|
|
132
|
+
field: 'rules.pullRequest.requiredApprovingReviewCount',
|
|
133
|
+
reason,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const methods = props.rules?.pullRequest?.allowedMergeMethods;
|
|
138
|
+
if (methods !== undefined) {
|
|
139
|
+
const reason = allowedMergeMethodsRefusal(methods);
|
|
140
|
+
if (reason !== undefined) {
|
|
141
|
+
return new RulesetConstraintRefused({
|
|
142
|
+
field: 'rules.pullRequest.allowedMergeMethods',
|
|
143
|
+
reason,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const repositoryRulesetHandlers = RepositoryRuleset.Provider.of({
|
|
151
|
+
// Account-wide, cross-repo enumeration duplicates upstream `GitHub.Ruleset`'s own `list` for
|
|
152
|
+
// no benefit here — every RepositoryRuleset is declared once, by name, in a checkout's own
|
|
153
|
+
// stack (S12 already defaults this; nuke should never touch a `retain` ruleset regardless).
|
|
154
|
+
list: () => Effect.succeed([]),
|
|
155
|
+
nuke: { skip: true },
|
|
156
|
+
|
|
157
|
+
read: ({ output, olds }) =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const octokit = makeRulesetOctokit(olds.baseUrl);
|
|
160
|
+
if (output === undefined) {
|
|
161
|
+
const match = yield* probeByName(octokit, {
|
|
162
|
+
owner: olds.owner,
|
|
163
|
+
repo: olds.repository,
|
|
164
|
+
name: olds.name,
|
|
165
|
+
target: olds.target ?? 'branch',
|
|
166
|
+
});
|
|
167
|
+
// Identical is not ours (H1): a name match with no prior state needs adopt(true).
|
|
168
|
+
return match === undefined ? undefined : Unowned(attrsOf(match));
|
|
169
|
+
}
|
|
170
|
+
const byId = yield* octokit.get({
|
|
171
|
+
owner: olds.owner,
|
|
172
|
+
repo: olds.repository,
|
|
173
|
+
rulesetId: output.rulesetId,
|
|
174
|
+
});
|
|
175
|
+
if (byId !== undefined) return attrsOf(byId);
|
|
176
|
+
const byName = yield* probeByName(octokit, {
|
|
177
|
+
owner: olds.owner,
|
|
178
|
+
repo: olds.repository,
|
|
179
|
+
name: olds.name,
|
|
180
|
+
target: olds.target ?? 'branch',
|
|
181
|
+
});
|
|
182
|
+
return byName === undefined ? undefined : attrsOf(byName);
|
|
183
|
+
}),
|
|
184
|
+
|
|
185
|
+
// Plain function, not `Effect.fn(function* …)` — there is nothing here to `yield*`, and an
|
|
186
|
+
// empty generator is its own lint hazard (oxlint's `require-yield`) for a reason: it reads as
|
|
187
|
+
// an unfinished `Effect.gen` body.
|
|
188
|
+
diff: ({ news, olds }) =>
|
|
189
|
+
Effect.succeed(
|
|
190
|
+
isResolved(news) &&
|
|
191
|
+
olds !== undefined &&
|
|
192
|
+
(news.owner !== olds.owner || news.repository !== olds.repository)
|
|
193
|
+
? ({ action: 'replace' } as const)
|
|
194
|
+
: undefined,
|
|
195
|
+
),
|
|
196
|
+
|
|
197
|
+
reconcile: ({ news, output }) =>
|
|
198
|
+
reconcileRuleset({
|
|
199
|
+
octokit: makeRulesetOctokit(news.baseUrl),
|
|
200
|
+
news,
|
|
201
|
+
output,
|
|
202
|
+
constraintRefusal,
|
|
203
|
+
attrsOf,
|
|
204
|
+
}),
|
|
205
|
+
|
|
206
|
+
delete: ({ olds, output }) =>
|
|
207
|
+
Effect.gen(function* () {
|
|
208
|
+
if (output?.rulesetId === undefined) return;
|
|
209
|
+
const octokit = makeRulesetOctokit(olds.baseUrl);
|
|
210
|
+
yield* octokit.delete({
|
|
211
|
+
owner: olds.owner,
|
|
212
|
+
repo: olds.repository,
|
|
213
|
+
rulesetId: output.rulesetId,
|
|
214
|
+
});
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
export const RepositoryRulesetProvider = () =>
|
|
219
|
+
Provider.succeed(RepositoryRuleset, repositoryRulesetHandlers);
|
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
* runtime-portable (no `Bun.*`, no `node:*`), so a future Linux consumer (systemd units, under
|
|
4
4
|
* `/linux`) can import it unchanged.
|
|
5
5
|
*
|
|
6
|
-
* ★
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* traffic — is measured in docs/launchd-ports.md, not assumed.
|
|
6
|
+
* ★ KEYS ON THE PORT NUMBER ALONE, NOT A NEW RULE. `portClaimProblems` below matches the estate's
|
|
7
|
+
* existing port-collision strictness — address and protocol never read — deliberately: a looser
|
|
8
|
+
* or stricter rule here would be a second, inconsistent source of truth for jobs migrating off a
|
|
9
|
+
* host's own port registry (still enforcing that same strictness until a job leaves it). What
|
|
10
|
+
* this trades away — a wildcard bind beside a specific one on the same port both succeed,
|
|
11
|
+
* silently splitting traffic — is measured in docs/launchd-ports.md, not assumed.
|
|
13
12
|
* ★ IN THE STACK PROGRAM, NOT A PROVIDER — same shape as `catalogBinary()` (../release/catalog.ts).
|
|
14
13
|
* Two reasons, both structural to Alchemy 2.0.0-beta.79: Alchemy never diffs a first `create`
|
|
15
14
|
* whose props still hold an unresolved `Output`, and every mini job's `programArguments` carries
|
|
@@ -38,8 +37,8 @@ const describeAddress = (address: string | undefined): string => address ?? 'uns
|
|
|
38
37
|
const describeOwner = (owner: string): string => (owner === '' ? '(empty owner)' : owner);
|
|
39
38
|
|
|
40
39
|
/**
|
|
41
|
-
* Everything wrong with a set of claims; empty when none collide. ★ KEYS ON `port` ALONE
|
|
42
|
-
*
|
|
40
|
+
* Everything wrong with a set of claims; empty when none collide. ★ KEYS ON `port` ALONE — see the
|
|
41
|
+
* file header for why.
|
|
43
42
|
*/
|
|
44
43
|
export const portClaimProblems = (claims: readonly PortClaim[]): string[] => {
|
|
45
44
|
const found: string[] = [];
|