@homeflare/alchemy 0.27.1 → 0.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/cloudflare/fake-r2-lock.d.ts +53 -0
- package/dist/cloudflare/fake-r2-lock.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +4 -5
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +79 -103
- package/dist/cloudflare/index.js.map +6 -7
- package/dist/cloudflare/mesh-node-api.d.ts +3 -2
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -1
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/r2-bucket-lock.d.ts +28 -21
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +16 -5
- package/dist/forgejo/branch-protection-form.d.ts.map +1 -1
- package/dist/forgejo/branch-protection.d.ts +34 -3
- package/dist/forgejo/branch-protection.d.ts.map +1 -1
- package/dist/forgejo/fake-forgejo.d.ts +41 -0
- package/dist/forgejo/fake-forgejo.d.ts.map +1 -0
- package/dist/forgejo/index.js +220 -284
- package/dist/forgejo/index.js.map +14 -15
- package/dist/forgejo/org-actions-secrets.d.ts +42 -12
- package/dist/forgejo/org-actions-secrets.d.ts.map +1 -1
- package/dist/forgejo/org-label.d.ts +30 -3
- package/dist/forgejo/org-label.d.ts.map +1 -1
- package/dist/forgejo/org-team.d.ts +37 -4
- package/dist/forgejo/org-team.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook-form.d.ts +9 -18
- package/dist/forgejo/repo-webhook-form.d.ts.map +1 -1
- package/dist/forgejo/repo-webhook.d.ts +35 -3
- package/dist/forgejo/repo-webhook.d.ts.map +1 -1
- package/dist/forgejo/repository.d.ts +47 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +37 -37
- package/dist/forgejo/resource.d.ts.map +1 -1
- package/dist/forgejo/team-member.d.ts +48 -4
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/forgejo/values.d.ts +9 -5
- package/dist/forgejo/values.d.ts.map +1 -1
- package/docs/credentials.md +6 -0
- package/docs/distilled-interim.md +151 -0
- package/docs/peers.md +15 -6
- package/docs/upstream-conformance.md +46 -22
- package/package.json +5 -2
- package/src/cloudflare/fake-r2-lock.ts +120 -0
- package/src/cloudflare/index.ts +4 -5
- package/src/cloudflare/mesh-node-api.ts +3 -2
- package/src/cloudflare/providers.ts +13 -16
- package/src/cloudflare/r2-bucket-lock.test.ts +139 -0
- package/src/cloudflare/r2-bucket-lock.ts +59 -51
- package/src/forgejo/branch-protection-form.ts +12 -5
- package/src/forgejo/branch-protection.ts +58 -26
- package/src/forgejo/fake-forgejo.ts +54 -0
- package/src/forgejo/org-actions-secrets.ts +44 -36
- package/src/forgejo/org-label.test.ts +100 -0
- package/src/forgejo/org-label.ts +46 -36
- package/src/forgejo/org-team.ts +40 -27
- package/src/forgejo/repo-webhook-form.ts +10 -27
- package/src/forgejo/repo-webhook.ts +66 -35
- package/src/forgejo/repository.test.ts +125 -0
- package/src/forgejo/repository.ts +72 -52
- package/src/forgejo/resource.ts +80 -79
- package/src/forgejo/team-member.test.ts +97 -0
- package/src/forgejo/team-member.ts +56 -38
- package/src/forgejo/values.ts +9 -17
- package/src/proxmox/distilled-task-await.test.ts +124 -0
- package/dist/cloudflare/client.d.ts +0 -45
- package/dist/cloudflare/client.d.ts.map +0 -1
- package/dist/forgejo/client.d.ts +0 -37
- package/dist/forgejo/client.d.ts.map +0 -1
- package/src/cloudflare/client.ts +0 -76
- package/src/forgejo/client.ts +0 -136
|
@@ -1,36 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Forgejo.OrgSecret` — org-level Actions secret metadata at `/orgs/{org}/actions/secrets/{name}`.
|
|
3
3
|
*
|
|
4
|
-
* ★ CHOICE: AUTHORED WITH WRITE-ONLY VALUE VIA ENV, NOT REFUSED.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `ForgejoSpec` instead of hand-rolling provider handlers.
|
|
4
|
+
* ★ CHOICE: AUTHORED WITH WRITE-ONLY VALUE VIA ENV, NOT REFUSED. `organization.Secret` (the
|
|
5
|
+
* package's typed output) has only `name` and `created_at` — never `data` — so attributes stay
|
|
6
|
+
* metadata-only. Create/update is `updateOrgSecret`, a PUT with `{ data }`, which does not fit
|
|
7
|
+
* POST/PATCH; this family uses the `upsert` seam on `ForgejoSpec` instead of a plain create.
|
|
9
8
|
*
|
|
10
9
|
* ⛔ THE SECRET VALUE IS NEVER A PROP OR AN ATTRIBUTE. It is read from `FORGEJO_ORG_SECRET_{org}_{name}`
|
|
11
10
|
* (see orgSecretEnvKey) at PUT time when the name is absent from the list. Once present, reconcile
|
|
12
11
|
* noops — the API cannot confirm the value matches, and re-PUT on every deploy would rotate
|
|
13
12
|
* unintentionally. Rotate by destroy-then-create or a manual PUT outside Alchemy.
|
|
14
13
|
*
|
|
15
|
-
* ⚠️ PUT ANSWERS 201/204 WITH NO BODY — client.ts treats empty responses as `undefined`; read-back
|
|
16
|
-
* uses the list endpoint, not the PUT response.
|
|
17
|
-
*
|
|
18
14
|
* ⛔ TOKEN NEEDS `write:organization` TO PUT — list needs `read:organization`.
|
|
19
15
|
*
|
|
20
16
|
* ⛔ THE FILE NAME IS PLURAL ON PURPOSE AND MUST STAY THAT WAY. `.gitignore:198` carries
|
|
21
17
|
* `*-secret.*` — the rule that stops `client-secret.json` and friends being committed — and it
|
|
22
18
|
* matches a SOURCE file called `org-secret.ts` just as happily. This family was first authored
|
|
23
19
|
* under that name: it existed on disk, typechecked, passed oxlint, was imported by alchemy.run.ts
|
|
24
|
-
* and appeared in NO `git status`.
|
|
25
|
-
*
|
|
26
|
-
* that now catches the class; this comment is for whoever is tempted to rename it back.
|
|
20
|
+
* and appeared in NO `git status`. `scripts/husky/ignored-imports.ts` is the gate that now
|
|
21
|
+
* catches the class; this comment is for whoever is tempted to rename it back.
|
|
27
22
|
*/
|
|
28
23
|
import { Resource } from 'alchemy';
|
|
29
24
|
import * as Provider from 'alchemy/Provider';
|
|
25
|
+
import * as organization from '@distilled.cloud/forgejo/organization';
|
|
26
|
+
import * as Data from 'effect/Data';
|
|
30
27
|
import * as Effect from 'effect/Effect';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import { orgSecretEnvKey, text } from './values.ts';
|
|
28
|
+
import { type ForgejoRequirements, type ForgejoSpec, forgejoHandlers } from './resource.ts';
|
|
29
|
+
import { orgSecretEnvKey } from './values.ts';
|
|
34
30
|
|
|
35
31
|
export interface OrgSecretProps {
|
|
36
32
|
org: string;
|
|
@@ -54,46 +50,58 @@ export interface ForgejoOrgSecret extends Resource<
|
|
|
54
50
|
|
|
55
51
|
export const ForgejoOrgSecret = Resource<ForgejoOrgSecret>('Forgejo.OrgSecret');
|
|
56
52
|
|
|
53
|
+
/** Domain refusal, not a distilled error — the env var this family reads the secret value from is unset. */
|
|
54
|
+
export class ForgejoSecretEnvUnsetError extends Data.TaggedError('ForgejoSecretEnvUnsetError')<{
|
|
55
|
+
readonly message: string;
|
|
56
|
+
}> {}
|
|
57
|
+
|
|
57
58
|
const secretData = (org: string, name: string) =>
|
|
58
59
|
Effect.gen(function* () {
|
|
59
60
|
const key = orgSecretEnvKey(org, name);
|
|
60
61
|
const raw = process.env[key];
|
|
61
62
|
if (raw === undefined || raw.trim() === '') {
|
|
62
63
|
return yield* Effect.fail(
|
|
63
|
-
new
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`orgs/${org}/actions/secrets/${name}`,
|
|
67
|
-
`${key} is unset. Export the secret value there — never as an Alchemy prop.`,
|
|
68
|
-
),
|
|
64
|
+
new ForgejoSecretEnvUnsetError({
|
|
65
|
+
message: `${key} is unset. Export the secret value there — never as an Alchemy prop.`,
|
|
66
|
+
}),
|
|
69
67
|
);
|
|
70
68
|
}
|
|
71
69
|
return raw.trim();
|
|
72
70
|
});
|
|
73
71
|
|
|
74
|
-
|
|
72
|
+
/** ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see repository.ts. */
|
|
73
|
+
export const spec: ForgejoSpec<
|
|
74
|
+
OrgSecretProps,
|
|
75
|
+
organization.Secret,
|
|
76
|
+
OrgSecretAttributes,
|
|
77
|
+
| organization.OrgListActionsSecretsError
|
|
78
|
+
| organization.UpdateOrgSecretError
|
|
79
|
+
| organization.DeleteOrgSecretError
|
|
80
|
+
| ForgejoSecretEnvUnsetError
|
|
81
|
+
> = {
|
|
75
82
|
attributes: (live, props) => ({
|
|
76
|
-
createdAt:
|
|
83
|
+
createdAt: live.created_at,
|
|
77
84
|
name: props.name,
|
|
78
85
|
org: props.org,
|
|
79
86
|
}),
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
Effect.
|
|
85
|
-
Array.isArray(rows) ? rows.find((row) => row['name'] === props.name) : undefined,
|
|
86
|
-
),
|
|
87
|
+
destroy: (props) => organization.deleteOrgSecret({ org: props.org, secretname: props.name }),
|
|
88
|
+
fetchLive: (props) =>
|
|
89
|
+
organization.orgListActionsSecrets({ org: props.org, limit: 200 }).pipe(
|
|
90
|
+
Effect.map((rows) => rows.find((row) => row.name === props.name)),
|
|
91
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
87
92
|
),
|
|
88
93
|
matches: () => true,
|
|
89
|
-
path: (props) => `orgs/${props.org}/actions/secrets/${props.name}`,
|
|
90
94
|
upsert: (props, before) =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
before !== undefined
|
|
96
|
+
? Effect.void
|
|
97
|
+
: Effect.flatMap(secretData(props.org, props.name), (data) =>
|
|
98
|
+
Effect.asVoid(
|
|
99
|
+
organization.updateOrgSecret({ data, org: props.org, secretname: props.name }),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const handlers = forgejoHandlers(spec);
|
|
97
105
|
|
|
98
106
|
export const ForgejoOrgSecretProvider = () =>
|
|
99
107
|
Provider.effect(ForgejoOrgSecret, Effect.succeed(ForgejoOrgSecret.Provider.of(handlers)));
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Forgejo.OrgLabel`'s `fetchLive` against a fake Forgejo — the "list, then find by name" pattern
|
|
3
|
+
* (labels, teams, hooks and org secrets all share it, since Gitea addresses them by numeric id,
|
|
4
|
+
* not name). Two absent cases fold to `undefined` the same way, for different reasons:
|
|
5
|
+
*
|
|
6
|
+
* - the org itself is gone: `orgListLabels` answers a real distilled `NotFound` (404), caught by
|
|
7
|
+
* the same `catchTag('NotFound', ...)` idiom every `fetchLive` in this family ends with.
|
|
8
|
+
* - the org exists but has no label by that name: the list call succeeds, and `.find()` returns
|
|
9
|
+
* `undefined` on its own — no error, let alone a typed one, is involved.
|
|
10
|
+
*
|
|
11
|
+
* A 401 on the list still propagates unfolded, same as repository.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as organization from '@distilled.cloud/forgejo/organization';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import { fakeFailure, fakeForgejo, fakeForgejoLayer } from './fake-forgejo.ts';
|
|
17
|
+
import { spec } from './org-label.ts';
|
|
18
|
+
|
|
19
|
+
const LABELS_PATH = '/api/v1/orgs/homeflare/labels';
|
|
20
|
+
|
|
21
|
+
const fetchLabel = (fetchFn: typeof globalThis.fetch, name: string) =>
|
|
22
|
+
organization.orgListLabels({ limit: 200, org: 'homeflare' }).pipe(
|
|
23
|
+
Effect.map((rows) => rows.find((row) => row.name === name)),
|
|
24
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
25
|
+
Effect.provide(fakeForgejoLayer(fetchFn)),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe('Forgejo.OrgLabel fetchLive', () => {
|
|
29
|
+
test('a live label matched by name decodes into the typed row', async () => {
|
|
30
|
+
const fake = fakeForgejo((method, url) =>
|
|
31
|
+
method === 'GET' && url.pathname === LABELS_PATH
|
|
32
|
+
? Response.json([{ color: 'ff0000', id: 7, name: 'bug' }])
|
|
33
|
+
: fakeFailure(404, 'not found'),
|
|
34
|
+
);
|
|
35
|
+
const live = await Effect.runPromise(fetchLabel(fake.fetch, 'bug'));
|
|
36
|
+
expect(live?.id).toBe(7);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a name absent from a successful list is undefined — no error at all', async () => {
|
|
40
|
+
const fake = fakeForgejo(() => Response.json([{ color: 'ff0000', id: 7, name: 'bug' }]));
|
|
41
|
+
const live = await Effect.runPromise(fetchLabel(fake.fetch, 'enhancement'));
|
|
42
|
+
expect(live).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the org itself missing (404 on the list) folds to undefined too', async () => {
|
|
46
|
+
const fake = fakeForgejo(() => fakeFailure(404, 'org does not exist'));
|
|
47
|
+
const live = await Effect.runPromise(fetchLabel(fake.fetch, 'bug'));
|
|
48
|
+
expect(live).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a 401 on the list propagates — never folded to absent', async () => {
|
|
52
|
+
// ⚠️ 401 rather than 403: `orgListLabels` only declares `NotFound` beyond the shared default
|
|
53
|
+
// channel — Unauthorized is in that shared set for every operation regardless.
|
|
54
|
+
const fake = fakeForgejo(() => fakeFailure(401, 'bad token'));
|
|
55
|
+
const failure = await Effect.runPromise(Effect.flip(fetchLabel(fake.fetch, 'bug')));
|
|
56
|
+
expect(failure._tag).toBe('Unauthorized');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Forgejo.OrgLabel spec — the actual exported production functions', () => {
|
|
61
|
+
// ⛔ Proves the real `fetchLive` + `attributes` in org-label.ts, not a parallel
|
|
62
|
+
// reimplementation — see the same note in repository.test.ts.
|
|
63
|
+
const props = { color: 'ff0000', name: 'bug', org: 'homeflare' };
|
|
64
|
+
|
|
65
|
+
const readThrough = (fetchFn: typeof globalThis.fetch) =>
|
|
66
|
+
Effect.runPromise(
|
|
67
|
+
spec.fetchLive(props).pipe(
|
|
68
|
+
Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
|
|
69
|
+
Effect.provide(fakeForgejoLayer(fetchFn)),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
test('spec.fetchLive + spec.attributes match the old field mapping', async () => {
|
|
74
|
+
const fake = fakeForgejo((method, url) =>
|
|
75
|
+
method === 'GET' && url.pathname === LABELS_PATH
|
|
76
|
+
? Response.json([
|
|
77
|
+
{ color: 'FF0000', description: 'something is broken', id: 7, name: 'bug' },
|
|
78
|
+
])
|
|
79
|
+
: fakeFailure(404, 'not found'),
|
|
80
|
+
);
|
|
81
|
+
// ⚠️ `color()` lowercases — the wire value `FF0000` must compare equal to the declared `ff0000`.
|
|
82
|
+
expect(await readThrough(fake.fetch)).toEqual({
|
|
83
|
+
color: 'ff0000',
|
|
84
|
+
description: 'something is broken',
|
|
85
|
+
exclusive: false,
|
|
86
|
+
labelId: 7,
|
|
87
|
+
name: 'bug',
|
|
88
|
+
org: 'homeflare',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a label absent from the list through spec.fetchLive is undefined', async () => {
|
|
93
|
+
const fake = fakeForgejo((method, url) =>
|
|
94
|
+
method === 'GET' && url.pathname === LABELS_PATH
|
|
95
|
+
? Response.json([{ color: '00ff00', id: 9, name: 'enhancement' }])
|
|
96
|
+
: fakeFailure(404, 'not found'),
|
|
97
|
+
);
|
|
98
|
+
expect(await readThrough(fake.fetch)).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
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)));
|