@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.
Files changed (72) hide show
  1. package/README.md +3 -2
  2. package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
  3. package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
  4. package/dist/cloudflare/index.d.ts +4 -5
  5. package/dist/cloudflare/index.d.ts.map +1 -1
  6. package/dist/cloudflare/index.js +79 -103
  7. package/dist/cloudflare/index.js.map +6 -7
  8. package/dist/cloudflare/mesh-node-api.d.ts +3 -2
  9. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
  10. package/dist/cloudflare/providers.d.ts +1 -1
  11. package/dist/cloudflare/providers.d.ts.map +1 -1
  12. package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
  13. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  14. package/dist/forgejo/branch-protection-form.d.ts +16 -5
  15. package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
  16. package/dist/forgejo/branch-protection.d.ts +34 -3
  17. package/dist/forgejo/branch-protection.d.ts.map +1 -1
  18. package/dist/forgejo/fake-forgejo.d.ts +41 -0
  19. package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
  20. package/dist/forgejo/index.js +220 -284
  21. package/dist/forgejo/index.js.map +14 -15
  22. package/dist/forgejo/org-actions-secrets.d.ts +42 -12
  23. package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
  24. package/dist/forgejo/org-label.d.ts +30 -3
  25. package/dist/forgejo/org-label.d.ts.map +1 -1
  26. package/dist/forgejo/org-team.d.ts +37 -4
  27. package/dist/forgejo/org-team.d.ts.map +1 -1
  28. package/dist/forgejo/repo-webhook-form.d.ts +9 -18
  29. package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
  30. package/dist/forgejo/repo-webhook.d.ts +35 -3
  31. package/dist/forgejo/repo-webhook.d.ts.map +1 -1
  32. package/dist/forgejo/repository.d.ts +47 -2
  33. package/dist/forgejo/repository.d.ts.map +1 -1
  34. package/dist/forgejo/resource.d.ts +37 -37
  35. package/dist/forgejo/resource.d.ts.map +1 -1
  36. package/dist/forgejo/team-member.d.ts +48 -4
  37. package/dist/forgejo/team-member.d.ts.map +1 -1
  38. package/dist/forgejo/values.d.ts +9 -5
  39. package/dist/forgejo/values.d.ts.map +1 -1
  40. package/docs/credentials.md +6 -0
  41. package/docs/distilled-interim.md +151 -0
  42. package/docs/peers.md +15 -6
  43. package/docs/upstream-conformance.md +46 -22
  44. package/package.json +5 -2
  45. package/src/cloudflare/fake-r2-lock.ts +120 -0
  46. package/src/cloudflare/index.ts +4 -5
  47. package/src/cloudflare/mesh-node-api.ts +3 -2
  48. package/src/cloudflare/providers.ts +13 -16
  49. package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
  50. package/src/cloudflare/r2-bucket-lock.ts +59 -51
  51. package/src/forgejo/branch-protection-form.ts +12 -5
  52. package/src/forgejo/branch-protection.ts +58 -26
  53. package/src/forgejo/fake-forgejo.ts +54 -0
  54. package/src/forgejo/org-actions-secrets.ts +44 -36
  55. package/src/forgejo/org-label.test.ts +100 -0
  56. package/src/forgejo/org-label.ts +46 -36
  57. package/src/forgejo/org-team.ts +40 -27
  58. package/src/forgejo/repo-webhook-form.ts +10 -27
  59. package/src/forgejo/repo-webhook.ts +66 -35
  60. package/src/forgejo/repository.test.ts +125 -0
  61. package/src/forgejo/repository.ts +72 -52
  62. package/src/forgejo/resource.ts +80 -79
  63. package/src/forgejo/team-member.test.ts +97 -0
  64. package/src/forgejo/team-member.ts +56 -38
  65. package/src/forgejo/values.ts +9 -17
  66. package/src/proxmox/distilled-task-await.test.ts +124 -0
  67. package/dist/cloudflare/client.d.ts +0 -45
  68. package/dist/cloudflare/client.d.ts.map +0 -1
  69. package/dist/forgejo/client.d.ts +0 -37
  70. package/dist/forgejo/client.d.ts.map +0 -1
  71. package/src/cloudflare/client.ts +0 -76
  72. package/src/forgejo/client.ts +0 -136
