@fungi.computer/bonsai 0.0.0-stage → 0.1.0
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 +43 -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 +848 -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/present.test.ts +27 -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 +47 -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 +719 -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,530 @@
|
|
|
1
|
+
/** Stored proposal lifecycle and views; Git, reviews and merges have separate owners. */
|
|
2
|
+
import {
|
|
3
|
+
copyListSnapshot,
|
|
4
|
+
readCopyPageOffset,
|
|
5
|
+
sliceReadCopyPage,
|
|
6
|
+
} from "./read-copy-page.js";
|
|
7
|
+
import {
|
|
8
|
+
readCopyPageInput,
|
|
9
|
+
type ReadCopyPageInput,
|
|
10
|
+
} from "../shared/contract.js";
|
|
11
|
+
import { ReadCopyBudgetExceeded } from "@fungi.computer/agar/read-copy";
|
|
12
|
+
import { replayKey } from "@fungi.computer/cairn";
|
|
13
|
+
import type { AppGitPushFact } from "@fungi.computer/agar/git";
|
|
14
|
+
import {
|
|
15
|
+
changeRecord,
|
|
16
|
+
policyDecision,
|
|
17
|
+
changeIdentityOf,
|
|
18
|
+
type ChangeRecord,
|
|
19
|
+
type Change,
|
|
20
|
+
type ChangeDetail,
|
|
21
|
+
type ChangePage,
|
|
22
|
+
type PolicyDecision,
|
|
23
|
+
} from "../shared/contract.js";
|
|
24
|
+
import { activityEnvelope, type HookEvent } from "../shared/hooks.js";
|
|
25
|
+
import { AppMethodRefusal } from "@fungi.computer/agar/backend";
|
|
26
|
+
import { changeCopyKey, changeObjectsSchema } from "./state.js";
|
|
27
|
+
import { decide, review, mayPush } from "./policy.js";
|
|
28
|
+
import { compareChange, projectionStore } from "./history.js";
|
|
29
|
+
import { recoverExpiredLease } from "./merge.js";
|
|
30
|
+
import type { Bonsai, BonsaiActor } from "./service.js";
|
|
31
|
+
import { getRepoDetail } from "./repository-copy.js";
|
|
32
|
+
import { loadChangeCopy, refreshChangeCopy } from "./change-copy.js";
|
|
33
|
+
import { readCopyFrame } from "./git.js";
|
|
34
|
+
const ZERO = "0".repeat(40);
|
|
35
|
+
function known(bonsai: Bonsai, name: string) {
|
|
36
|
+
const row = bonsai.state.repo(name);
|
|
37
|
+
if (row === null)
|
|
38
|
+
throw new AppMethodRefusal("not_found", `No repository ${name}`);
|
|
39
|
+
return row;
|
|
40
|
+
}
|
|
41
|
+
function subject(message: string): string {
|
|
42
|
+
const line = (message.split("\n")[0] ?? "").slice(0, 200);
|
|
43
|
+
return line.length === 0 ? "New Change" : line;
|
|
44
|
+
}
|
|
45
|
+
async function summarizeChange(
|
|
46
|
+
bonsai: Bonsai,
|
|
47
|
+
actor: BonsaiActor,
|
|
48
|
+
row: ChangeRecord,
|
|
49
|
+
signal: AbortSignal,
|
|
50
|
+
) {
|
|
51
|
+
const saved = await loadChangeCopy(bonsai, row, signal);
|
|
52
|
+
const authors = saved.headAuthors;
|
|
53
|
+
const facts = bonsai.state.reviewFacts(actor, row, row.head, authors);
|
|
54
|
+
const verdict = review(facts);
|
|
55
|
+
const summary = {
|
|
56
|
+
...row,
|
|
57
|
+
status: verdict.status,
|
|
58
|
+
mergeable: verdict.decision,
|
|
59
|
+
base: saved.base,
|
|
60
|
+
ahead: saved.ahead.length,
|
|
61
|
+
behind: saved.behind,
|
|
62
|
+
} satisfies Change;
|
|
63
|
+
return { saved, facts, summary };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function describeChange(
|
|
67
|
+
bonsai: Bonsai,
|
|
68
|
+
actor: BonsaiActor,
|
|
69
|
+
row: ChangeRecord,
|
|
70
|
+
signal: AbortSignal,
|
|
71
|
+
): Promise<ChangeDetail> {
|
|
72
|
+
const { saved, facts, summary } = await summarizeChange(
|
|
73
|
+
bonsai,
|
|
74
|
+
actor,
|
|
75
|
+
row,
|
|
76
|
+
signal,
|
|
77
|
+
);
|
|
78
|
+
const bytes =
|
|
79
|
+
saved.detailHash === null
|
|
80
|
+
? null
|
|
81
|
+
: bonsai.state.copies.readBlob(saved.detailHash);
|
|
82
|
+
if (bytes === null) {
|
|
83
|
+
bonsai.state.changeCopies.stale(changeCopyKey(row));
|
|
84
|
+
const rebuilt = await loadChangeCopy(bonsai, row, signal);
|
|
85
|
+
const bytes =
|
|
86
|
+
rebuilt.detailHash === null
|
|
87
|
+
? null
|
|
88
|
+
: bonsai.state.copies.readBlob(rebuilt.detailHash);
|
|
89
|
+
if (bytes === null)
|
|
90
|
+
throw new AppMethodRefusal("conflict", "Change copy is rebuilding.");
|
|
91
|
+
return describeChange(bonsai, actor, row, signal);
|
|
92
|
+
}
|
|
93
|
+
const objects = changeObjectsSchema.parse(
|
|
94
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
|
95
|
+
);
|
|
96
|
+
return {
|
|
97
|
+
change: summary,
|
|
98
|
+
actions: {
|
|
99
|
+
approve: decide("approve", facts),
|
|
100
|
+
close: decide("close", facts),
|
|
101
|
+
},
|
|
102
|
+
...objects,
|
|
103
|
+
review: {
|
|
104
|
+
approvals: bonsai.state.approvals(row.repo, row.number),
|
|
105
|
+
checks: bonsai.state.checks(row.repo, row.number),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function changes(
|
|
111
|
+
bonsai: Bonsai,
|
|
112
|
+
actor: BonsaiActor,
|
|
113
|
+
repo: string,
|
|
114
|
+
state?: ChangeRecord["state"],
|
|
115
|
+
pagination: ReadCopyPageInput = {},
|
|
116
|
+
): Promise<ChangePage> {
|
|
117
|
+
known(bonsai, repo);
|
|
118
|
+
const options = readCopyPageInput.parse(pagination);
|
|
119
|
+
const listing = () =>
|
|
120
|
+
bonsai.state
|
|
121
|
+
.changes(repo)
|
|
122
|
+
.filter((row) => state === undefined || row.state === state);
|
|
123
|
+
const listed = listing();
|
|
124
|
+
const identity = (rows: ChangeRecord[]) =>
|
|
125
|
+
rows.map((row) => [
|
|
126
|
+
row.number,
|
|
127
|
+
row.head,
|
|
128
|
+
row.target,
|
|
129
|
+
row.state,
|
|
130
|
+
row.revision,
|
|
131
|
+
]);
|
|
132
|
+
const scope = JSON.stringify(["changes", repo, state ?? null]);
|
|
133
|
+
const snapshot = await copyListSnapshot(identity(listed));
|
|
134
|
+
const offset = readCopyPageOffset(options.after, scope, snapshot);
|
|
135
|
+
const selected = sliceReadCopyPage(
|
|
136
|
+
listed,
|
|
137
|
+
options.limit,
|
|
138
|
+
offset,
|
|
139
|
+
scope,
|
|
140
|
+
snapshot,
|
|
141
|
+
);
|
|
142
|
+
if (
|
|
143
|
+
options.after !== undefined &&
|
|
144
|
+
selected.items.some(
|
|
145
|
+
(row) =>
|
|
146
|
+
bonsai.state.changeCopies.read(changeCopyKey(row)).kind !== "ready",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
throw new AppMethodRefusal(
|
|
150
|
+
"conflict",
|
|
151
|
+
"Saved Change page is unavailable; restart from the first page.",
|
|
152
|
+
);
|
|
153
|
+
return readCopyFrame(bonsai.store, async (store, signal) => {
|
|
154
|
+
const results: Change[] = [];
|
|
155
|
+
for (const row of selected.items)
|
|
156
|
+
results.push(
|
|
157
|
+
(await summarizeChange({ ...bonsai, store }, actor, row, signal))
|
|
158
|
+
.summary,
|
|
159
|
+
);
|
|
160
|
+
if ((await copyListSnapshot(identity(listing()))) !== snapshot)
|
|
161
|
+
throw new AppMethodRefusal(
|
|
162
|
+
"conflict",
|
|
163
|
+
"The saved page changed; restart from the first page.",
|
|
164
|
+
);
|
|
165
|
+
return selected.next === null
|
|
166
|
+
? { changes: results }
|
|
167
|
+
: { changes: results, next: selected.next };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function change(
|
|
172
|
+
bonsai: Bonsai,
|
|
173
|
+
actor: BonsaiActor,
|
|
174
|
+
repo: string,
|
|
175
|
+
number: number,
|
|
176
|
+
): Promise<ChangeDetail> {
|
|
177
|
+
return readCopyFrame(bonsai.store, (store, signal) =>
|
|
178
|
+
describeChange(
|
|
179
|
+
{ ...bonsai, store },
|
|
180
|
+
actor,
|
|
181
|
+
bonsai.state.requireChange(repo, number),
|
|
182
|
+
signal,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function openChange(
|
|
188
|
+
bonsai: Bonsai,
|
|
189
|
+
actor: BonsaiActor,
|
|
190
|
+
input: {
|
|
191
|
+
repo: string;
|
|
192
|
+
sourceRef: string;
|
|
193
|
+
targetRef?: string | undefined;
|
|
194
|
+
title?: string | undefined;
|
|
195
|
+
body?: string | undefined;
|
|
196
|
+
},
|
|
197
|
+
requestKey: string = replayKey(),
|
|
198
|
+
): Promise<ChangeRecord> {
|
|
199
|
+
return readCopyFrame(bonsai.store, (store) =>
|
|
200
|
+
openChangeFrame({ ...bonsai, store }, actor, input, requestKey),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function openChangeFrame(
|
|
205
|
+
bonsai: Bonsai,
|
|
206
|
+
actor: BonsaiActor,
|
|
207
|
+
input: {
|
|
208
|
+
repo: string;
|
|
209
|
+
sourceRef: string;
|
|
210
|
+
targetRef?: string | undefined;
|
|
211
|
+
title?: string | undefined;
|
|
212
|
+
body?: string | undefined;
|
|
213
|
+
},
|
|
214
|
+
requestKey: string = replayKey(),
|
|
215
|
+
): Promise<ChangeRecord> {
|
|
216
|
+
const key = `open:${requestKey}`;
|
|
217
|
+
const digest = JSON.stringify({ actor, input });
|
|
218
|
+
const cached = bonsai.state.receipt(key, digest, changeRecord);
|
|
219
|
+
if (cached !== null) {
|
|
220
|
+
return cached.result;
|
|
221
|
+
}
|
|
222
|
+
const source = known(bonsai, input.repo);
|
|
223
|
+
const repo = source.parent ?? source.name;
|
|
224
|
+
const [targetRepo, sourceRepo] = await Promise.all([
|
|
225
|
+
getRepoDetail(bonsai, repo),
|
|
226
|
+
getRepoDetail(bonsai, source.name),
|
|
227
|
+
]);
|
|
228
|
+
const targetRef = input.targetRef ?? targetRepo.defaultBranch;
|
|
229
|
+
if (source.name === repo && input.sourceRef === targetRef)
|
|
230
|
+
throw new AppMethodRefusal("invalid", "Choose a different source branch");
|
|
231
|
+
const head = sourceRepo.refs.find(
|
|
232
|
+
(ref) => ref.name === `refs/heads/${input.sourceRef}`,
|
|
233
|
+
)?.hash;
|
|
234
|
+
const target = targetRepo.refs.find(
|
|
235
|
+
(ref) => ref.name === `refs/heads/${targetRef}`,
|
|
236
|
+
)?.hash;
|
|
237
|
+
if (!head || !target)
|
|
238
|
+
throw new AppMethodRefusal(
|
|
239
|
+
"not_found",
|
|
240
|
+
"The repository branch is unavailable",
|
|
241
|
+
);
|
|
242
|
+
const history = await compareChange(
|
|
243
|
+
projectionStore(bonsai),
|
|
244
|
+
repo,
|
|
245
|
+
source.name,
|
|
246
|
+
head,
|
|
247
|
+
target,
|
|
248
|
+
);
|
|
249
|
+
if (history === null)
|
|
250
|
+
throw new AppMethodRefusal("not_found", "The proposed head is missing");
|
|
251
|
+
const tip =
|
|
252
|
+
history.ahead[0] ?? (await bonsai.store.commit(source.name, head));
|
|
253
|
+
if (tip === null)
|
|
254
|
+
throw new AppMethodRefusal("not_found", "The proposed head is missing");
|
|
255
|
+
const cloneUrl = sourceRepo.cloneUrl;
|
|
256
|
+
const result = bonsai.state.transition(key, digest, changeRecord, () => {
|
|
257
|
+
const decision = decide("open", {
|
|
258
|
+
actor,
|
|
259
|
+
change: {
|
|
260
|
+
state: "open",
|
|
261
|
+
head: head,
|
|
262
|
+
target: target,
|
|
263
|
+
openedBy: actor.name,
|
|
264
|
+
conflict: null,
|
|
265
|
+
},
|
|
266
|
+
head: head,
|
|
267
|
+
approvals: [],
|
|
268
|
+
checks: [],
|
|
269
|
+
headAuthors: [],
|
|
270
|
+
isMaintainer: actor.kind === "human",
|
|
271
|
+
mayPush: mayPush(actor, bonsai.state.repo(source.name)),
|
|
272
|
+
sourceOwner: source.owner,
|
|
273
|
+
soleMember: false,
|
|
274
|
+
});
|
|
275
|
+
if (!decision.allowed)
|
|
276
|
+
throw new AppMethodRefusal(
|
|
277
|
+
"forbidden",
|
|
278
|
+
"Only the source writer can open this Change",
|
|
279
|
+
);
|
|
280
|
+
const existing = bonsai.state
|
|
281
|
+
.changes(repo)
|
|
282
|
+
.find(
|
|
283
|
+
(row) =>
|
|
284
|
+
row.sourceRepo === source.name &&
|
|
285
|
+
row.sourceRef === input.sourceRef &&
|
|
286
|
+
row.targetRef === targetRef &&
|
|
287
|
+
(row.state === "open" || row.state === "merging"),
|
|
288
|
+
);
|
|
289
|
+
if (existing !== undefined) return { result: existing, events: [] };
|
|
290
|
+
const at = Date.now();
|
|
291
|
+
const candidate = {
|
|
292
|
+
repo,
|
|
293
|
+
sourceRepo: source.name,
|
|
294
|
+
sourceRef: input.sourceRef,
|
|
295
|
+
targetRef,
|
|
296
|
+
title: input.title ?? subject(tip.message),
|
|
297
|
+
body: input.body ?? "",
|
|
298
|
+
openedBy: actor.name,
|
|
299
|
+
openedKind: actor.kind,
|
|
300
|
+
createdAt: at,
|
|
301
|
+
updatedAt: at,
|
|
302
|
+
state: "open",
|
|
303
|
+
head: head,
|
|
304
|
+
target: target,
|
|
305
|
+
conflict: null,
|
|
306
|
+
lease: null,
|
|
307
|
+
mergedCommit: null,
|
|
308
|
+
mergedBy: null,
|
|
309
|
+
closedBy: null,
|
|
310
|
+
};
|
|
311
|
+
const row = bonsai.state.insertChange(
|
|
312
|
+
changeRecord.omit({ number: true, revision: true }).parse(candidate),
|
|
313
|
+
);
|
|
314
|
+
return {
|
|
315
|
+
result: row,
|
|
316
|
+
events: [
|
|
317
|
+
bonsai.state.changeEvent(row, actor.name, "change.opened", cloneUrl),
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
await refreshChangeCopy(bonsai, result, history);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (!(error instanceof ReadCopyBudgetExceeded)) throw error;
|
|
325
|
+
bonsai.state.changeCopies.stale(changeCopyKey(result));
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function closeChange(
|
|
331
|
+
bonsai: Bonsai,
|
|
332
|
+
actor: BonsaiActor,
|
|
333
|
+
repo: string,
|
|
334
|
+
number: number,
|
|
335
|
+
requestKey: string = replayKey(),
|
|
336
|
+
): Promise<PolicyDecision> {
|
|
337
|
+
const key = `close:${requestKey}`;
|
|
338
|
+
const digest = JSON.stringify({ actor, repo, number });
|
|
339
|
+
const cached = bonsai.state.receipt(key, digest, policyDecision);
|
|
340
|
+
if (cached !== null) {
|
|
341
|
+
return cached.result;
|
|
342
|
+
}
|
|
343
|
+
const initial = await recoverExpiredLease(
|
|
344
|
+
bonsai,
|
|
345
|
+
bonsai.state.requireChange(repo, number),
|
|
346
|
+
);
|
|
347
|
+
const cloneUrl = await bonsai.store.cloneUrl(initial.sourceRepo);
|
|
348
|
+
const result = bonsai.state.transition(key, digest, policyDecision, () => {
|
|
349
|
+
const row = bonsai.state.requireChange(repo, number);
|
|
350
|
+
const decision = decide(
|
|
351
|
+
"close",
|
|
352
|
+
bonsai.state.reviewFacts(actor, row, row.head, []),
|
|
353
|
+
);
|
|
354
|
+
if (!decision.allowed) return { result: decision, events: [] };
|
|
355
|
+
const next = bonsai.state.reviseChange(
|
|
356
|
+
{
|
|
357
|
+
...row,
|
|
358
|
+
state: "closed",
|
|
359
|
+
closedBy: actor.name,
|
|
360
|
+
updatedAt: Date.now(),
|
|
361
|
+
},
|
|
362
|
+
row.revision,
|
|
363
|
+
);
|
|
364
|
+
return {
|
|
365
|
+
result: decision,
|
|
366
|
+
events: [
|
|
367
|
+
bonsai.state.changeEvent(next, actor.name, "change.closed", cloneUrl),
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Apply every prepared ref update within the host push receipt transaction. */
|
|
375
|
+
export function reconcilePush(
|
|
376
|
+
bonsai: Bonsai,
|
|
377
|
+
prepared: Readonly<{
|
|
378
|
+
repo: string;
|
|
379
|
+
canonical: string;
|
|
380
|
+
actor: BonsaiActor;
|
|
381
|
+
updates: AppGitPushFact["updates"];
|
|
382
|
+
newCommits: readonly Readonly<{ hash: string; message: string }>[];
|
|
383
|
+
commitsByRef: ReadonlyMap<string, readonly string[]>;
|
|
384
|
+
cloneUrls: ReadonlyMap<string, string>;
|
|
385
|
+
targetHistory: ReadonlyMap<string, ReadonlySet<string>>;
|
|
386
|
+
defaultBranch: string;
|
|
387
|
+
defaultTarget: string;
|
|
388
|
+
}>,
|
|
389
|
+
): HookEvent[] {
|
|
390
|
+
const {
|
|
391
|
+
repo,
|
|
392
|
+
canonical,
|
|
393
|
+
actor,
|
|
394
|
+
newCommits,
|
|
395
|
+
commitsByRef,
|
|
396
|
+
cloneUrls,
|
|
397
|
+
targetHistory,
|
|
398
|
+
defaultBranch,
|
|
399
|
+
defaultTarget,
|
|
400
|
+
} = prepared;
|
|
401
|
+
const events: HookEvent[] = [];
|
|
402
|
+
for (const update of prepared.updates) {
|
|
403
|
+
if (!update.ref.startsWith("refs/heads/")) continue;
|
|
404
|
+
const sourceRef = update.ref.slice("refs/heads/".length);
|
|
405
|
+
const branchWork = newCommits.filter((commit) =>
|
|
406
|
+
commitsByRef.get(update.ref)?.includes(commit.hash),
|
|
407
|
+
);
|
|
408
|
+
const at = Date.now();
|
|
409
|
+
const rows = bonsai.state.changes(canonical);
|
|
410
|
+
for (const row of rows) {
|
|
411
|
+
if (row.state !== "open" && row.state !== "merging") continue;
|
|
412
|
+
const url = cloneUrls.get(row.sourceRepo);
|
|
413
|
+
if (url === undefined)
|
|
414
|
+
throw new Error("Missing prepared source clone URL");
|
|
415
|
+
if (
|
|
416
|
+
row.sourceRepo === repo &&
|
|
417
|
+
row.sourceRef === sourceRef &&
|
|
418
|
+
row.head !== update.after
|
|
419
|
+
) {
|
|
420
|
+
let revised: ChangeRecord = {
|
|
421
|
+
...row,
|
|
422
|
+
head: update.after === ZERO ? row.head : update.after,
|
|
423
|
+
updatedAt: at,
|
|
424
|
+
conflict: null,
|
|
425
|
+
};
|
|
426
|
+
if (update.after === ZERO)
|
|
427
|
+
revised = {
|
|
428
|
+
...revised,
|
|
429
|
+
state: "closed",
|
|
430
|
+
closedBy: actor.name,
|
|
431
|
+
};
|
|
432
|
+
const next = bonsai.state.reviseChange(revised, row.revision);
|
|
433
|
+
events.push(
|
|
434
|
+
bonsai.state.changeEvent(
|
|
435
|
+
next,
|
|
436
|
+
actor.name,
|
|
437
|
+
update.after === ZERO ? "change.closed" : "change.updated",
|
|
438
|
+
url,
|
|
439
|
+
{ before: row.head, after: update.after },
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
} else if (
|
|
443
|
+
row.repo === repo &&
|
|
444
|
+
row.targetRef === sourceRef &&
|
|
445
|
+
row.target !== update.after
|
|
446
|
+
) {
|
|
447
|
+
const merged = targetHistory.get(update.ref)?.has(row.head) ?? false;
|
|
448
|
+
let revised: ChangeRecord = {
|
|
449
|
+
...row,
|
|
450
|
+
target: update.after,
|
|
451
|
+
updatedAt: at,
|
|
452
|
+
conflict: null,
|
|
453
|
+
};
|
|
454
|
+
if (merged)
|
|
455
|
+
revised = {
|
|
456
|
+
...revised,
|
|
457
|
+
state: "merged",
|
|
458
|
+
mergedBy: actor.name,
|
|
459
|
+
mergedCommit: update.after,
|
|
460
|
+
lease: null,
|
|
461
|
+
};
|
|
462
|
+
const next = bonsai.state.reviseChange(revised, row.revision);
|
|
463
|
+
if (merged) {
|
|
464
|
+
const stored = bonsai.state.record({
|
|
465
|
+
at,
|
|
466
|
+
type: "merge",
|
|
467
|
+
repo: row.repo,
|
|
468
|
+
actor: actor.name,
|
|
469
|
+
data: {
|
|
470
|
+
change: changeIdentityOf(next),
|
|
471
|
+
from: row.sourceRepo,
|
|
472
|
+
ref: row.targetRef,
|
|
473
|
+
before: row.target,
|
|
474
|
+
after: update.after,
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
events.push(
|
|
478
|
+
activityEnvelope(stored, {
|
|
479
|
+
name: row.sourceRepo,
|
|
480
|
+
canonical: row.repo,
|
|
481
|
+
cloneUrl: url,
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (
|
|
488
|
+
repo !== canonical &&
|
|
489
|
+
update.after !== ZERO &&
|
|
490
|
+
branchWork.length > 0 &&
|
|
491
|
+
!bonsai.state
|
|
492
|
+
.changes(canonical)
|
|
493
|
+
.some(
|
|
494
|
+
(row) =>
|
|
495
|
+
row.sourceRepo === repo &&
|
|
496
|
+
row.sourceRef === sourceRef &&
|
|
497
|
+
row.targetRef === defaultBranch &&
|
|
498
|
+
(row.state === "open" || row.state === "merging"),
|
|
499
|
+
)
|
|
500
|
+
) {
|
|
501
|
+
const row = bonsai.state.insertChange({
|
|
502
|
+
repo: canonical,
|
|
503
|
+
sourceRepo: repo,
|
|
504
|
+
sourceRef,
|
|
505
|
+
targetRef: defaultBranch,
|
|
506
|
+
title: subject(branchWork[0]?.message ?? ""),
|
|
507
|
+
body: "",
|
|
508
|
+
openedBy: actor.name,
|
|
509
|
+
openedKind: actor.kind,
|
|
510
|
+
createdAt: at,
|
|
511
|
+
updatedAt: at,
|
|
512
|
+
state: "open",
|
|
513
|
+
head: update.after,
|
|
514
|
+
target: defaultTarget,
|
|
515
|
+
conflict: null,
|
|
516
|
+
lease: null,
|
|
517
|
+
mergedCommit: null,
|
|
518
|
+
mergedBy: null,
|
|
519
|
+
closedBy: null,
|
|
520
|
+
});
|
|
521
|
+
const url = cloneUrls.get(repo);
|
|
522
|
+
if (url === undefined)
|
|
523
|
+
throw new Error("Missing prepared source clone URL");
|
|
524
|
+
events.push(
|
|
525
|
+
bonsai.state.changeEvent(row, actor.name, "change.opened", url),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return events;
|
|
530
|
+
}
|