@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,160 @@
|
|
|
1
|
+
/** Pure decisions over host identity, the stored proposal and pinned review facts. */
|
|
2
|
+
import type {
|
|
3
|
+
RepoSummary,
|
|
4
|
+
Change,
|
|
5
|
+
ChangeRecord,
|
|
6
|
+
PolicyDecision,
|
|
7
|
+
Approval,
|
|
8
|
+
Check,
|
|
9
|
+
} from "../shared/contract.js";
|
|
10
|
+
import type { AppMethodCallContext } from "@fungi.computer/puffball/app-method-runtime";
|
|
11
|
+
import type { BonsaiActor } from "./service.js";
|
|
12
|
+
|
|
13
|
+
export type PolicyFacts = Readonly<{
|
|
14
|
+
actor: BonsaiActor;
|
|
15
|
+
change: Pick<
|
|
16
|
+
ChangeRecord,
|
|
17
|
+
"state" | "head" | "target" | "openedBy" | "conflict"
|
|
18
|
+
>;
|
|
19
|
+
head: string;
|
|
20
|
+
approvals: readonly Pick<Approval, "actor" | "head" | "kind">[];
|
|
21
|
+
checks: readonly Pick<Check, "head" | "status">[];
|
|
22
|
+
headAuthors: readonly string[];
|
|
23
|
+
isMaintainer: boolean;
|
|
24
|
+
mayPush: boolean;
|
|
25
|
+
sourceOwner: string | null;
|
|
26
|
+
/** Host-attested: the acting person is their Team's only member. */
|
|
27
|
+
soleMember: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
export type Action =
|
|
30
|
+
"open" | "approve" | "merge" | "close" | "reportCheck" | "requestChecks";
|
|
31
|
+
|
|
32
|
+
/** Someone who neither opened the Change nor authored its head. */
|
|
33
|
+
function independent(facts: PolicyFacts, actor: string): boolean {
|
|
34
|
+
return actor !== facts.change.openedBy && !facts.headAuthors.includes(actor);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Review asks for a second person. A Team with only one member has none, so
|
|
39
|
+
* the host-attested sole member may approve and merge their own work. Their
|
|
40
|
+
* approval is recorded as `self` for the review history, and each decision
|
|
41
|
+
* reads the attestation afresh, so it stops applying once the Team grows.
|
|
42
|
+
* Agents are never members, so they never self-approve.
|
|
43
|
+
*/
|
|
44
|
+
function soleSelf(facts: PolicyFacts): boolean {
|
|
45
|
+
return facts.soleMember && facts.actor.kind === "human";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How the acting person's approval of this head is recorded. */
|
|
49
|
+
export function approvalKind(facts: PolicyFacts): "human" | "self" {
|
|
50
|
+
return independent(facts, facts.actor.name) ? "human" : "self";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decide(action: Action, facts: PolicyFacts): PolicyDecision {
|
|
54
|
+
if (action === "open")
|
|
55
|
+
return facts.mayPush
|
|
56
|
+
? { allowed: true }
|
|
57
|
+
: { allowed: false, reason: "not-source-writer" };
|
|
58
|
+
if (action === "merge" && facts.actor.kind !== "human")
|
|
59
|
+
return { allowed: false, reason: "agents-cannot-merge" };
|
|
60
|
+
if (action === "approve" && facts.actor.kind !== "human")
|
|
61
|
+
return { allowed: false, reason: "agents-cannot-approve" };
|
|
62
|
+
if (action === "reportCheck" || action === "requestChecks") {
|
|
63
|
+
if (facts.head !== facts.change.head)
|
|
64
|
+
return { allowed: false, reason: "moved" };
|
|
65
|
+
return facts.change.state === "open" || facts.change.state === "merging"
|
|
66
|
+
? { allowed: true }
|
|
67
|
+
: { allowed: false, reason: "not-open" };
|
|
68
|
+
}
|
|
69
|
+
if (facts.change.state === "merging")
|
|
70
|
+
return { allowed: false, reason: "merging" };
|
|
71
|
+
if (facts.change.state !== "open")
|
|
72
|
+
return { allowed: false, reason: "not-open" };
|
|
73
|
+
if (action === "close")
|
|
74
|
+
return facts.isMaintainer ||
|
|
75
|
+
facts.actor.name === facts.change.openedBy ||
|
|
76
|
+
facts.actor.name === facts.sourceOwner
|
|
77
|
+
? { allowed: true }
|
|
78
|
+
: { allowed: false, reason: "not-authorized" };
|
|
79
|
+
if (facts.head !== facts.change.head)
|
|
80
|
+
return { allowed: false, reason: "moved" };
|
|
81
|
+
if (!facts.isMaintainer) return { allowed: false, reason: "not-maintainer" };
|
|
82
|
+
if (action === "approve")
|
|
83
|
+
return independent(facts, facts.actor.name) || soleSelf(facts)
|
|
84
|
+
? { allowed: true }
|
|
85
|
+
: { allowed: false, reason: "self-approval" };
|
|
86
|
+
const conflict = facts.change.conflict;
|
|
87
|
+
if (
|
|
88
|
+
conflict?.head === facts.change.head &&
|
|
89
|
+
conflict.target === facts.change.target
|
|
90
|
+
)
|
|
91
|
+
return { allowed: false, reason: "conflict" };
|
|
92
|
+
const checks = facts.checks.filter(
|
|
93
|
+
(check) => check.head === facts.change.head,
|
|
94
|
+
);
|
|
95
|
+
if (checks.some((check) => check.status === "failed"))
|
|
96
|
+
return { allowed: false, reason: "checks-failed" };
|
|
97
|
+
if (
|
|
98
|
+
checks.some(
|
|
99
|
+
(check) => check.status === "queued" || check.status === "running",
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return { allowed: false, reason: "checks-running" };
|
|
103
|
+
const approvers = new Set(
|
|
104
|
+
facts.approvals
|
|
105
|
+
.filter(
|
|
106
|
+
(approval) =>
|
|
107
|
+
approval.head === facts.change.head &&
|
|
108
|
+
approval.kind === "human" &&
|
|
109
|
+
independent(facts, approval.actor),
|
|
110
|
+
)
|
|
111
|
+
.map((approval) => approval.actor),
|
|
112
|
+
);
|
|
113
|
+
if (independent(facts, facts.actor.name) || soleSelf(facts))
|
|
114
|
+
approvers.add(facts.actor.name);
|
|
115
|
+
return approvers.size > 0
|
|
116
|
+
? { allowed: true }
|
|
117
|
+
: { allowed: false, reason: "needs-approval" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type Review = Readonly<{
|
|
121
|
+
status: Change["status"];
|
|
122
|
+
decision: PolicyDecision;
|
|
123
|
+
}>;
|
|
124
|
+
export function review(facts: PolicyFacts): Review {
|
|
125
|
+
const decision = decide("merge", facts);
|
|
126
|
+
const state = facts.change.state;
|
|
127
|
+
if (state !== "open") return { status: state, decision };
|
|
128
|
+
if (decision.allowed) return { status: "ready", decision };
|
|
129
|
+
switch (decision.reason) {
|
|
130
|
+
case "conflict":
|
|
131
|
+
case "checks-failed":
|
|
132
|
+
case "checks-running":
|
|
133
|
+
return { status: decision.reason, decision };
|
|
134
|
+
default:
|
|
135
|
+
return { status: "needs-approval", decision };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Host-issued IDs own forks; display names are never authorization facts. */
|
|
140
|
+
export function mayPush(actor: BonsaiActor, repo: RepoSummary | null): boolean {
|
|
141
|
+
return (
|
|
142
|
+
repo !== null &&
|
|
143
|
+
(actor.kind === "human" ||
|
|
144
|
+
(repo.parent !== null && repo.owner === actor.name))
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The acting identity, as the host attributed the call. */
|
|
149
|
+
export function callerActor(caller: AppMethodCallContext): BonsaiActor {
|
|
150
|
+
switch (caller.actor.kind) {
|
|
151
|
+
case "member":
|
|
152
|
+
return caller.soleMember === true
|
|
153
|
+
? { name: caller.actor.memberId, kind: "human", soleMember: true }
|
|
154
|
+
: { name: caller.actor.memberId, kind: "human" };
|
|
155
|
+
case "agent":
|
|
156
|
+
return { name: caller.actor.agentId, kind: "agent" };
|
|
157
|
+
case "team-key":
|
|
158
|
+
return { name: caller.actor.keyId, kind: "agent" };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** App-owned cursors select a page of a saved copy, without provider work. */
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import type { JsonValue } from "../shared/contract.js";
|
|
4
|
+
import { AppMethodRefusal } from "@fungi.computer/agar/backend";
|
|
5
|
+
|
|
6
|
+
const cursor = z.strictObject({
|
|
7
|
+
version: z.literal(1),
|
|
8
|
+
scope: z.string(),
|
|
9
|
+
snapshot: z.string().nullable(),
|
|
10
|
+
offset: z.number().int().nonnegative(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export function readCopyPageOffset(
|
|
14
|
+
after: string | undefined,
|
|
15
|
+
scope: string,
|
|
16
|
+
snapshot: string | null,
|
|
17
|
+
): number {
|
|
18
|
+
if (after === undefined) return 0;
|
|
19
|
+
let decoded: z.output<typeof cursor>;
|
|
20
|
+
try {
|
|
21
|
+
const bytes = Uint8Array.from(atob(after), (char) => char.charCodeAt(0));
|
|
22
|
+
decoded = cursor.parse(
|
|
23
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
|
|
24
|
+
);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new AppMethodRefusal("invalid", "Invalid saved-copy cursor.");
|
|
27
|
+
}
|
|
28
|
+
if (decoded.scope !== scope)
|
|
29
|
+
throw new AppMethodRefusal("invalid", "Cursor belongs to another view.");
|
|
30
|
+
if (decoded.snapshot !== snapshot)
|
|
31
|
+
throw new AppMethodRefusal(
|
|
32
|
+
"conflict",
|
|
33
|
+
"The saved page changed; restart from the first page.",
|
|
34
|
+
);
|
|
35
|
+
return decoded.offset;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sliceReadCopyPage<T>(
|
|
39
|
+
values: readonly T[],
|
|
40
|
+
limit: number,
|
|
41
|
+
offset: number,
|
|
42
|
+
scope: string,
|
|
43
|
+
snapshot: string | null,
|
|
44
|
+
) {
|
|
45
|
+
if (offset > values.length)
|
|
46
|
+
throw new AppMethodRefusal("invalid", "Cursor is outside the saved page.");
|
|
47
|
+
const items = values.slice(offset, offset + limit);
|
|
48
|
+
const nextOffset = offset + items.length;
|
|
49
|
+
const next =
|
|
50
|
+
nextOffset < values.length
|
|
51
|
+
? btoa(
|
|
52
|
+
String.fromCharCode(
|
|
53
|
+
...new TextEncoder().encode(
|
|
54
|
+
JSON.stringify(
|
|
55
|
+
cursor.parse({
|
|
56
|
+
version: 1,
|
|
57
|
+
scope,
|
|
58
|
+
snapshot,
|
|
59
|
+
offset: nextOffset,
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
: null;
|
|
66
|
+
return { items, next };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function copyListSnapshot(value: JsonValue): Promise<string> {
|
|
70
|
+
const digest = await crypto.subtle.digest(
|
|
71
|
+
"SHA-256",
|
|
72
|
+
new TextEncoder().encode(JSON.stringify(value)),
|
|
73
|
+
);
|
|
74
|
+
return Array.from(new Uint8Array(digest), (byte) =>
|
|
75
|
+
byte.toString(16).padStart(2, "0"),
|
|
76
|
+
).join("");
|
|
77
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Mutable read copies share one durable repository-ref readiness gate. */
|
|
2
|
+
import { readCopyFrame } from "./git.js";
|
|
3
|
+
import { AppMethodRefusal } from "@fungi.computer/agar/backend";
|
|
4
|
+
import type { Bonsai } from "./service.js";
|
|
5
|
+
import type { RepoDetail } from "../shared/contract.js";
|
|
6
|
+
|
|
7
|
+
/** Local only: callers may not rebuild a mutable view from pending or missing refs. */
|
|
8
|
+
export function readRepositoryRefs(
|
|
9
|
+
bonsai: Bonsai,
|
|
10
|
+
repo: string,
|
|
11
|
+
): Omit<RepoDetail, "repo"> {
|
|
12
|
+
if (bonsai.state.repo(repo) === null)
|
|
13
|
+
throw new AppMethodRefusal("not_found", `No repository ${repo}`);
|
|
14
|
+
const copy = bonsai.state.repositoryCopies.read(repo);
|
|
15
|
+
if (bonsai.state.repositoryRefStatus(repo).pending || copy.kind !== "ready")
|
|
16
|
+
throw new AppMethodRefusal(
|
|
17
|
+
"unavailable",
|
|
18
|
+
"Repository refs are rebuilding.",
|
|
19
|
+
);
|
|
20
|
+
return copy.value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Foreground metadata publication uses the same revision as its provider read. */
|
|
24
|
+
export async function refreshRepositoryCopy(
|
|
25
|
+
bonsai: Bonsai,
|
|
26
|
+
name: string,
|
|
27
|
+
signal: AbortSignal,
|
|
28
|
+
): Promise<RepoDetail> {
|
|
29
|
+
const summary = bonsai.state.repo(name);
|
|
30
|
+
if (summary === null)
|
|
31
|
+
throw new AppMethodRefusal("not_found", `No repository ${name}`);
|
|
32
|
+
const status = bonsai.state.repositoryRefStatus(name);
|
|
33
|
+
const saved = bonsai.state.repositoryCopies.read(name);
|
|
34
|
+
if (!status.pending && saved.kind === "ready")
|
|
35
|
+
return { repo: summary, ...saved.value };
|
|
36
|
+
const info = await bonsai.store.info(name);
|
|
37
|
+
if (info === null)
|
|
38
|
+
throw new AppMethodRefusal("not_found", "Repository storage is missing");
|
|
39
|
+
const refs = await bonsai.store.refs(name);
|
|
40
|
+
const value = {
|
|
41
|
+
defaultBranch: info.defaultBranch,
|
|
42
|
+
refs: refs.filter((ref) => ref.name !== "HEAD"),
|
|
43
|
+
cloneUrl: await bonsai.store.cloneUrl(name),
|
|
44
|
+
empty: refs.length === 0,
|
|
45
|
+
};
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
if (
|
|
48
|
+
!bonsai.state.publishRepositoryRefs(
|
|
49
|
+
name,
|
|
50
|
+
status.revision,
|
|
51
|
+
refs.find((ref) => ref.name === `refs/heads/${info.defaultBranch}`)
|
|
52
|
+
?.hash ?? null,
|
|
53
|
+
value,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
throw new AppMethodRefusal(
|
|
57
|
+
"unavailable",
|
|
58
|
+
"Repository refs changed during rebuild.",
|
|
59
|
+
);
|
|
60
|
+
return { repo: summary, ...value };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function getRepoDetail(
|
|
64
|
+
bonsai: Bonsai,
|
|
65
|
+
name: string,
|
|
66
|
+
): Promise<RepoDetail> {
|
|
67
|
+
return readCopyFrame(bonsai.store, (store, signal) =>
|
|
68
|
+
refreshRepositoryCopy({ ...bonsai, store }, name, signal),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/** Pinned approvals and check attempts, backed by native receipts. */
|
|
2
|
+
import { replayKey } from "@fungi.computer/cairn";
|
|
3
|
+
import {
|
|
4
|
+
approvalResult,
|
|
5
|
+
checkRequestResult,
|
|
6
|
+
checkReportResult,
|
|
7
|
+
type ChangeRecord,
|
|
8
|
+
type ApprovalResult,
|
|
9
|
+
type CheckRequestResult,
|
|
10
|
+
type CheckReportResult,
|
|
11
|
+
type Check,
|
|
12
|
+
} from "../shared/contract.js";
|
|
13
|
+
import type { HookEvent } from "../shared/hooks.js";
|
|
14
|
+
import { AppMethodRefusal } from "@fungi.computer/agar/backend";
|
|
15
|
+
import { approvalKind, decide } from "./policy.js";
|
|
16
|
+
import { authorsAtHead } from "./history.js";
|
|
17
|
+
import { recoverExpiredLease } from "./merge.js";
|
|
18
|
+
import type { Bonsai, BonsaiActor } from "./service.js";
|
|
19
|
+
|
|
20
|
+
export async function approveChange(
|
|
21
|
+
bonsai: Bonsai,
|
|
22
|
+
actor: BonsaiActor,
|
|
23
|
+
input: { repo: string; number: number; head: string },
|
|
24
|
+
requestKey: string = replayKey(),
|
|
25
|
+
): Promise<ApprovalResult> {
|
|
26
|
+
const key = `approve:${requestKey}`;
|
|
27
|
+
const digest = JSON.stringify({ actor, input });
|
|
28
|
+
const cached = bonsai.state.receipt(key, digest, approvalResult);
|
|
29
|
+
if (cached !== null) {
|
|
30
|
+
return cached.result;
|
|
31
|
+
}
|
|
32
|
+
const initial = await recoverExpiredLease(
|
|
33
|
+
bonsai,
|
|
34
|
+
bonsai.state.requireChange(input.repo, input.number),
|
|
35
|
+
);
|
|
36
|
+
const [authors, cloneUrl] = await Promise.all([
|
|
37
|
+
authorsAtHead(bonsai, initial),
|
|
38
|
+
bonsai.store.cloneUrl(initial.sourceRepo),
|
|
39
|
+
]);
|
|
40
|
+
const result = bonsai.state.transition(key, digest, approvalResult, () => {
|
|
41
|
+
const row = bonsai.state.requireChange(input.repo, input.number);
|
|
42
|
+
if (row.head !== input.head)
|
|
43
|
+
return {
|
|
44
|
+
result: approvalResult.parse({ kind: "moved", head: row.head }),
|
|
45
|
+
events: [],
|
|
46
|
+
};
|
|
47
|
+
const facts = bonsai.state.reviewFacts(actor, row, input.head, authors);
|
|
48
|
+
const decision = decide("approve", facts);
|
|
49
|
+
if (!decision.allowed)
|
|
50
|
+
return {
|
|
51
|
+
result: approvalResult.parse({
|
|
52
|
+
kind: "refused",
|
|
53
|
+
reason: decision.reason,
|
|
54
|
+
}),
|
|
55
|
+
events: [],
|
|
56
|
+
};
|
|
57
|
+
const exists = bonsai.state
|
|
58
|
+
.approvals(row.repo, row.number)
|
|
59
|
+
.some(
|
|
60
|
+
(approval) =>
|
|
61
|
+
approval.head === row.head && approval.actor === actor.name,
|
|
62
|
+
);
|
|
63
|
+
if (exists)
|
|
64
|
+
return { result: approvalResult.parse({ kind: "approved" }), events: [] };
|
|
65
|
+
const next = bonsai.state.reviseChange(
|
|
66
|
+
{ ...row, updatedAt: Date.now() },
|
|
67
|
+
row.revision,
|
|
68
|
+
);
|
|
69
|
+
bonsai.state.approve(row.repo, row.number, {
|
|
70
|
+
actor: actor.name,
|
|
71
|
+
kind: approvalKind(facts),
|
|
72
|
+
head: row.head,
|
|
73
|
+
at: next.updatedAt,
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
result: approvalResult.parse({ kind: "approved" }),
|
|
77
|
+
events: [
|
|
78
|
+
bonsai.state.changeEvent(next, actor.name, "change.approved", cloneUrl),
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function requestChecks(
|
|
86
|
+
bonsai: Bonsai,
|
|
87
|
+
actor: BonsaiActor,
|
|
88
|
+
input: {
|
|
89
|
+
repo: string;
|
|
90
|
+
number: number;
|
|
91
|
+
head: string;
|
|
92
|
+
names?: string[] | undefined;
|
|
93
|
+
},
|
|
94
|
+
requestKey: string = replayKey(),
|
|
95
|
+
): Promise<CheckRequestResult> {
|
|
96
|
+
const key = `checks-request:${requestKey}`;
|
|
97
|
+
const digest = JSON.stringify({ actor, input });
|
|
98
|
+
const cached = bonsai.state.receipt(key, digest, checkRequestResult);
|
|
99
|
+
if (cached !== null) {
|
|
100
|
+
return cached.result;
|
|
101
|
+
}
|
|
102
|
+
const initial = await recoverExpiredLease(
|
|
103
|
+
bonsai,
|
|
104
|
+
bonsai.state.requireChange(input.repo, input.number),
|
|
105
|
+
);
|
|
106
|
+
const cloneUrl = await bonsai.store.cloneUrl(initial.sourceRepo);
|
|
107
|
+
const result = bonsai.state.transition(
|
|
108
|
+
key,
|
|
109
|
+
digest,
|
|
110
|
+
checkRequestResult,
|
|
111
|
+
() => {
|
|
112
|
+
const row = bonsai.state.requireChange(input.repo, input.number);
|
|
113
|
+
if (row.head !== input.head)
|
|
114
|
+
return {
|
|
115
|
+
result: checkRequestResult.parse({ kind: "moved", head: row.head }),
|
|
116
|
+
events: [],
|
|
117
|
+
};
|
|
118
|
+
const decision = decide(
|
|
119
|
+
"requestChecks",
|
|
120
|
+
bonsai.state.reviewFacts(actor, row, input.head, []),
|
|
121
|
+
);
|
|
122
|
+
if (!decision.allowed)
|
|
123
|
+
return {
|
|
124
|
+
result: checkRequestResult.parse({
|
|
125
|
+
kind: "refused",
|
|
126
|
+
reason: decision.reason,
|
|
127
|
+
}),
|
|
128
|
+
events: [],
|
|
129
|
+
};
|
|
130
|
+
const next = bonsai.state.reviseChange(
|
|
131
|
+
{ ...row, updatedAt: Date.now() },
|
|
132
|
+
row.revision,
|
|
133
|
+
);
|
|
134
|
+
const names = [
|
|
135
|
+
...new Set(
|
|
136
|
+
input.names ??
|
|
137
|
+
bonsai.state
|
|
138
|
+
.checks(row.repo, row.number)
|
|
139
|
+
.filter((check) => check.head === row.head)
|
|
140
|
+
.map((check) => check.name),
|
|
141
|
+
),
|
|
142
|
+
];
|
|
143
|
+
const event =
|
|
144
|
+
input.names === undefined
|
|
145
|
+
? bonsai.state.changeEvent(
|
|
146
|
+
next,
|
|
147
|
+
actor.name,
|
|
148
|
+
"change.checks_requested",
|
|
149
|
+
cloneUrl,
|
|
150
|
+
)
|
|
151
|
+
: bonsai.state.changeEvent(
|
|
152
|
+
next,
|
|
153
|
+
actor.name,
|
|
154
|
+
"change.checks_requested",
|
|
155
|
+
cloneUrl,
|
|
156
|
+
{ names },
|
|
157
|
+
);
|
|
158
|
+
for (const name of names)
|
|
159
|
+
bonsai.state.reportCheck(row.repo, row.number, {
|
|
160
|
+
head: row.head,
|
|
161
|
+
name,
|
|
162
|
+
status: "queued",
|
|
163
|
+
reporter: actor.name,
|
|
164
|
+
attemptId: event.data.eventId,
|
|
165
|
+
runUrl: null,
|
|
166
|
+
summary: null,
|
|
167
|
+
updatedAt: next.updatedAt,
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
result: checkRequestResult.parse({
|
|
171
|
+
kind: "requested",
|
|
172
|
+
attemptId: event.data.eventId,
|
|
173
|
+
}),
|
|
174
|
+
events: [event],
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function checkAttempt(
|
|
182
|
+
bonsai: Bonsai,
|
|
183
|
+
row: ChangeRecord,
|
|
184
|
+
attemptId: string,
|
|
185
|
+
): HookEvent | null {
|
|
186
|
+
const event = bonsai.state.event(attemptId);
|
|
187
|
+
if (
|
|
188
|
+
event === null ||
|
|
189
|
+
(event.type !== "change.opened" &&
|
|
190
|
+
event.type !== "change.updated" &&
|
|
191
|
+
event.type !== "change.checks_requested")
|
|
192
|
+
)
|
|
193
|
+
return null;
|
|
194
|
+
return event.data.change.repo === row.repo &&
|
|
195
|
+
event.data.change.number === row.number &&
|
|
196
|
+
event.data.change.head === row.head
|
|
197
|
+
? event
|
|
198
|
+
: null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function reportCheck(
|
|
202
|
+
bonsai: Bonsai,
|
|
203
|
+
actor: BonsaiActor,
|
|
204
|
+
input: {
|
|
205
|
+
repo: string;
|
|
206
|
+
number: number;
|
|
207
|
+
head: string;
|
|
208
|
+
name: string;
|
|
209
|
+
status: Check["status"];
|
|
210
|
+
attemptId: string;
|
|
211
|
+
runUrl?: string | undefined;
|
|
212
|
+
summary?: string | undefined;
|
|
213
|
+
},
|
|
214
|
+
requestKey: string = replayKey(),
|
|
215
|
+
): Promise<CheckReportResult> {
|
|
216
|
+
const key = `check-report:${requestKey}`;
|
|
217
|
+
const digest = JSON.stringify({ actor, input });
|
|
218
|
+
const cached = bonsai.state.receipt(key, digest, checkReportResult);
|
|
219
|
+
if (cached !== null) {
|
|
220
|
+
return cached.result;
|
|
221
|
+
}
|
|
222
|
+
const initial = await recoverExpiredLease(
|
|
223
|
+
bonsai,
|
|
224
|
+
bonsai.state.requireChange(input.repo, input.number),
|
|
225
|
+
);
|
|
226
|
+
const cloneUrl = await bonsai.store.cloneUrl(initial.sourceRepo);
|
|
227
|
+
const result = bonsai.state.transition(key, digest, checkReportResult, () => {
|
|
228
|
+
const row = bonsai.state.requireChange(input.repo, input.number);
|
|
229
|
+
if (row.head !== input.head)
|
|
230
|
+
return {
|
|
231
|
+
result: checkReportResult.parse({ kind: "moved", head: row.head }),
|
|
232
|
+
events: [],
|
|
233
|
+
};
|
|
234
|
+
const decision = decide(
|
|
235
|
+
"reportCheck",
|
|
236
|
+
bonsai.state.reviewFacts(actor, row, input.head, []),
|
|
237
|
+
);
|
|
238
|
+
if (!decision.allowed)
|
|
239
|
+
return {
|
|
240
|
+
result: checkReportResult.parse({
|
|
241
|
+
kind: "refused",
|
|
242
|
+
reason: decision.reason,
|
|
243
|
+
}),
|
|
244
|
+
events: [],
|
|
245
|
+
};
|
|
246
|
+
const attempt = checkAttempt(bonsai, row, input.attemptId);
|
|
247
|
+
if (attempt === null)
|
|
248
|
+
throw new AppMethodRefusal(
|
|
249
|
+
"invalid",
|
|
250
|
+
"Checks require an actual Change trigger event identity",
|
|
251
|
+
);
|
|
252
|
+
const previous = bonsai.state
|
|
253
|
+
.checks(row.repo, row.number)
|
|
254
|
+
.find((check) => check.head === row.head && check.name === input.name);
|
|
255
|
+
const runUrl = input.runUrl ?? null;
|
|
256
|
+
const summary = input.summary ?? null;
|
|
257
|
+
if (previous !== undefined) {
|
|
258
|
+
const previousAttempt = checkAttempt(bonsai, row, previous.attemptId);
|
|
259
|
+
if (
|
|
260
|
+
previousAttempt !== null &&
|
|
261
|
+
previousAttempt.data.activity.id > attempt.data.activity.id
|
|
262
|
+
)
|
|
263
|
+
return {
|
|
264
|
+
result: checkReportResult.parse({ kind: "settled" }),
|
|
265
|
+
events: [],
|
|
266
|
+
};
|
|
267
|
+
if (previous.attemptId === input.attemptId) {
|
|
268
|
+
if (previous.status === "passed" || previous.status === "failed") {
|
|
269
|
+
const same =
|
|
270
|
+
previous.status === input.status &&
|
|
271
|
+
previous.runUrl === runUrl &&
|
|
272
|
+
previous.summary === summary &&
|
|
273
|
+
previous.reporter === actor.name;
|
|
274
|
+
return {
|
|
275
|
+
result: checkReportResult.parse({
|
|
276
|
+
kind: same ? "recorded" : "settled",
|
|
277
|
+
}),
|
|
278
|
+
events: [],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (previous.status === "running" && input.status === "queued")
|
|
282
|
+
return {
|
|
283
|
+
result: checkReportResult.parse({ kind: "settled" }),
|
|
284
|
+
events: [],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const next = bonsai.state.reviseChange(
|
|
289
|
+
{ ...row, updatedAt: Date.now() },
|
|
290
|
+
row.revision,
|
|
291
|
+
);
|
|
292
|
+
bonsai.state.reportCheck(row.repo, row.number, {
|
|
293
|
+
head: row.head,
|
|
294
|
+
name: input.name,
|
|
295
|
+
status: input.status,
|
|
296
|
+
reporter: actor.name,
|
|
297
|
+
attemptId: input.attemptId,
|
|
298
|
+
runUrl,
|
|
299
|
+
summary,
|
|
300
|
+
updatedAt: next.updatedAt,
|
|
301
|
+
});
|
|
302
|
+
const events =
|
|
303
|
+
input.status === "passed" || input.status === "failed"
|
|
304
|
+
? [
|
|
305
|
+
bonsai.state.changeEvent(
|
|
306
|
+
next,
|
|
307
|
+
actor.name,
|
|
308
|
+
"change.checked",
|
|
309
|
+
cloneUrl,
|
|
310
|
+
{ name: input.name, status: input.status },
|
|
311
|
+
),
|
|
312
|
+
]
|
|
313
|
+
: [];
|
|
314
|
+
return { result: checkReportResult.parse({ kind: "recorded" }), events };
|
|
315
|
+
});
|
|
316
|
+
return result;
|
|
317
|
+
}
|