@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.
Files changed (76) 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/dist/launchd/index.js.map +2 -2
  41. package/dist/launchd/port-claims.d.ts +2 -2
  42. package/dist/launchd/port-claims.d.ts.map +1 -1
  43. package/docs/credentials.md +6 -0
  44. package/docs/distilled-interim.md +151 -0
  45. package/docs/launchd-ports.md +14 -17
  46. package/docs/peers.md +15 -6
  47. package/docs/upstream-conformance.md +46 -22
  48. package/package.json +2 -2
  49. package/src/cloudflare/fake-r2-lock.ts +120 -0
  50. package/src/cloudflare/index.ts +4 -5
  51. package/src/cloudflare/mesh-node-api.ts +3 -2
  52. package/src/cloudflare/providers.ts +13 -16
  53. package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
  54. package/src/cloudflare/r2-bucket-lock.ts +59 -51
  55. package/src/forgejo/branch-protection-form.ts +12 -5
  56. package/src/forgejo/branch-protection.ts +58 -26
  57. package/src/forgejo/fake-forgejo.ts +54 -0
  58. package/src/forgejo/org-actions-secrets.ts +44 -36
  59. package/src/forgejo/org-label.test.ts +100 -0
  60. package/src/forgejo/org-label.ts +46 -36
  61. package/src/forgejo/org-team.ts +40 -27
  62. package/src/forgejo/repo-webhook-form.ts +10 -27
  63. package/src/forgejo/repo-webhook.ts +66 -35
  64. package/src/forgejo/repository.test.ts +125 -0
  65. package/src/forgejo/repository.ts +72 -52
  66. package/src/forgejo/resource.ts +80 -79
  67. package/src/forgejo/team-member.test.ts +97 -0
  68. package/src/forgejo/team-member.ts +56 -38
  69. package/src/forgejo/values.ts +9 -17
  70. package/src/launchd/port-claims.ts +8 -9
  71. package/dist/cloudflare/client.d.ts +0 -45
  72. package/dist/cloudflare/client.d.ts.map +0 -1
  73. package/dist/forgejo/client.d.ts +0 -37
  74. package/dist/forgejo/client.d.ts.map +0 -1
  75. package/src/cloudflare/client.ts +0 -76
  76. package/src/forgejo/client.ts +0 -136
@@ -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)));
@@ -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
+ });