@fungi.computer/bonsai 0.0.0-stage → 0.0.1
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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +835 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +5 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +715 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Bonsai HTTP contract. The Worker produces these shapes and the web UI
|
|
3
|
+
* (or any other client) parses them at its boundary. Nothing here knows about
|
|
4
|
+
* Cloudflare, Fungi or React.
|
|
5
|
+
*/
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import {
|
|
8
|
+
gitRepositoryNameSchema as repoName,
|
|
9
|
+
gitSha1Schema as hash,
|
|
10
|
+
} from "@fungi.computer/puffball/git-representation";
|
|
11
|
+
import { commit } from "@fungi.computer/agar/roles/files";
|
|
12
|
+
|
|
13
|
+
export const productName = "Bonsai";
|
|
14
|
+
|
|
15
|
+
/** Any JSON value: App method inputs and results travel as JSON. */
|
|
16
|
+
export const jsonValue = z.json();
|
|
17
|
+
export type JsonValue = z.infer<typeof jsonValue>;
|
|
18
|
+
|
|
19
|
+
const agent = z.strictObject({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
name: z.string(),
|
|
22
|
+
color: z.string(),
|
|
23
|
+
kind: z.enum(["agent", "human"]),
|
|
24
|
+
createdAt: z.number(),
|
|
25
|
+
});
|
|
26
|
+
export type Agent = z.infer<typeof agent>;
|
|
27
|
+
|
|
28
|
+
export const ref = z.strictObject({
|
|
29
|
+
name: z.string(),
|
|
30
|
+
hash,
|
|
31
|
+
});
|
|
32
|
+
export type Ref = z.infer<typeof ref>;
|
|
33
|
+
|
|
34
|
+
export const repoSummary = z.strictObject({
|
|
35
|
+
name: repoName,
|
|
36
|
+
description: z.string().nullable(),
|
|
37
|
+
parent: repoName.nullable(),
|
|
38
|
+
owner: z.string().nullable(),
|
|
39
|
+
createdAt: z.number(),
|
|
40
|
+
lastPushAt: z.number().nullable(),
|
|
41
|
+
forks: z.array(repoName),
|
|
42
|
+
/** Locally stored open or merging Changes; canonical summaries include their family. */
|
|
43
|
+
openChanges: z.number().int().nonnegative(),
|
|
44
|
+
/** Pushes per hour bucket for the last 24 hours, oldest first. */
|
|
45
|
+
activity: z.array(z.number()),
|
|
46
|
+
});
|
|
47
|
+
export type RepoSummary = z.infer<typeof repoSummary>;
|
|
48
|
+
|
|
49
|
+
export const repoDetail = z.strictObject({
|
|
50
|
+
repo: repoSummary,
|
|
51
|
+
defaultBranch: z.string(),
|
|
52
|
+
refs: z.array(ref),
|
|
53
|
+
cloneUrl: z.string(),
|
|
54
|
+
empty: z.boolean(),
|
|
55
|
+
});
|
|
56
|
+
export type RepoDetail = z.infer<typeof repoDetail>;
|
|
57
|
+
|
|
58
|
+
const fileDiff = z.strictObject({
|
|
59
|
+
path: z.string(),
|
|
60
|
+
oldPath: z.string().nullable(),
|
|
61
|
+
status: z.enum(["added", "removed", "modified"]),
|
|
62
|
+
additions: z.number(),
|
|
63
|
+
deletions: z.number(),
|
|
64
|
+
binary: z.boolean(),
|
|
65
|
+
/** True when the file was too large to show. */
|
|
66
|
+
truncated: z.boolean(),
|
|
67
|
+
/** Both sides in full, for the diff viewer; null when binary or too large. */
|
|
68
|
+
oldText: z.string().nullable(),
|
|
69
|
+
newText: z.string().nullable(),
|
|
70
|
+
});
|
|
71
|
+
export type FileDiff = z.infer<typeof fileDiff>;
|
|
72
|
+
|
|
73
|
+
const diff = z.strictObject({
|
|
74
|
+
files: z.array(fileDiff),
|
|
75
|
+
additions: z.number(),
|
|
76
|
+
deletions: z.number(),
|
|
77
|
+
/** Files beyond the response limit, counted but not listed. */
|
|
78
|
+
omittedFiles: z.number(),
|
|
79
|
+
});
|
|
80
|
+
export type Diff = z.infer<typeof diff>;
|
|
81
|
+
|
|
82
|
+
export const commitDetail = z.strictObject({
|
|
83
|
+
commit,
|
|
84
|
+
agent: z.string().nullable(),
|
|
85
|
+
diff,
|
|
86
|
+
});
|
|
87
|
+
export type CommitDetail = z.infer<typeof commitDetail>;
|
|
88
|
+
|
|
89
|
+
export const readCopyPageInput = z.strictObject({
|
|
90
|
+
limit: z.number().int().min(1).max(100).default(100),
|
|
91
|
+
after: z.string().optional(),
|
|
92
|
+
});
|
|
93
|
+
export type ReadCopyPageInput = z.input<typeof readCopyPageInput>;
|
|
94
|
+
|
|
95
|
+
/** One lane of the network graph: a branch in the canonical repo or a fork. */
|
|
96
|
+
const graphLane = z.strictObject({
|
|
97
|
+
id: z.string(),
|
|
98
|
+
repo: repoName,
|
|
99
|
+
ref: z.string(),
|
|
100
|
+
owner: z.string().nullable(),
|
|
101
|
+
head: hash,
|
|
102
|
+
canonical: z.boolean(),
|
|
103
|
+
});
|
|
104
|
+
export type GraphLane = z.infer<typeof graphLane>;
|
|
105
|
+
|
|
106
|
+
const graphNode = z.strictObject({
|
|
107
|
+
hash,
|
|
108
|
+
parents: z.array(hash),
|
|
109
|
+
message: z.string(),
|
|
110
|
+
author: z.string(),
|
|
111
|
+
agent: z.string().nullable(),
|
|
112
|
+
/** The repository the commit was first pushed to, when Bonsai saw the push. */
|
|
113
|
+
origin: repoName.nullable(),
|
|
114
|
+
committedAt: z.number(),
|
|
115
|
+
/** Lanes whose history contains this commit, canonical lanes first. */
|
|
116
|
+
lanes: z.array(z.string()),
|
|
117
|
+
});
|
|
118
|
+
export type GraphNode = z.infer<typeof graphNode>;
|
|
119
|
+
|
|
120
|
+
export const graph = z.strictObject({
|
|
121
|
+
lanes: z.array(graphLane),
|
|
122
|
+
nodes: z.array(graphNode),
|
|
123
|
+
});
|
|
124
|
+
export type Graph = z.infer<typeof graph>;
|
|
125
|
+
|
|
126
|
+
const refUpdate = z.strictObject({
|
|
127
|
+
ref: z.string(),
|
|
128
|
+
before: hash,
|
|
129
|
+
after: hash,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** Identity included in every Change event, pinned to the proposed head. */
|
|
133
|
+
export const changeIdentity = z.strictObject({
|
|
134
|
+
repo: repoName,
|
|
135
|
+
number: z.number().int().positive(),
|
|
136
|
+
sourceRepo: repoName,
|
|
137
|
+
sourceRef: z.string(),
|
|
138
|
+
targetRef: z.string(),
|
|
139
|
+
head: hash,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/** Activity recorded by Bonsai. Hook deliveries carry the same events. */
|
|
143
|
+
export const activity = z.discriminatedUnion("type", [
|
|
144
|
+
z.strictObject({
|
|
145
|
+
id: z.number(),
|
|
146
|
+
at: z.number(),
|
|
147
|
+
type: z.enum([
|
|
148
|
+
"change.opened",
|
|
149
|
+
"change.updated",
|
|
150
|
+
"change.approved",
|
|
151
|
+
"change.checks_requested",
|
|
152
|
+
"change.checked",
|
|
153
|
+
"change.closed",
|
|
154
|
+
]),
|
|
155
|
+
repo: repoName,
|
|
156
|
+
actor: z.string(),
|
|
157
|
+
data: z.strictObject({
|
|
158
|
+
change: changeIdentity,
|
|
159
|
+
before: hash.optional(),
|
|
160
|
+
after: hash.optional(),
|
|
161
|
+
name: z.string().optional(),
|
|
162
|
+
status: z.enum(["queued", "running", "passed", "failed"]).optional(),
|
|
163
|
+
names: z.array(z.string()).optional(),
|
|
164
|
+
}),
|
|
165
|
+
}),
|
|
166
|
+
z.strictObject({
|
|
167
|
+
id: z.number(),
|
|
168
|
+
at: z.number(),
|
|
169
|
+
type: z.literal("push"),
|
|
170
|
+
repo: repoName,
|
|
171
|
+
actor: z.string(),
|
|
172
|
+
data: z.strictObject({
|
|
173
|
+
updates: z.array(refUpdate),
|
|
174
|
+
commits: z.array(z.strictObject({ hash, message: z.string() })),
|
|
175
|
+
paths: z.array(z.string()),
|
|
176
|
+
}),
|
|
177
|
+
}),
|
|
178
|
+
z.strictObject({
|
|
179
|
+
id: z.number(),
|
|
180
|
+
at: z.number(),
|
|
181
|
+
type: z.literal("fork"),
|
|
182
|
+
repo: repoName,
|
|
183
|
+
actor: z.string(),
|
|
184
|
+
data: z.strictObject({ fork: repoName }),
|
|
185
|
+
}),
|
|
186
|
+
z.strictObject({
|
|
187
|
+
id: z.number(),
|
|
188
|
+
at: z.number(),
|
|
189
|
+
type: z.literal("repo.created"),
|
|
190
|
+
repo: repoName,
|
|
191
|
+
actor: z.string(),
|
|
192
|
+
data: z.strictObject({}),
|
|
193
|
+
}),
|
|
194
|
+
z.strictObject({
|
|
195
|
+
id: z.number(),
|
|
196
|
+
at: z.number(),
|
|
197
|
+
type: z.literal("merge"),
|
|
198
|
+
repo: repoName,
|
|
199
|
+
actor: z.string(),
|
|
200
|
+
data: z.strictObject({
|
|
201
|
+
change: changeIdentity,
|
|
202
|
+
from: repoName,
|
|
203
|
+
ref: z.string(),
|
|
204
|
+
before: hash,
|
|
205
|
+
after: hash,
|
|
206
|
+
}),
|
|
207
|
+
}),
|
|
208
|
+
z.strictObject({
|
|
209
|
+
id: z.number(),
|
|
210
|
+
at: z.number(),
|
|
211
|
+
type: z.literal("merge.conflict"),
|
|
212
|
+
repo: repoName,
|
|
213
|
+
actor: z.string(),
|
|
214
|
+
data: z.strictObject({
|
|
215
|
+
change: changeIdentity,
|
|
216
|
+
from: repoName,
|
|
217
|
+
ref: z.string(),
|
|
218
|
+
paths: z.array(z.string()),
|
|
219
|
+
}),
|
|
220
|
+
}),
|
|
221
|
+
]);
|
|
222
|
+
export type Activity = z.infer<typeof activity>;
|
|
223
|
+
|
|
224
|
+
/** Stored proposal identity; review facts are pinned to its head. */
|
|
225
|
+
export const changeRecord = z.strictObject({
|
|
226
|
+
repo: repoName,
|
|
227
|
+
number: z.number().int().positive(),
|
|
228
|
+
sourceRepo: repoName,
|
|
229
|
+
sourceRef: z.string(),
|
|
230
|
+
targetRef: z.string(),
|
|
231
|
+
title: z.string().min(1).max(200),
|
|
232
|
+
body: z.string().max(20_000),
|
|
233
|
+
openedBy: z.string(),
|
|
234
|
+
openedKind: z.enum(["human", "agent"]),
|
|
235
|
+
createdAt: z.number(),
|
|
236
|
+
updatedAt: z.number(),
|
|
237
|
+
state: z.enum(["open", "merging", "merged", "closed"]),
|
|
238
|
+
head: hash,
|
|
239
|
+
target: hash,
|
|
240
|
+
revision: z.number().int().nonnegative(),
|
|
241
|
+
conflict: z
|
|
242
|
+
.strictObject({ head: hash, target: hash, paths: z.array(z.string()) })
|
|
243
|
+
.nullable(),
|
|
244
|
+
lease: z
|
|
245
|
+
.strictObject({
|
|
246
|
+
id: z.string(),
|
|
247
|
+
by: z.string(),
|
|
248
|
+
head: hash,
|
|
249
|
+
at: z.number(),
|
|
250
|
+
})
|
|
251
|
+
.nullable(),
|
|
252
|
+
mergedCommit: hash.nullable(),
|
|
253
|
+
mergedBy: z.string().nullable(),
|
|
254
|
+
closedBy: z.string().nullable(),
|
|
255
|
+
/** Present only after a queued merge could not complete. */
|
|
256
|
+
mergeFailure: z
|
|
257
|
+
.enum(["access_changed", "sync_failed", "head_moved"])
|
|
258
|
+
.optional(),
|
|
259
|
+
});
|
|
260
|
+
export type ChangeRecord = z.infer<typeof changeRecord>;
|
|
261
|
+
/** Project the exact identity repeated in every Change delivery. */
|
|
262
|
+
export function changeIdentityOf(row: ChangeRecord) {
|
|
263
|
+
return {
|
|
264
|
+
repo: row.repo,
|
|
265
|
+
number: row.number,
|
|
266
|
+
sourceRepo: row.sourceRepo,
|
|
267
|
+
sourceRef: row.sourceRef,
|
|
268
|
+
targetRef: row.targetRef,
|
|
269
|
+
head: row.head,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const policyReason = z.enum([
|
|
273
|
+
"not-open",
|
|
274
|
+
"merging",
|
|
275
|
+
"moved",
|
|
276
|
+
"conflict",
|
|
277
|
+
"needs-approval",
|
|
278
|
+
"self-approval",
|
|
279
|
+
"checks-failed",
|
|
280
|
+
"checks-running",
|
|
281
|
+
"not-maintainer",
|
|
282
|
+
"agents-cannot-merge",
|
|
283
|
+
"agents-cannot-approve",
|
|
284
|
+
"not-source-writer",
|
|
285
|
+
"not-authorized",
|
|
286
|
+
]);
|
|
287
|
+
export const policyDecision = z.discriminatedUnion("allowed", [
|
|
288
|
+
z.strictObject({ allowed: z.literal(true) }),
|
|
289
|
+
z.strictObject({ allowed: z.literal(false), reason: policyReason }),
|
|
290
|
+
]);
|
|
291
|
+
export type PolicyDecision = z.infer<typeof policyDecision>;
|
|
292
|
+
const changeStatus = z.enum([
|
|
293
|
+
"conflict",
|
|
294
|
+
"checks-failed",
|
|
295
|
+
"checks-running",
|
|
296
|
+
"needs-approval",
|
|
297
|
+
"ready",
|
|
298
|
+
"merging",
|
|
299
|
+
"merged",
|
|
300
|
+
"closed",
|
|
301
|
+
]);
|
|
302
|
+
/** `self` is a sole Team member's approval of their own work. */
|
|
303
|
+
export const approval = z.strictObject({
|
|
304
|
+
actor: z.string(),
|
|
305
|
+
kind: z.enum(["human", "agent", "self"]),
|
|
306
|
+
head: hash,
|
|
307
|
+
at: z.number(),
|
|
308
|
+
});
|
|
309
|
+
export type Approval = z.infer<typeof approval>;
|
|
310
|
+
export const check = z.strictObject({
|
|
311
|
+
head: hash,
|
|
312
|
+
name: z.string().min(1).max(100),
|
|
313
|
+
status: z.enum(["queued", "running", "passed", "failed"]),
|
|
314
|
+
reporter: z.string(),
|
|
315
|
+
attemptId: z.string().min(1).max(512),
|
|
316
|
+
runUrl: z.url().nullable(),
|
|
317
|
+
summary: z.string().max(4000).nullable(),
|
|
318
|
+
updatedAt: z.number(),
|
|
319
|
+
});
|
|
320
|
+
export type Check = z.infer<typeof check>;
|
|
321
|
+
const change = changeRecord.extend({
|
|
322
|
+
status: changeStatus,
|
|
323
|
+
mergeable: policyDecision,
|
|
324
|
+
base: hash.nullable(),
|
|
325
|
+
ahead: z.number(),
|
|
326
|
+
behind: z.number(),
|
|
327
|
+
});
|
|
328
|
+
export type Change = z.infer<typeof change>;
|
|
329
|
+
export const changePage = z.strictObject({
|
|
330
|
+
changes: z.array(change),
|
|
331
|
+
next: z.string().optional(),
|
|
332
|
+
});
|
|
333
|
+
export type ChangePage = z.infer<typeof changePage>;
|
|
334
|
+
export const changeDetail = z.strictObject({
|
|
335
|
+
change,
|
|
336
|
+
actions: z.strictObject({ approve: policyDecision, close: policyDecision }),
|
|
337
|
+
commits: z.array(z.strictObject({ commit, agent: z.string().nullable() })),
|
|
338
|
+
diff,
|
|
339
|
+
review: z.strictObject({
|
|
340
|
+
approvals: z.array(approval),
|
|
341
|
+
checks: z.array(check),
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
344
|
+
export type ChangeDetail = z.infer<typeof changeDetail>;
|
|
345
|
+
export const mergeResult = z.discriminatedUnion("kind", [
|
|
346
|
+
z.strictObject({ kind: z.literal("merging") }),
|
|
347
|
+
z.strictObject({ kind: z.literal("merged"), before: hash, after: hash }),
|
|
348
|
+
z.strictObject({ kind: z.literal("moved"), head: hash }),
|
|
349
|
+
z.strictObject({
|
|
350
|
+
kind: z.literal("conflict"),
|
|
351
|
+
paths: z.array(z.string()),
|
|
352
|
+
head: hash,
|
|
353
|
+
}),
|
|
354
|
+
z.strictObject({ kind: z.literal("refused"), reason: policyReason }),
|
|
355
|
+
]);
|
|
356
|
+
export type MergeResult = z.infer<typeof mergeResult>;
|
|
357
|
+
|
|
358
|
+
export const approvalResult = z.discriminatedUnion("kind", [
|
|
359
|
+
z.strictObject({ kind: z.literal("approved") }),
|
|
360
|
+
z.strictObject({ kind: z.literal("moved"), head: hash }),
|
|
361
|
+
z.strictObject({ kind: z.literal("refused"), reason: policyReason }),
|
|
362
|
+
]);
|
|
363
|
+
export const checkRequestResult = z.discriminatedUnion("kind", [
|
|
364
|
+
z.strictObject({ kind: z.literal("requested"), attemptId: z.string() }),
|
|
365
|
+
z.strictObject({ kind: z.literal("moved"), head: hash }),
|
|
366
|
+
z.strictObject({ kind: z.literal("refused"), reason: policyReason }),
|
|
367
|
+
]);
|
|
368
|
+
export const checkReportResult = z.discriminatedUnion("kind", [
|
|
369
|
+
z.strictObject({ kind: z.literal("recorded") }),
|
|
370
|
+
z.strictObject({ kind: z.literal("moved"), head: hash }),
|
|
371
|
+
z.strictObject({ kind: z.literal("settled") }),
|
|
372
|
+
z.strictObject({ kind: z.literal("refused"), reason: policyReason }),
|
|
373
|
+
]);
|
|
374
|
+
export type ApprovalResult = z.infer<typeof approvalResult>;
|
|
375
|
+
export type CheckRequestResult = z.infer<typeof checkRequestResult>;
|
|
376
|
+
export type CheckReportResult = z.infer<typeof checkReportResult>;
|
|
377
|
+
|
|
378
|
+
export const activityPage = z.strictObject({ events: z.array(activity) });
|
|
379
|
+
|
|
380
|
+
export const agentPage = z.strictObject({ agents: z.array(agent) });
|
|
381
|
+
|
|
382
|
+
/** The Markdown files under issues/, keyed by path relative to issues/. */
|
|
383
|
+
export const issueFiles = z.strictObject({
|
|
384
|
+
ref: z.string(),
|
|
385
|
+
commit,
|
|
386
|
+
files: z.record(z.string(), z.string()),
|
|
387
|
+
truncated: z.boolean(),
|
|
388
|
+
});
|
|
389
|
+
export type IssueFiles = z.infer<typeof issueFiles>;
|
|
390
|
+
export const repoPage = z.strictObject({ repos: z.array(repoSummary) });
|
|
391
|
+
|
|
392
|
+
/** One App build job as its owner sees it; a settled success carries its Release. */
|
|
393
|
+
export const appBuildView = z.strictObject({
|
|
394
|
+
jobId: z.string().min(1).max(256),
|
|
395
|
+
appId: z.string().min(1).max(256),
|
|
396
|
+
releaseId: z.string().min(1).max(256),
|
|
397
|
+
sourceRevision: z.string().min(1).max(256),
|
|
398
|
+
status: z.enum(["running", "succeeded", "failed", "revoked"]),
|
|
399
|
+
detail: z.string().max(65_536).nullable(),
|
|
400
|
+
/** The recorded Release, parsed by its owner where it is installed. */
|
|
401
|
+
release: z.unknown(),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
export const errorBody = z.strictObject({ error: z.string() });
|
package/shared/hooks.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bonsai's events: the public, versioned envelope Bonsai emits through its host
|
|
3
|
+
* (the Oyster EVENTS binding) and the signatures that prove a delivery came
|
|
4
|
+
* from Bonsai. Runtime-neutral (Web Crypto only), so any consumer can import it
|
|
5
|
+
* to verify and parse a delivery.
|
|
6
|
+
*
|
|
7
|
+
* The body follows the Standard Webhooks payload shape (type, timestamp and
|
|
8
|
+
* data). A standalone host delivers it as an HTTP POST signed with Standard
|
|
9
|
+
* Webhooks headers (webhook-id, webhook-timestamp, webhook-signature) and,
|
|
10
|
+
* for receivers built for other git hosts, X-Hub-Signature-256 (sha256=HEX of
|
|
11
|
+
* the body), X-GitHub-Delivery and X-GitHub-Event. Bonsai-Signature
|
|
12
|
+
* (t=SECONDS,v1=HEX of "SECONDS.BODY") is checked by `verifyHook` below.
|
|
13
|
+
* A subscriber answers 2xx to accept; anything else is retried with backoff.
|
|
14
|
+
*/
|
|
15
|
+
import * as z from "zod";
|
|
16
|
+
import { activity, changeIdentity, type Activity } from "./contract.js";
|
|
17
|
+
|
|
18
|
+
export const hookSpec = "bonsai.event/v1";
|
|
19
|
+
|
|
20
|
+
/** Every Change delivery carries its stored proposal identity. */
|
|
21
|
+
const changeTypes = [
|
|
22
|
+
"change.opened",
|
|
23
|
+
"change.updated",
|
|
24
|
+
"change.approved",
|
|
25
|
+
"change.checks_requested",
|
|
26
|
+
"change.checked",
|
|
27
|
+
"change.merged",
|
|
28
|
+
"change.conflicted",
|
|
29
|
+
"change.closed",
|
|
30
|
+
] as const;
|
|
31
|
+
const gitTypes = ["git.push", "git.fork", "repo.created"] as const;
|
|
32
|
+
const eventData = z.strictObject({
|
|
33
|
+
spec: z.literal(hookSpec),
|
|
34
|
+
eventId: z.string(),
|
|
35
|
+
repository: z.strictObject({
|
|
36
|
+
name: z.string(),
|
|
37
|
+
canonical: z.string(),
|
|
38
|
+
cloneUrl: z.url(),
|
|
39
|
+
}),
|
|
40
|
+
actor: z.string(),
|
|
41
|
+
activity,
|
|
42
|
+
});
|
|
43
|
+
const gitEnvelope = z.strictObject({
|
|
44
|
+
type: z.enum(gitTypes),
|
|
45
|
+
timestamp: z.iso.datetime(),
|
|
46
|
+
data: eventData,
|
|
47
|
+
});
|
|
48
|
+
const changeEnvelope = z.strictObject({
|
|
49
|
+
type: z.enum(changeTypes),
|
|
50
|
+
timestamp: z.iso.datetime(),
|
|
51
|
+
data: eventData.extend({ change: changeIdentity }),
|
|
52
|
+
});
|
|
53
|
+
export const hookEvent = z.discriminatedUnion("type", [
|
|
54
|
+
gitEnvelope,
|
|
55
|
+
changeEnvelope,
|
|
56
|
+
]);
|
|
57
|
+
export type HookEvent = z.infer<typeof hookEvent>;
|
|
58
|
+
export type HookEventType = HookEvent["type"];
|
|
59
|
+
/** The precise declared event schema comes from the same envelope owner. */
|
|
60
|
+
export function hookEventFor(type: HookEventType) {
|
|
61
|
+
return type.startsWith("change.")
|
|
62
|
+
? changeEnvelope.extend({ type: z.literal(type) })
|
|
63
|
+
: gitEnvelope.extend({ type: z.literal(type) });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const eventTypes = {
|
|
67
|
+
push: "git.push",
|
|
68
|
+
fork: "git.fork",
|
|
69
|
+
merge: "change.merged",
|
|
70
|
+
"merge.conflict": "change.conflicted",
|
|
71
|
+
"repo.created": "repo.created",
|
|
72
|
+
"change.opened": "change.opened",
|
|
73
|
+
"change.updated": "change.updated",
|
|
74
|
+
"change.approved": "change.approved",
|
|
75
|
+
"change.checks_requested": "change.checks_requested",
|
|
76
|
+
"change.checked": "change.checked",
|
|
77
|
+
"change.closed": "change.closed",
|
|
78
|
+
} as const satisfies Record<Activity["type"], HookEventType>;
|
|
79
|
+
/** Build stable envelopes from committed activity; Change links identify the source repo. */
|
|
80
|
+
export function activityEnvelope(
|
|
81
|
+
stored: Activity,
|
|
82
|
+
repository: HookEvent["data"]["repository"],
|
|
83
|
+
): HookEvent {
|
|
84
|
+
const identity = "change" in stored.data ? stored.data.change : null;
|
|
85
|
+
const payload = {
|
|
86
|
+
type: eventTypes[stored.type],
|
|
87
|
+
timestamp: new Date(stored.at).toISOString(),
|
|
88
|
+
data: {
|
|
89
|
+
spec: hookSpec,
|
|
90
|
+
eventId: `evt_${String(stored.id)}`,
|
|
91
|
+
repository,
|
|
92
|
+
actor: stored.actor,
|
|
93
|
+
activity: stored,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
if (identity === null) return hookEvent.parse(payload);
|
|
97
|
+
return hookEvent.parse({
|
|
98
|
+
...payload,
|
|
99
|
+
data: { ...payload.data, change: identity },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const MAX_SKEW_SECONDS = 300;
|
|
104
|
+
|
|
105
|
+
const encoder = new TextEncoder();
|
|
106
|
+
|
|
107
|
+
async function hmac(secret: string, message: string): Promise<string> {
|
|
108
|
+
const key = await crypto.subtle.importKey(
|
|
109
|
+
"raw",
|
|
110
|
+
encoder.encode(secret),
|
|
111
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
112
|
+
false,
|
|
113
|
+
["sign"],
|
|
114
|
+
);
|
|
115
|
+
const signature = await crypto.subtle.sign(
|
|
116
|
+
"HMAC",
|
|
117
|
+
key,
|
|
118
|
+
encoder.encode(message),
|
|
119
|
+
);
|
|
120
|
+
return [...new Uint8Array(signature)]
|
|
121
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
122
|
+
.join("");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function secretBytes(secret: string): Uint8Array<ArrayBuffer> {
|
|
126
|
+
if (!secret.startsWith("whsec_")) return encoder.encode(secret);
|
|
127
|
+
try {
|
|
128
|
+
return Uint8Array.from(atob(secret.slice(6)), (char) => char.charCodeAt(0));
|
|
129
|
+
} catch {
|
|
130
|
+
return encoder.encode(secret);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The Standard Webhooks signature header value (`v1,<base64>`): HMAC-SHA256
|
|
136
|
+
* of "id.timestamp.body", keyed by the decoded `whsec_` secret.
|
|
137
|
+
*/
|
|
138
|
+
export async function signStandardWebhook(
|
|
139
|
+
secret: string,
|
|
140
|
+
id: string,
|
|
141
|
+
timestamp: number,
|
|
142
|
+
body: string,
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
const key = await crypto.subtle.importKey(
|
|
145
|
+
"raw",
|
|
146
|
+
secretBytes(secret),
|
|
147
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
148
|
+
false,
|
|
149
|
+
["sign"],
|
|
150
|
+
);
|
|
151
|
+
const signature = await crypto.subtle.sign(
|
|
152
|
+
"HMAC",
|
|
153
|
+
key,
|
|
154
|
+
encoder.encode(`${id}.${String(timestamp)}.${body}`),
|
|
155
|
+
);
|
|
156
|
+
return `v1,${btoa(String.fromCharCode(...new Uint8Array(signature)))}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The Bonsai-Signature header value for a body sent at `timestamp` (seconds). */
|
|
160
|
+
export async function signHook(
|
|
161
|
+
secret: string,
|
|
162
|
+
body: string,
|
|
163
|
+
timestamp: number,
|
|
164
|
+
): Promise<string> {
|
|
165
|
+
return `t=${String(timestamp)},v1=${await hmac(secret, `${String(timestamp)}.${body}`)}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function constantTimeEqual(left: string, right: string): boolean {
|
|
169
|
+
if (left.length !== right.length) return false;
|
|
170
|
+
let difference = 0;
|
|
171
|
+
for (let index = 0; index < left.length; index += 1)
|
|
172
|
+
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
173
|
+
return difference === 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const signatureParts = /^t=(\d+),v1=([0-9a-f]{64})$/u;
|
|
177
|
+
|
|
178
|
+
export type HookVerification =
|
|
179
|
+
| Readonly<{ ok: true; event: HookEvent }>
|
|
180
|
+
| Readonly<{ ok: false; reason: "signature" | "stale" | "shape" }>;
|
|
181
|
+
|
|
182
|
+
/** Verify a delivery's signature and freshness, then parse its body. */
|
|
183
|
+
export async function verifyHook(
|
|
184
|
+
secret: string,
|
|
185
|
+
body: string,
|
|
186
|
+
header: string | null,
|
|
187
|
+
now = Math.floor(Date.now() / 1000),
|
|
188
|
+
): Promise<HookVerification> {
|
|
189
|
+
const match = signatureParts.exec(header ?? "");
|
|
190
|
+
if (!match) return { ok: false, reason: "signature" };
|
|
191
|
+
const [, stamp = "", digest = ""] = match;
|
|
192
|
+
const timestamp = Number.parseInt(stamp, 10);
|
|
193
|
+
if (Math.abs(now - timestamp) > MAX_SKEW_SECONDS)
|
|
194
|
+
return { ok: false, reason: "stale" };
|
|
195
|
+
const expected = await hmac(secret, `${stamp}.${body}`);
|
|
196
|
+
if (!constantTimeEqual(expected, digest))
|
|
197
|
+
return { ok: false, reason: "signature" };
|
|
198
|
+
let json: unknown;
|
|
199
|
+
try {
|
|
200
|
+
json = JSON.parse(body);
|
|
201
|
+
} catch {
|
|
202
|
+
return { ok: false, reason: "shape" };
|
|
203
|
+
}
|
|
204
|
+
const parsed = hookEvent.safeParse(json);
|
|
205
|
+
return parsed.success
|
|
206
|
+
? { ok: true, event: parsed.data }
|
|
207
|
+
: { ok: false, reason: "shape" };
|
|
208
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
export const tileId = z.enum([
|
|
4
|
+
"contributors",
|
|
5
|
+
"actors",
|
|
6
|
+
"ages",
|
|
7
|
+
"churn",
|
|
8
|
+
"calendar",
|
|
9
|
+
"network",
|
|
10
|
+
"live",
|
|
11
|
+
"changes",
|
|
12
|
+
"next",
|
|
13
|
+
"readme",
|
|
14
|
+
]);
|
|
15
|
+
export type TileId = z.infer<typeof tileId>;
|
|
16
|
+
export const tileTitles = {
|
|
17
|
+
contributors: "Contributors",
|
|
18
|
+
actors: "Agents & people",
|
|
19
|
+
ages: "Code age",
|
|
20
|
+
churn: "File churn",
|
|
21
|
+
calendar: "Contribution calendar",
|
|
22
|
+
network: "Network",
|
|
23
|
+
live: "Live",
|
|
24
|
+
changes: "Changes",
|
|
25
|
+
next: "Next up",
|
|
26
|
+
readme: "README",
|
|
27
|
+
} satisfies Record<TileId, string>;
|
|
28
|
+
const placement = z.strictObject({
|
|
29
|
+
id: tileId,
|
|
30
|
+
col: z.int().min(0).max(3),
|
|
31
|
+
row: z.int().min(0).max(48),
|
|
32
|
+
w: z.int().min(1).max(4),
|
|
33
|
+
h: z.int().min(1).max(4),
|
|
34
|
+
});
|
|
35
|
+
export const insightLayout = z.strictObject({
|
|
36
|
+
view: z.literal("overview"),
|
|
37
|
+
placements: z
|
|
38
|
+
.array(placement)
|
|
39
|
+
.max(10)
|
|
40
|
+
.refine(
|
|
41
|
+
(tiles) => new Set(tiles.map((tile) => tile.id)).size === tiles.length,
|
|
42
|
+
"Each tile appears once",
|
|
43
|
+
),
|
|
44
|
+
});
|
|
45
|
+
export type InsightLayout = z.infer<typeof insightLayout>;
|
|
46
|
+
export const defaultLayout: InsightLayout = {
|
|
47
|
+
view: "overview",
|
|
48
|
+
placements: [
|
|
49
|
+
{ id: "network", col: 0, row: 0, w: 3, h: 2 },
|
|
50
|
+
{ id: "live", col: 3, row: 0, w: 1, h: 2 },
|
|
51
|
+
{ id: "changes", col: 0, row: 2, w: 2, h: 1 },
|
|
52
|
+
{ id: "actors", col: 2, row: 2, w: 1, h: 1 },
|
|
53
|
+
{ id: "next", col: 3, row: 2, w: 1, h: 1 },
|
|
54
|
+
{ id: "contributors", col: 0, row: 3, w: 2, h: 1 },
|
|
55
|
+
{ id: "readme", col: 2, row: 3, w: 2, h: 1 },
|
|
56
|
+
{ id: "calendar", col: 0, row: 4, w: 4, h: 1 },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
export const savedLayout = z.strictObject({ tiles: insightLayout.nullable() });
|
|
60
|
+
|
|
61
|
+
export const ageBandLabels = [
|
|
62
|
+
"Under a week",
|
|
63
|
+
"1–4 weeks",
|
|
64
|
+
"1–3 months",
|
|
65
|
+
"Over 3 months",
|
|
66
|
+
"Before sample / unknown",
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
export const codeInsights = z.strictObject({
|
|
70
|
+
head: z.string().nullable(),
|
|
71
|
+
commits: z.int().nonnegative(),
|
|
72
|
+
/** First-parent history and a bounded sample of current text files. */
|
|
73
|
+
files: z.array(
|
|
74
|
+
z.strictObject({
|
|
75
|
+
path: z.string(),
|
|
76
|
+
lines: z.int().nonnegative(),
|
|
77
|
+
changes: z.int().nonnegative(),
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
/** Oldest-first first-parent snapshots, then today; current sampled paths only. */
|
|
81
|
+
ageHistory: z.array(
|
|
82
|
+
z.strictObject({
|
|
83
|
+
at: z.number(),
|
|
84
|
+
lines: z.array(z.int().nonnegative()).length(ageBandLabels.length),
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
omittedFiles: z.int().nonnegative(),
|
|
88
|
+
partial: z.boolean(),
|
|
89
|
+
});
|
|
90
|
+
export type CodeInsights = z.infer<typeof codeInsights>;
|