@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,1063 @@
|
|
|
1
|
+
import { bindingOf } from "./git-binding.fixture.js";
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { commitFiles } from "@fungi.computer/agar/git-commit";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
import * as z from "zod";
|
|
11
|
+
import {
|
|
12
|
+
appGitPushFactSchema,
|
|
13
|
+
type AppGitPushFact,
|
|
14
|
+
} from "@fungi.computer/agar/git";
|
|
15
|
+
import {
|
|
16
|
+
parseAppMethodCallContext,
|
|
17
|
+
type AppMethodCallContext,
|
|
18
|
+
} from "@fungi.computer/puffball/app-method-runtime";
|
|
19
|
+
import { checkRequestResult, checkReportResult } from "../shared/contract.js";
|
|
20
|
+
import type { Commit } from "@fungi.computer/agar/roles/files";
|
|
21
|
+
import { hookEvent, type HookEvent } from "../shared/hooks.js";
|
|
22
|
+
import { ensureBonsaiTables, BonsaiState } from "../backend/state.js";
|
|
23
|
+
import type { GitStore } from "../backend/git.js";
|
|
24
|
+
import { observePush } from "../backend/service.js";
|
|
25
|
+
import { change } from "../backend/changes.js";
|
|
26
|
+
import { refreshChangeCopy } from "../backend/change-copy.js";
|
|
27
|
+
import { appBackend } from "../backend/methods.js";
|
|
28
|
+
|
|
29
|
+
const base = "a".repeat(40);
|
|
30
|
+
const first = "b".repeat(40);
|
|
31
|
+
const next = "c".repeat(40);
|
|
32
|
+
const zero = "0".repeat(40);
|
|
33
|
+
const recorded = z.literal(true);
|
|
34
|
+
function commit(hash: string, parents: string[], message: string): Commit {
|
|
35
|
+
return {
|
|
36
|
+
hash,
|
|
37
|
+
parents,
|
|
38
|
+
message,
|
|
39
|
+
treeHash: "e".repeat(40),
|
|
40
|
+
author: { name: "Morel", email: "morel@test.invalid" },
|
|
41
|
+
committer: { name: "Morel", email: "morel@test.invalid" },
|
|
42
|
+
authoredAt: 1,
|
|
43
|
+
committedAt: 1,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fixture() {
|
|
48
|
+
const database = new DatabaseSync(":memory:");
|
|
49
|
+
const owner = fromNodeSqlite(database);
|
|
50
|
+
ensureBonsaiTables(owner.sql);
|
|
51
|
+
const state = new BonsaiState(owner.sql, owner.transactionSync);
|
|
52
|
+
state.registerRepo({
|
|
53
|
+
name: "pond",
|
|
54
|
+
description: null,
|
|
55
|
+
parent: null,
|
|
56
|
+
owner: null,
|
|
57
|
+
});
|
|
58
|
+
state.registerRepo({
|
|
59
|
+
name: "pond--morel",
|
|
60
|
+
description: null,
|
|
61
|
+
parent: "pond",
|
|
62
|
+
owner: "morel",
|
|
63
|
+
});
|
|
64
|
+
const commits = new Map(
|
|
65
|
+
[
|
|
66
|
+
commit(base, [], "Start"),
|
|
67
|
+
commit(first, [base], "Add fish"),
|
|
68
|
+
commit(next, [first], "Add waves"),
|
|
69
|
+
].map((item) => [item.hash, item]),
|
|
70
|
+
);
|
|
71
|
+
const branches = new Map([
|
|
72
|
+
["pond", new Map([["main", base]])],
|
|
73
|
+
["pond--morel", new Map([["main", first]])],
|
|
74
|
+
]);
|
|
75
|
+
const unused = () => Promise.reject(new Error("Unexpected Git write"));
|
|
76
|
+
const store: GitStore = {
|
|
77
|
+
get binding() {
|
|
78
|
+
return bindingOf(this);
|
|
79
|
+
},
|
|
80
|
+
cloneUrl: (name) => Promise.resolve(`https://git.test/${name}.git`),
|
|
81
|
+
create: unused,
|
|
82
|
+
fork: unused,
|
|
83
|
+
info: (name) =>
|
|
84
|
+
Promise.resolve({
|
|
85
|
+
name,
|
|
86
|
+
defaultBranch: "main",
|
|
87
|
+
lastPushAt: null,
|
|
88
|
+
source: null,
|
|
89
|
+
}),
|
|
90
|
+
log: (_name, ref, limit) => {
|
|
91
|
+
let current = commits.get(branches.get(_name)?.get(ref) ?? ref);
|
|
92
|
+
const history: Commit[] = [];
|
|
93
|
+
while (current !== undefined && history.length < limit) {
|
|
94
|
+
history.push(current);
|
|
95
|
+
current = commits.get(current.parents[0] ?? "");
|
|
96
|
+
}
|
|
97
|
+
return Promise.resolve(history);
|
|
98
|
+
},
|
|
99
|
+
commit: (_name, hash) => Promise.resolve(commits.get(hash) ?? null),
|
|
100
|
+
tree: () => Promise.resolve([]),
|
|
101
|
+
blob: unused,
|
|
102
|
+
exchange: unused,
|
|
103
|
+
refs: (name) =>
|
|
104
|
+
Promise.resolve(
|
|
105
|
+
[...(branches.get(name)?.entries() ?? [])].map(([name, hash]) => ({
|
|
106
|
+
name: `refs/heads/${name}`,
|
|
107
|
+
hash,
|
|
108
|
+
})),
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
const delivered: HookEvent[] = [];
|
|
112
|
+
const bonsai = {
|
|
113
|
+
state,
|
|
114
|
+
store,
|
|
115
|
+
events: {
|
|
116
|
+
emit: (_type: string, payload: unknown) => {
|
|
117
|
+
delivered.push(hookEvent.parse(payload));
|
|
118
|
+
return Promise.resolve();
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
const push = (
|
|
123
|
+
eventId: string,
|
|
124
|
+
repository: string,
|
|
125
|
+
before: string,
|
|
126
|
+
after: string,
|
|
127
|
+
ref = "refs/heads/main",
|
|
128
|
+
writer: AppGitPushFact["writer"] = {
|
|
129
|
+
kind: "actor",
|
|
130
|
+
actor: { kind: "agent", agentId: "morel" },
|
|
131
|
+
},
|
|
132
|
+
) =>
|
|
133
|
+
observePush(
|
|
134
|
+
bonsai,
|
|
135
|
+
appGitPushFactSchema.parse({
|
|
136
|
+
eventId,
|
|
137
|
+
repository,
|
|
138
|
+
writer,
|
|
139
|
+
updates: [{ ref, before, after }],
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
return { database, state, bonsai, commits, branches, delivered, push };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe("Stored Change lifecycle from accepted host pushes", () => {
|
|
146
|
+
it("lists three prepared koi fork Changes without reading Git objects", async () => {
|
|
147
|
+
const f = fixture();
|
|
148
|
+
try {
|
|
149
|
+
f.state.registerRepo({
|
|
150
|
+
name: "pond--chanterelle",
|
|
151
|
+
description: null,
|
|
152
|
+
parent: "pond",
|
|
153
|
+
owner: "chanterelle",
|
|
154
|
+
});
|
|
155
|
+
f.state.registerRepo({
|
|
156
|
+
name: "pond--enoki",
|
|
157
|
+
description: null,
|
|
158
|
+
parent: "pond",
|
|
159
|
+
owner: "enoki",
|
|
160
|
+
});
|
|
161
|
+
for (const [index, agent] of [
|
|
162
|
+
"morel",
|
|
163
|
+
"chanterelle",
|
|
164
|
+
"enoki",
|
|
165
|
+
].entries()) {
|
|
166
|
+
let parent = base;
|
|
167
|
+
for (let n = 1; n <= 3; n += 1) {
|
|
168
|
+
const hash = (index * 3 + n).toString(16).repeat(40);
|
|
169
|
+
f.commits.set(hash, {
|
|
170
|
+
...commit(hash, [parent], `Koi ${agent} ${String(n)}`),
|
|
171
|
+
treeHash: hash,
|
|
172
|
+
});
|
|
173
|
+
parent = hash;
|
|
174
|
+
}
|
|
175
|
+
f.branches.set(`pond--${agent}`, new Map([["main", parent]]));
|
|
176
|
+
f.state.insertChange({
|
|
177
|
+
repo: "pond",
|
|
178
|
+
sourceRepo: `pond--${agent}`,
|
|
179
|
+
sourceRef: "main",
|
|
180
|
+
targetRef: "main",
|
|
181
|
+
title: `Koi ${agent}`,
|
|
182
|
+
body: "",
|
|
183
|
+
openedBy: agent,
|
|
184
|
+
openedKind: "agent",
|
|
185
|
+
createdAt: index + 1,
|
|
186
|
+
updatedAt: index + 1,
|
|
187
|
+
state: "open",
|
|
188
|
+
head: parent,
|
|
189
|
+
target: base,
|
|
190
|
+
conflict:
|
|
191
|
+
index === 2
|
|
192
|
+
? { head: parent, target: base, paths: ["src/pond.ts"] }
|
|
193
|
+
: null,
|
|
194
|
+
lease: null,
|
|
195
|
+
mergedCommit: null,
|
|
196
|
+
mergedBy: null,
|
|
197
|
+
closedBy: null,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
for (const row of f.state.changes("pond"))
|
|
201
|
+
await refreshChangeCopy(f.bonsai, row);
|
|
202
|
+
const noDiff = () =>
|
|
203
|
+
Promise.reject(
|
|
204
|
+
new Error("Changes listing must not load file diff objects"),
|
|
205
|
+
);
|
|
206
|
+
const reads: string[] = [];
|
|
207
|
+
let active = 0;
|
|
208
|
+
let peak = 0;
|
|
209
|
+
const log = async (repo: string, ref: string, limit: number) => {
|
|
210
|
+
reads.push(`${repo}:${ref}`);
|
|
211
|
+
active += 1;
|
|
212
|
+
peak = Math.max(peak, active);
|
|
213
|
+
await Promise.resolve();
|
|
214
|
+
const result = await f.bonsai.store.log(repo, ref, limit);
|
|
215
|
+
active -= 1;
|
|
216
|
+
return result;
|
|
217
|
+
};
|
|
218
|
+
const result = await appBackend.call(
|
|
219
|
+
"changes.list",
|
|
220
|
+
{ repo: "pond" },
|
|
221
|
+
{
|
|
222
|
+
bonsai: {
|
|
223
|
+
...f.bonsai,
|
|
224
|
+
store: {
|
|
225
|
+
...f.bonsai.store,
|
|
226
|
+
log,
|
|
227
|
+
commit: noDiff,
|
|
228
|
+
tree: noDiff,
|
|
229
|
+
blob: noDiff,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{ computerId: null, actor: { kind: "member", memberId: "Levi" } },
|
|
234
|
+
);
|
|
235
|
+
expect(reads).toHaveLength(0);
|
|
236
|
+
expect(peak).toBe(0);
|
|
237
|
+
expect(result).toMatchObject({
|
|
238
|
+
changes: [
|
|
239
|
+
{ number: 3, ahead: 3, behind: 0, status: "conflict" },
|
|
240
|
+
{ number: 2, ahead: 3, behind: 0, status: "ready" },
|
|
241
|
+
{ number: 1, ahead: 3, behind: 0, status: "ready" },
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
f.database.close();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("attributes accepted installation pushes to their host-held caller", async () => {
|
|
250
|
+
const f = fixture();
|
|
251
|
+
try {
|
|
252
|
+
await f.push(
|
|
253
|
+
"installation-agent",
|
|
254
|
+
"pond--morel",
|
|
255
|
+
base,
|
|
256
|
+
first,
|
|
257
|
+
"refs/heads/main",
|
|
258
|
+
{
|
|
259
|
+
kind: "installation",
|
|
260
|
+
installationId: "bonsai",
|
|
261
|
+
onBehalfOf: { kind: "agent", agentId: "morel" },
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
expect(f.state.changes("pond")[0]).toMatchObject({
|
|
265
|
+
openedBy: "morel",
|
|
266
|
+
openedKind: "agent",
|
|
267
|
+
});
|
|
268
|
+
expect(
|
|
269
|
+
f.state.events(null, 20).find((event) => event.type === "push")?.actor,
|
|
270
|
+
).toBe("morel");
|
|
271
|
+
} finally {
|
|
272
|
+
f.database.close();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("attributes accepted member pushes to the stable member ID", async () => {
|
|
277
|
+
const f = fixture();
|
|
278
|
+
try {
|
|
279
|
+
await f.push(
|
|
280
|
+
"installation-member",
|
|
281
|
+
"pond--morel",
|
|
282
|
+
base,
|
|
283
|
+
first,
|
|
284
|
+
"refs/heads/main",
|
|
285
|
+
{
|
|
286
|
+
kind: "installation",
|
|
287
|
+
installationId: "bonsai",
|
|
288
|
+
onBehalfOf: { kind: "member", memberId: "Levi" },
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
expect(f.state.changes("pond")[0]).toMatchObject({
|
|
292
|
+
openedBy: "Levi",
|
|
293
|
+
openedKind: "human",
|
|
294
|
+
});
|
|
295
|
+
} finally {
|
|
296
|
+
f.database.close();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("opens numbered work, pins review to the head and closes a deleted source", async () => {
|
|
301
|
+
const f = fixture();
|
|
302
|
+
try {
|
|
303
|
+
await f.push("push-one", "pond--morel", base, first);
|
|
304
|
+
const opened = f.state.changes("pond")[0];
|
|
305
|
+
expect(opened).toMatchObject({
|
|
306
|
+
repo: "pond",
|
|
307
|
+
number: 1,
|
|
308
|
+
sourceRepo: "pond--morel",
|
|
309
|
+
sourceRef: "main",
|
|
310
|
+
targetRef: "main",
|
|
311
|
+
head: first,
|
|
312
|
+
target: base,
|
|
313
|
+
title: "Add fish",
|
|
314
|
+
openedBy: "morel",
|
|
315
|
+
state: "open",
|
|
316
|
+
});
|
|
317
|
+
f.state.transition("approval", "at-first", recorded, () => {
|
|
318
|
+
f.state.approve("pond", 1, {
|
|
319
|
+
actor: "Ada",
|
|
320
|
+
kind: "human",
|
|
321
|
+
head: first,
|
|
322
|
+
at: 2,
|
|
323
|
+
});
|
|
324
|
+
f.state.reportCheck("pond", 1, {
|
|
325
|
+
head: first,
|
|
326
|
+
name: "tests",
|
|
327
|
+
status: "passed",
|
|
328
|
+
reporter: "Ada",
|
|
329
|
+
attemptId: "event-one",
|
|
330
|
+
runUrl: null,
|
|
331
|
+
summary: null,
|
|
332
|
+
updatedAt: 2,
|
|
333
|
+
});
|
|
334
|
+
return { result: true, events: [] };
|
|
335
|
+
});
|
|
336
|
+
expect(
|
|
337
|
+
(await change(f.bonsai, { name: "morel", kind: "human" }, "pond", 1))
|
|
338
|
+
.change.mergeable,
|
|
339
|
+
).toEqual({ allowed: true });
|
|
340
|
+
f.branches.get("pond--morel")?.set("main", next);
|
|
341
|
+
await f.push("push-two", "pond--morel", first, next);
|
|
342
|
+
const updated = await change(
|
|
343
|
+
f.bonsai,
|
|
344
|
+
{ name: "morel", kind: "human" },
|
|
345
|
+
"pond",
|
|
346
|
+
1,
|
|
347
|
+
);
|
|
348
|
+
expect(updated.change).toMatchObject({
|
|
349
|
+
head: next,
|
|
350
|
+
revision: 1,
|
|
351
|
+
mergeable: { allowed: false, reason: "needs-approval" },
|
|
352
|
+
});
|
|
353
|
+
expect(updated.review.approvals).toEqual([
|
|
354
|
+
{ actor: "Ada", kind: "human", head: first, at: 2 },
|
|
355
|
+
]);
|
|
356
|
+
expect(updated.review.checks[0]?.head).toBe(first);
|
|
357
|
+
const event = f.delivered.find(
|
|
358
|
+
(event) => event.type === "change.updated",
|
|
359
|
+
);
|
|
360
|
+
expect(event?.data.repository.cloneUrl).toBe(
|
|
361
|
+
"https://git.test/pond--morel.git",
|
|
362
|
+
);
|
|
363
|
+
expect(event?.data.activity.data).toMatchObject({
|
|
364
|
+
before: first,
|
|
365
|
+
after: next,
|
|
366
|
+
});
|
|
367
|
+
await f.push("delete-source", "pond--morel", next, zero);
|
|
368
|
+
expect(f.state.change("pond", 1)).toMatchObject({
|
|
369
|
+
state: "closed",
|
|
370
|
+
closedBy: "morel",
|
|
371
|
+
head: next,
|
|
372
|
+
});
|
|
373
|
+
const count = f.state.events(null, 100).length;
|
|
374
|
+
await f.push("delete-source", "pond--morel", next, zero);
|
|
375
|
+
expect(f.state.events(null, 100)).toHaveLength(count);
|
|
376
|
+
} finally {
|
|
377
|
+
f.database.close();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("settles a proposal when the accepted target contains its proposed head", async () => {
|
|
382
|
+
const f = fixture();
|
|
383
|
+
try {
|
|
384
|
+
await f.push("push-one", "pond--morel", base, first);
|
|
385
|
+
f.branches.get("pond")?.set("main", next);
|
|
386
|
+
await f.push("target-move", "pond", base, next);
|
|
387
|
+
expect(f.state.change("pond", 1)).toMatchObject({
|
|
388
|
+
state: "merged",
|
|
389
|
+
head: first,
|
|
390
|
+
target: next,
|
|
391
|
+
mergedCommit: next,
|
|
392
|
+
mergedBy: "morel",
|
|
393
|
+
});
|
|
394
|
+
expect(
|
|
395
|
+
f.delivered.filter((event) => event.type === "change.merged"),
|
|
396
|
+
).toHaveLength(1);
|
|
397
|
+
} finally {
|
|
398
|
+
f.database.close();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("atomically opens every proposal from a push larger than one drain page", async () => {
|
|
403
|
+
const f = fixture();
|
|
404
|
+
try {
|
|
405
|
+
await observePush(
|
|
406
|
+
f.bonsai,
|
|
407
|
+
appGitPushFactSchema.parse({
|
|
408
|
+
eventId: "many",
|
|
409
|
+
repository: "pond--morel",
|
|
410
|
+
writer: { kind: "actor", actor: { kind: "agent", agentId: "morel" } },
|
|
411
|
+
updates: Array.from({ length: 65 }, (_, i) => ({
|
|
412
|
+
ref: `refs/heads/topic-${String(i)}`,
|
|
413
|
+
before: base,
|
|
414
|
+
after: first,
|
|
415
|
+
})),
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
expect(f.state.changes("pond")).toHaveLength(65);
|
|
419
|
+
expect(f.delivered).toHaveLength(32);
|
|
420
|
+
const layout = {
|
|
421
|
+
repo: "pond",
|
|
422
|
+
tiles: { view: "overview", placements: [] },
|
|
423
|
+
};
|
|
424
|
+
await appBackend.call(
|
|
425
|
+
"insights.layout.save",
|
|
426
|
+
layout,
|
|
427
|
+
{ bonsai: f.bonsai },
|
|
428
|
+
caller("drain-next"),
|
|
429
|
+
);
|
|
430
|
+
expect(f.delivered).toHaveLength(64);
|
|
431
|
+
await appBackend.call(
|
|
432
|
+
"insights.layout.save",
|
|
433
|
+
layout,
|
|
434
|
+
{ bonsai: f.bonsai },
|
|
435
|
+
caller("drain-last"),
|
|
436
|
+
);
|
|
437
|
+
expect(f.delivered).toHaveLength(66);
|
|
438
|
+
expect(
|
|
439
|
+
f.delivered.filter((event) => event.type === "change.opened"),
|
|
440
|
+
).toHaveLength(65);
|
|
441
|
+
expect(new Set(f.delivered.map((event) => event.data.eventId)).size).toBe(
|
|
442
|
+
66,
|
|
443
|
+
);
|
|
444
|
+
} finally {
|
|
445
|
+
f.database.close();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("refuses an Agent merge before Git transport or a lease", async () => {
|
|
451
|
+
const f = fixture();
|
|
452
|
+
try {
|
|
453
|
+
await f.push("open-work", "pond--morel", base, first);
|
|
454
|
+
const result = await appBackend.call(
|
|
455
|
+
"changes.merge",
|
|
456
|
+
{ repo: "pond", number: 1, head: first },
|
|
457
|
+
{ bonsai: f.bonsai },
|
|
458
|
+
{
|
|
459
|
+
computerId: null,
|
|
460
|
+
actor: { kind: "agent", agentId: "morel" },
|
|
461
|
+
idempotencyKey: "agent-merge",
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
expect(result).toEqual({ kind: "refused", reason: "agents-cannot-merge" });
|
|
465
|
+
expect(f.state.change("pond", 1)).toMatchObject({
|
|
466
|
+
state: "open",
|
|
467
|
+
lease: null,
|
|
468
|
+
});
|
|
469
|
+
} finally {
|
|
470
|
+
f.database.close();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
function caller(key: string): AppMethodCallContext {
|
|
475
|
+
return {
|
|
476
|
+
computerId: null,
|
|
477
|
+
actor: { kind: "member", memberId: "Levi" },
|
|
478
|
+
idempotencyKey: key,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
it("uses real event identities for same-head check reruns, refuses late terminal reports and survives reopen", async () => {
|
|
483
|
+
const f = fixture();
|
|
484
|
+
try {
|
|
485
|
+
await f.push("open-work", "pond--morel", base, first);
|
|
486
|
+
const identity = { repo: "pond", number: 1, head: first };
|
|
487
|
+
const requested = checkRequestResult.parse(
|
|
488
|
+
await appBackend.call(
|
|
489
|
+
"checks.request",
|
|
490
|
+
{ ...identity, names: ["tests"] },
|
|
491
|
+
{ bonsai: f.bonsai },
|
|
492
|
+
caller("request-one"),
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
if (requested.kind !== "requested")
|
|
496
|
+
throw new Error("Expected a check request");
|
|
497
|
+
expect(
|
|
498
|
+
f.delivered.some(
|
|
499
|
+
(event) =>
|
|
500
|
+
event.type === "change.checks_requested" &&
|
|
501
|
+
event.data.eventId === requested.attemptId,
|
|
502
|
+
),
|
|
503
|
+
).toBe(true);
|
|
504
|
+
const report = {
|
|
505
|
+
...identity,
|
|
506
|
+
name: "tests",
|
|
507
|
+
attemptId: requested.attemptId,
|
|
508
|
+
};
|
|
509
|
+
expect(
|
|
510
|
+
await appBackend.call(
|
|
511
|
+
"checks.report",
|
|
512
|
+
{ ...report, status: "running" },
|
|
513
|
+
{ bonsai: f.bonsai },
|
|
514
|
+
caller("running-one"),
|
|
515
|
+
),
|
|
516
|
+
).toEqual({ kind: "recorded" });
|
|
517
|
+
expect(
|
|
518
|
+
await appBackend.call(
|
|
519
|
+
"checks.report",
|
|
520
|
+
{ ...report, status: "failed" },
|
|
521
|
+
{ bonsai: f.bonsai },
|
|
522
|
+
caller("failed-one"),
|
|
523
|
+
),
|
|
524
|
+
).toEqual({ kind: "recorded" });
|
|
525
|
+
expect(
|
|
526
|
+
await appBackend.call(
|
|
527
|
+
"checks.report",
|
|
528
|
+
{ ...report, status: "passed" },
|
|
529
|
+
{ bonsai: f.bonsai },
|
|
530
|
+
caller("conflicting-terminal"),
|
|
531
|
+
),
|
|
532
|
+
).toEqual({ kind: "settled" });
|
|
533
|
+
const rerun = checkRequestResult.parse(
|
|
534
|
+
await appBackend.call(
|
|
535
|
+
"checks.request",
|
|
536
|
+
{ ...identity, names: ["tests"] },
|
|
537
|
+
{ bonsai: f.bonsai },
|
|
538
|
+
caller("request-two"),
|
|
539
|
+
),
|
|
540
|
+
);
|
|
541
|
+
if (rerun.kind !== "requested") throw new Error("Expected a rerun");
|
|
542
|
+
expect(rerun.attemptId).not.toBe(requested.attemptId);
|
|
543
|
+
expect(
|
|
544
|
+
await appBackend.call(
|
|
545
|
+
"checks.report",
|
|
546
|
+
{ ...report, status: "failed" },
|
|
547
|
+
{ bonsai: f.bonsai },
|
|
548
|
+
caller("late-old-attempt"),
|
|
549
|
+
),
|
|
550
|
+
).toEqual({ kind: "settled" });
|
|
551
|
+
const newer = { ...report, attemptId: rerun.attemptId, status: "passed" };
|
|
552
|
+
expect(
|
|
553
|
+
checkReportResult.parse(
|
|
554
|
+
await appBackend.call(
|
|
555
|
+
"checks.report",
|
|
556
|
+
newer,
|
|
557
|
+
{ bonsai: f.bonsai },
|
|
558
|
+
caller("passed-two"),
|
|
559
|
+
),
|
|
560
|
+
),
|
|
561
|
+
).toEqual({ kind: "recorded" });
|
|
562
|
+
const owner = fromNodeSqlite(f.database);
|
|
563
|
+
const reopened = {
|
|
564
|
+
...f.bonsai,
|
|
565
|
+
state: new BonsaiState(owner.sql, owner.transactionSync),
|
|
566
|
+
};
|
|
567
|
+
const count = f.delivered.length;
|
|
568
|
+
expect(
|
|
569
|
+
await appBackend.call(
|
|
570
|
+
"checks.report",
|
|
571
|
+
newer,
|
|
572
|
+
{ bonsai: reopened },
|
|
573
|
+
caller("passed-two"),
|
|
574
|
+
),
|
|
575
|
+
).toEqual({ kind: "recorded" });
|
|
576
|
+
expect(f.delivered).toHaveLength(count);
|
|
577
|
+
expect(reopened.state.checks("pond", 1)[0]).toMatchObject({
|
|
578
|
+
attemptId: rerun.attemptId,
|
|
579
|
+
status: "passed",
|
|
580
|
+
runUrl: null,
|
|
581
|
+
});
|
|
582
|
+
await expect(
|
|
583
|
+
appBackend.call(
|
|
584
|
+
"checks.report",
|
|
585
|
+
{ ...newer, status: "failed" },
|
|
586
|
+
{ bonsai: reopened },
|
|
587
|
+
caller("passed-two"),
|
|
588
|
+
),
|
|
589
|
+
).rejects.toThrow("different");
|
|
590
|
+
await expect(
|
|
591
|
+
appBackend.call(
|
|
592
|
+
"checks.report",
|
|
593
|
+
{ ...newer, workflow: "forged-workflow" },
|
|
594
|
+
{ bonsai: reopened },
|
|
595
|
+
caller("forged-attribution"),
|
|
596
|
+
),
|
|
597
|
+
).rejects.toThrow();
|
|
598
|
+
await expect(
|
|
599
|
+
appBackend.call(
|
|
600
|
+
"checks.report",
|
|
601
|
+
{ ...newer, attemptId: "invented" },
|
|
602
|
+
{ bonsai: reopened },
|
|
603
|
+
caller("invented-attempt"),
|
|
604
|
+
),
|
|
605
|
+
).rejects.toThrow("actual Change trigger");
|
|
606
|
+
} finally {
|
|
607
|
+
f.database.close();
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("refuses concurrent merges and retries while the first Git exchange is pending", async () => {
|
|
612
|
+
const f = fixture();
|
|
613
|
+
let failExchange: ((reason: Error) => void) | undefined;
|
|
614
|
+
let exchangeStarted: (() => void) | undefined;
|
|
615
|
+
const started = new Promise<void>((resolve) => {
|
|
616
|
+
exchangeStarted = resolve;
|
|
617
|
+
});
|
|
618
|
+
const transport = new Promise<never>((_resolve, reject) => {
|
|
619
|
+
failExchange = reject;
|
|
620
|
+
});
|
|
621
|
+
const bonsai = {
|
|
622
|
+
...f.bonsai,
|
|
623
|
+
store: {
|
|
624
|
+
...f.bonsai.store,
|
|
625
|
+
exchange: () => {
|
|
626
|
+
exchangeStarted?.();
|
|
627
|
+
return transport;
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
try {
|
|
632
|
+
await f.push("open-work", "pond--morel", base, first);
|
|
633
|
+
const input = { repo: "pond", number: 1, head: first };
|
|
634
|
+
const merging = appBackend.call(
|
|
635
|
+
"changes.merge",
|
|
636
|
+
input,
|
|
637
|
+
{ bonsai },
|
|
638
|
+
caller("first-merge"),
|
|
639
|
+
);
|
|
640
|
+
const outcome = merging.catch((error: unknown) => error);
|
|
641
|
+
await started;
|
|
642
|
+
expect(
|
|
643
|
+
await appBackend.call(
|
|
644
|
+
"changes.merge",
|
|
645
|
+
input,
|
|
646
|
+
{ bonsai },
|
|
647
|
+
caller("second-merge"),
|
|
648
|
+
),
|
|
649
|
+
).toEqual({ kind: "refused", reason: "merging" });
|
|
650
|
+
expect(
|
|
651
|
+
await appBackend.call(
|
|
652
|
+
"changes.merge",
|
|
653
|
+
input,
|
|
654
|
+
{ bonsai },
|
|
655
|
+
caller("first-merge"),
|
|
656
|
+
),
|
|
657
|
+
).toEqual({ kind: "refused", reason: "merging" });
|
|
658
|
+
failExchange?.(new Error("Git transport unavailable"));
|
|
659
|
+
expect(await outcome).toBeInstanceOf(Error);
|
|
660
|
+
expect(
|
|
661
|
+
f.delivered.filter((event) => event.type === "change.merged"),
|
|
662
|
+
).toEqual([]);
|
|
663
|
+
} finally {
|
|
664
|
+
failExchange?.(new Error("Test finished"));
|
|
665
|
+
f.database.close();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("recovers an expired failed-transport lease on the next approval call", async () => {
|
|
670
|
+
const f = fixture();
|
|
671
|
+
const time = vi.spyOn(Date, "now");
|
|
672
|
+
try {
|
|
673
|
+
await f.push("open-work", "pond--morel", base, first);
|
|
674
|
+
await expect(
|
|
675
|
+
appBackend.call(
|
|
676
|
+
"changes.merge",
|
|
677
|
+
{ repo: "pond", number: 1, head: first },
|
|
678
|
+
{ bonsai: f.bonsai },
|
|
679
|
+
caller("failed-merge"),
|
|
680
|
+
),
|
|
681
|
+
).rejects.toThrow("Unexpected Git write");
|
|
682
|
+
const lease = f.state.requireChange("pond", 1).lease;
|
|
683
|
+
if (lease === null) throw new Error("Expected durable interrupted lease");
|
|
684
|
+
time.mockReturnValue(lease.at + 120_001);
|
|
685
|
+
expect(
|
|
686
|
+
await appBackend.call(
|
|
687
|
+
"changes.approve",
|
|
688
|
+
{ repo: "pond", number: 1, head: first },
|
|
689
|
+
{ bonsai: f.bonsai },
|
|
690
|
+
caller("after-timeout"),
|
|
691
|
+
),
|
|
692
|
+
).toEqual({ kind: "approved" });
|
|
693
|
+
expect(f.state.requireChange("pond", 1)).toMatchObject({
|
|
694
|
+
state: "open",
|
|
695
|
+
lease: null,
|
|
696
|
+
});
|
|
697
|
+
} finally {
|
|
698
|
+
time.mockRestore();
|
|
699
|
+
f.database.close();
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("merges the reviewed head through native Git and replays the committed receipt without a second write", async () => {
|
|
704
|
+
const f = fixture();
|
|
705
|
+
const root = mkdtempSync(join(tmpdir(), "bonsai-merge-"));
|
|
706
|
+
const run = (...args: string[]) =>
|
|
707
|
+
execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
|
708
|
+
let beforeReceive: (() => Promise<void>) | null = null;
|
|
709
|
+
const store: GitStore = {
|
|
710
|
+
get binding() {
|
|
711
|
+
return bindingOf(this);
|
|
712
|
+
},
|
|
713
|
+
...f.bonsai.store,
|
|
714
|
+
exchange: async (name, request) => {
|
|
715
|
+
if (
|
|
716
|
+
name === "pond" &&
|
|
717
|
+
request.service === "receive-pack" &&
|
|
718
|
+
request.phase === "exchange"
|
|
719
|
+
) {
|
|
720
|
+
const hook = beforeReceive;
|
|
721
|
+
beforeReceive = null;
|
|
722
|
+
await hook?.();
|
|
723
|
+
}
|
|
724
|
+
const advertise = request.phase === "advertise";
|
|
725
|
+
const result = spawnSync("git", ["http-backend"], {
|
|
726
|
+
input: request.body ?? new Uint8Array(),
|
|
727
|
+
env: {
|
|
728
|
+
...process.env,
|
|
729
|
+
GIT_PROJECT_ROOT: root,
|
|
730
|
+
GIT_HTTP_EXPORT_ALL: "1",
|
|
731
|
+
PATH_INFO: advertise
|
|
732
|
+
? `/${name}.git/info/refs`
|
|
733
|
+
: `/${name}.git/git-${request.service}`,
|
|
734
|
+
REQUEST_METHOD: advertise ? "GET" : "POST",
|
|
735
|
+
QUERY_STRING: advertise ? `service=git-${request.service}` : "",
|
|
736
|
+
CONTENT_TYPE: request.headers["content-type"] ?? "",
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
const output = new Uint8Array(result.stdout);
|
|
740
|
+
const split = new TextDecoder().decode(output).indexOf("\r\n\r\n");
|
|
741
|
+
if (result.status !== 0 || split < 0)
|
|
742
|
+
return Promise.reject(new Error("Native Git fixture failed"));
|
|
743
|
+
return Promise.resolve({
|
|
744
|
+
status: 200,
|
|
745
|
+
headers: {
|
|
746
|
+
"content-type": `application/x-git-${request.service}-${advertise ? "advertisement" : "result"}`,
|
|
747
|
+
},
|
|
748
|
+
body: new Uint8Array(output.subarray(split + 4)),
|
|
749
|
+
});
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
const bonsai = { ...f.bonsai, store };
|
|
753
|
+
try {
|
|
754
|
+
run("init", "--bare", "-q", "-b", "main", "pond.git");
|
|
755
|
+
run("--git-dir=pond.git", "config", "http.receivepack", "true");
|
|
756
|
+
const author = { name: "Morel", email: "morel@test.invalid" };
|
|
757
|
+
const initial = await commitFiles(store, {
|
|
758
|
+
repository: "pond",
|
|
759
|
+
branch: "main",
|
|
760
|
+
expectedHead: null,
|
|
761
|
+
message: "Start",
|
|
762
|
+
author,
|
|
763
|
+
changes: [
|
|
764
|
+
{ path: "food.txt", content: new TextEncoder().encode("food\n") },
|
|
765
|
+
],
|
|
766
|
+
});
|
|
767
|
+
if (initial.kind !== "committed")
|
|
768
|
+
throw new Error("Expected initial native commit");
|
|
769
|
+
run("clone", "--bare", "-q", "pond.git", "pond--morel.git");
|
|
770
|
+
run("--git-dir=pond--morel.git", "config", "http.receivepack", "true");
|
|
771
|
+
const proposed = await commitFiles(store, {
|
|
772
|
+
repository: "pond--morel",
|
|
773
|
+
branch: "main",
|
|
774
|
+
expectedHead: initial.commit,
|
|
775
|
+
message: "Add fish",
|
|
776
|
+
author,
|
|
777
|
+
changes: [
|
|
778
|
+
{ path: "fish.txt", content: new TextEncoder().encode("fish\n") },
|
|
779
|
+
],
|
|
780
|
+
});
|
|
781
|
+
if (proposed.kind !== "committed")
|
|
782
|
+
throw new Error("Expected proposed native commit");
|
|
783
|
+
f.commits.set(initial.commit, commit(initial.commit, [], "Start"));
|
|
784
|
+
f.commits.set(
|
|
785
|
+
proposed.commit,
|
|
786
|
+
commit(proposed.commit, [initial.commit], "Add fish"),
|
|
787
|
+
);
|
|
788
|
+
f.branches.set("pond", new Map([["main", initial.commit]]));
|
|
789
|
+
f.branches.set("pond--morel", new Map([["main", proposed.commit]]));
|
|
790
|
+
await f.push("native-open", "pond--morel", initial.commit, proposed.commit);
|
|
791
|
+
let newHead = "";
|
|
792
|
+
beforeReceive = async () => {
|
|
793
|
+
const changed = await commitFiles(store, {
|
|
794
|
+
repository: "pond--morel",
|
|
795
|
+
branch: "main",
|
|
796
|
+
expectedHead: proposed.commit,
|
|
797
|
+
message: "Add waves",
|
|
798
|
+
author,
|
|
799
|
+
changes: [
|
|
800
|
+
{ path: "waves.txt", content: new TextEncoder().encode("waves\n") },
|
|
801
|
+
],
|
|
802
|
+
});
|
|
803
|
+
if (changed.kind !== "committed")
|
|
804
|
+
throw new Error("Expected concurrent source write");
|
|
805
|
+
newHead = changed.commit;
|
|
806
|
+
f.commits.set(
|
|
807
|
+
changed.commit,
|
|
808
|
+
commit(changed.commit, [proposed.commit], "Add waves"),
|
|
809
|
+
);
|
|
810
|
+
f.branches.set("pond--morel", new Map([["main", changed.commit]]));
|
|
811
|
+
await f.push(
|
|
812
|
+
"native-source-moved",
|
|
813
|
+
"pond--morel",
|
|
814
|
+
proposed.commit,
|
|
815
|
+
changed.commit,
|
|
816
|
+
);
|
|
817
|
+
};
|
|
818
|
+
const input = { repo: "pond", number: 1, head: proposed.commit };
|
|
819
|
+
const result = await appBackend.call(
|
|
820
|
+
"changes.merge",
|
|
821
|
+
input,
|
|
822
|
+
{ bonsai },
|
|
823
|
+
caller("native-merge"),
|
|
824
|
+
);
|
|
825
|
+
const accepted = run("--git-dir=pond.git", "rev-parse", "main");
|
|
826
|
+
expect(result).toEqual({
|
|
827
|
+
kind: "merged",
|
|
828
|
+
before: initial.commit,
|
|
829
|
+
after: accepted,
|
|
830
|
+
});
|
|
831
|
+
expect(
|
|
832
|
+
run("--git-dir=pond.git", "show", "-s", "--format=%P", accepted),
|
|
833
|
+
).toBe(`${initial.commit} ${proposed.commit}`);
|
|
834
|
+
expect(run("--git-dir=pond.git", "show", "main:fish.txt")).toBe("fish");
|
|
835
|
+
expect(
|
|
836
|
+
run("--git-dir=pond.git", "ls-tree", "-r", "--name-only", accepted),
|
|
837
|
+
).toBe("fish.txt\nfood.txt");
|
|
838
|
+
const owner = fromNodeSqlite(f.database);
|
|
839
|
+
const restarted = {
|
|
840
|
+
...bonsai,
|
|
841
|
+
state: new BonsaiState(owner.sql, owner.transactionSync),
|
|
842
|
+
};
|
|
843
|
+
expect(
|
|
844
|
+
await appBackend.call(
|
|
845
|
+
"changes.merge",
|
|
846
|
+
input,
|
|
847
|
+
{ bonsai: restarted },
|
|
848
|
+
caller("native-merge"),
|
|
849
|
+
),
|
|
850
|
+
).toEqual(result);
|
|
851
|
+
expect(run("--git-dir=pond.git", "rev-parse", "main")).toBe(accepted);
|
|
852
|
+
expect(
|
|
853
|
+
f.delivered.filter((event) => event.type === "change.merged"),
|
|
854
|
+
).toHaveLength(1);
|
|
855
|
+
expect(
|
|
856
|
+
f.delivered.find((event) => event.type === "change.merged")?.data,
|
|
857
|
+
).toMatchObject({ change: { head: proposed.commit } });
|
|
858
|
+
await expect(
|
|
859
|
+
appBackend.call(
|
|
860
|
+
"changes.merge",
|
|
861
|
+
{ ...input, head: newHead },
|
|
862
|
+
{ bonsai: restarted },
|
|
863
|
+
caller("native-merge"),
|
|
864
|
+
),
|
|
865
|
+
).rejects.toThrow("Receipt key reused with different contents");
|
|
866
|
+
expect(f.state.requireChange("pond", 1)).toMatchObject({
|
|
867
|
+
state: "open",
|
|
868
|
+
head: newHead,
|
|
869
|
+
target: accepted,
|
|
870
|
+
lease: null,
|
|
871
|
+
});
|
|
872
|
+
} finally {
|
|
873
|
+
f.database.close();
|
|
874
|
+
rmSync(root, { recursive: true, force: true });
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("reads viewer approve and close decisions from host identity and the current Change", async () => {
|
|
879
|
+
const f = fixture();
|
|
880
|
+
try {
|
|
881
|
+
await f.push("viewer-actions-open", "pond--morel", base, first);
|
|
882
|
+
const input = { repo: "pond", number: 1 };
|
|
883
|
+
const cases = [
|
|
884
|
+
{
|
|
885
|
+
actor: { kind: "member", memberId: "Levi" },
|
|
886
|
+
actions: { approve: { allowed: true }, close: { allowed: true } },
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
actor: { kind: "member", memberId: "morel" },
|
|
890
|
+
actions: {
|
|
891
|
+
approve: { allowed: false, reason: "self-approval" },
|
|
892
|
+
close: { allowed: true },
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
actor: { kind: "agent", agentId: "morel" },
|
|
897
|
+
actions: {
|
|
898
|
+
approve: { allowed: false, reason: "agents-cannot-approve" },
|
|
899
|
+
close: { allowed: true },
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
actor: { kind: "agent", agentId: "another-agent" },
|
|
904
|
+
actions: {
|
|
905
|
+
approve: { allowed: false, reason: "agents-cannot-approve" },
|
|
906
|
+
close: { allowed: false, reason: "not-authorized" },
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
];
|
|
910
|
+
for (const candidate of cases) {
|
|
911
|
+
const context = parseAppMethodCallContext({
|
|
912
|
+
...caller("viewer-actions-read"),
|
|
913
|
+
actor: candidate.actor,
|
|
914
|
+
});
|
|
915
|
+
expect(
|
|
916
|
+
await appBackend.call(
|
|
917
|
+
"changes.get",
|
|
918
|
+
input,
|
|
919
|
+
{ bonsai: f.bonsai },
|
|
920
|
+
context,
|
|
921
|
+
),
|
|
922
|
+
).toMatchObject({ actions: candidate.actions });
|
|
923
|
+
}
|
|
924
|
+
expect(
|
|
925
|
+
await appBackend.call(
|
|
926
|
+
"changes.close",
|
|
927
|
+
input,
|
|
928
|
+
{ bonsai: f.bonsai },
|
|
929
|
+
caller("viewer-actions-close"),
|
|
930
|
+
),
|
|
931
|
+
).toEqual({ allowed: true });
|
|
932
|
+
expect(
|
|
933
|
+
await appBackend.call(
|
|
934
|
+
"changes.get",
|
|
935
|
+
input,
|
|
936
|
+
{ bonsai: f.bonsai },
|
|
937
|
+
caller("viewer-actions-after-close"),
|
|
938
|
+
),
|
|
939
|
+
).toMatchObject({
|
|
940
|
+
actions: {
|
|
941
|
+
approve: { allowed: false, reason: "not-open" },
|
|
942
|
+
close: { allowed: false, reason: "not-open" },
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
} finally {
|
|
946
|
+
f.database.close();
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
describe("Sole Team member review", () => {
|
|
951
|
+
const ownPush = (f: ReturnType<typeof fixture>) =>
|
|
952
|
+
f.push("own-work", "pond--morel", base, first, "refs/heads/main", {
|
|
953
|
+
kind: "actor",
|
|
954
|
+
actor: { kind: "member", memberId: "Levi" },
|
|
955
|
+
});
|
|
956
|
+
const input = { repo: "pond", number: 1, head: first };
|
|
957
|
+
const sole = (key: string) =>
|
|
958
|
+
parseAppMethodCallContext({ ...caller(key), soleMember: true });
|
|
959
|
+
|
|
960
|
+
it("keeps another reviewer required when the host does not attest a sole member", async () => {
|
|
961
|
+
const f = fixture();
|
|
962
|
+
try {
|
|
963
|
+
await ownPush(f);
|
|
964
|
+
expect(
|
|
965
|
+
await appBackend.call(
|
|
966
|
+
"changes.approve",
|
|
967
|
+
input,
|
|
968
|
+
{ bonsai: f.bonsai },
|
|
969
|
+
caller("multi-approve"),
|
|
970
|
+
),
|
|
971
|
+
).toEqual({ kind: "refused", reason: "self-approval" });
|
|
972
|
+
expect(
|
|
973
|
+
await appBackend.call(
|
|
974
|
+
"changes.merge",
|
|
975
|
+
input,
|
|
976
|
+
{ bonsai: f.bonsai },
|
|
977
|
+
caller("multi-merge"),
|
|
978
|
+
),
|
|
979
|
+
).toEqual({ kind: "refused", reason: "needs-approval" });
|
|
980
|
+
expect(f.state.approvals("pond", 1)).toEqual([]);
|
|
981
|
+
} finally {
|
|
982
|
+
f.database.close();
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it("lets the attested sole member approve and merge their own Change as a recorded self-approval", async () => {
|
|
987
|
+
const f = fixture();
|
|
988
|
+
try {
|
|
989
|
+
await ownPush(f);
|
|
990
|
+
expect(
|
|
991
|
+
await appBackend.call(
|
|
992
|
+
"changes.approve",
|
|
993
|
+
input,
|
|
994
|
+
{ bonsai: f.bonsai },
|
|
995
|
+
sole("sole-approve"),
|
|
996
|
+
),
|
|
997
|
+
).toEqual({ kind: "approved" });
|
|
998
|
+
expect(f.state.approvals("pond", 1)).toMatchObject([
|
|
999
|
+
{ actor: "Levi", kind: "self", head: first },
|
|
1000
|
+
]);
|
|
1001
|
+
expect(
|
|
1002
|
+
await appBackend.call(
|
|
1003
|
+
"changes.get",
|
|
1004
|
+
{ repo: "pond", number: 1 },
|
|
1005
|
+
{ bonsai: f.bonsai },
|
|
1006
|
+
sole("sole-read"),
|
|
1007
|
+
),
|
|
1008
|
+
).toMatchObject({
|
|
1009
|
+
change: { status: "ready", mergeable: { allowed: true } },
|
|
1010
|
+
});
|
|
1011
|
+
// Admission passed: the merge reaches the fixture's refused Git write.
|
|
1012
|
+
await expect(
|
|
1013
|
+
appBackend.call(
|
|
1014
|
+
"changes.merge",
|
|
1015
|
+
input,
|
|
1016
|
+
{ bonsai: f.bonsai },
|
|
1017
|
+
sole("sole-merge"),
|
|
1018
|
+
),
|
|
1019
|
+
).rejects.toThrow("Unexpected Git write");
|
|
1020
|
+
expect(f.state.requireChange("pond", 1).lease?.by).toBe("Levi");
|
|
1021
|
+
} finally {
|
|
1022
|
+
f.database.close();
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("records the sole member's approval of an Agent's Change as independent, and Agents cannot claim sole membership", async () => {
|
|
1027
|
+
const f = fixture();
|
|
1028
|
+
try {
|
|
1029
|
+
await f.push("agent-work", "pond--morel", base, first);
|
|
1030
|
+
expect(
|
|
1031
|
+
await appBackend.call(
|
|
1032
|
+
"changes.approve",
|
|
1033
|
+
input,
|
|
1034
|
+
{ bonsai: f.bonsai },
|
|
1035
|
+
parseAppMethodCallContext({
|
|
1036
|
+
...caller("agent-approve"),
|
|
1037
|
+
actor: { kind: "agent", agentId: "morel" },
|
|
1038
|
+
}),
|
|
1039
|
+
),
|
|
1040
|
+
).toEqual({ kind: "refused", reason: "agents-cannot-approve" });
|
|
1041
|
+
expect(() =>
|
|
1042
|
+
parseAppMethodCallContext({
|
|
1043
|
+
...caller("agent-sole"),
|
|
1044
|
+
actor: { kind: "agent", agentId: "morel" },
|
|
1045
|
+
soleMember: true,
|
|
1046
|
+
}),
|
|
1047
|
+
).toThrow("sole member");
|
|
1048
|
+
expect(
|
|
1049
|
+
await appBackend.call(
|
|
1050
|
+
"changes.approve",
|
|
1051
|
+
input,
|
|
1052
|
+
{ bonsai: f.bonsai },
|
|
1053
|
+
sole("owner-approves-agent"),
|
|
1054
|
+
),
|
|
1055
|
+
).toEqual({ kind: "approved" });
|
|
1056
|
+
expect(f.state.approvals("pond", 1)).toMatchObject([
|
|
1057
|
+
{ actor: "Levi", kind: "human", head: first },
|
|
1058
|
+
]);
|
|
1059
|
+
} finally {
|
|
1060
|
+
f.database.close();
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
});
|