@homeflare/alchemy 0.27.1 → 0.27.3
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/docs/credentials.md +6 -0
- package/docs/distilled-interim.md +151 -0
- package/docs/peers.md +15 -6
- package/docs/upstream-conformance.md +46 -22
- package/package.json +5 -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/proxmox/distilled-task-await.test.ts +124 -0
- 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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Forgejo.Repository`'s `fetchLive` against a fake Forgejo, proving the tagged-error paths that
|
|
3
|
+
* replaced `client.ts`'s status-carrying `ForgejoError`:
|
|
4
|
+
*
|
|
5
|
+
* - a real distilled `NotFound` (HTTP 404) folds to `undefined` — resource.ts's engine sees the
|
|
6
|
+
* repository as absent, the same outcome the old `absentOn404` produced for a 404.
|
|
7
|
+
* - a real distilled `Forbidden` (HTTP 403) is NOT folded — it fails the read, because folding it
|
|
8
|
+
* would plan a create over a repository this token simply cannot see.
|
|
9
|
+
*
|
|
10
|
+
* Both run the REAL `@distilled.cloud/forgejo` protocol (path assembly, JSON decode, status→typed
|
|
11
|
+
* error matching) against an in-memory `fetch`; nothing about the matching is asserted by hand.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as repository from '@distilled.cloud/forgejo/repository';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import { FAKE_BASE, fakeFailure, fakeForgejo, fakeForgejoLayer } from './fake-forgejo.ts';
|
|
17
|
+
import { spec } from './repository.ts';
|
|
18
|
+
|
|
19
|
+
const REPO_PATH = '/api/v1/repos/homeflare/kit';
|
|
20
|
+
|
|
21
|
+
const liveRepo = () =>
|
|
22
|
+
Response.json({
|
|
23
|
+
clone_url: `${FAKE_BASE}/homeflare/kit.git`,
|
|
24
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
25
|
+
default_branch: 'main',
|
|
26
|
+
description: 'the kit',
|
|
27
|
+
full_name: 'homeflare/kit',
|
|
28
|
+
has_issues: true,
|
|
29
|
+
has_projects: true,
|
|
30
|
+
has_wiki: false,
|
|
31
|
+
html_url: `${FAKE_BASE}/homeflare/kit`,
|
|
32
|
+
id: 42,
|
|
33
|
+
name: 'kit',
|
|
34
|
+
owner: { id: 1, login: 'homeflare' },
|
|
35
|
+
private: true,
|
|
36
|
+
ssh_url: 'git@forgejo.example.com:homeflare/kit.git',
|
|
37
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Forgejo.Repository fetchLive', () => {
|
|
41
|
+
test('a live repository decodes into typed attributes', async () => {
|
|
42
|
+
const fake = fakeForgejo((method, url) =>
|
|
43
|
+
method === 'GET' && url.pathname === REPO_PATH ? liveRepo() : fakeFailure(404, 'not found'),
|
|
44
|
+
);
|
|
45
|
+
const live = await Effect.runPromise(
|
|
46
|
+
repository
|
|
47
|
+
.getRepo({ owner: 'homeflare', repo: 'kit' })
|
|
48
|
+
.pipe(Effect.provide(fakeForgejoLayer(fake.fetch))),
|
|
49
|
+
);
|
|
50
|
+
expect(live.id).toBe(42);
|
|
51
|
+
expect(live.has_wiki).toBe(false);
|
|
52
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: REPO_PATH }]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a 404 folds to undefined — the exact `fetchLive` idiom repository.ts uses', async () => {
|
|
56
|
+
// ⚠️ This is the same one-line `.pipe(Effect.catchTag('NotFound', ...))` repository.ts's
|
|
57
|
+
// `fetchLive` ends with, exercised against a real distilled decode of a real 404 response —
|
|
58
|
+
// the direct successor to client.ts's `absentOn404` folding a raw HTTP 404.
|
|
59
|
+
const fake = fakeForgejo(() => fakeFailure(404, 'repository does not exist'));
|
|
60
|
+
const result = await Effect.runPromise(
|
|
61
|
+
repository.getRepo({ owner: 'homeflare', repo: 'ghost' }).pipe(
|
|
62
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
63
|
+
Effect.provide(fakeForgejoLayer(fake.fetch)),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
expect(result).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a 401 propagates — Unauthorized is a real failure, never folded to absent', async () => {
|
|
70
|
+
// ⚠️ 401 rather than 403: `getRepo`'s own declared errors are only `NotFound` (plus the
|
|
71
|
+
// channel every operation shares) — Unauthorized is in that shared default set, decoded
|
|
72
|
+
// for every operation regardless of what it declares, so this does not depend on
|
|
73
|
+
// `getRepo` happening to also list Forbidden.
|
|
74
|
+
const fake = fakeForgejo(() => fakeFailure(401, 'bad token'));
|
|
75
|
+
const failure = await Effect.runPromise(
|
|
76
|
+
Effect.flip(
|
|
77
|
+
repository.getRepo({ owner: 'homeflare', repo: 'kit' }).pipe(
|
|
78
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
79
|
+
Effect.provide(fakeForgejoLayer(fake.fetch)),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
expect(failure._tag).toBe('Unauthorized');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('Forgejo.Repository spec — the actual exported production functions', () => {
|
|
88
|
+
// ⛔ THE POINT OF THIS BLOCK. The describe above proves the SDK's own `getRepo` +
|
|
89
|
+
// `catchTag('NotFound', ...)` idiom works; it does not touch a single line of
|
|
90
|
+
// repository.ts. This block calls `spec.fetchLive` and `spec.attributes` — the exact object
|
|
91
|
+
// `forgejoHandlers(spec)` wraps into `ForgejoRepositoryProvider` — so a typo that dropped or
|
|
92
|
+
// mistagged the `catchTag` in `fetchLive`, or a drifted `attributes()` mapping, fails HERE.
|
|
93
|
+
const props = { name: 'kit', org: 'homeflare' };
|
|
94
|
+
|
|
95
|
+
const readThrough = (fetchFn: typeof globalThis.fetch) =>
|
|
96
|
+
Effect.runPromise(
|
|
97
|
+
spec.fetchLive(props).pipe(
|
|
98
|
+
Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
|
|
99
|
+
Effect.provide(fakeForgejoLayer(fetchFn)),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
test('spec.fetchLive + spec.attributes match the old field mapping', async () => {
|
|
104
|
+
const fake = fakeForgejo((method, url) =>
|
|
105
|
+
method === 'GET' && url.pathname === REPO_PATH ? liveRepo() : fakeFailure(404, 'not found'),
|
|
106
|
+
);
|
|
107
|
+
expect(await readThrough(fake.fetch)).toEqual({
|
|
108
|
+
defaultBranch: 'main',
|
|
109
|
+
description: 'the kit',
|
|
110
|
+
empty: false,
|
|
111
|
+
hasIssues: true,
|
|
112
|
+
hasProjects: true,
|
|
113
|
+
hasWiki: false,
|
|
114
|
+
name: 'kit',
|
|
115
|
+
org: 'homeflare',
|
|
116
|
+
private: true,
|
|
117
|
+
repoId: 42,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a 404 through spec.fetchLive is undefined, not a thrown failure', async () => {
|
|
122
|
+
const fake = fakeForgejo(() => fakeFailure(404, 'repository does not exist'));
|
|
123
|
+
expect(await readThrough(fake.fetch)).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ★ MEASURED 2026-09-13 against the live daemon (`GET /api/v1/repos/<org>/<repo>`):
|
|
5
5
|
* responses are flat JSON with `private`, `has_issues`, `default_branch`, etc. Create is
|
|
6
|
-
* `POST /orgs/{org}/repos`; read/update/delete use `/repos/{owner}/{repo}`.
|
|
6
|
+
* `POST /orgs/{org}/repos`; read/update/delete use `/repos/{owner}/{repo}`. Now called through
|
|
7
|
+
* `@distilled.cloud/forgejo`'s `organization.createOrgRepo` / `repository.{getRepo,editRepo,
|
|
8
|
+
* deleteRepo}` instead of the retired hand-rolled client (resource.ts).
|
|
9
|
+
*
|
|
10
|
+
* ⛔ `CreateOrgRepoRequest` HAS NO `has_issues` / `has_wiki` / `has_projects`. Verified against
|
|
11
|
+
* the package's typed schema: Forgejo's own `POST /orgs/{org}/repos` never accepted them either
|
|
12
|
+
* (the hand-rolled client sent them anyway; Gitea's JSON decoder silently drops unknown keys,
|
|
13
|
+
* so they were always ignored on create). `editRepo`'s schema does carry them, and
|
|
14
|
+
* `updateForm`/`matches` below still converge them on the first non-create reconcile — the same
|
|
15
|
+
* two-step behaviour the old client had, now provable at the type level instead of by reading
|
|
16
|
+
* Gitea's source.
|
|
7
17
|
*
|
|
8
18
|
* ⛔ `defaultRemovalPolicy: 'retain'` — history, issues and packages in a repo are irreplaceable.
|
|
9
19
|
* Opt into deletion with `.pipe(RemovalPolicy.destroy())`; `delete` below is fully implemented.
|
|
10
20
|
*/
|
|
11
21
|
import { Resource } from 'alchemy';
|
|
12
22
|
import * as Provider from 'alchemy/Provider';
|
|
23
|
+
import * as organization from '@distilled.cloud/forgejo/organization';
|
|
24
|
+
import * as repository from '@distilled.cloud/forgejo/repository';
|
|
13
25
|
import * as Effect from 'effect/Effect';
|
|
14
|
-
import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
|
|
15
|
-
import { bool, text } from './values.ts';
|
|
26
|
+
import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
|
|
16
27
|
|
|
17
28
|
export interface RepositoryProps {
|
|
18
29
|
/** Organization login — the owner segment in `<org>/<repo>`. */
|
|
@@ -55,51 +66,65 @@ export const ForgejoRepository = Resource<ForgejoRepository>('Forgejo.Repository
|
|
|
55
66
|
defaultRemovalPolicy: 'retain',
|
|
56
67
|
});
|
|
57
68
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
69
|
+
const editForm = (props: RepositoryProps) => ({
|
|
70
|
+
...(props.defaultBranch === undefined ? {} : { default_branch: props.defaultBranch }),
|
|
71
|
+
...(props.description === undefined ? {} : { description: props.description }),
|
|
72
|
+
...(props.hasIssues === undefined ? {} : { has_issues: props.hasIssues }),
|
|
73
|
+
...(props.hasProjects === undefined ? {} : { has_projects: props.hasProjects }),
|
|
74
|
+
...(props.hasWiki === undefined ? {} : { has_wiki: props.hasWiki }),
|
|
75
|
+
...(props.private === undefined ? {} : { private: props.private }),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ★ EXPORTED, NOT JUST PASSED INLINE TO `forgejoHandlers` BELOW — so a test can call
|
|
80
|
+
* `spec.fetchLive` / `spec.attributes` directly (repository.test.ts does), against an explicit
|
|
81
|
+
* fake `Credentials` layer, without touching `forgejoHandlers`'s baked-in `CredentialsFromEnv`.
|
|
82
|
+
* That layer resolves `FORGEJO_URL` / `FORGEJO_TOKEN` through Effect's `Config`, whose default
|
|
83
|
+
* provider snapshots `process.env` once — a test-time `process.env` mutation is invisible to it,
|
|
84
|
+
* so the only way to run this exact production code against a fake server is to call it with an
|
|
85
|
+
* explicit `Credentials` layer, bypassing the env read entirely.
|
|
86
|
+
*/
|
|
87
|
+
export const spec: ForgejoSpec<
|
|
88
|
+
RepositoryProps,
|
|
89
|
+
repository.Repository,
|
|
90
|
+
RepositoryAttributes,
|
|
91
|
+
| organization.CreateOrgRepoError
|
|
92
|
+
| repository.GetRepoError
|
|
93
|
+
| repository.EditRepoError
|
|
94
|
+
| repository.DeleteRepoError
|
|
95
|
+
> = {
|
|
96
|
+
attributes: (live, props) => ({
|
|
97
|
+
defaultBranch: live.default_branch,
|
|
98
|
+
description: live.description ?? '',
|
|
99
|
+
empty: live.empty ?? false,
|
|
100
|
+
hasIssues: live.has_issues ?? true,
|
|
101
|
+
hasProjects: live.has_projects ?? true,
|
|
102
|
+
hasWiki: live.has_wiki ?? true,
|
|
82
103
|
name: props.name,
|
|
83
|
-
|
|
104
|
+
org: props.org,
|
|
105
|
+
private: live.private ?? true,
|
|
106
|
+
repoId: live.id,
|
|
84
107
|
}),
|
|
108
|
+
create: (props) =>
|
|
109
|
+
organization.createOrgRepo({
|
|
110
|
+
auto_init: props.autoInit ?? false,
|
|
111
|
+
description: props.description ?? '',
|
|
112
|
+
name: props.name,
|
|
113
|
+
org: props.org,
|
|
114
|
+
private: props.private ?? true,
|
|
115
|
+
}),
|
|
116
|
+
destroy: (props) => repository.deleteRepo({ owner: props.org, repo: props.name }),
|
|
117
|
+
fetchLive: (props) =>
|
|
118
|
+
repository
|
|
119
|
+
.getRepo({ owner: props.org, repo: props.name })
|
|
120
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
85
121
|
/**
|
|
86
122
|
* ⛔ AN UNDECLARED FIELD IS NEITHER COMPARED NOR SENT — the rule every other family in this
|
|
87
123
|
* estate follows (storage.ts, backup-job.ts, and all four Pbs.* families say it in those
|
|
88
|
-
* words)
|
|
89
|
-
*
|
|
90
|
-
* 🔴 WHAT THE EXCEPTION DID. Five fields were compared against a DEFAULT rather than skipped:
|
|
91
|
-
* `description ?? ''`, and `?? true` for private, hasIssues, hasWiki and hasProjects. So a
|
|
92
|
-
* declaration that simply did not mention `hasWiki` asserted "the wiki is ON", and one that
|
|
93
|
-
* did not mention `description` asserted "the description is EMPTY" — and `updateForm` sent
|
|
94
|
-
* both. Declaring a repo you only wanted to rename would have wiped its description and
|
|
95
|
-
* switched features on, reported as a plain `update`. Only `defaultBranch` was written
|
|
96
|
-
* correctly, on the line below, which is what the other five now copy.
|
|
124
|
+
* words). Declaring a repo you only wanted to rename leaves its description and feature
|
|
125
|
+
* toggles alone; dropping a line no longer resets a field.
|
|
97
126
|
*
|
|
98
|
-
* ⚠️
|
|
99
|
-
* Forgejo UI or with a direct PATCH. It costs no forever-diff, because an undeclared field is
|
|
100
|
-
* not compared either.
|
|
101
|
-
*
|
|
102
|
-
* ⚠️ `private` IS THE ONE WORTH DECLARING ANYWAY ON EVERY REPO. Leaving it undeclared now means
|
|
127
|
+
* ⚠️ `private` IS THE ONE WORTH DECLARING ANYWAY ON EVERY REPO. Leaving it undeclared means
|
|
103
128
|
* Alchemy will not notice a repository being made public. That is the correct trade for a
|
|
104
129
|
* generic family — undeclared is unmanaged, not assumed — but it is a real gap, so say it
|
|
105
130
|
* explicitly in each declaration rather than relying on a default that no longer exists.
|
|
@@ -111,16 +136,11 @@ const handlers = forgejoHandlers<RepositoryProps, RepositoryAttributes>({
|
|
|
111
136
|
(props.hasWiki === undefined || attributes.hasWiki === props.hasWiki) &&
|
|
112
137
|
(props.hasProjects === undefined || attributes.hasProjects === props.hasProjects) &&
|
|
113
138
|
(props.defaultBranch === undefined || attributes.defaultBranch === props.defaultBranch),
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
...(props.hasProjects === undefined ? {} : { has_projects: props.hasProjects }),
|
|
120
|
-
...(props.hasWiki === undefined ? {} : { has_wiki: props.hasWiki }),
|
|
121
|
-
...(props.private === undefined ? {} : { private: props.private }),
|
|
122
|
-
}),
|
|
123
|
-
});
|
|
139
|
+
update: (props) =>
|
|
140
|
+
repository.editRepo({ owner: props.org, repo: props.name, ...editForm(props) }),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const handlers = forgejoHandlers(spec);
|
|
124
144
|
|
|
125
145
|
export const ForgejoRepositoryProvider = () =>
|
|
126
146
|
Provider.effect(ForgejoRepository, Effect.succeed(ForgejoRepository.Provider.of(handlers)));
|
package/src/forgejo/resource.ts
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One shape for every Forgejo object — the same four operations, different
|
|
2
|
+
* One shape for every Forgejo object — the same four operations, different distilled call.
|
|
3
3
|
*
|
|
4
4
|
* ⛔ THE ALTERNATIVE IS COPYING THE SAME HANDLERS PER RESOURCE. Repositories, org labels, teams,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* or the isResolved guard and the plan lies.
|
|
5
|
+
* webhooks, branch protection and org secrets are all create / read / update / delete against
|
|
6
|
+
* `@distilled.cloud/forgejo`'s typed operations. Hand-rolling the fourth copy is where someone
|
|
7
|
+
* drops the read-back or the isResolved guard and the plan lies.
|
|
8
8
|
*
|
|
9
|
-
* ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE. A new Forgejo object
|
|
10
|
-
*
|
|
9
|
+
* ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE. A new Forgejo object is a spec (which distilled
|
|
10
|
+
* operations to call) plus attribute mapping, not a hundred and thirty lines of its own.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* 🔴 WHY THIS REPLACED `client.ts`'s HAND-ROLLED `HttpClient` CALLS. Measured against Tim's
|
|
13
|
+
* distilled research page 2026-09-23: upstream Alchemy only accepts providers that call
|
|
14
|
+
* `@distilled.cloud/<vendor>` operations and `catchTag` their typed errors — not a raw
|
|
15
|
+
* `fetch`/`HttpClient` client over a hand-maintained path table. `@distilled.cloud/forgejo`
|
|
16
|
+
* published at 1.0.0-rc.12 covers every operation this family calls (verified operation by
|
|
17
|
+
* operation while porting each resource file).
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
|
|
20
|
+
* direct successor to the old, centralized `absentOn404`. `Effect.catchTag`'s tag-literal
|
|
21
|
+
* inference needs a concrete error union to resolve `"NotFound"` against; a spec's `E` is only
|
|
22
|
+
* concrete at each resource file's own call site (`RepoGetBranchProtectionError`, `GetRepoError`,
|
|
23
|
+
* …), not inside this shared engine's generic functions. So every `fetchLive` below ends
|
|
24
|
+
* `.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined)))` itself — one line,
|
|
25
|
+
* `catchTag`, never a status comparison — and this engine trusts the result is already folded.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ GITEA ANSWERS WITH THE OBJECT DIRECTLY — NOT `{"data": ...}`, and the distilled SDK decodes
|
|
28
|
+
* that shape already. `reconcile` still READS BACK after every write rather than trusting a
|
|
29
|
+
* write response alone — a create/update can 200 with a body this family does not fully trust
|
|
30
|
+
* until the follow-up `read`.
|
|
14
31
|
*
|
|
15
32
|
* ★ WHY `Forgejo.Repository` DECLARES `defaultRemovalPolicy: 'retain'`, WRITTEN ONCE HERE.
|
|
16
33
|
* Alchemy's own `GitHub.Repository` defaults to retain "because their contents are
|
|
@@ -21,42 +38,36 @@
|
|
|
21
38
|
*/
|
|
22
39
|
import { isResolved } from 'alchemy/Diff';
|
|
23
40
|
import type { Input } from 'alchemy/Input';
|
|
41
|
+
import { CredentialsFromEnv } from '@distilled.cloud/forgejo/Credentials';
|
|
42
|
+
import type { ForgejoOpContext } from '@distilled.cloud/forgejo/Protocol';
|
|
24
43
|
import * as Effect from 'effect/Effect';
|
|
25
44
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
26
|
-
import { ForgejoError, forgejo } from './client.ts';
|
|
27
45
|
|
|
28
46
|
/**
|
|
29
|
-
* ★ WHAT EVERY
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* use is not harmless — it makes a stack provide a layer for nothing, and it hides the one
|
|
35
|
-
* service you DO need.
|
|
36
|
-
*
|
|
37
|
-
* What is actually required is `HttpClient`, for the reason in client.ts: Alchemy's own
|
|
38
|
-
* providers call through Effect's client rather than the global `fetch`.
|
|
47
|
+
* ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
|
|
48
|
+
* `Forgejo.ForgejoOpContext` is `Credentials | HttpClient.HttpClient`; `forgejoHandlers` closes
|
|
49
|
+
* over `CredentialsFromEnv` itself (same `FORGEJO_URL` / `FORGEJO_TOKEN` names client.ts read,
|
|
50
|
+
* still resolved at call time — see credentials.ts's own note on that), so a consuming stack
|
|
51
|
+
* only has to provide `HttpClient.HttpClient`, exactly as it did before this migration.
|
|
39
52
|
*/
|
|
40
53
|
export type ForgejoRequirements = HttpClient.HttpClient;
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
/**
|
|
56
|
+
* One Forgejo object's distilled calls. `Live` is whatever the SDK decodes (a `Repository`, a
|
|
57
|
+
* `Label`, a composite for team membership, …) — no more untyped `Record<string, unknown>`.
|
|
58
|
+
*
|
|
59
|
+
* `E` is left to each resource file to declare (the union of the distilled operations' error
|
|
60
|
+
* types it actually calls, e.g. `RepoCreateHookError | RepoEditHookError | ...`), so a call site's
|
|
61
|
+
* precise per-operation error union flows straight through instead of being widened by hand here.
|
|
62
|
+
*/
|
|
63
|
+
export type ForgejoSpec<Props extends object, Live, Attributes, E> = {
|
|
64
|
+
/** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
|
|
65
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, ForgejoOpContext>;
|
|
66
|
+
readonly attributes: (live: Live, props: Props) => Attributes | undefined;
|
|
51
67
|
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*/
|
|
56
|
-
readonly locate?: (
|
|
57
|
-
props: Props,
|
|
58
|
-
) => Effect.Effect<Record<string, unknown> | undefined, ForgejoError, ForgejoRequirements>;
|
|
59
|
-
readonly wirePath?: (props: Props, live: Record<string, unknown>) => string;
|
|
68
|
+
/** Absent for the two existence-only families (`OrgSecret`, `TeamMember`), which use `upsert` instead. */
|
|
69
|
+
readonly create?: (props: Props) => Effect.Effect<unknown, E, ForgejoOpContext>;
|
|
70
|
+
readonly update?: (props: Props, live: Live) => Effect.Effect<unknown, E, ForgejoOpContext>;
|
|
60
71
|
/**
|
|
61
72
|
* When create is PUT upsert rather than POST — org actions secrets have no POST and no GET-by-name.
|
|
62
73
|
* `before === undefined` means absent; the callback decides whether to write. Existence-only families
|
|
@@ -64,36 +75,18 @@ export type ForgejoSpec<Props extends object, Attributes> = {
|
|
|
64
75
|
*/
|
|
65
76
|
readonly upsert?: (
|
|
66
77
|
props: Props,
|
|
67
|
-
before:
|
|
68
|
-
) => Effect.Effect<void,
|
|
78
|
+
before: Live | undefined,
|
|
79
|
+
) => Effect.Effect<void, E, ForgejoOpContext>;
|
|
80
|
+
readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, ForgejoOpContext>;
|
|
69
81
|
};
|
|
70
82
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
io.pipe(
|
|
74
|
-
Effect.catchIf(
|
|
75
|
-
(cause): cause is ForgejoError => cause instanceof ForgejoError && cause.status === 404,
|
|
76
|
-
() => Effect.succeed(undefined as A),
|
|
77
|
-
),
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
export const forgejoOperations = <Props extends object, Attributes>(
|
|
81
|
-
spec: ForgejoSpec<Props, Attributes>,
|
|
83
|
+
export const forgejoOperations = <Props extends object, Live, Attributes, E>(
|
|
84
|
+
spec: ForgejoSpec<Props, Live, Attributes, E>,
|
|
82
85
|
) => {
|
|
83
|
-
const fetchLive = (props: Props) =>
|
|
84
|
-
absentOn404(
|
|
85
|
-
spec.locate !== undefined
|
|
86
|
-
? spec.locate(props)
|
|
87
|
-
: forgejo<Record<string, unknown>>('GET', spec.path(props)),
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
const objectPath = (props: Props, live: Record<string, unknown>) =>
|
|
91
|
-
spec.wirePath === undefined ? spec.path(props) : spec.wirePath(props, live);
|
|
92
|
-
|
|
93
86
|
const read = (props: Props) =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
spec
|
|
88
|
+
.fetchLive(props)
|
|
89
|
+
.pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
|
|
97
90
|
|
|
98
91
|
return {
|
|
99
92
|
read,
|
|
@@ -104,34 +97,36 @@ export const forgejoOperations = <Props extends object, Attributes>(
|
|
|
104
97
|
const live = yield* read(news);
|
|
105
98
|
if (live === undefined) return { action: 'update' } as const;
|
|
106
99
|
if (spec.matches(live, news)) return { action: 'noop' } as const;
|
|
107
|
-
return spec.
|
|
100
|
+
return spec.update === undefined
|
|
108
101
|
? ({ action: 'replace' } as const)
|
|
109
102
|
: ({ action: 'update' } as const);
|
|
110
103
|
}),
|
|
111
104
|
|
|
112
105
|
reconcile: (news: Props) =>
|
|
113
106
|
Effect.gen(function* () {
|
|
114
|
-
const before = yield* fetchLive(news);
|
|
107
|
+
const before = yield* spec.fetchLive(news);
|
|
115
108
|
if (spec.upsert !== undefined) {
|
|
116
109
|
yield* spec.upsert(news, before);
|
|
117
110
|
} else if (before === undefined) {
|
|
118
|
-
|
|
119
|
-
|
|
111
|
+
if (spec.create === undefined) {
|
|
112
|
+
return yield* Effect.die(
|
|
113
|
+
new Error('forgejoOperations: spec declares neither create nor upsert'),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
yield* spec.create(news);
|
|
117
|
+
} else if (spec.update !== undefined) {
|
|
120
118
|
const mapped = spec.attributes(before, news);
|
|
121
119
|
if (mapped === undefined || spec.matches(mapped, news)) {
|
|
122
120
|
/* adoption or already converged — no PATCH */
|
|
123
121
|
} else {
|
|
124
|
-
|
|
125
|
-
if (Object.keys(form).length > 0) {
|
|
126
|
-
yield* forgejo('PATCH', objectPath(news, before), form);
|
|
127
|
-
}
|
|
122
|
+
yield* spec.update(news, before);
|
|
128
123
|
}
|
|
129
124
|
}
|
|
130
125
|
const after = yield* read(news);
|
|
131
126
|
if (after === undefined) {
|
|
132
127
|
return yield* Effect.die(
|
|
133
128
|
new Error(
|
|
134
|
-
|
|
129
|
+
'the write returned no error but the object is still absent. ' +
|
|
135
130
|
'Gitea returns the object directly — read back rather than trusting the status code.',
|
|
136
131
|
),
|
|
137
132
|
);
|
|
@@ -141,9 +136,9 @@ export const forgejoOperations = <Props extends object, Attributes>(
|
|
|
141
136
|
|
|
142
137
|
destroy: (olds: Props) =>
|
|
143
138
|
Effect.gen(function* () {
|
|
144
|
-
const live = yield* fetchLive(olds);
|
|
139
|
+
const live = yield* spec.fetchLive(olds);
|
|
145
140
|
if (live === undefined) return;
|
|
146
|
-
yield*
|
|
141
|
+
yield* spec.destroy(olds, live);
|
|
147
142
|
}),
|
|
148
143
|
};
|
|
149
144
|
};
|
|
@@ -151,21 +146,27 @@ export const forgejoOperations = <Props extends object, Attributes>(
|
|
|
151
146
|
/**
|
|
152
147
|
* The five provider handlers for a spec'd Forgejo object, wired once.
|
|
153
148
|
*
|
|
149
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
|
|
150
|
+
* `FORGEJO_URL` / `FORGEJO_TOKEN` on the calling fiber per request (credentials.ts's own note),
|
|
151
|
+
* so this closure keeps that laziness — nothing is captured at module load.
|
|
152
|
+
*
|
|
154
153
|
* ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as pveHandlers in
|
|
155
154
|
* <estate>/proxmox: wrapping `Provider.effect` here needs casts against Alchemy's `Props<R>`.
|
|
156
155
|
*
|
|
157
156
|
* ⚠️ `list` answers empty — Forgejo index endpoints return the whole org; adoption stays explicit.
|
|
158
157
|
*/
|
|
159
|
-
export const forgejoHandlers = <Props extends object, Attributes extends object>(
|
|
160
|
-
spec: ForgejoSpec<Props, Attributes>,
|
|
158
|
+
export const forgejoHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
159
|
+
spec: ForgejoSpec<Props, Live, Attributes, E>,
|
|
161
160
|
) => {
|
|
162
161
|
const ops = forgejoOperations(spec);
|
|
162
|
+
const withCredentials = <A>(effect: Effect.Effect<A, E, ForgejoOpContext>) =>
|
|
163
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
163
164
|
return {
|
|
164
165
|
list: () => Effect.succeed([]),
|
|
165
|
-
read: ({ olds }: { olds: Props }) => ops.read(olds),
|
|
166
|
+
read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
|
|
166
167
|
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
167
|
-
ops.diff(news, output),
|
|
168
|
-
reconcile: ({ news }: { news: Props }) => ops.reconcile(news),
|
|
169
|
-
delete: ({ olds }: { olds: Props }) => ops.destroy(olds),
|
|
168
|
+
withCredentials(ops.diff(news, output)),
|
|
169
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
170
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
170
171
|
};
|
|
171
172
|
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Forgejo.TeamMember`'s `fetchLive`: the one family in this set where a resource-level domain
|
|
3
|
+
* error (`ForgejoTeamNotFoundError` — "no team named X", not from the distilled SDK) sits
|
|
4
|
+
* alongside the SDK's own `NotFound`. Proves the two stay distinct: only the SDK's tag folds.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as organization from '@distilled.cloud/forgejo/organization';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { fakeFailure, fakeForgejo, fakeForgejoLayer } from './fake-forgejo.ts';
|
|
10
|
+
import { ForgejoTeamNotFoundError, spec } from './team-member.ts';
|
|
11
|
+
|
|
12
|
+
const TEAMS_PATH = '/api/v1/orgs/homeflare/teams';
|
|
13
|
+
const TEAMS_LIST_PATH = `${TEAMS_PATH}?limit=200`;
|
|
14
|
+
const MEMBER_PATH = (id: number) => `/api/v1/teams/${String(id)}/members/tim`;
|
|
15
|
+
|
|
16
|
+
const fetchMembership = (fetchFn: typeof globalThis.fetch, team: string) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const rows = yield* organization.orgListTeams({ limit: 200, org: 'homeflare' });
|
|
19
|
+
const id = rows.find((row) => row.name === team)?.id;
|
|
20
|
+
if (id === undefined) {
|
|
21
|
+
return yield* Effect.fail(
|
|
22
|
+
new ForgejoTeamNotFoundError({ message: `no team named ${team} in org homeflare` }),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return yield* organization.orgListTeamMember({ id, username: 'tim' });
|
|
26
|
+
}).pipe(
|
|
27
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
28
|
+
Effect.provide(fakeForgejoLayer(fetchFn)),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
describe('Forgejo.TeamMember fetchLive', () => {
|
|
32
|
+
test('a member not on the team (404 on the membership GET) folds to undefined', async () => {
|
|
33
|
+
const fake = fakeForgejo((method, url) =>
|
|
34
|
+
method === 'GET' && url.pathname === TEAMS_PATH
|
|
35
|
+
? Response.json([{ id: 5, name: 'Owners' }])
|
|
36
|
+
: fakeFailure(404, 'member not found'),
|
|
37
|
+
);
|
|
38
|
+
const result = await Effect.runPromise(fetchMembership(fake.fetch, 'Owners'));
|
|
39
|
+
expect(result).toBeUndefined();
|
|
40
|
+
expect(fake.seen.map((s) => s.path)).toEqual([TEAMS_LIST_PATH, MEMBER_PATH(5)]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a team the org has no record of is a real failure, not absence', async () => {
|
|
44
|
+
// ⛔ THE POINT OF THIS TEST. `ForgejoTeamNotFoundError` has its own `_tag` — it is not the
|
|
45
|
+
// SDK's `NotFound` — so the same `catchTag('NotFound', ...)` that just folded a missing
|
|
46
|
+
// membership above must NOT fold this. A team-name typo failing loudly beats it silently
|
|
47
|
+
// planning to add a member to a team that was never there.
|
|
48
|
+
const fake = fakeForgejo((method, url) =>
|
|
49
|
+
method === 'GET' && url.pathname === TEAMS_PATH ? Response.json([]) : fakeFailure(404, 'x'),
|
|
50
|
+
);
|
|
51
|
+
const failure = await Effect.runPromise(Effect.flip(fetchMembership(fake.fetch, 'Ghosts')));
|
|
52
|
+
expect(failure._tag).toBe('ForgejoTeamNotFoundError');
|
|
53
|
+
expect(failure.message).toContain('Ghosts');
|
|
54
|
+
expect(fake.seen.map((s) => s.path)).toEqual([TEAMS_LIST_PATH]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('Forgejo.TeamMember spec — the actual exported production functions', () => {
|
|
59
|
+
// ⛔ Proves the real `fetchLive` + `attributes` in team-member.ts, not a parallel
|
|
60
|
+
// reimplementation — see the same note in repository.test.ts.
|
|
61
|
+
const props = { org: 'homeflare', team: 'Owners', username: 'tim' };
|
|
62
|
+
|
|
63
|
+
const readThrough = (fetchFn: typeof globalThis.fetch) =>
|
|
64
|
+
Effect.runPromise(
|
|
65
|
+
spec.fetchLive(props).pipe(
|
|
66
|
+
Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
|
|
67
|
+
Effect.provide(fakeForgejoLayer(fetchFn)),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
test('a member on the team reads through spec.fetchLive + spec.attributes', async () => {
|
|
72
|
+
const fake = fakeForgejo((method, url) => {
|
|
73
|
+
if (method === 'GET' && url.pathname === TEAMS_PATH) {
|
|
74
|
+
return Response.json([{ id: 5, name: 'Owners' }]);
|
|
75
|
+
}
|
|
76
|
+
if (method === 'GET' && url.pathname === MEMBER_PATH(5)) {
|
|
77
|
+
return Response.json({ id: 99, login: 'tim' });
|
|
78
|
+
}
|
|
79
|
+
return fakeFailure(404, 'not found');
|
|
80
|
+
});
|
|
81
|
+
expect(await readThrough(fake.fetch)).toEqual({
|
|
82
|
+
org: 'homeflare',
|
|
83
|
+
team: 'Owners',
|
|
84
|
+
teamId: 5,
|
|
85
|
+
username: 'tim',
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a member not on the team through spec.fetchLive is undefined', async () => {
|
|
90
|
+
const fake = fakeForgejo((method, url) =>
|
|
91
|
+
method === 'GET' && url.pathname === TEAMS_PATH
|
|
92
|
+
? Response.json([{ id: 5, name: 'Owners' }])
|
|
93
|
+
: fakeFailure(404, 'not a member'),
|
|
94
|
+
);
|
|
95
|
+
expect(await readThrough(fake.fetch)).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|