@homeflare/alchemy 0.27.0 → 0.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
- package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +4 -5
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +79 -103
- package/dist/cloudflare/index.js.map +6 -7
- package/dist/cloudflare/mesh-node-api.d.ts +3 -2
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +16 -5
- package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +34 -3
- package/dist/forgejo/branch-protection.d.ts.map +1 -1
- package/dist/forgejo/fake-forgejo.d.ts +41 -0
- package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
- package/dist/forgejo/index.js +220 -284
- package/dist/forgejo/index.js.map +14 -15
- package/dist/forgejo/org-actions-secrets.d.ts +42 -12
- package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
- package/dist/forgejo/org-label.d.ts +30 -3
- package/dist/forgejo/org-label.d.ts.map +1 -1
- package/dist/forgejo/org-team.d.ts +37 -4
- package/dist/forgejo/org-team.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook-form.d.ts +9 -18
- package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook.d.ts +35 -3
- package/dist/forgejo/repo-webhook.d.ts.map +1 -1
- package/dist/forgejo/repository.d.ts +47 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +37 -37
- package/dist/forgejo/resource.d.ts.map +1 -1
- package/dist/forgejo/team-member.d.ts +48 -4
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/forgejo/values.d.ts +9 -5
- package/dist/forgejo/values.d.ts.map +1 -1
- package/dist/launchd/index.js.map +2 -2
- package/dist/launchd/port-claims.d.ts +2 -2
- package/dist/launchd/port-claims.d.ts.map +1 -1
- package/docs/credentials.md +6 -0
- package/docs/distilled-interim.md +151 -0
- package/docs/launchd-ports.md +14 -17
- package/docs/peers.md +15 -6
- package/docs/upstream-conformance.md +46 -22
- package/package.json +2 -2
- package/src/cloudflare/fake-r2-lock.ts +120 -0
- package/src/cloudflare/index.ts +4 -5
- package/src/cloudflare/mesh-node-api.ts +3 -2
- package/src/cloudflare/providers.ts +13 -16
- package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
- package/src/cloudflare/r2-bucket-lock.ts +59 -51
- package/src/forgejo/branch-protection-form.ts +12 -5
- package/src/forgejo/branch-protection.ts +58 -26
- package/src/forgejo/fake-forgejo.ts +54 -0
- package/src/forgejo/org-actions-secrets.ts +44 -36
- package/src/forgejo/org-label.test.ts +100 -0
- package/src/forgejo/org-label.ts +46 -36
- package/src/forgejo/org-team.ts +40 -27
- package/src/forgejo/repo-webhook-form.ts +10 -27
- package/src/forgejo/repo-webhook.ts +66 -35
- package/src/forgejo/repository.test.ts +125 -0
- package/src/forgejo/repository.ts +72 -52
- package/src/forgejo/resource.ts +80 -79
- package/src/forgejo/team-member.test.ts +97 -0
- package/src/forgejo/team-member.ts +56 -38
- package/src/forgejo/values.ts +9 -17
- package/src/launchd/port-claims.ts +8 -9
- package/dist/cloudflare/client.d.ts +0 -45
- package/dist/cloudflare/client.d.ts.map +0 -1
- package/dist/forgejo/client.d.ts +0 -37
- package/dist/forgejo/client.d.ts.map +0 -1
- package/src/cloudflare/client.ts +0 -76
- package/src/forgejo/client.ts +0 -136
package/src/forgejo/org-label.ts
CHANGED
|
@@ -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:
|
|
16
|
-
*
|
|
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 {
|
|
25
|
-
import {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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)));
|
package/src/forgejo/org-team.ts
CHANGED
|
@@ -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
|
-
* ⚠️
|
|
8
|
-
*
|
|
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 {
|
|
16
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
73
|
-
description:
|
|
74
|
-
includesAllRepositories:
|
|
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[
|
|
80
|
-
unitsMap: stringRecord(live
|
|
92
|
+
teamId: live.id,
|
|
93
|
+
units: stringArray(live.units ?? []),
|
|
94
|
+
unitsMap: stringRecord(live.units_map ?? {}),
|
|
81
95
|
};
|
|
82
96
|
},
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Effect.map((rows) =>
|
|
88
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
*
|
|
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
|
|
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}`. `
|
|
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 {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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 —
|
|
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
|
-
|
|
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
|
|
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:
|
|
79
|
-
authorizationHeader:
|
|
80
|
-
branchFilter:
|
|
81
|
-
contentType:
|
|
82
|
-
events: stringArray(live[
|
|
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:
|
|
99
|
+
type: live.type as RepoWebhookType,
|
|
100
|
+
url: live.url,
|
|
89
101
|
};
|
|
90
102
|
},
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
});
|