@homeflare/alchemy 0.27.0 → 0.27.2
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 +3 -2
- package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
- package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +4 -5
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +79 -103
- package/dist/cloudflare/index.js.map +6 -7
- package/dist/cloudflare/mesh-node-api.d.ts +3 -2
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +16 -5
- package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +34 -3
- package/dist/forgejo/branch-protection.d.ts.map +1 -1
- package/dist/forgejo/fake-forgejo.d.ts +41 -0
- package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
- package/dist/forgejo/index.js +220 -284
- package/dist/forgejo/index.js.map +14 -15
- package/dist/forgejo/org-actions-secrets.d.ts +42 -12
- package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
- package/dist/forgejo/org-label.d.ts +30 -3
- package/dist/forgejo/org-label.d.ts.map +1 -1
- package/dist/forgejo/org-team.d.ts +37 -4
- package/dist/forgejo/org-team.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook-form.d.ts +9 -18
- package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook.d.ts +35 -3
- package/dist/forgejo/repo-webhook.d.ts.map +1 -1
- package/dist/forgejo/repository.d.ts +47 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +37 -37
- package/dist/forgejo/resource.d.ts.map +1 -1
- package/dist/forgejo/team-member.d.ts +48 -4
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/forgejo/values.d.ts +9 -5
- package/dist/forgejo/values.d.ts.map +1 -1
- 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/credentials.md +6 -0
- package/docs/distilled-interim.md +151 -0
- package/docs/launchd-ports.md +14 -17
- package/docs/peers.md +15 -6
- package/docs/upstream-conformance.md +46 -22
- package/package.json +2 -2
- package/src/cloudflare/fake-r2-lock.ts +120 -0
- package/src/cloudflare/index.ts +4 -5
- package/src/cloudflare/mesh-node-api.ts +3 -2
- package/src/cloudflare/providers.ts +13 -16
- package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
- package/src/cloudflare/r2-bucket-lock.ts +59 -51
- package/src/forgejo/branch-protection-form.ts +12 -5
- package/src/forgejo/branch-protection.ts +58 -26
- package/src/forgejo/fake-forgejo.ts +54 -0
- package/src/forgejo/org-actions-secrets.ts +44 -36
- package/src/forgejo/org-label.test.ts +100 -0
- package/src/forgejo/org-label.ts +46 -36
- package/src/forgejo/org-team.ts +40 -27
- package/src/forgejo/repo-webhook-form.ts +10 -27
- package/src/forgejo/repo-webhook.ts +66 -35
- package/src/forgejo/repository.test.ts +125 -0
- package/src/forgejo/repository.ts +72 -52
- package/src/forgejo/resource.ts +80 -79
- package/src/forgejo/team-member.test.ts +97 -0
- package/src/forgejo/team-member.ts +56 -38
- package/src/forgejo/values.ts +9 -17
- package/src/launchd/port-claims.ts +8 -9
- package/dist/cloudflare/client.d.ts +0 -45
- package/dist/cloudflare/client.d.ts.map +0 -1
- package/dist/forgejo/client.d.ts +0 -37
- package/dist/forgejo/client.d.ts.map +0 -1
- package/src/cloudflare/client.ts +0 -76
- package/src/forgejo/client.ts +0 -136
|
@@ -14,16 +14,17 @@ import { Resource, type ResourceClass } from 'alchemy/Resource';
|
|
|
14
14
|
* every gate completely green. The operator's rule (2026-09-15) is to cover such a gap with a
|
|
15
15
|
* custom resource over the vendor's SDK, which is what this is.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
17
|
+
* ★ OVER `@distilled.cloud/cloudflare/r2`, THE SAME SDK `Cloudflare.MeshNode` USES
|
|
18
|
+
* (mesh-node-api.ts), NOT THE `cloudflare` NPM SDK THIS RESOURCE USED TO CALL. Measured
|
|
19
|
+
* 2026-09-23 in distilled `1.0.0-rc.12` (the version alchemy@2.0.0-beta.79 pins),
|
|
20
|
+
* `packages/cloudflare/src/services/r2.ts`: `getBucketLock` / `putBucketLock` cover the same
|
|
21
|
+
* `GET`/`PUT /accounts/{account_id}/r2/buckets/{bucket_name}/lock` route the old SDK's
|
|
22
|
+
* `r2.buckets.locks.get`/`.update` did — same `rules[]` body, the `cf-r2-jurisdiction` header
|
|
23
|
+
* spelled as the `jurisdiction` field — but typed as `NoSuchBucket | InvalidRoute |
|
|
24
|
+
* CloudflareRateLimited | CloudflareError` instead of an opaque HTTP status, so the "bucket
|
|
25
|
+
* does not exist yet" case below is `catchTag('NoSuchBucket', …)`, never a status check. This
|
|
26
|
+
* is also the swap that lets `client.ts` (the hand-rolled `cloudflare@4.5.0` wrapper) go away:
|
|
27
|
+
* nothing else in the kit imported it.
|
|
27
28
|
*
|
|
28
29
|
* ⚠️ THE RULE SET IS A REPLACE, NOT A MERGE. `PUT` takes the WHOLE list, exactly like R2's
|
|
29
30
|
* lifecycle rules — which is the trap a consuming stack records for
|
|
@@ -31,10 +32,12 @@ import { Resource, type ResourceClass } from 'alchemy/Resource';
|
|
|
31
32
|
* drop them, so `read` fetches them first and `reconcile` refuses to write when they already
|
|
32
33
|
* match.
|
|
33
34
|
*/
|
|
34
|
-
import type
|
|
35
|
-
import {
|
|
35
|
+
import type { CloudflareOpContext } from '@distilled.cloud/cloudflare/r2';
|
|
36
|
+
import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
|
|
37
|
+
import * as r2 from '@distilled.cloud/cloudflare/r2';
|
|
38
|
+
import * as Context from 'effect/Context';
|
|
36
39
|
import * as Effect from 'effect/Effect';
|
|
37
|
-
import
|
|
40
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
38
41
|
import {
|
|
39
42
|
type Jurisdiction,
|
|
40
43
|
type R2LockRule,
|
|
@@ -95,10 +98,12 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
|
|
|
95
98
|
* exist yet: `404 {"success":false,"errors":[{"code":10006,"message":"The specified bucket does
|
|
96
99
|
* not exist."}]}` — raised from `read`, during PLANNING, before anything is created. Alchemy
|
|
97
100
|
* calls `read` for a resource with no prior state, so on every first deploy the lock is asked
|
|
98
|
-
* about a bucket the same plan is about to create.
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
101
|
+
* about a bucket the same plan is about to create. Code 10006 is exactly distilled's
|
|
102
|
+
* `NoSuchBucket` tag (r2.ts's error matcher, `[{ code: 10006 }]`), so `catchTag` on it is the
|
|
103
|
+
* same case the status check used to cover — but by the vendor's own error identity, not by
|
|
104
|
+
* reading a number off the response. Letting it propagate would make the resource impossible to
|
|
105
|
+
* plan; swallowing every failure would also hide a 403 from a mis-scoped token, which is the
|
|
106
|
+
* failure this estate spends the most time on. So: `NoSuchBucket` → absent, everything else dies.
|
|
102
107
|
*
|
|
103
108
|
* ⛔ AN EMPTY LIVE RULE SET IS "NO LOCK", NOT "A LOCK WITH NOTHING IN IT", so it reads as absent
|
|
104
109
|
* and the resource CREATES. The API answers `{}` for a bucket that has never been locked and
|
|
@@ -110,26 +115,17 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
|
|
|
110
115
|
* all, because `reconcile` below returns before writing.
|
|
111
116
|
*/
|
|
112
117
|
export const readLock = (
|
|
113
|
-
client: Cloudflare,
|
|
114
118
|
props: { accountId: string; bucketName: string; jurisdiction?: Jurisdiction },
|
|
115
119
|
written: boolean,
|
|
116
|
-
): Effect.Effect<R2BucketLockAttributes | undefined> =>
|
|
120
|
+
): Effect.Effect<R2BucketLockAttributes | undefined, never, CloudflareOpContext> =>
|
|
117
121
|
Effect.gen(function* () {
|
|
118
122
|
const jurisdiction = jurisdictionOf(props);
|
|
119
|
-
const live = yield*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
catch: (cause) => cause,
|
|
126
|
-
}).pipe(
|
|
127
|
-
Effect.catchIf(
|
|
128
|
-
(cause) => cause instanceof NotFoundError,
|
|
129
|
-
() => Effect.succeed(undefined),
|
|
130
|
-
),
|
|
131
|
-
Effect.orDie,
|
|
132
|
-
);
|
|
123
|
+
const live = yield* r2
|
|
124
|
+
.getBucketLock({ accountId: props.accountId, bucketName: props.bucketName, jurisdiction })
|
|
125
|
+
.pipe(
|
|
126
|
+
Effect.catchTag('NoSuchBucket', () => Effect.succeed(undefined)),
|
|
127
|
+
Effect.orDie,
|
|
128
|
+
);
|
|
133
129
|
if (live === undefined) return undefined;
|
|
134
130
|
const rules = (live.rules ?? []) as readonly R2LockRule[];
|
|
135
131
|
if (rules.length === 0) return undefined;
|
|
@@ -146,22 +142,25 @@ export const readLock = (
|
|
|
146
142
|
* rule set; re-sending an identical one on every deploy is a write against the only thing
|
|
147
143
|
* standing between this repository and a delete, for no change. `output` is what the last deploy
|
|
148
144
|
* persisted (or what `read` just adopted), so the comparison costs nothing either.
|
|
145
|
+
* ⛔ `NoSuchBucket` IS NOT CAUGHT HERE. Unlike `read`, a `reconcile` that hits it means the bucket
|
|
146
|
+
* this declaration names is genuinely gone — that is a real failure to surface, not a case to
|
|
147
|
+
* paper over, so it dies like every other tagged error `putBucketLock` can raise.
|
|
149
148
|
*/
|
|
150
149
|
export const reconcileLock = (
|
|
151
|
-
client: Cloudflare,
|
|
152
150
|
news: R2BucketLockProps,
|
|
153
151
|
output: R2BucketLockAttributes | undefined,
|
|
154
|
-
): Effect.Effect<R2BucketLockAttributes> =>
|
|
152
|
+
): Effect.Effect<R2BucketLockAttributes, never, CloudflareOpContext> =>
|
|
155
153
|
Effect.gen(function* () {
|
|
156
154
|
const jurisdiction = jurisdictionOf(news);
|
|
157
155
|
if (output !== undefined && rulesEqual(output.rules, news.rules)) return output;
|
|
158
|
-
yield*
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
yield* r2
|
|
157
|
+
.putBucketLock({
|
|
158
|
+
accountId: news.accountId,
|
|
159
|
+
bucketName: news.bucketName,
|
|
161
160
|
jurisdiction,
|
|
162
161
|
...toBody(news.rules),
|
|
163
|
-
})
|
|
164
|
-
|
|
162
|
+
})
|
|
163
|
+
.pipe(Effect.orDie);
|
|
165
164
|
return { bucketName: news.bucketName, jurisdiction, rules: news.rules };
|
|
166
165
|
});
|
|
167
166
|
|
|
@@ -173,25 +172,34 @@ export const reconcileLock = (
|
|
|
173
172
|
* no ordinary deploy should ever reach it.
|
|
174
173
|
*/
|
|
175
174
|
export const deleteLock = (
|
|
176
|
-
client: Cloudflare,
|
|
177
175
|
output: R2BucketLockAttributes,
|
|
178
176
|
accountId: string,
|
|
179
|
-
): Effect.Effect<void> =>
|
|
177
|
+
): Effect.Effect<void, never, CloudflareOpContext> =>
|
|
180
178
|
Effect.gen(function* () {
|
|
181
|
-
yield*
|
|
182
|
-
|
|
183
|
-
|
|
179
|
+
yield* r2
|
|
180
|
+
.putBucketLock({
|
|
181
|
+
accountId,
|
|
182
|
+
bucketName: output.bucketName,
|
|
184
183
|
jurisdiction: output.jurisdiction,
|
|
185
184
|
rules: [],
|
|
186
|
-
})
|
|
187
|
-
|
|
185
|
+
})
|
|
186
|
+
.pipe(Effect.orDie);
|
|
188
187
|
});
|
|
189
188
|
|
|
190
189
|
export const R2BucketLockProvider = () =>
|
|
191
190
|
Provider.effect(
|
|
192
191
|
R2BucketLock,
|
|
193
192
|
Effect.gen(function* () {
|
|
194
|
-
|
|
193
|
+
// ★ CAPTURED AT BUILD, LIKE `MeshNodeProvider`. `Credentials` and `HttpClient.HttpClient`
|
|
194
|
+
// are the whole of distilled's `CloudflareOpContext` (r2.ts re-exports the type); pinning
|
|
195
|
+
// them here rather than reading ambient context inside each handler keeps this provider
|
|
196
|
+
// correct whether or not the stack also merges `Cloudflare.providers()`.
|
|
197
|
+
const services = Context.make(Credentials, yield* Credentials).pipe(
|
|
198
|
+
Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
|
199
|
+
);
|
|
200
|
+
const run = <A, E>(effect: Effect.Effect<A, E, CloudflareOpContext>) =>
|
|
201
|
+
Effect.provideContext(effect, services);
|
|
202
|
+
|
|
195
203
|
return R2BucketLock.Provider.of({
|
|
196
204
|
/**
|
|
197
205
|
* ⛔ EMPTY, AND ALCHEMY'S OWN CONTRACT ASKS FOR EXACTLY THAT. `ProviderService.list`:
|
|
@@ -211,10 +219,10 @@ export const R2BucketLockProvider = () =>
|
|
|
211
219
|
read: Effect.fn(function* ({ olds, output }) {
|
|
212
220
|
const props = olds ?? undefined;
|
|
213
221
|
if (props === undefined) return undefined;
|
|
214
|
-
return yield* readLock(
|
|
222
|
+
return yield* run(readLock(props, output !== undefined));
|
|
215
223
|
}),
|
|
216
|
-
reconcile: Effect.fn(({ news, output }) => reconcileLock(
|
|
217
|
-
delete: Effect.fn(({ olds, output }) => deleteLock(
|
|
224
|
+
reconcile: Effect.fn(({ news, output }) => run(reconcileLock(news, output))),
|
|
225
|
+
delete: Effect.fn(({ olds, output }) => run(deleteLock(output, olds.accountId))),
|
|
218
226
|
});
|
|
219
227
|
}),
|
|
220
228
|
);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Create/
|
|
2
|
+
* Create/edit request bodies for `Forgejo.BranchProtection` — extracted for the 250-line cap.
|
|
3
3
|
*
|
|
4
|
-
* ★
|
|
5
|
-
* `
|
|
4
|
+
* ★ `repoCreateBranchProtection`'s request carries `branch_name`; `repoEditBranchProtection`'s
|
|
5
|
+
* carries `name` (the rule-name path segment) instead — both are covered by the two exports
|
|
6
|
+
* below, so branch-protection.ts never builds the field list twice.
|
|
6
7
|
*/
|
|
7
8
|
import type { BranchProtectionProps } from './branch-protection.ts';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
const shared = (props: BranchProtectionProps) => ({
|
|
10
11
|
...(props.applyToAdmins === undefined ? {} : { apply_to_admins: props.applyToAdmins }),
|
|
11
12
|
...(props.dismissStaleApprovals === undefined
|
|
12
13
|
? {}
|
|
@@ -25,5 +26,11 @@ export const branchProtectionForm = (props: BranchProtectionProps) => ({
|
|
|
25
26
|
...(props.statusCheckContexts === undefined
|
|
26
27
|
? {}
|
|
27
28
|
: { status_check_contexts: props.statusCheckContexts }),
|
|
28
|
-
rule_name: props.branchName,
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
export const createBranchProtectionForm = (props: BranchProtectionProps) => ({
|
|
32
|
+
...shared(props),
|
|
33
|
+
branch_name: props.branchName,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const editBranchProtectionForm = (props: BranchProtectionProps) => shared(props);
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
* `Forgejo.BranchProtection` — one rule at `/repos/{owner}/{repo}/branch_protections/{name}`.
|
|
3
3
|
*
|
|
4
4
|
* ★ READ OFF `<estate>/mcp-servers/docs/api/upstream/forgejo.json`: create is POST collection;
|
|
5
|
-
* read/update/delete use the branch/rule name in the path segment `{name}`.
|
|
6
|
-
* `
|
|
5
|
+
* read/update/delete use the branch/rule name in the path segment `{name}`. Now through
|
|
6
|
+
* `@distilled.cloud/forgejo`'s `repository.repoCreateBranchProtection` /
|
|
7
|
+
* `repoGetBranchProtection` / `repoEditBranchProtection` / `repoDeleteBranchProtection`.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ `BranchProtection.rule_name` IS A REQUIRED STRING IN THE PACKAGE'S SCHEMA — the deprecated
|
|
10
|
+
* `branch_name` fallback the hand-rolled client carried is gone; a response missing `rule_name`
|
|
11
|
+
* now fails the operation's own decode instead of this family silently treating it as absent.
|
|
7
12
|
*
|
|
8
13
|
* ⛔ `defaultRemovalPolicy: 'retain'` — dropping a protection rule from the stack must not silently
|
|
9
14
|
* unprotect a branch; opt into `.pipe(RemovalPolicy.destroy())` to DELETE.
|
|
@@ -12,10 +17,11 @@
|
|
|
12
17
|
*/
|
|
13
18
|
import { Resource } from 'alchemy';
|
|
14
19
|
import * as Provider from 'alchemy/Provider';
|
|
20
|
+
import * as repository from '@distilled.cloud/forgejo/repository';
|
|
15
21
|
import * as Effect from 'effect/Effect';
|
|
16
|
-
import {
|
|
17
|
-
import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
|
|
18
|
-
import {
|
|
22
|
+
import { createBranchProtectionForm, editBranchProtectionForm } from './branch-protection-form.ts';
|
|
23
|
+
import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
|
|
24
|
+
import { stringArray } from './values.ts';
|
|
19
25
|
|
|
20
26
|
export interface BranchProtectionProps {
|
|
21
27
|
owner: string;
|
|
@@ -59,26 +65,45 @@ export const ForgejoBranchProtection = Resource<ForgejoBranchProtection>(
|
|
|
59
65
|
{ defaultRemovalPolicy: 'retain' },
|
|
60
66
|
);
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
/** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
|
|
69
|
+
export const spec: ForgejoSpec<
|
|
70
|
+
BranchProtectionProps,
|
|
71
|
+
repository.BranchProtection,
|
|
72
|
+
BranchProtectionAttributes,
|
|
73
|
+
| repository.RepoCreateBranchProtectionError
|
|
74
|
+
| repository.RepoGetBranchProtectionError
|
|
75
|
+
| repository.RepoEditBranchProtectionError
|
|
76
|
+
| repository.RepoDeleteBranchProtectionError
|
|
77
|
+
> = {
|
|
78
|
+
attributes: (live, props) => ({
|
|
79
|
+
applyToAdmins: live.apply_to_admins ?? false,
|
|
80
|
+
branchName: props.branchName,
|
|
81
|
+
dismissStaleApprovals: live.dismiss_stale_approvals ?? false,
|
|
82
|
+
enablePush: live.enable_push ?? true,
|
|
83
|
+
enableStatusCheck: live.enable_status_check ?? false,
|
|
84
|
+
ignoreStaleApprovals: live.ignore_stale_approvals ?? false,
|
|
85
|
+
owner: props.owner,
|
|
86
|
+
repo: props.repo,
|
|
87
|
+
requireSignedCommits: live.require_signed_commits ?? false,
|
|
88
|
+
requiredApprovals: live.required_approvals ?? 0,
|
|
89
|
+
statusCheckContexts: stringArray(live.status_check_contexts ?? []),
|
|
90
|
+
}),
|
|
91
|
+
create: (props) =>
|
|
92
|
+
repository.repoCreateBranchProtection({
|
|
93
|
+
owner: props.owner,
|
|
94
|
+
repo: props.repo,
|
|
95
|
+
...createBranchProtectionForm(props),
|
|
96
|
+
}),
|
|
97
|
+
destroy: (props) =>
|
|
98
|
+
repository.repoDeleteBranchProtection({
|
|
73
99
|
owner: props.owner,
|
|
74
100
|
repo: props.repo,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
createForm: branchProtectionForm,
|
|
101
|
+
name: props.branchName,
|
|
102
|
+
}),
|
|
103
|
+
fetchLive: (props) =>
|
|
104
|
+
repository
|
|
105
|
+
.repoGetBranchProtection({ owner: props.owner, repo: props.repo, name: props.branchName })
|
|
106
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
82
107
|
matches: (attributes, props) =>
|
|
83
108
|
attributes.requiredApprovals === (props.requiredApprovals ?? 0) &&
|
|
84
109
|
attributes.enablePush === (props.enablePush ?? true) &&
|
|
@@ -90,9 +115,16 @@ const handlers = forgejoHandlers<BranchProtectionProps, BranchProtectionAttribut
|
|
|
90
115
|
(props.statusCheckContexts === undefined ||
|
|
91
116
|
stringArray(props.statusCheckContexts).join('\0') ===
|
|
92
117
|
attributes.statusCheckContexts.join('\0')),
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
update: (props) =>
|
|
119
|
+
repository.repoEditBranchProtection({
|
|
120
|
+
owner: props.owner,
|
|
121
|
+
repo: props.repo,
|
|
122
|
+
name: props.branchName,
|
|
123
|
+
...editBranchProtectionForm(props),
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const handlers = forgejoHandlers(spec);
|
|
96
128
|
|
|
97
129
|
export const ForgejoBranchProtectionProvider = () =>
|
|
98
130
|
Provider.effect(
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal in-memory Forgejo v1 API for this family's tests — an in-memory `fetch` handed to
|
|
3
|
+
* Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
|
|
4
|
+
* `cloudflare/fake-mesh.ts`. Tests exercise distilled's REAL path assembly, JSON encode/decode
|
|
5
|
+
* and status→typed-error matching (`404` → `NotFound`, `403` → `Forbidden`, …) — nothing about
|
|
6
|
+
* that matching is re-implemented here, only the wire responses a scenario needs.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
|
|
9
|
+
* `forgejo.example.com`, and the token is a placeholder.
|
|
10
|
+
*/
|
|
11
|
+
import { type Credentials, credentials } from '@distilled.cloud/forgejo/Credentials';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
14
|
+
|
|
15
|
+
export const FAKE_BASE = 'https://forgejo.example.com';
|
|
16
|
+
export const FAKE_TOKEN = 'placeholder-forgejo-token';
|
|
17
|
+
|
|
18
|
+
export type Seen = { readonly method: string; readonly path: string };
|
|
19
|
+
|
|
20
|
+
/** Forgejo's failure envelope — `{ message, url }` — matching protocol.ts's `errorEnvelope`. */
|
|
21
|
+
export const fakeFailure = (status: number, message: string) =>
|
|
22
|
+
Response.json({ message, url: 'https://forgejo.example.com/api/v1/swagger' }, { status });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `route` decides every response; this wrapper only records what was sent and adapts to
|
|
26
|
+
* `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
|
|
27
|
+
*/
|
|
28
|
+
export const fakeForgejo = (route: (method: string, url: URL) => Response) => {
|
|
29
|
+
const seen: Seen[] = [];
|
|
30
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
31
|
+
const request =
|
|
32
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
|
|
35
|
+
return route(request.method, url);
|
|
36
|
+
}) as typeof globalThis.fetch;
|
|
37
|
+
return { fetch, seen };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* distilled Credentials + the real FetchHttpClient over the fake — `ForgejoOpContext`, what every
|
|
42
|
+
* resource file's exported `spec.fetchLive` needs.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ NOT PAIRED WITH `handlers` (each resource file's `forgejoHandlers(spec)` export). `handlers`
|
|
45
|
+
* bakes in `CredentialsFromEnv`, which resolves `FORGEJO_URL` / `FORGEJO_TOKEN` through Effect's
|
|
46
|
+
* `Config` — whose default provider snapshots `process.env` once and never re-reads it (measured
|
|
47
|
+
* 2026-09-23: mutating `process.env` mid-test-run had no effect). So a test cannot point
|
|
48
|
+
* `handlers` at a fake server; it calls `spec.fetchLive`/`spec.attributes` directly instead, with
|
|
49
|
+
* this explicit layer, exercising exactly the same production code without that env seam.
|
|
50
|
+
*/
|
|
51
|
+
export const fakeForgejoLayer = (
|
|
52
|
+
fetchFn: typeof globalThis.fetch,
|
|
53
|
+
creds: Layer.Layer<Credentials> = credentials({ baseUrl: FAKE_BASE, token: FAKE_TOKEN }),
|
|
54
|
+
) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
|
|
@@ -1,36 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Forgejo.OrgSecret` — org-level Actions secret metadata at `/orgs/{org}/actions/secrets/{name}`.
|
|
3
3
|
*
|
|
4
|
-
* ★ CHOICE: AUTHORED WITH WRITE-ONLY VALUE VIA ENV, NOT REFUSED.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `ForgejoSpec` instead of hand-rolling provider handlers.
|
|
4
|
+
* ★ CHOICE: AUTHORED WITH WRITE-ONLY VALUE VIA ENV, NOT REFUSED. `organization.Secret` (the
|
|
5
|
+
* package's typed output) has only `name` and `created_at` — never `data` — so attributes stay
|
|
6
|
+
* metadata-only. Create/update is `updateOrgSecret`, a PUT with `{ data }`, which does not fit
|
|
7
|
+
* POST/PATCH; this family uses the `upsert` seam on `ForgejoSpec` instead of a plain create.
|
|
9
8
|
*
|
|
10
9
|
* ⛔ THE SECRET VALUE IS NEVER A PROP OR AN ATTRIBUTE. It is read from `FORGEJO_ORG_SECRET_{org}_{name}`
|
|
11
10
|
* (see orgSecretEnvKey) at PUT time when the name is absent from the list. Once present, reconcile
|
|
12
11
|
* noops — the API cannot confirm the value matches, and re-PUT on every deploy would rotate
|
|
13
12
|
* unintentionally. Rotate by destroy-then-create or a manual PUT outside Alchemy.
|
|
14
13
|
*
|
|
15
|
-
* ⚠️ PUT ANSWERS 201/204 WITH NO BODY — client.ts treats empty responses as `undefined`; read-back
|
|
16
|
-
* uses the list endpoint, not the PUT response.
|
|
17
|
-
*
|
|
18
14
|
* ⛔ TOKEN NEEDS `write:organization` TO PUT — list needs `read:organization`.
|
|
19
15
|
*
|
|
20
16
|
* ⛔ THE FILE NAME IS PLURAL ON PURPOSE AND MUST STAY THAT WAY. `.gitignore:198` carries
|
|
21
17
|
* `*-secret.*` — the rule that stops `client-secret.json` and friends being committed — and it
|
|
22
18
|
* matches a SOURCE file called `org-secret.ts` just as happily. This family was first authored
|
|
23
19
|
* under that name: it existed on disk, typechecked, passed oxlint, was imported by alchemy.run.ts
|
|
24
|
-
* and appeared in NO `git status`.
|
|
25
|
-
*
|
|
26
|
-
* that now catches the class; this comment is for whoever is tempted to rename it back.
|
|
20
|
+
* and appeared in NO `git status`. `scripts/husky/ignored-imports.ts` is the gate that now
|
|
21
|
+
* catches the class; this comment is for whoever is tempted to rename it back.
|
|
27
22
|
*/
|
|
28
23
|
import { Resource } from 'alchemy';
|
|
29
24
|
import * as Provider from 'alchemy/Provider';
|
|
25
|
+
import * as organization from '@distilled.cloud/forgejo/organization';
|
|
26
|
+
import * as Data from 'effect/Data';
|
|
30
27
|
import * as Effect from 'effect/Effect';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import { orgSecretEnvKey, text } from './values.ts';
|
|
28
|
+
import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
|
|
29
|
+
import { orgSecretEnvKey } from './values.ts';
|
|
34
30
|
|
|
35
31
|
export interface OrgSecretProps {
|
|
36
32
|
org: string;
|
|
@@ -54,46 +50,58 @@ export interface ForgejoOrgSecret extends Resource<
|
|
|
54
50
|
|
|
55
51
|
export const ForgejoOrgSecret = Resource<ForgejoOrgSecret>('Forgejo.OrgSecret');
|
|
56
52
|
|
|
53
|
+
/** Domain refusal, not a distilled error — the env var this family reads the secret value from is unset. */
|
|
54
|
+
export class ForgejoSecretEnvUnsetError extends Data.TaggedError('ForgejoSecretEnvUnsetError')<{
|
|
55
|
+
readonly message: string;
|
|
56
|
+
}> {}
|
|
57
|
+
|
|
57
58
|
const secretData = (org: string, name: string) =>
|
|
58
59
|
Effect.gen(function* () {
|
|
59
60
|
const key = orgSecretEnvKey(org, name);
|
|
60
61
|
const raw = process.env[key];
|
|
61
62
|
if (raw === undefined || raw.trim() === '') {
|
|
62
63
|
return yield* Effect.fail(
|
|
63
|
-
new
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`orgs/${org}/actions/secrets/${name}`,
|
|
67
|
-
`${key} is unset. Export the secret value there — never as an Alchemy prop.`,
|
|
68
|
-
),
|
|
64
|
+
new ForgejoSecretEnvUnsetError({
|
|
65
|
+
message: `${key} is unset. Export the secret value there — never as an Alchemy prop.`,
|
|
66
|
+
}),
|
|
69
67
|
);
|
|
70
68
|
}
|
|
71
69
|
return raw.trim();
|
|
72
70
|
});
|
|
73
71
|
|
|
74
|
-
|
|
72
|
+
/** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
|
|
73
|
+
export const spec: ForgejoSpec<
|
|
74
|
+
OrgSecretProps,
|
|
75
|
+
organization.Secret,
|
|
76
|
+
OrgSecretAttributes,
|
|
77
|
+
| organization.OrgListActionsSecretsError
|
|
78
|
+
| organization.UpdateOrgSecretError
|
|
79
|
+
| organization.DeleteOrgSecretError
|
|
80
|
+
| ForgejoSecretEnvUnsetError
|
|
81
|
+
> = {
|
|
75
82
|
attributes: (live, props) => ({
|
|
76
|
-
createdAt:
|
|
83
|
+
createdAt: live.created_at,
|
|
77
84
|
name: props.name,
|
|
78
85
|
org: props.org,
|
|
79
86
|
}),
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
Effect.
|
|
85
|
-
Array.isArray(rows) ? rows.find((row) => row['name'] === props.name) : undefined,
|
|
86
|
-
),
|
|
87
|
+
destroy: (props) => organization.deleteOrgSecret({ org: props.org, secretname: props.name }),
|
|
88
|
+
fetchLive: (props) =>
|
|
89
|
+
organization.orgListActionsSecrets({ org: props.org, limit: 200 }).pipe(
|
|
90
|
+
Effect.map((rows) => rows.find((row) => row.name === props.name)),
|
|
91
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
87
92
|
),
|
|
88
93
|
matches: () => true,
|
|
89
|
-
path: (props) => `orgs/${props.org}/actions/secrets/${props.name}`,
|
|
90
94
|
upsert: (props, before) =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
before !== undefined
|
|
96
|
+
? Effect.void
|
|
97
|
+
: Effect.flatMap(secretData(props.org, props.name), (data) =>
|
|
98
|
+
Effect.asVoid(
|
|
99
|
+
organization.updateOrgSecret({ data, org: props.org, secretname: props.name }),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const handlers = forgejoHandlers(spec);
|
|
97
105
|
|
|
98
106
|
export const ForgejoOrgSecretProvider = () =>
|
|
99
107
|
Provider.effect(ForgejoOrgSecret, Effect.succeed(ForgejoOrgSecret.Provider.of(handlers)));
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Forgejo.OrgLabel`'s `fetchLive` against a fake Forgejo — the "list, then find by name" pattern
|
|
3
|
+
* (labels, teams, hooks and org secrets all share it, since Gitea addresses them by numeric id,
|
|
4
|
+
* not name). Two absent cases fold to `undefined` the same way, for different reasons:
|
|
5
|
+
*
|
|
6
|
+
* - the org itself is gone: `orgListLabels` answers a real distilled `NotFound` (404), caught by
|
|
7
|
+
* the same `catchTag('NotFound', ...)` idiom every `fetchLive` in this family ends with.
|
|
8
|
+
* - the org exists but has no label by that name: the list call succeeds, and `.find()` returns
|
|
9
|
+
* `undefined` on its own — no error, let alone a typed one, is involved.
|
|
10
|
+
*
|
|
11
|
+
* A 401 on the list still propagates unfolded, same as repository.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as organization from '@distilled.cloud/forgejo/organization';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import { fakeFailure, fakeForgejo, fakeForgejoLayer } from './fake-forgejo.ts';
|
|
17
|
+
import { spec } from './org-label.ts';
|
|
18
|
+
|
|
19
|
+
const LABELS_PATH = '/api/v1/orgs/homeflare/labels';
|
|
20
|
+
|
|
21
|
+
const fetchLabel = (fetchFn: typeof globalThis.fetch, name: string) =>
|
|
22
|
+
organization.orgListLabels({ limit: 200, org: 'homeflare' }).pipe(
|
|
23
|
+
Effect.map((rows) => rows.find((row) => row.name === name)),
|
|
24
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
25
|
+
Effect.provide(fakeForgejoLayer(fetchFn)),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe('Forgejo.OrgLabel fetchLive', () => {
|
|
29
|
+
test('a live label matched by name decodes into the typed row', async () => {
|
|
30
|
+
const fake = fakeForgejo((method, url) =>
|
|
31
|
+
method === 'GET' && url.pathname === LABELS_PATH
|
|
32
|
+
? Response.json([{ color: 'ff0000', id: 7, name: 'bug' }])
|
|
33
|
+
: fakeFailure(404, 'not found'),
|
|
34
|
+
);
|
|
35
|
+
const live = await Effect.runPromise(fetchLabel(fake.fetch, 'bug'));
|
|
36
|
+
expect(live?.id).toBe(7);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a name absent from a successful list is undefined — no error at all', async () => {
|
|
40
|
+
const fake = fakeForgejo(() => Response.json([{ color: 'ff0000', id: 7, name: 'bug' }]));
|
|
41
|
+
const live = await Effect.runPromise(fetchLabel(fake.fetch, 'enhancement'));
|
|
42
|
+
expect(live).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the org itself missing (404 on the list) folds to undefined too', async () => {
|
|
46
|
+
const fake = fakeForgejo(() => fakeFailure(404, 'org does not exist'));
|
|
47
|
+
const live = await Effect.runPromise(fetchLabel(fake.fetch, 'bug'));
|
|
48
|
+
expect(live).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a 401 on the list propagates — never folded to absent', async () => {
|
|
52
|
+
// ⚠️ 401 rather than 403: `orgListLabels` only declares `NotFound` beyond the shared default
|
|
53
|
+
// channel — Unauthorized is in that shared set for every operation regardless.
|
|
54
|
+
const fake = fakeForgejo(() => fakeFailure(401, 'bad token'));
|
|
55
|
+
const failure = await Effect.runPromise(Effect.flip(fetchLabel(fake.fetch, 'bug')));
|
|
56
|
+
expect(failure._tag).toBe('Unauthorized');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Forgejo.OrgLabel spec — the actual exported production functions', () => {
|
|
61
|
+
// ⛔ Proves the real `fetchLive` + `attributes` in org-label.ts, not a parallel
|
|
62
|
+
// reimplementation — see the same note in repository.test.ts.
|
|
63
|
+
const props = { color: 'ff0000', name: 'bug', org: 'homeflare' };
|
|
64
|
+
|
|
65
|
+
const readThrough = (fetchFn: typeof globalThis.fetch) =>
|
|
66
|
+
Effect.runPromise(
|
|
67
|
+
spec.fetchLive(props).pipe(
|
|
68
|
+
Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
|
|
69
|
+
Effect.provide(fakeForgejoLayer(fetchFn)),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
test('spec.fetchLive + spec.attributes match the old field mapping', async () => {
|
|
74
|
+
const fake = fakeForgejo((method, url) =>
|
|
75
|
+
method === 'GET' && url.pathname === LABELS_PATH
|
|
76
|
+
? Response.json([
|
|
77
|
+
{ color: 'FF0000', description: 'something is broken', id: 7, name: 'bug' },
|
|
78
|
+
])
|
|
79
|
+
: fakeFailure(404, 'not found'),
|
|
80
|
+
);
|
|
81
|
+
// ⚠️ `color()` lowercases — the wire value `FF0000` must compare equal to the declared `ff0000`.
|
|
82
|
+
expect(await readThrough(fake.fetch)).toEqual({
|
|
83
|
+
color: 'ff0000',
|
|
84
|
+
description: 'something is broken',
|
|
85
|
+
exclusive: false,
|
|
86
|
+
labelId: 7,
|
|
87
|
+
name: 'bug',
|
|
88
|
+
org: 'homeflare',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a label absent from the list through spec.fetchLive is undefined', async () => {
|
|
93
|
+
const fake = fakeForgejo((method, url) =>
|
|
94
|
+
method === 'GET' && url.pathname === LABELS_PATH
|
|
95
|
+
? Response.json([{ color: '00ff00', id: 9, name: 'enhancement' }])
|
|
96
|
+
: fakeFailure(404, 'not found'),
|
|
97
|
+
);
|
|
98
|
+
expect(await readThrough(fake.fetch)).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|