@homeflare/alchemy 0.25.1 → 0.27.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.
Files changed (118) hide show
  1. package/dist/github/declare-repo-baseline.d.ts +11 -0
  2. package/dist/github/declare-repo-baseline.d.ts.map +1 -0
  3. package/dist/github/index.d.ts +20 -11
  4. package/dist/github/index.d.ts.map +1 -1
  5. package/dist/github/index.js +715 -4
  6. package/dist/github/index.js.map +14 -4
  7. package/dist/github/repo-baseline-data.d.ts +75 -0
  8. package/dist/github/repo-baseline-data.d.ts.map +1 -0
  9. package/dist/github/repo-policy.d.ts +4 -4
  10. package/dist/github/repo-policy.d.ts.map +1 -1
  11. package/dist/github/repository-ruleset-constraints.d.ts +105 -0
  12. package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
  13. package/dist/github/repository-ruleset-errors.d.ts +97 -0
  14. package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
  15. package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
  16. package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
  17. package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
  18. package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
  19. package/dist/github/repository-ruleset-form.d.ts +30 -0
  20. package/dist/github/repository-ruleset-form.d.ts.map +1 -0
  21. package/dist/github/repository-ruleset-guards.d.ts +70 -0
  22. package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
  23. package/dist/github/repository-ruleset-octokit.d.ts +5 -0
  24. package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
  25. package/dist/github/repository-ruleset-probe.d.ts +87 -0
  26. package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
  27. package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
  28. package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
  29. package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
  30. package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
  31. package/dist/github/repository-ruleset.d.ts +89 -0
  32. package/dist/github/repository-ruleset.d.ts.map +1 -0
  33. package/dist/launchd/index.js +51 -47
  34. package/dist/launchd/index.js.map +5 -4
  35. package/dist/launchd/sudo-guard-mode.d.ts +62 -0
  36. package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
  37. package/dist/launchd/sudo-guard.d.ts +1 -38
  38. package/dist/launchd/sudo-guard.d.ts.map +1 -1
  39. package/dist/linux/fake-sudo-root.d.ts +26 -0
  40. package/dist/linux/fake-sudo-root.d.ts.map +1 -0
  41. package/dist/linux/fake-sudo.d.ts +56 -0
  42. package/dist/linux/fake-sudo.d.ts.map +1 -0
  43. package/dist/linux/index.d.ts +2 -0
  44. package/dist/linux/index.d.ts.map +1 -1
  45. package/dist/linux/index.js +1134 -2
  46. package/dist/linux/index.js.map +22 -5
  47. package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
  48. package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
  49. package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
  50. package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
  51. package/dist/linux/sudo-allowlist.d.ts +40 -0
  52. package/dist/linux/sudo-allowlist.d.ts.map +1 -0
  53. package/dist/linux/sudo-exec.d.ts +31 -0
  54. package/dist/linux/sudo-exec.d.ts.map +1 -0
  55. package/dist/linux/sudo-guard.d.ts +36 -0
  56. package/dist/linux/sudo-guard.d.ts.map +1 -0
  57. package/dist/linux/sudo-runner.d.ts +37 -0
  58. package/dist/linux/sudo-runner.d.ts.map +1 -0
  59. package/dist/linux/sudo-stage.d.ts +22 -0
  60. package/dist/linux/sudo-stage.d.ts.map +1 -0
  61. package/dist/linux/sudo-write.d.ts +25 -0
  62. package/dist/linux/sudo-write.d.ts.map +1 -0
  63. package/dist/linux/unit-form.d.ts +2 -0
  64. package/dist/linux/unit-form.d.ts.map +1 -1
  65. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  66. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
  67. package/docs/launchd-sudo.md +12 -3
  68. package/docs/linux-host.md +3 -3
  69. package/docs/linux-sudo.md +187 -0
  70. package/docs/repo-policy-ruleset-hazards.md +8 -0
  71. package/docs/repo-policy.md +27 -12
  72. package/docs/repository-ruleset.md +114 -0
  73. package/docs/upstream-conformance.md +12 -1
  74. package/docs/upstream-pr-ruleset-fixes.md +77 -0
  75. package/package.json +1 -1
  76. package/src/github/declare-repo-baseline.ts +42 -0
  77. package/src/github/index.ts +43 -11
  78. package/src/github/repo-baseline-data.test.ts +76 -0
  79. package/src/github/repo-baseline-data.ts +86 -0
  80. package/src/github/repo-policy.ts +17 -17
  81. package/src/github/repository-ruleset-constraints.ts +136 -0
  82. package/src/github/repository-ruleset-coverage.test.ts +82 -0
  83. package/src/github/repository-ruleset-errors.ts +136 -0
  84. package/src/github/repository-ruleset-fake-octokit.ts +131 -0
  85. package/src/github/repository-ruleset-fixtures.ts +183 -0
  86. package/src/github/repository-ruleset-form.ts +212 -0
  87. package/src/github/repository-ruleset-guards.ts +194 -0
  88. package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
  89. package/src/github/repository-ruleset-octokit.ts +140 -0
  90. package/src/github/repository-ruleset-probe.ts +116 -0
  91. package/src/github/repository-ruleset-reconcile.ts +176 -0
  92. package/src/github/repository-ruleset-test-helpers.ts +102 -0
  93. package/src/github/repository-ruleset-wire.test.ts +66 -0
  94. package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
  95. package/src/github/repository-ruleset-write.test.ts +83 -0
  96. package/src/github/repository-ruleset.ts +219 -0
  97. package/src/launchd/sudo-guard-mode.ts +111 -0
  98. package/src/launchd/sudo-guard.ts +3 -79
  99. package/src/launchd/sudo-modes.test.ts +38 -1
  100. package/src/linux/fake-sudo-root.ts +157 -0
  101. package/src/linux/fake-sudo.ts +142 -0
  102. package/src/linux/index.ts +2 -0
  103. package/src/linux/sudo-allowlist-chown.ts +71 -0
  104. package/src/linux/sudo-allowlist-dir.ts +103 -0
  105. package/src/linux/sudo-allowlist.test.ts +252 -0
  106. package/src/linux/sudo-allowlist.ts +223 -0
  107. package/src/linux/sudo-exec.ts +118 -0
  108. package/src/linux/sudo-guard.test.ts +136 -0
  109. package/src/linux/sudo-guard.ts +142 -0
  110. package/src/linux/sudo-lifecycle.test.ts +151 -0
  111. package/src/linux/sudo-runner.test.ts +206 -0
  112. package/src/linux/sudo-runner.ts +164 -0
  113. package/src/linux/sudo-stage.ts +50 -0
  114. package/src/linux/sudo-write.ts +167 -0
  115. package/src/linux/unit-form.ts +2 -1
  116. package/src/litellm/fake-litellm.ts +2 -0
  117. package/src/proxmox/client.test.ts +17 -3
  118. package/src/proxmox/fake-pve-lxc.ts +10 -1
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The real {@link RulesetOctokit} — every wire call this family makes, built from the EXPORTED
3
+ * `GitHubCredentials` service via `creds.octokit()`, exactly upstream's own `octokitFor` does
4
+ * (`node_modules/alchemy/src/GitHub/Octokit.ts`). H15: Octokit, not `@distilled.cloud/github`
5
+ * — its `S.Struct` request schemas have no field for `require_extra_approval_for_unattributed_
6
+ * changes` and would very likely strip an unknown key on encode; Octokit's REST methods hand
7
+ * their `parameters` object to `@octokit/endpoint` mostly as-is, so an extra JSON body key
8
+ * should reach the wire. repository-ruleset-wire.test.ts MEASURES this against a real Octokit
9
+ * instance with a fetch shim rather than asserting it from reading the source.
10
+ *
11
+ * ⚠️ "Reported success" is checked at the default branch's CURRENT TIP only, not its full
12
+ * history — a context that reported success three merges ago and has since started failing
13
+ * reads as reported (true), and one that has only ever run on a branch nobody has merged
14
+ * since reads as never-reported (false) until the next merge updates the tip. A full-history
15
+ * search is disproportionate to what this guard protects against (adding a context that
16
+ * plainly cannot report), and GitHub's check-runs/statuses APIs are themselves scoped to a
17
+ * single ref, not "ever, anywhere" — there is no cheaper vendor call that answers the wider
18
+ * question.
19
+ */
20
+ import type { Octokit as OctokitClient } from '@octokit/rest';
21
+ import { GitHubCredentials } from 'alchemy/GitHub';
22
+ import * as Effect from 'effect/Effect';
23
+ import type { CreateRulesetBody } from './repository-ruleset-constraints.ts';
24
+ import type { RulesetOctokit, RulesetRecord, RulesetSummary } from './repository-ruleset-probe.ts';
25
+
26
+ export type RealRulesetOctokit = RulesetOctokit<GitHubCredentials>;
27
+
28
+ const asError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)));
29
+
30
+ const octokitFor = (
31
+ baseUrl: string | undefined,
32
+ ): Effect.Effect<OctokitClient, Error, GitHubCredentials> =>
33
+ Effect.gen(function* () {
34
+ const creds = yield* yield* GitHubCredentials;
35
+ return baseUrl === undefined ? creds.octokit() : creds.octokit({ baseUrl });
36
+ }).pipe(Effect.mapError(asError));
37
+
38
+ const is404 = (e: unknown): boolean => (e as { status?: number } | undefined)?.status === 404;
39
+
40
+ export const makeRulesetOctokit = (baseUrl: string | undefined): RealRulesetOctokit => ({
41
+ list: ({ owner, repo }) =>
42
+ Effect.gen(function* () {
43
+ const octokit = yield* octokitFor(baseUrl);
44
+ const rulesets = yield* Effect.tryPromise({
45
+ try: () =>
46
+ octokit.paginate(octokit.rest.repos.getRepoRulesets, {
47
+ owner,
48
+ repo,
49
+ includes_parents: false,
50
+ per_page: 100,
51
+ }),
52
+ catch: asError,
53
+ });
54
+ return rulesets as RulesetSummary[];
55
+ }),
56
+
57
+ get: ({ owner, repo, rulesetId }) =>
58
+ Effect.gen(function* () {
59
+ const octokit = yield* octokitFor(baseUrl);
60
+ return yield* Effect.tryPromise({
61
+ try: () => octokit.rest.repos.getRepoRuleset({ owner, repo, ruleset_id: rulesetId }),
62
+ catch: (e) => e,
63
+ }).pipe(
64
+ Effect.map(({ data }) => data as RulesetRecord),
65
+ Effect.catchIf(is404, () => Effect.succeed(undefined)),
66
+ Effect.mapError(asError),
67
+ );
68
+ }),
69
+
70
+ create: (input: { owner: string; repo: string; body: CreateRulesetBody }) =>
71
+ Effect.gen(function* () {
72
+ const octokit = yield* octokitFor(baseUrl);
73
+ const { data } = yield* Effect.tryPromise({
74
+ try: () =>
75
+ octokit.rest.repos.createRepoRuleset({
76
+ owner: input.owner,
77
+ repo: input.repo,
78
+ ...input.body,
79
+ }),
80
+ catch: asError,
81
+ });
82
+ return data as RulesetRecord;
83
+ }),
84
+
85
+ update: (input: { owner: string; repo: string; rulesetId: number; body: CreateRulesetBody }) =>
86
+ Effect.gen(function* () {
87
+ const octokit = yield* octokitFor(baseUrl);
88
+ const { data } = yield* Effect.tryPromise({
89
+ try: () =>
90
+ octokit.rest.repos.updateRepoRuleset({
91
+ owner: input.owner,
92
+ repo: input.repo,
93
+ ruleset_id: input.rulesetId,
94
+ ...input.body,
95
+ }),
96
+ catch: asError,
97
+ });
98
+ return data as RulesetRecord;
99
+ }),
100
+
101
+ delete: ({ owner, repo, rulesetId }) =>
102
+ Effect.gen(function* () {
103
+ const octokit = yield* octokitFor(baseUrl);
104
+ yield* Effect.tryPromise({
105
+ try: () => octokit.rest.repos.deleteRepoRuleset({ owner, repo, ruleset_id: rulesetId }),
106
+ catch: (e) => e,
107
+ }).pipe(
108
+ Effect.asVoid,
109
+ Effect.catchIf(is404, () => Effect.void),
110
+ Effect.mapError(asError),
111
+ );
112
+ }),
113
+
114
+ hasContextReportedSuccess: ({ owner, repo, context }) =>
115
+ Effect.gen(function* () {
116
+ const octokit = yield* octokitFor(baseUrl);
117
+ const repository = yield* Effect.tryPromise({
118
+ try: () => octokit.rest.repos.get({ owner, repo }),
119
+ catch: asError,
120
+ });
121
+ const ref = repository.data.default_branch;
122
+ const [checkRuns, statuses] = yield* Effect.all([
123
+ Effect.tryPromise({
124
+ try: () => octokit.rest.checks.listForRef({ owner, repo, ref, check_name: context }),
125
+ catch: asError,
126
+ }),
127
+ Effect.tryPromise({
128
+ try: () => octokit.rest.repos.listCommitStatusesForRef({ owner, repo, ref }),
129
+ catch: asError,
130
+ }),
131
+ ]);
132
+ const checkReported = checkRuns.data.check_runs.some(
133
+ (run) => run.name === context && run.conclusion === 'success',
134
+ );
135
+ const statusReported = statuses.data.some(
136
+ (status) => status.context === context && status.state === 'success',
137
+ );
138
+ return checkReported || statusReported;
139
+ }),
140
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The name-probe: how this resource finds a ruleset it has no prior state for, and the one
3
+ * place `DuplicateRuleset` is raised. This is the fix for the hazard documented at the top of
4
+ * repository-ruleset.ts — upstream's `Ruleset#reconcile` only ever looks up by the id in prior
5
+ * `output`, so with none it calls `createRepoRuleset` unconditionally (measured in
6
+ * `node_modules/alchemy/src/GitHub/Ruleset.ts`, `reconcile`).
7
+ *
8
+ * `RulesetOctokit` is a small injected seam (mirrors `PgExecutor` in `postgres/database-sql.ts`)
9
+ * so the write-recording and GET-only fakes in the test files implement it directly — no HTTP
10
+ * mocking needed for the resource's own logic. `repository-ruleset.ts` supplies the real
11
+ * implementation, built from `octokitFor`/`GitHubCredentials` exactly as upstream's own
12
+ * `getRuleset`/`createRepoRuleset` calls do.
13
+ */
14
+ import * as Effect from 'effect/Effect';
15
+ import type { CreateRulesetBody } from './repository-ruleset-constraints.ts';
16
+ import { DuplicateRuleset } from './repository-ruleset-errors.ts';
17
+
18
+ // Every optional field spells out `| undefined` explicitly (not just `field?:`) so a fake or
19
+ // adapter built from a value that is itself `T | undefined` — the common shape once you have
20
+ // pulled a field off another object — can assign it directly under `exactOptionalPropertyTypes`
21
+ // without a conditional-spread dance at every call site.
22
+ export interface RulesetSummary {
23
+ readonly id: number;
24
+ readonly name: string;
25
+ readonly target?: string | undefined;
26
+ readonly source_type?: string | undefined;
27
+ }
28
+
29
+ export interface RulesetRecord extends RulesetSummary {
30
+ readonly node_id?: string | undefined;
31
+ readonly enforcement: string;
32
+ readonly bypass_actors?: readonly Record<string, unknown>[] | undefined;
33
+ readonly conditions?: unknown;
34
+ readonly rules?: readonly Record<string, unknown>[] | undefined;
35
+ readonly created_at?: string | undefined;
36
+ readonly updated_at?: string | undefined;
37
+ }
38
+
39
+ /** Every method carries `Error` in its error channel, matching upstream `Ruleset.ts`'s own
40
+ * `catch: (e) => e as Error` — a transport failure is not this resource's to type more
41
+ * precisely than upstream types its own. `R` is the real adapter's ambient requirement
42
+ * (`GitHubCredentials`, via `octokitFor` — see repository-ruleset-octokit.ts); test fakes need
43
+ * none, so they satisfy `RulesetOctokit<never>` — the default — without carrying it. */
44
+ export interface RulesetOctokit<R = never> {
45
+ /** Every ruleset owned directly by this repository (`includes_parents: false`, paginated) —
46
+ * the list endpoint omits `bypass_actors`, so a match is always re-read by id. */
47
+ readonly list: (input: {
48
+ readonly owner: string;
49
+ readonly repo: string;
50
+ }) => Effect.Effect<readonly RulesetSummary[], Error, R>;
51
+ /** `undefined` on a 404. */
52
+ readonly get: (input: {
53
+ readonly owner: string;
54
+ readonly repo: string;
55
+ readonly rulesetId: number;
56
+ }) => Effect.Effect<RulesetRecord | undefined, Error, R>;
57
+ readonly create: (input: {
58
+ readonly owner: string;
59
+ readonly repo: string;
60
+ readonly body: CreateRulesetBody;
61
+ }) => Effect.Effect<RulesetRecord, Error, R>;
62
+ readonly update: (input: {
63
+ readonly owner: string;
64
+ readonly repo: string;
65
+ readonly rulesetId: number;
66
+ readonly body: CreateRulesetBody;
67
+ }) => Effect.Effect<RulesetRecord, Error, R>;
68
+ /** A 404 is success (S11: idempotent delete). */
69
+ readonly delete: (input: {
70
+ readonly owner: string;
71
+ readonly repo: string;
72
+ readonly rulesetId: number;
73
+ }) => Effect.Effect<void, Error, R>;
74
+ /** Whether `context` has ever reported success against the repository's default branch —
75
+ * backs the `NeverReportedContext` guard. */
76
+ readonly hasContextReportedSuccess: (input: {
77
+ readonly owner: string;
78
+ readonly repo: string;
79
+ readonly context: string;
80
+ }) => Effect.Effect<boolean, Error, R>;
81
+ }
82
+
83
+ /**
84
+ * List + filter by name and target, then:
85
+ * - 0 matches → `undefined` (a create is coming).
86
+ * - 1 match → GET it by id (the list omits `bypass_actors`) and return the full record.
87
+ * - `>1` → fail with `DuplicateRuleset`, naming every id, before reading any of them.
88
+ */
89
+ export const probeByName = <R = never>(
90
+ octokit: RulesetOctokit<R>,
91
+ input: {
92
+ readonly owner: string;
93
+ readonly repo: string;
94
+ readonly name: string;
95
+ readonly target: string;
96
+ },
97
+ ): Effect.Effect<RulesetRecord | undefined, DuplicateRuleset | Error, R> =>
98
+ Effect.gen(function* () {
99
+ const all = yield* octokit.list({ owner: input.owner, repo: input.repo });
100
+ const matches = all.filter(
101
+ (r) => r.name === input.name && (r.target ?? 'branch') === input.target,
102
+ );
103
+ const [only, ...rest] = matches;
104
+ if (only === undefined) return undefined;
105
+ if (rest.length > 0) {
106
+ return yield* Effect.fail(
107
+ new DuplicateRuleset({
108
+ owner: input.owner,
109
+ repository: input.repo,
110
+ name: input.name,
111
+ ids: matches.map((m) => m.id),
112
+ }),
113
+ );
114
+ }
115
+ return yield* octokit.get({ owner: input.owner, repo: input.repo, rulesetId: only.id });
116
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `reconcile`'s body, apart from repository-ruleset.ts so the wiring there stays readable.
3
+ * One flow (S9): observe, then every plan-time and live-data refusal, then write only on
4
+ * drift (S10 — a matching noop makes zero calls), then an INDEPENDENT readback.
5
+ */
6
+ import * as Effect from 'effect/Effect';
7
+ import type { RepositoryRulesetAttributes, RepositoryRulesetProps } from './repository-ruleset.ts';
8
+ import type { CreateRulesetBody } from './repository-ruleset-constraints.ts';
9
+ import {
10
+ ReadbackMismatch,
11
+ type RepositoryRulesetError,
12
+ type RulesetConstraintRefused,
13
+ } from './repository-ruleset-errors.ts';
14
+ import { canonicalizeWireRuleset, desiredWireRuleset } from './repository-ruleset-form.ts';
15
+ import {
16
+ bypassWideningRefusal,
17
+ declaredRuleTypes,
18
+ newlyRequiredContexts,
19
+ refuseUnreportedContexts,
20
+ requiredChecksOmissionRefusal,
21
+ undeclaredLiveRuleRefusal,
22
+ } from './repository-ruleset-guards.ts';
23
+ import {
24
+ type RulesetOctokit,
25
+ type RulesetRecord,
26
+ probeByName,
27
+ } from './repository-ruleset-probe.ts';
28
+
29
+ const guardedPullRequestFields = (
30
+ rule: Record<string, unknown> | undefined,
31
+ ): Record<string, unknown> => {
32
+ const params = (rule?.parameters ?? {}) as Record<string, unknown>;
33
+ return {
34
+ required_approving_review_count: params.required_approving_review_count,
35
+ allowed_merge_methods: Array.isArray(params.allowed_merge_methods)
36
+ ? [...(params.allowed_merge_methods as string[])].sort()
37
+ : undefined,
38
+ require_extra_approval_for_unattributed_changes:
39
+ params.require_extra_approval_for_unattributed_changes,
40
+ };
41
+ };
42
+
43
+ /** Compares only the fields this resource exists to guarantee (H15's extension flag,
44
+ * `allowedMergeMethods`, the approval count) between what was SENT and an INDEPENDENT re-read —
45
+ * never the write response itself (S10). */
46
+ function readbackRefusal(
47
+ owner: string,
48
+ repository: string,
49
+ name: string,
50
+ sentRules: readonly Record<string, unknown>[],
51
+ independent: RulesetRecord,
52
+ ): ReadbackMismatch | undefined {
53
+ const sentPr = sentRules.find((r) => r.type === 'pull_request');
54
+ if (sentPr === undefined) return undefined;
55
+ const readPr = (independent.rules ?? []).find((r) => r.type === 'pull_request');
56
+ const sent = guardedPullRequestFields(sentPr);
57
+ const read = guardedPullRequestFields(readPr);
58
+ for (const field of Object.keys(sent) as (keyof typeof sent)[]) {
59
+ if (sent[field] === undefined) continue;
60
+ if (JSON.stringify(sent[field]) !== JSON.stringify(read[field])) {
61
+ return new ReadbackMismatch({
62
+ owner,
63
+ repository,
64
+ name,
65
+ field: `rules.pull_request.parameters.${field}`,
66
+ sent: sent[field],
67
+ read: read[field],
68
+ });
69
+ }
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /**
75
+ * `octokit` is INJECTED, never constructed in here — the real adapter comes from
76
+ * repository-ruleset.ts (`makeRulesetOctokit(news.baseUrl)`), and the write-recording and
77
+ * GET-only fakes in the test files pass their own. Nothing about the flow below depends on
78
+ * which one it is, which is the whole point of the seam (mirrors `reconcileWithClient(pg, …)`
79
+ * in postgres/database.ts taking its `PgExecutor` the same way).
80
+ */
81
+ export const reconcileRuleset = <R = never>(input: {
82
+ readonly octokit: RulesetOctokit<R>;
83
+ readonly news: RepositoryRulesetProps;
84
+ readonly output: RepositoryRulesetAttributes | undefined;
85
+ readonly constraintRefusal: (
86
+ props: RepositoryRulesetProps,
87
+ ) => RulesetConstraintRefused | undefined;
88
+ readonly attrsOf: (r: RulesetRecord) => RepositoryRulesetAttributes;
89
+ }): Effect.Effect<RepositoryRulesetAttributes, RepositoryRulesetError | Error, R> =>
90
+ Effect.gen(function* () {
91
+ const { news, output, constraintRefusal, attrsOf, octokit } = input;
92
+ const refusal = constraintRefusal(news);
93
+ if (refusal !== undefined) return yield* Effect.fail(refusal);
94
+
95
+ const owner = news.owner;
96
+ const repo = news.repository;
97
+ const target = news.target ?? 'branch';
98
+
99
+ let observed: RulesetRecord | undefined =
100
+ output === undefined
101
+ ? undefined
102
+ : yield* octokit.get({ owner, repo, rulesetId: output.rulesetId });
103
+ if (observed === undefined) {
104
+ observed = yield* probeByName(octokit, { owner, repo, name: news.name, target });
105
+ }
106
+
107
+ const bypassRefusal =
108
+ news.bypassActors === undefined
109
+ ? undefined
110
+ : bypassWideningRefusal({
111
+ owner,
112
+ repository: repo,
113
+ name: news.name,
114
+ declared: news.bypassActors,
115
+ live: observed,
116
+ });
117
+ if (bypassRefusal !== undefined) return yield* Effect.fail(bypassRefusal);
118
+
119
+ const ruleRefusal = undeclaredLiveRuleRefusal({
120
+ owner,
121
+ repository: repo,
122
+ name: news.name,
123
+ live: observed,
124
+ declaredTypes: declaredRuleTypes(news.rules),
125
+ });
126
+ if (ruleRefusal !== undefined) return yield* Effect.fail(ruleRefusal);
127
+
128
+ const checksRefusal = requiredChecksOmissionRefusal({
129
+ owner,
130
+ repository: repo,
131
+ name: news.name,
132
+ declared: news.rules,
133
+ live: observed,
134
+ });
135
+ if (checksRefusal !== undefined) return yield* Effect.fail(checksRefusal);
136
+
137
+ const newContexts = newlyRequiredContexts(news.rules, observed);
138
+ yield* refuseUnreportedContexts(octokit, owner, repo, newContexts);
139
+
140
+ const desired = desiredWireRuleset(news);
141
+ // `undefined` means unmanaged: the live value is sent back unchanged rather than the `[]`
142
+ // `desiredWireRuleset` defaults to. On a create there is nothing live to carry forward, so
143
+ // `[]` (already inside `desired`) is correct there too.
144
+ const body: CreateRulesetBody =
145
+ news.bypassActors === undefined && observed !== undefined
146
+ ? ({ ...desired, bypass_actors: observed.bypass_actors ?? [] } as CreateRulesetBody)
147
+ : desired;
148
+
149
+ let record: RulesetRecord;
150
+ let wrote = false;
151
+ if (observed === undefined) {
152
+ record = yield* octokit.create({ owner, repo, body });
153
+ wrote = true;
154
+ } else {
155
+ const same =
156
+ JSON.stringify(canonicalizeWireRuleset(body)) ===
157
+ JSON.stringify(canonicalizeWireRuleset(observed));
158
+ if (same) {
159
+ record = observed;
160
+ } else {
161
+ record = yield* octokit.update({ owner, repo, rulesetId: observed.id, body });
162
+ wrote = true;
163
+ }
164
+ }
165
+
166
+ if (wrote) {
167
+ const independent = yield* octokit.get({ owner, repo, rulesetId: record.id });
168
+ if (independent !== undefined) {
169
+ const sentRules = (body.rules ?? []) as unknown as Record<string, unknown>[];
170
+ const mismatch = readbackRefusal(owner, repo, news.name, sentRules, independent);
171
+ if (mismatch !== undefined) return yield* Effect.fail(mismatch);
172
+ }
173
+ }
174
+
175
+ return attrsOf(record);
176
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Shared plumbing for the write-recording fake tests, split out of
3
+ * repository-ruleset-write.test.ts and repository-ruleset-write-refusals.test.ts purely to keep
4
+ * both under the 250-line file cap — nothing here is a fixture of house-standard VALUES (that
5
+ * is repository-ruleset-fixtures.ts); this is just "run an Effect", "expect it to fail", and one
6
+ * baseline-shaped `RepositoryRulesetProps` for `widgets`.
7
+ */
8
+ import * as Effect from 'effect/Effect';
9
+ import type { RecordingFake } from './repository-ruleset-fake-octokit.ts';
10
+ import {
11
+ type RepositoryRulesetProps,
12
+ type RepositoryRulesetPullRequestRule,
13
+ attrsOf,
14
+ } from './repository-ruleset.ts';
15
+ import type {
16
+ RepositoryRulesetError,
17
+ RulesetConstraintRefused,
18
+ } from './repository-ruleset-errors.ts';
19
+ import { reconcileRuleset } from './repository-ruleset-reconcile.ts';
20
+ import type { RulesetOctokit } from './repository-ruleset-probe.ts';
21
+
22
+ export const run = <A, E>(eff: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(eff);
23
+ export const fails = <A, E>(eff: Effect.Effect<A, E>): Promise<E> =>
24
+ Effect.runPromise(Effect.flip(eff));
25
+
26
+ /** Every case across both files expects a TAGGED refusal, never the plain transport `Error` a
27
+ * real fetch failure would carry — narrowing here keeps every test's assertion a one-liner. */
28
+ export const tagOf = (error: RepositoryRulesetError | Error): string =>
29
+ '_tag' in error ? error._tag : `(untagged) ${error.message}`;
30
+
31
+ export const constraintRefusal = () => undefined as RulesetConstraintRefused | undefined;
32
+
33
+ export const BASE_PULL_REQUEST: RepositoryRulesetPullRequestRule = {
34
+ requiredApprovingReviewCount: 0,
35
+ allowedMergeMethods: ['squash'],
36
+ extraApprovalForUnattributedChanges: false,
37
+ };
38
+
39
+ export const props = (over: Partial<RepositoryRulesetProps> = {}): RepositoryRulesetProps => ({
40
+ owner: 'taslabs-net',
41
+ repository: 'widgets',
42
+ name: 'main',
43
+ target: 'branch',
44
+ enforcement: 'active',
45
+ conditions: { include: ['~DEFAULT_BRANCH'], exclude: [] },
46
+ rules: {
47
+ deletion: true,
48
+ nonFastForward: true,
49
+ pullRequest: BASE_PULL_REQUEST,
50
+ requiredStatusChecks: { checks: [{ context: 'ci' }] },
51
+ },
52
+ ...over,
53
+ });
54
+
55
+ /** Reconciles `p` against a `RecordingFake`, with `output` (when given) shaped as a real
56
+ * `RepositoryRulesetAttributes` — every field but `rulesetId` defaulted, since no test here
57
+ * reads `nodeId`/timestamps. */
58
+ export const reconcileAgainst = (
59
+ fake: RecordingFake,
60
+ p: RepositoryRulesetProps,
61
+ output?: { rulesetId: number },
62
+ ) =>
63
+ reconcileRuleset({
64
+ octokit: fake.octokit,
65
+ news: p,
66
+ output:
67
+ output === undefined
68
+ ? undefined
69
+ : {
70
+ ...output,
71
+ nodeId: undefined,
72
+ name: p.name,
73
+ createdAt: undefined,
74
+ updatedAt: undefined,
75
+ },
76
+ constraintRefusal,
77
+ attrsOf,
78
+ });
79
+
80
+ /** For the hand-built fakes in the refusals file, which construct a `RulesetOctokit` directly
81
+ * rather than going through `RecordingFake`. */
82
+ export const reconcileWithOctokit = (
83
+ octokit: RulesetOctokit,
84
+ p: RepositoryRulesetProps,
85
+ output?: { rulesetId: number },
86
+ ) =>
87
+ reconcileRuleset({
88
+ octokit,
89
+ news: p,
90
+ output:
91
+ output === undefined
92
+ ? undefined
93
+ : {
94
+ ...output,
95
+ nodeId: undefined,
96
+ name: p.name,
97
+ createdAt: undefined,
98
+ updatedAt: undefined,
99
+ },
100
+ constraintRefusal,
101
+ attrsOf,
102
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * (iii) MEASURES, against a real `@octokit/rest` instance with a fetch shim (no network), the
3
+ * one open question H15 depends on: does Octokit's own request builder forward
4
+ * `require_extra_approval_for_unattributed_changes` — a field its own TypeScript types do not
5
+ * declare — onto the wire, or silently strip it the way `@distilled.cloud/github`'s `S.Struct`
6
+ * request schemas are reasoned (not measured) to? This is a MEASUREMENT, not an assertion about
7
+ * intent: if this test ever starts failing, H15's premise no longer holds and the client choice
8
+ * in repository-ruleset.ts needs revisiting, not a quiet update to make the test pass again.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { Octokit } from '@octokit/rest';
12
+ import { desiredWireRuleset } from './repository-ruleset-form.ts';
13
+
14
+ interface CapturedRequest {
15
+ readonly url: string;
16
+ readonly method: string;
17
+ readonly body: unknown;
18
+ }
19
+
20
+ function makeCapturingFetch(captured: CapturedRequest[]): typeof fetch {
21
+ return (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
22
+ captured.push({
23
+ url: String(input),
24
+ method: init?.method ?? 'GET',
25
+ body: init?.body === undefined ? undefined : JSON.parse(init.body as string),
26
+ });
27
+ return new Response(JSON.stringify({ id: 1, name: 'main', rules: [] }), {
28
+ status: 201,
29
+ headers: { 'content-type': 'application/json' },
30
+ });
31
+ }) as typeof fetch;
32
+ }
33
+
34
+ describe('does Octokit forward an untyped extra body field to the wire?', () => {
35
+ test('require_extra_approval_for_unattributed_changes survives createRepoRuleset', async () => {
36
+ const captured: CapturedRequest[] = [];
37
+ const octokit = new Octokit({
38
+ auth: 'fake-token-not-a-real-credential',
39
+ request: { fetch: makeCapturingFetch(captured) },
40
+ });
41
+
42
+ const body = desiredWireRuleset({
43
+ owner: 'taslabs-net',
44
+ repository: 'widgets',
45
+ name: 'main',
46
+ rules: {
47
+ pullRequest: {
48
+ requiredApprovingReviewCount: 0,
49
+ allowedMergeMethods: ['squash'],
50
+ extraApprovalForUnattributedChanges: false,
51
+ },
52
+ },
53
+ });
54
+
55
+ await octokit.rest.repos.createRepoRuleset({ owner: 'taslabs-net', repo: 'widgets', ...body });
56
+
57
+ expect(captured).toHaveLength(1);
58
+ const sent = captured[0]?.body as {
59
+ rules?: { type: string; parameters?: Record<string, unknown> }[];
60
+ };
61
+ const pr = sent?.rules?.find((r) => r.type === 'pull_request');
62
+ // MEASURED: this is what actually crossed the wire, not what Octokit's types say is legal.
63
+ expect(pr?.parameters?.require_extra_approval_for_unattributed_changes).toBe(false);
64
+ expect(pr?.parameters?.allowed_merge_methods).toEqual(['squash']);
65
+ });
66
+ });