@@ -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. The OpenAPI in
5
- * `<estate>/mcp-servers/docs/api/upstream/forgejo.json` shows `Secret` returns only `name` and
6
- * `created_at` — never the `data` field — so attributes stay metadata-only. Create/update is PUT
7
- * with body `{ data }`, which does not fit POST/PATCH; this family uses the `upsert` seam on
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`. It would have been committed as a broken import for everyone
25
- * else, with the local tree green the whole time. `scripts/husky/ignored-imports.ts` is the gate
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 { ForgejoError, forgejo } from './client.ts';
32
- import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
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 ForgejoError(
64
- 0,
65
- 'PUT',
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
- const handlers = forgejoHandlers<OrgSecretProps, OrgSecretAttributes>({
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: text(live['created_at']),
83
+ createdAt: live.created_at,
77
84
  name: props.name,
78
85
  org: props.org,
79
86
  }),
80
- collection: (props) => `orgs/${props.org}/actions/secrets/${props.name}`,
81
- createForm: () => ({}),
82
- locate: (props) =>
83
- forgejo<Record<string, unknown>[]>('GET', `orgs/${props.org}/actions/secrets?limit=200`).pipe(
84
- Effect.map((rows) =>
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
- Effect.gen(function* () {
92
- if (before !== undefined) return;
93
- const data = yield* secretData(props.org, props.name);
94
- yield* forgejo('PUT', `orgs/${props.org}/actions/secrets/${props.name}`, { data });
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
+ });
@@ -12,18 +12,19 @@
12
12
  * from the stack does not DELETE it unless the caller opts into `.pipe(RemovalPolicy.destroy())`.
13
13
  * That matches the script's never-delete posture while still allowing a deliberate destroy.
14
14
  *
15
- * ⚠️ THE WIRE PATH USES NUMERIC ID, NOT NAME — measured on live org labels: PATCH and DELETE are
16
- * `/orgs/{org}/labels/{id}`. `locate` lists by name; `wirePath` uses the id from that row.
15
+ * ⚠️ THE WIRE PATH USES NUMERIC ID, NOT NAME — measured on live org labels: edit and delete are
16
+ * `orgEditLabel`/`orgDeleteLabel({ org, id })`. `fetchLive` lists by name; the id comes off that
17
+ * row for `update`/`destroy`, the same seam the hand-rolled `wirePath` used.
17
18
  *
18
19
  * ⛔ TOKEN NEEDS `write:organization` FOR RECONCILE — the mcp-read token lacks it (POST 403
19
20
  * measured 2026-09-13). Plan/deploy needs a provision-scoped credential in `FORGEJO_TOKEN`.
20
21
  */
21
22
  import { Resource } from 'alchemy';
22
23
  import * as Provider from 'alchemy/Provider';
24
+ import * as organization from '@distilled.cloud/forgejo/organization';
23
25
  import * as Effect from 'effect/Effect';
24
- import { forgejo } from './client.ts';
25
- import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
26
- import { bool, color, text } from './values.ts';
26
+ import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
27
+ import { color } from './values.ts';
27
28
 
28
29
  export interface OrgLabelProps {
29
30
  /** Organization login — `homeflare` / `HomeFlare` must match the live org. */
@@ -57,44 +58,53 @@ export const ForgejoOrgLabel = Resource<ForgejoOrgLabel>('Forgejo.OrgLabel', {
57
58
  defaultRemovalPolicy: 'retain',
58
59
  });
59
60
 
60
- const handlers = forgejoHandlers<OrgLabelProps, OrgLabelAttributes>({
61
- attributes: (live, props) => {
62
- const id = live['id'];
63
- if (typeof id !== 'number') return undefined;
64
- return {
65
- color: color(live['color']),
66
- description: text(live['description']),
67
- exclusive: bool(live['exclusive']),
68
- labelId: id,
69
- name: props.name,
70
- org: props.org,
71
- };
72
- },
73
- collection: (props) => `orgs/${props.org}/labels`,
74
- createForm: (props) => ({
75
- color: color(props.color),
76
- description: props.description ?? '',
77
- exclusive: props.exclusive ?? false,
61
+ /** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
62
+ export const spec: ForgejoSpec<
63
+ OrgLabelProps,
64
+ organization.Label,
65
+ OrgLabelAttributes,
66
+ | organization.OrgCreateLabelError
67
+ | organization.OrgEditLabelError
68
+ | organization.OrgDeleteLabelError
69
+ | organization.OrgListLabelsError
70
+ > = {
71
+ attributes: (live, props) => ({
72
+ color: color(live.color),
73
+ description: live.description ?? '',
74
+ exclusive: live.exclusive ?? false,
75
+ labelId: live.id,
78
76
  name: props.name,
77
+ org: props.org,
79
78
  }),
80
- locate: (props) =>
81
- forgejo<Record<string, unknown>[]>('GET', `orgs/${props.org}/labels?limit=200`).pipe(
82
- Effect.map((rows) =>
83
- Array.isArray(rows) ? rows.find((row) => row['name'] === props.name) : undefined,
84
- ),
79
+ create: (props) =>
80
+ organization.orgCreateLabel({
81
+ color: color(props.color),
82
+ description: props.description ?? '',
83
+ exclusive: props.exclusive ?? false,
84
+ name: props.name,
85
+ org: props.org,
86
+ }),
87
+ destroy: (props, live) => organization.orgDeleteLabel({ org: props.org, id: live.id }),
88
+ fetchLive: (props) =>
89
+ organization.orgListLabels({ org: props.org, limit: 200 }).pipe(
90
+ Effect.map((rows) => rows.find((row) => row.name === props.name)),
91
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
85
92
  ),
86
93
  matches: (attributes, props) =>
87
94
  attributes.color === color(props.color) &&
88
95
  attributes.description === (props.description ?? '') &&
89
96
  attributes.exclusive === (props.exclusive ?? false),
90
- path: (props) => `orgs/${props.org}/labels/${props.name}`,
91
- updateForm: (props) => ({
92
- color: color(props.color),
93
- description: props.description ?? '',
94
- exclusive: props.exclusive ?? false,
95
- }),
96
- wirePath: (props, live) => `orgs/${props.org}/labels/${String(live['id'])}`,
97
- });
97
+ update: (props, live) =>
98
+ organization.orgEditLabel({
99
+ color: color(props.color),
100
+ description: props.description ?? '',
101
+ exclusive: props.exclusive ?? false,
102
+ id: live.id,
103
+ org: props.org,
104
+ }),
105
+ };
106
+
107
+ export const handlers = forgejoHandlers(spec);
98
108
 
99
109
  export const ForgejoOrgLabelProvider = () =>
100
110
  Provider.effect(ForgejoOrgLabel, Effect.succeed(ForgejoOrgLabel.Provider.of(handlers)));
@@ -3,24 +3,31 @@
3
3
  *
4
4
  * ★ READ OFF `<estate>/mcp-servers/docs/api/upstream/forgejo.json` (Forgejo 16.x OpenAPI): create is
5
5
  * `POST /orgs/{org}/teams`; read/update/delete use numeric id at `/teams/{id}` — not the name.
6
+ * Now `organization.orgCreateTeam` / `orgListTeams` / `orgEditTeam` / `orgDeleteTeam`.
6
7
  *
7
- * ⚠️ PATCH AND DELETE USE `/teams/{id}` — same seam as org labels. `locate` lists by name;
8
- * `wirePath` carries the id from that row.
8
+ * ⚠️ `orgEditTeam`/`orgDeleteTeam` TAKE ONLY `{ id, ... }` — NO `org` FIELD. The package's schema
9
+ * confirms Forgejo's edit/delete routes are `/teams/{id}` with no org segment; `fetchLive` lists
10
+ * by name and `update`/`destroy` carry the id off that row, same seam as org labels.
11
+ *
12
+ * ⚠️ `live.permission` IS TYPED `"none" | "read" | "write" | "admin" | "owner"` IN THE PACKAGE —
13
+ * wider than this family's own `TeamPermission`. The guard below keeps the original behaviour:
14
+ * an out-of-range value (`none`/`owner`) folds `attributes` to `undefined` rather than lying
15
+ * about which of the three this family manages the team is at.
9
16
  *
10
17
  * ⛔ TOKEN NEEDS `write:organization` TO CREATE OR PATCH — read list/get needs `read:organization`.
11
18
  */
12
19
  import { Resource } from 'alchemy';
13
20
  import * as Provider from 'alchemy/Provider';
21
+ import * as organization from '@distilled.cloud/forgejo/organization';
14
22
  import * as Effect from 'effect/Effect';
15
- import { forgejo } from './client.ts';
16
- import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
17
- import { bool, recordEqual, stringArray, stringRecord, text } from './values.ts';
23
+ import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
24
+ import { recordEqual, stringArray, stringRecord } from './values.ts';
18
25
 
19
26
  export type TeamPermission = 'read' | 'write' | 'admin';
20
27
 
21
28
  export interface OrgTeamProps {
22
29
  org: string;
23
- /** Team name — primary locate key; sent on every PATCH body. */
30
+ /** Team name — primary locate key; sent on every edit body. */
24
31
  name: string;
25
32
  description?: string;
26
33
  permission?: TeamPermission;
@@ -62,31 +69,37 @@ const teamForm = (props: OrgTeamProps) => ({
62
69
  ...(props.unitsMap === undefined ? {} : { units_map: props.unitsMap }),
63
70
  });
64
71
 
65
- const handlers = forgejoHandlers<OrgTeamProps, OrgTeamAttributes>({
72
+ /** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
73
+ export const spec: ForgejoSpec<
74
+ OrgTeamProps,
75
+ organization.Team,
76
+ OrgTeamAttributes,
77
+ | organization.OrgCreateTeamError
78
+ | organization.OrgEditTeamError
79
+ | organization.OrgDeleteTeamError
80
+ | organization.OrgListTeamsError
81
+ > = {
66
82
  attributes: (live, props) => {
67
- const id = live['id'];
68
- if (typeof id !== 'number') return undefined;
69
- const permission = text(live['permission'], 'read');
83
+ const permission = live.permission ?? 'read';
70
84
  if (permission !== 'read' && permission !== 'write' && permission !== 'admin') return undefined;
71
85
  return {
72
- canCreateOrgRepo: bool(live['can_create_org_repo']),
73
- description: text(live['description']),
74
- includesAllRepositories: bool(live['includes_all_repositories']),
86
+ canCreateOrgRepo: live.can_create_org_repo ?? false,
87
+ description: live.description ?? '',
88
+ includesAllRepositories: live.includes_all_repositories ?? false,
75
89
  name: props.name,
76
90
  org: props.org,
77
91
  permission,
78
- teamId: id,
79
- units: stringArray(live['units']),
80
- unitsMap: stringRecord(live['units_map']),
92
+ teamId: live.id,
93
+ units: stringArray(live.units ?? []),
94
+ unitsMap: stringRecord(live.units_map ?? {}),
81
95
  };
82
96
  },
83
- collection: (props) => `orgs/${props.org}/teams`,
84
- createForm: teamForm,
85
- locate: (props) =>
86
- forgejo<Record<string, unknown>[]>('GET', `orgs/${props.org}/teams?limit=200`).pipe(
87
- Effect.map((rows) =>
88
- Array.isArray(rows) ? rows.find((row) => row['name'] === props.name) : undefined,
89
- ),
97
+ create: (props) => organization.orgCreateTeam({ org: props.org, ...teamForm(props) }),
98
+ destroy: (_props, live) => organization.orgDeleteTeam({ id: live.id }),
99
+ fetchLive: (props) =>
100
+ organization.orgListTeams({ org: props.org, limit: 200 }).pipe(
101
+ Effect.map((rows) => rows.find((row) => row.name === props.name)),
102
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
90
103
  ),
91
104
  matches: (attributes, props) =>
92
105
  attributes.description === (props.description ?? '') &&
@@ -96,10 +109,10 @@ const handlers = forgejoHandlers<OrgTeamProps, OrgTeamAttributes>({
96
109
  (props.units === undefined ||
97
110
  stringArray(props.units).join('\0') === attributes.units.join('\0')) &&
98
111
  (props.unitsMap === undefined || recordEqual(attributes.unitsMap, props.unitsMap)),
99
- path: (props) => `orgs/${props.org}/teams/${props.name}`,
100
- updateForm: teamForm,
101
- wirePath: (_props, live) => `teams/${String(live['id'])}`,
102
- });
112
+ update: (props, live) => organization.orgEditTeam({ id: live.id, ...teamForm(props) }),
113
+ };
114
+
115
+ export const handlers = forgejoHandlers(spec);
103
116
 
104
117
  export const ForgejoOrgTeamProvider = () =>
105
118
  Provider.effect(ForgejoOrgTeam, Effect.succeed(ForgejoOrgTeam.Provider.of(handlers)));
@@ -1,15 +1,20 @@
1
1
  /**
2
- * JSON bodies for `Forgejo.RepoWebhook` — split to keep repo-webhook.ts under the line cap.
2
+ * `config` bodies for `Forgejo.RepoWebhook` — split to keep repo-webhook.ts under the line cap.
3
3
  *
4
4
  * ⛔ `config.secret` IS NEVER A PROP. When present it is read from `FORGEJO_HOOK_SECRET_*` at
5
5
  * create/update time only (see hookSecretEnvKey in values.ts). Alchemy attributes are Postgres
6
6
  * plaintext — a webhook HMAC secret must not be stored there.
7
+ *
8
+ * ⛔ NO `name` FIELD ON EITHER SIDE. `@distilled.cloud/forgejo`'s `RepoCreateHookRequest` and
9
+ * `Hook` (the create input and the read/list output) have no `name` property — verified against
10
+ * the package's schema. The hand-rolled client sent `name: props.name` on create anyway; Gitea's
11
+ * JSON decoder silently dropped it, so `locate` never actually matched by name either (its
12
+ * `row['name']` read was always `undefined` on an untyped response) — matching was always by
13
+ * URL in practice. `props.name`/`RepoWebhookAttributes.name` stay as this family's own stable
14
+ * key; nothing on the wire carries it.
7
15
  */
8
16
  import type { RepoWebhookProps } from './repo-webhook.ts';
9
- import { hookSecretEnvKey, stringRecord, text } from './values.ts';
10
-
11
- export const hookUrl = (live: Record<string, unknown>) =>
12
- text(live['url']) || text(stringRecord(live['config'])['url']);
17
+ import { hookSecretEnvKey } from './values.ts';
13
18
 
14
19
  export const hookConfigForm = (props: RepoWebhookProps, includeSecret: boolean) => {
15
20
  const config: Record<string, string> = {
@@ -23,25 +28,3 @@ export const hookConfigForm = (props: RepoWebhookProps, includeSecret: boolean)
23
28
  }
24
29
  return config;
25
30
  };
26
-
27
- export const createHookForm = (props: RepoWebhookProps) => ({
28
- active: props.active ?? true,
29
- ...(props.authorizationHeader === undefined
30
- ? {}
31
- : { authorization_header: props.authorizationHeader }),
32
- ...(props.branchFilter === undefined ? {} : { branch_filter: props.branchFilter }),
33
- config: hookConfigForm(props, true),
34
- events: props.events,
35
- name: props.name,
36
- type: props.type,
37
- });
38
-
39
- export const updateHookForm = (props: RepoWebhookProps) => ({
40
- active: props.active ?? true,
41
- ...(props.authorizationHeader === undefined
42
- ? {}
43
- : { authorization_header: props.authorizationHeader }),
44
- ...(props.branchFilter === undefined ? {} : { branch_filter: props.branchFilter }),
45
- config: hookConfigForm(props, false),
46
- events: props.events,
47
- });
@@ -2,7 +2,13 @@
2
2
  * `Forgejo.RepoWebhook` — one repository webhook under `/repos/{owner}/{repo}/hooks`.
3
3
  *
4
4
  * ★ READ OFF `<estate>/mcp-servers/docs/api/upstream/forgejo.json`: create is POST collection; read/update/delete
5
- * use numeric hook id at `/repos/{owner}/{repo}/hooks/{id}`. `locate` lists and matches on `name`.
5
+ * use numeric hook id at `/repos/{owner}/{repo}/hooks/{id}`. Now `repository.repoCreateHook` /
6
+ * `repoListHooks` / `repoEditHook` / `repoDeleteHook`. `fetchLive` lists and matches on `url` —
7
+ * see repo-webhook-form.ts for why name was never the real match key.
8
+ *
9
+ * ⚠️ `Hook.url` IS A REQUIRED STRING IN THE PACKAGE'S SCHEMA — the `config.url` fallback the
10
+ * hand-rolled client carried (`stringRecord(live['config'])['url']`) is gone; a response
11
+ * without `url` now fails the operation's decode.
6
12
  *
7
13
  * ⛔ NO WEBHOOK `secret` IN PROPS OR ATTRIBUTES. Optional HMAC material is env-only at write time
8
14
  * (`FORGEJO_HOOK_SECRET_*` — see repo-webhook-form.ts). `hookConfigPublic` strips `secret` on read.
@@ -11,11 +17,11 @@
11
17
  */
12
18
  import { Resource } from 'alchemy';
13
19
  import * as Provider from 'alchemy/Provider';
20
+ import * as repository from '@distilled.cloud/forgejo/repository';
14
21
  import * as Effect from 'effect/Effect';
15
- import { forgejo } from './client.ts';
16
- import { createHookForm, hookUrl, updateHookForm } from './repo-webhook-form.ts';
17
- import { type ForgejoRequirements, forgejoHandlers } from './resource.ts';
18
- import { bool, hookConfigPublic, stringArray, text } from './values.ts';
22
+ import { hookConfigForm } from './repo-webhook-form.ts';
23
+ import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
24
+ import { hookConfigPublic, stringArray } from './values.ts';
19
25
 
20
26
  export type RepoWebhookType =
21
27
  | 'dingtalk'
@@ -33,7 +39,7 @@ export type RepoWebhookType =
33
39
  export interface RepoWebhookProps {
34
40
  owner: string;
35
41
  repo: string;
36
- /** Stable locate key — also sent as Hook.name on create. */
42
+ /** Stable locate key on this side only — Forgejo hooks have no `name` field (see the form file). */
37
43
  name: string;
38
44
  type: RepoWebhookType;
39
45
  url: string;
@@ -68,38 +74,51 @@ export interface ForgejoRepoWebhook extends Resource<
68
74
 
69
75
  export const ForgejoRepoWebhook = Resource<ForgejoRepoWebhook>('Forgejo.RepoWebhook');
70
76
 
71
- const handlers = forgejoHandlers<RepoWebhookProps, RepoWebhookAttributes>({
77
+ /** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
78
+ export const spec: ForgejoSpec<
79
+ RepoWebhookProps,
80
+ repository.Hook,
81
+ RepoWebhookAttributes,
82
+ | repository.RepoCreateHookError
83
+ | repository.RepoEditHookError
84
+ | repository.RepoDeleteHookError
85
+ | repository.RepoListHooksError
86
+ > = {
72
87
  attributes: (live, props) => {
73
- const id = live['id'];
74
- if (typeof id !== 'number') return undefined;
75
- const config = hookConfigPublic(live['config']);
76
- const type = text(live['type']);
88
+ const config = hookConfigPublic(live.config);
77
89
  return {
78
- active: bool(live['active'], true),
79
- authorizationHeader: text(live['authorization_header']),
80
- branchFilter: text(live['branch_filter']),
81
- contentType: text(config['content_type'], text(live['content_type'], 'json')),
82
- events: stringArray(live['events']),
83
- hookId: id,
90
+ active: live.active ?? true,
91
+ authorizationHeader: live.authorization_header ?? '',
92
+ branchFilter: live.branch_filter ?? '',
93
+ contentType: config['content_type'] ?? live.content_type ?? 'json',
94
+ events: stringArray(live.events ?? []),
95
+ hookId: live.id,
84
96
  name: props.name,
85
97
  owner: props.owner,
86
98
  repo: props.repo,
87
- type: type as RepoWebhookType,
88
- url: hookUrl(live),
99
+ type: live.type as RepoWebhookType,
100
+ url: live.url,
89
101
  };
90
102
  },
91
- collection: (props) => `repos/${props.owner}/${props.repo}/hooks`,
92
- createForm: createHookForm,
93
- locate: (props) =>
94
- forgejo<Record<string, unknown>[]>(
95
- 'GET',
96
- `repos/${props.owner}/${props.repo}/hooks?limit=200`,
97
- ).pipe(
98
- Effect.map((rows) =>
99
- Array.isArray(rows)
100
- ? rows.find((row) => text(row['name']) === props.name || hookUrl(row) === props.url)
101
- : undefined,
102
- ),
103
+ create: (props) =>
104
+ repository.repoCreateHook({
105
+ active: props.active ?? true,
106
+ ...(props.authorizationHeader === undefined
107
+ ? {}
108
+ : { authorization_header: props.authorizationHeader }),
109
+ ...(props.branchFilter === undefined ? {} : { branch_filter: props.branchFilter }),
110
+ config: hookConfigForm(props, true),
111
+ events: props.events,
112
+ owner: props.owner,
113
+ repo: props.repo,
114
+ type: props.type,
115
+ }),
116
+ destroy: (props, live) =>
117
+ repository.repoDeleteHook({ owner: props.owner, repo: props.repo, id: live.id }),
118
+ fetchLive: (props) =>
119
+ repository.repoListHooks({ owner: props.owner, repo: props.repo, limit: 200 }).pipe(
120
+ Effect.map((rows) => rows.find((row) => row.url === props.url)),
121
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
103
122
  ),
104
123
  matches: (attributes, props) =>
105
124
  attributes.type === props.type &&
@@ -109,10 +128,22 @@ const handlers = forgejoHandlers<RepoWebhookProps, RepoWebhookAttributes>({
109
128
  attributes.branchFilter === (props.branchFilter ?? '') &&
110
129
  attributes.authorizationHeader === (props.authorizationHeader ?? '') &&
111
130
  stringArray(props.events).join('\0') === attributes.events.join('\0'),
112
- path: (props) => `repos/${props.owner}/${props.repo}/hooks/${props.name}`,
113
- updateForm: updateHookForm,
114
- wirePath: (props, live) => `repos/${props.owner}/${props.repo}/hooks/${String(live['id'])}`,
115
- });
131
+ update: (props, live) =>
132
+ repository.repoEditHook({
133
+ active: props.active ?? true,
134
+ ...(props.authorizationHeader === undefined
135
+ ? {}
136
+ : { authorization_header: props.authorizationHeader }),
137
+ ...(props.branchFilter === undefined ? {} : { branch_filter: props.branchFilter }),
138
+ config: hookConfigForm(props, false),
139
+ events: props.events,
140
+ id: live.id,
141
+ owner: props.owner,
142
+ repo: props.repo,
143
+ }),
144
+ };
145
+
146
+ export const handlers = forgejoHandlers(spec);
116
147
 
117
148
  export const ForgejoRepoWebhookProvider = () =>
118
149
  Provider.effect(ForgejoRepoWebhook, Effect.succeed(ForgejoRepoWebhook.Provider.of(handlers)));