@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,719 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Tooltip,
|
|
3
|
+
TooltipTrigger,
|
|
4
|
+
TooltipContent,
|
|
5
|
+
} from "@fungi.computer/caps/components/tooltip";
|
|
6
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
8
|
+
import { Alert } from "@fungi.computer/caps/components/alert";
|
|
9
|
+
import { Button } from "@fungi.computer/caps/components/button";
|
|
10
|
+
import {
|
|
11
|
+
DataList,
|
|
12
|
+
DataListHeading,
|
|
13
|
+
DataRow,
|
|
14
|
+
} from "@fungi.computer/caps/components/data-list";
|
|
15
|
+
import { InlineCode } from "@fungi.computer/caps/components/inline-code";
|
|
16
|
+
import { Status } from "@fungi.computer/caps/components/status";
|
|
17
|
+
import type { MergeResult, PolicyDecision } from "../shared/contract.js";
|
|
18
|
+
import {
|
|
19
|
+
useAgents,
|
|
20
|
+
useChange,
|
|
21
|
+
useChanges,
|
|
22
|
+
useEvents,
|
|
23
|
+
useGraph,
|
|
24
|
+
useRepo,
|
|
25
|
+
mergeChange,
|
|
26
|
+
approveChange,
|
|
27
|
+
closeChange,
|
|
28
|
+
} from "./api.js";
|
|
29
|
+
import { BonsaiBoard, type BoardTile } from "./board.js";
|
|
30
|
+
import { NetworkGraph } from "./network-graph.js";
|
|
31
|
+
import {
|
|
32
|
+
AgentMark,
|
|
33
|
+
AgentName,
|
|
34
|
+
agentColors,
|
|
35
|
+
Failure,
|
|
36
|
+
Loading,
|
|
37
|
+
} from "./parts.js";
|
|
38
|
+
import { firstLine, relativeTime, shortHash } from "./format.js";
|
|
39
|
+
import { href, Link, useRouter } from "./router.js";
|
|
40
|
+
import {
|
|
41
|
+
changeStatusLabel,
|
|
42
|
+
changeStatusTone,
|
|
43
|
+
needsAttention,
|
|
44
|
+
} from "./change-rows.js";
|
|
45
|
+
import { RepoNavigation } from "./repo-navigation.js";
|
|
46
|
+
import { useWorkingNow } from "./working-now.js";
|
|
47
|
+
import { DiffView, Markdown } from "./views.js";
|
|
48
|
+
|
|
49
|
+
function policyMessage(
|
|
50
|
+
reason: Extract<PolicyDecision, { allowed: false }>["reason"],
|
|
51
|
+
head: string,
|
|
52
|
+
): string {
|
|
53
|
+
const messages = {
|
|
54
|
+
"not-open": "This Change is no longer open.",
|
|
55
|
+
merging: "A merge is already in progress.",
|
|
56
|
+
moved: "The proposed head moved. Review the latest commit.",
|
|
57
|
+
conflict: "Resolve the conflicts and push the source branch again.",
|
|
58
|
+
"needs-approval":
|
|
59
|
+
"Needs one approval from someone who did not write this work.",
|
|
60
|
+
"self-approval": "Someone who did not write this work must approve it.",
|
|
61
|
+
"checks-failed": `Checks failed on ${shortHash(head)}.`,
|
|
62
|
+
"checks-running": `Checks are still running on ${shortHash(head)}.`,
|
|
63
|
+
"not-maintainer": "Only a repository maintainer can do this.",
|
|
64
|
+
"agents-cannot-merge": "A person must merge this Change.",
|
|
65
|
+
"agents-cannot-approve":
|
|
66
|
+
"A person must approve this Change. Agent reviews are checks.",
|
|
67
|
+
"not-source-writer": "You need write access to the source repository.",
|
|
68
|
+
"not-authorized": "You do not have permission to do this.",
|
|
69
|
+
} satisfies Record<
|
|
70
|
+
Extract<PolicyDecision, { allowed: false }>["reason"],
|
|
71
|
+
string
|
|
72
|
+
>;
|
|
73
|
+
return messages[reason];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mergeMessage(result: MergeResult, head: string): string {
|
|
77
|
+
switch (result.kind) {
|
|
78
|
+
case "merging":
|
|
79
|
+
return "Merging…";
|
|
80
|
+
case "merged":
|
|
81
|
+
return `Merged as ${shortHash(result.after)}.`;
|
|
82
|
+
case "moved":
|
|
83
|
+
// Covers both a moved source head and an exhausted stale-CAS retry
|
|
84
|
+
// against a moved target: `result.head` is only meaningful for the
|
|
85
|
+
// former, so the wording names neither specifically.
|
|
86
|
+
return "The merge did not complete because the branch moved. Review the refreshed Change and merge again.";
|
|
87
|
+
case "conflict":
|
|
88
|
+
return `Conflicts in ${result.paths.join(", ")}. Update the source branch, resolve and push again.`;
|
|
89
|
+
case "refused":
|
|
90
|
+
return `Merge refused: ${policyMessage(result.reason, head)}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ChangePage({ repo, number }: { repo: string; number: number }) {
|
|
95
|
+
const detail = useChange(repo, number);
|
|
96
|
+
const list = useChanges(repo);
|
|
97
|
+
const graph = useGraph(repo);
|
|
98
|
+
const repoDetail = useRepo(repo);
|
|
99
|
+
const events = useEvents(repo);
|
|
100
|
+
const recentPushes = useWorkingNow(events.data?.events ?? []);
|
|
101
|
+
const agents = useAgents();
|
|
102
|
+
const colors = useMemo(() => agentColors(agents.data?.agents), [agents.data]);
|
|
103
|
+
const client = useQueryClient();
|
|
104
|
+
const { go } = useRouter();
|
|
105
|
+
const [pending, setPending] = useState<"merge" | "approve" | "close" | null>(
|
|
106
|
+
null,
|
|
107
|
+
);
|
|
108
|
+
const merging =
|
|
109
|
+
pending === "merge" || detail.data?.change.state === "merging";
|
|
110
|
+
const waitingForMerge = detail.data?.change.state === "merging";
|
|
111
|
+
const refreshChange = detail.refetch;
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!waitingForMerge) return;
|
|
114
|
+
const timer = window.setInterval(() => {
|
|
115
|
+
void refreshChange();
|
|
116
|
+
}, 1000);
|
|
117
|
+
return () => {
|
|
118
|
+
window.clearInterval(timer);
|
|
119
|
+
};
|
|
120
|
+
}, [waitingForMerge, refreshChange]);
|
|
121
|
+
// Tracks whether polling has actually observed this Change as "merging",
|
|
122
|
+
// so a settled background merge can be told apart from the brief window
|
|
123
|
+
// right after the click, before the first refetch lands.
|
|
124
|
+
const sawMerging = useRef(false);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (waitingForMerge) sawMerging.current = true;
|
|
127
|
+
}, [waitingForMerge]);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
sawMerging.current = false;
|
|
130
|
+
}, [repo, number]);
|
|
131
|
+
const [wide, setWide] = useState(false);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const media = matchMedia("(min-width: 1024px)");
|
|
134
|
+
const update = () => {
|
|
135
|
+
setWide(media.matches);
|
|
136
|
+
};
|
|
137
|
+
update();
|
|
138
|
+
media.addEventListener("change", update);
|
|
139
|
+
return () => {
|
|
140
|
+
media.removeEventListener("change", update);
|
|
141
|
+
};
|
|
142
|
+
}, []);
|
|
143
|
+
const [outcome, setOutcome] = useState<string | null>(null);
|
|
144
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
145
|
+
// RepoNavigation only shows this target while its own route.tab is
|
|
146
|
+
// "overview" (never true here, the Change page always passes "changes"),
|
|
147
|
+
// so the slot stays unused; same `controlsTarget` plumbing as pages.tsx.
|
|
148
|
+
const [, setControlsTarget] = useState<HTMLDivElement | null>(null);
|
|
149
|
+
const changes = list.data?.changes ?? [];
|
|
150
|
+
const index = changes.findIndex(
|
|
151
|
+
(change) => change.repo === repo && change.number === number,
|
|
152
|
+
);
|
|
153
|
+
const previous = index > 0 ? changes[index - 1] : undefined;
|
|
154
|
+
const next = index >= 0 ? changes[index + 1] : undefined;
|
|
155
|
+
const openChangeCount = changes.filter(
|
|
156
|
+
(item) => item.state === "open" || item.state === "merging",
|
|
157
|
+
).length;
|
|
158
|
+
const changesNeedAttention = changes.some(needsAttention);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const keydown = (event: KeyboardEvent) => {
|
|
161
|
+
if (
|
|
162
|
+
event.defaultPrevented ||
|
|
163
|
+
event.repeat ||
|
|
164
|
+
event.metaKey ||
|
|
165
|
+
event.ctrlKey ||
|
|
166
|
+
event.altKey ||
|
|
167
|
+
pending !== null
|
|
168
|
+
)
|
|
169
|
+
return;
|
|
170
|
+
if (
|
|
171
|
+
event.target instanceof HTMLElement &&
|
|
172
|
+
event.target.closest(
|
|
173
|
+
"input,textarea,select,[contenteditable=true],[role=textbox]",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
return;
|
|
177
|
+
const target =
|
|
178
|
+
event.key === "[" ? previous : event.key === "]" ? next : undefined;
|
|
179
|
+
if (!target) return;
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
go(href({ page: "change", repo: target.repo, number: target.number }));
|
|
182
|
+
};
|
|
183
|
+
document.addEventListener("keydown", keydown);
|
|
184
|
+
return () => {
|
|
185
|
+
document.removeEventListener("keydown", keydown);
|
|
186
|
+
};
|
|
187
|
+
}, [previous, next, go, pending]);
|
|
188
|
+
// A Change review is never blocked by the repo/activity reads behind the
|
|
189
|
+
// nav header: those only decorate RepoNavigation, which degrades to
|
|
190
|
+
// nothing on its own (below) if repos.get or activity.list is pending or
|
|
191
|
+
// failed. Only the Change's own query gates this page.
|
|
192
|
+
if (detail.isPending) return <Loading label="Loading change" />;
|
|
193
|
+
if (detail.isError)
|
|
194
|
+
return (
|
|
195
|
+
<Failure
|
|
196
|
+
error={detail.error}
|
|
197
|
+
retry={() => {
|
|
198
|
+
void detail.refetch();
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
);
|
|
202
|
+
const { change, commits, diff, review, actions } = detail.data;
|
|
203
|
+
const conflict = change.conflict;
|
|
204
|
+
// A settled background merge always leaves a typed mergeFailure (caught
|
|
205
|
+
// below -- access_changed, sync_failed, or head_moved) or a conflict
|
|
206
|
+
// matching the current head; the last branch below is defensive, so a
|
|
207
|
+
// stuck "Merging…" never has nothing to show once polling has actually
|
|
208
|
+
// observed this Change as "merging" and its state has since left that.
|
|
209
|
+
// (Before that observation, the row may still be showing the pre-click
|
|
210
|
+
// snapshot; `sawMerging` tells the two apart.)
|
|
211
|
+
const displayedOutcome =
|
|
212
|
+
change.mergeFailure === "access_changed"
|
|
213
|
+
? "Merge failed: access changed"
|
|
214
|
+
: change.mergeFailure === "sync_failed"
|
|
215
|
+
? "Merge failed · Retry"
|
|
216
|
+
: change.mergeFailure === "head_moved"
|
|
217
|
+
? change.mergedCommit !== null
|
|
218
|
+
? `Merged as ${shortHash(change.mergedCommit)}. Review the new head before merging again.`
|
|
219
|
+
: "The source moved. Review the refreshed Change and merge again."
|
|
220
|
+
: outcome === "Merging…" && change.state !== "merging"
|
|
221
|
+
? change.state === "merged" && change.mergedCommit !== null
|
|
222
|
+
? `Merged as ${shortHash(change.mergedCommit)}.`
|
|
223
|
+
: conflict !== null && conflict.head === change.head
|
|
224
|
+
? mergeMessage(
|
|
225
|
+
{
|
|
226
|
+
kind: "conflict",
|
|
227
|
+
paths: conflict.paths,
|
|
228
|
+
head: change.head,
|
|
229
|
+
},
|
|
230
|
+
change.head,
|
|
231
|
+
)
|
|
232
|
+
: sawMerging.current
|
|
233
|
+
? "The merge did not complete. Review the Change and try again."
|
|
234
|
+
: outcome
|
|
235
|
+
: outcome;
|
|
236
|
+
const perform = async (action: "merge" | "approve" | "close") => {
|
|
237
|
+
setPending(action);
|
|
238
|
+
setOutcome(null);
|
|
239
|
+
setActionError(null);
|
|
240
|
+
try {
|
|
241
|
+
if (action === "merge") {
|
|
242
|
+
// A second merge click starts a fresh observation window: without
|
|
243
|
+
// this, a prior merge's "seen merging" would stick, and the
|
|
244
|
+
// defensive fallback above could flash before this click's own
|
|
245
|
+
// first poll confirms "merging" again.
|
|
246
|
+
sawMerging.current = false;
|
|
247
|
+
const result = await mergeChange(
|
|
248
|
+
change.repo,
|
|
249
|
+
change.number,
|
|
250
|
+
change.head,
|
|
251
|
+
);
|
|
252
|
+
setOutcome(mergeMessage(result, change.head));
|
|
253
|
+
} else if (action === "approve") {
|
|
254
|
+
const result = await approveChange(
|
|
255
|
+
change.repo,
|
|
256
|
+
change.number,
|
|
257
|
+
change.head,
|
|
258
|
+
);
|
|
259
|
+
setOutcome(
|
|
260
|
+
result.kind === "approved"
|
|
261
|
+
? `Approved ${shortHash(change.head)}.`
|
|
262
|
+
: result.kind === "moved"
|
|
263
|
+
? `The reviewed head moved to ${shortHash(result.head)}. Review the refreshed Change before approving.`
|
|
264
|
+
: `Approval refused: ${policyMessage(result.reason, change.head)}`,
|
|
265
|
+
);
|
|
266
|
+
} else {
|
|
267
|
+
const result = await closeChange(change.repo, change.number);
|
|
268
|
+
setOutcome(
|
|
269
|
+
result.allowed
|
|
270
|
+
? "Change closed."
|
|
271
|
+
: `Close refused: ${policyMessage(result.reason, change.head)}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
await Promise.all(
|
|
275
|
+
["change", "changes", "graph", "events", "repo"].map((key) =>
|
|
276
|
+
client.invalidateQueries({ queryKey: [key, repo] }),
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
setActionError(
|
|
281
|
+
error instanceof Error ? error.message : "The action failed.",
|
|
282
|
+
);
|
|
283
|
+
} finally {
|
|
284
|
+
setPending(null);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const laneIds = new Set(
|
|
288
|
+
graph.data?.lanes
|
|
289
|
+
.filter(
|
|
290
|
+
(lane) =>
|
|
291
|
+
(lane.repo === change.repo && lane.ref === change.targetRef) ||
|
|
292
|
+
(lane.repo === change.sourceRepo && lane.ref === change.sourceRef),
|
|
293
|
+
)
|
|
294
|
+
.map((lane) => lane.id),
|
|
295
|
+
);
|
|
296
|
+
const laneGraph = graph.data
|
|
297
|
+
? {
|
|
298
|
+
...graph.data,
|
|
299
|
+
lanes: graph.data.lanes.filter((lane) => laneIds.has(lane.id)),
|
|
300
|
+
nodes: graph.data.nodes.filter((node) =>
|
|
301
|
+
node.lanes.some((id) => laneIds.has(id)),
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
: null;
|
|
305
|
+
// Nothing real to draw: an empty lane graph (0 lanes and nodes) is as
|
|
306
|
+
// good as none for this tile, same as `laneGraph === null`.
|
|
307
|
+
const hasLaneGraph =
|
|
308
|
+
laneGraph !== null &&
|
|
309
|
+
(laneGraph.lanes.length > 0 || laneGraph.nodes.length > 0);
|
|
310
|
+
const fileCount = diff.files.length + diff.omittedFiles;
|
|
311
|
+
const sourceOwner =
|
|
312
|
+
laneGraph?.lanes.find(
|
|
313
|
+
(lane) =>
|
|
314
|
+
lane.repo === change.sourceRepo && lane.ref === change.sourceRef,
|
|
315
|
+
)?.owner ?? change.sourceRepo;
|
|
316
|
+
const mergeReason = change.mergeable.allowed
|
|
317
|
+
? undefined
|
|
318
|
+
: policyMessage(change.mergeable.reason, change.head);
|
|
319
|
+
const mergeButton = (
|
|
320
|
+
<Button
|
|
321
|
+
variant="primary"
|
|
322
|
+
loading={merging}
|
|
323
|
+
disabled={!change.mergeable.allowed || pending !== null}
|
|
324
|
+
onClick={() => void perform("merge")}
|
|
325
|
+
>
|
|
326
|
+
{merging ? "Merging…" : `Merge into ${change.targetRef}`}
|
|
327
|
+
</Button>
|
|
328
|
+
);
|
|
329
|
+
const currentChecks = review.checks.filter(
|
|
330
|
+
(check) => check.head === change.head,
|
|
331
|
+
);
|
|
332
|
+
const tiles: BoardTile[] = [
|
|
333
|
+
{
|
|
334
|
+
id: "head",
|
|
335
|
+
title: "Change",
|
|
336
|
+
autoHeight: true,
|
|
337
|
+
status: (
|
|
338
|
+
<Status
|
|
339
|
+
label={changeStatusLabel(change.status)}
|
|
340
|
+
tone={changeStatusTone[change.status]}
|
|
341
|
+
size="compact"
|
|
342
|
+
/>
|
|
343
|
+
),
|
|
344
|
+
body: (
|
|
345
|
+
<div className="space-y-3">
|
|
346
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
347
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
348
|
+
<AgentMark name={change.openedBy} colors={colors} size="lg" />
|
|
349
|
+
<div className="min-w-0">
|
|
350
|
+
<h1 className="caps-card-title">
|
|
351
|
+
#{change.number} {change.title}
|
|
352
|
+
</h1>
|
|
353
|
+
<p className="text-sm">
|
|
354
|
+
<AgentName name={change.openedBy} colors={colors} /> wants to
|
|
355
|
+
merge{" "}
|
|
356
|
+
<InlineCode>
|
|
357
|
+
{change.sourceRepo}/{change.sourceRef}
|
|
358
|
+
</InlineCode>{" "}
|
|
359
|
+
into{" "}
|
|
360
|
+
<InlineCode>
|
|
361
|
+
{change.repo}/{change.targetRef}
|
|
362
|
+
</InlineCode>
|
|
363
|
+
</p>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
367
|
+
{actions.approve.allowed ? (
|
|
368
|
+
<Button
|
|
369
|
+
variant="outline"
|
|
370
|
+
disabled={pending !== null}
|
|
371
|
+
onClick={() => void perform("approve")}
|
|
372
|
+
>
|
|
373
|
+
{pending === "approve" ? "Approving…" : "Approve"}
|
|
374
|
+
</Button>
|
|
375
|
+
) : null}
|
|
376
|
+
{actions.close.allowed ? (
|
|
377
|
+
<Button
|
|
378
|
+
variant="outline"
|
|
379
|
+
disabled={pending !== null}
|
|
380
|
+
onClick={() => void perform("close")}
|
|
381
|
+
>
|
|
382
|
+
{pending === "close" ? "Closing…" : "Close"}
|
|
383
|
+
</Button>
|
|
384
|
+
) : null}
|
|
385
|
+
{change.mergeable.allowed ? (
|
|
386
|
+
mergeButton
|
|
387
|
+
) : (
|
|
388
|
+
<Tooltip>
|
|
389
|
+
<TooltipTrigger asChild>
|
|
390
|
+
<span tabIndex={0} aria-label={mergeReason}>
|
|
391
|
+
{mergeButton}
|
|
392
|
+
</span>
|
|
393
|
+
</TooltipTrigger>
|
|
394
|
+
<TooltipContent>{mergeReason}</TooltipContent>
|
|
395
|
+
</Tooltip>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<p className="text-sm text-[var(--stipe-fg-muted)]">
|
|
400
|
+
{change.ahead} ahead · {change.behind} behind · {fileCount}{" "}
|
|
401
|
+
{fileCount === 1 ? "file" : "files"} · +{diff.additions} / −
|
|
402
|
+
{diff.deletions} · updated {relativeTime(change.updatedAt)} · head{" "}
|
|
403
|
+
<InlineCode>{shortHash(change.head)}</InlineCode>
|
|
404
|
+
</p>
|
|
405
|
+
{change.body ? <Markdown text={change.body} /> : null}
|
|
406
|
+
{change.conflict ? (
|
|
407
|
+
<Alert
|
|
408
|
+
tone="warning"
|
|
409
|
+
title="Resolve conflicts before merging"
|
|
410
|
+
description={`Bonsai recorded conflicts in ${change.conflict.paths.join(", ")}. Update ${sourceOwner}/${change.sourceRef} from ${change.targetRef}, resolve the files and push again.`}
|
|
411
|
+
/>
|
|
412
|
+
) : !change.mergeable.allowed ? (
|
|
413
|
+
<p className="text-sm text-[var(--stipe-fg-muted)]">
|
|
414
|
+
{mergeReason}
|
|
415
|
+
</p>
|
|
416
|
+
) : null}
|
|
417
|
+
{displayedOutcome ? <p role="status">{displayedOutcome}</p> : null}
|
|
418
|
+
{actionError ? (
|
|
419
|
+
<Alert tone="error" description={actionError} />
|
|
420
|
+
) : null}
|
|
421
|
+
</div>
|
|
422
|
+
),
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
id: "lane",
|
|
426
|
+
title: "Lane",
|
|
427
|
+
autoHeight: true,
|
|
428
|
+
body: (
|
|
429
|
+
<>
|
|
430
|
+
{graph.isError ? (
|
|
431
|
+
<Failure
|
|
432
|
+
error={graph.error}
|
|
433
|
+
retry={() => {
|
|
434
|
+
void graph.refetch();
|
|
435
|
+
}}
|
|
436
|
+
/>
|
|
437
|
+
) : graph.isPending ? (
|
|
438
|
+
<Loading label="Loading lane" />
|
|
439
|
+
) : hasLaneGraph ? (
|
|
440
|
+
<NetworkGraph
|
|
441
|
+
shortLabels
|
|
442
|
+
graph={laneGraph}
|
|
443
|
+
colors={colors}
|
|
444
|
+
fresh={new Set()}
|
|
445
|
+
onSelect={(node) => {
|
|
446
|
+
go(
|
|
447
|
+
href({
|
|
448
|
+
page: "commit",
|
|
449
|
+
repo: node.origin ?? change.sourceRepo,
|
|
450
|
+
hash: node.hash,
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
}}
|
|
454
|
+
/>
|
|
455
|
+
) : null}
|
|
456
|
+
<p className="text-xs text-[var(--stipe-fg-muted)]">
|
|
457
|
+
Reviewed head <InlineCode>{shortHash(change.head)}</InlineCode> ·
|
|
458
|
+
target <InlineCode>{shortHash(change.target)}</InlineCode>
|
|
459
|
+
{change.base
|
|
460
|
+
? ` · base ${shortHash(change.base)}`
|
|
461
|
+
: " · no shared base"}
|
|
462
|
+
</p>
|
|
463
|
+
</>
|
|
464
|
+
),
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
id: "commits",
|
|
468
|
+
title: "Commits",
|
|
469
|
+
autoHeight: true,
|
|
470
|
+
body: (
|
|
471
|
+
<DataList>
|
|
472
|
+
{commits.map(({ commit, agent }) => (
|
|
473
|
+
<DataRow key={commit.hash}>
|
|
474
|
+
<div className="min-w-0">
|
|
475
|
+
<Link
|
|
476
|
+
to={href({
|
|
477
|
+
page: "commit",
|
|
478
|
+
repo: change.sourceRepo,
|
|
479
|
+
hash: commit.hash,
|
|
480
|
+
})}
|
|
481
|
+
>
|
|
482
|
+
{firstLine(commit.message)}
|
|
483
|
+
</Link>
|
|
484
|
+
<p className="text-xs text-[var(--stipe-fg-muted)]">
|
|
485
|
+
{agent ? (
|
|
486
|
+
<AgentName name={agent} colors={colors} />
|
|
487
|
+
) : (
|
|
488
|
+
commit.author.name
|
|
489
|
+
)}{" "}
|
|
490
|
+
· {shortHash(commit.hash)} ·{" "}
|
|
491
|
+
{relativeTime(commit.committedAt * 1000)}
|
|
492
|
+
</p>
|
|
493
|
+
</div>
|
|
494
|
+
</DataRow>
|
|
495
|
+
))}
|
|
496
|
+
</DataList>
|
|
497
|
+
),
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
id: "files",
|
|
501
|
+
title: "Files",
|
|
502
|
+
autoHeight: true,
|
|
503
|
+
body: (
|
|
504
|
+
<>
|
|
505
|
+
<DataList>
|
|
506
|
+
{diff.files.map((file) => (
|
|
507
|
+
<DataRow key={file.path}>
|
|
508
|
+
<Button
|
|
509
|
+
variant="link"
|
|
510
|
+
size="sm"
|
|
511
|
+
className="min-w-0 max-w-full justify-start px-0"
|
|
512
|
+
onClick={() =>
|
|
513
|
+
document
|
|
514
|
+
.getElementById(`change-file-${file.path}`)
|
|
515
|
+
?.scrollIntoView({ block: "start" })
|
|
516
|
+
}
|
|
517
|
+
>
|
|
518
|
+
<span className="truncate">{file.path}</span>
|
|
519
|
+
</Button>
|
|
520
|
+
<span className="text-xs text-[var(--stipe-fg-muted)]">
|
|
521
|
+
+{file.additions} / −{file.deletions}
|
|
522
|
+
</span>
|
|
523
|
+
</DataRow>
|
|
524
|
+
))}
|
|
525
|
+
</DataList>
|
|
526
|
+
{diff.omittedFiles > 0 ? (
|
|
527
|
+
<p>{diff.omittedFiles} files omitted from this bounded diff.</p>
|
|
528
|
+
) : null}
|
|
529
|
+
</>
|
|
530
|
+
),
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
id: "diff",
|
|
534
|
+
title: "Diff",
|
|
535
|
+
autoHeight: true,
|
|
536
|
+
body: diff.files.length ? (
|
|
537
|
+
<div className="space-y-4">
|
|
538
|
+
{diff.files.map((file) => (
|
|
539
|
+
<section key={file.path} id={`change-file-${file.path}`}>
|
|
540
|
+
<DiffView file={file} />
|
|
541
|
+
</section>
|
|
542
|
+
))}
|
|
543
|
+
</div>
|
|
544
|
+
) : (
|
|
545
|
+
<p>No changed files in this comparison.</p>
|
|
546
|
+
),
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
id: "review",
|
|
550
|
+
title: "Review",
|
|
551
|
+
autoHeight: true,
|
|
552
|
+
status:
|
|
553
|
+
currentChecks.length > 0 ? (
|
|
554
|
+
<span className="text-xs text-[var(--stipe-fg-muted)]">
|
|
555
|
+
{currentChecks.filter((check) => check.status === "passed").length}{" "}
|
|
556
|
+
of {currentChecks.length} passed
|
|
557
|
+
</span>
|
|
558
|
+
) : undefined,
|
|
559
|
+
body: (
|
|
560
|
+
<div className="space-y-3">
|
|
561
|
+
{review.checks.length > 0 ? (
|
|
562
|
+
<section>
|
|
563
|
+
<DataListHeading level={3}>Checks</DataListHeading>
|
|
564
|
+
<DataList>
|
|
565
|
+
{review.checks.map((check) => (
|
|
566
|
+
<DataRow
|
|
567
|
+
key={`${check.head}:${check.name}:${check.attemptId}`}
|
|
568
|
+
>
|
|
569
|
+
<div className="min-w-0">
|
|
570
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
571
|
+
<Status
|
|
572
|
+
size="compact"
|
|
573
|
+
tone={
|
|
574
|
+
check.status === "passed"
|
|
575
|
+
? "success"
|
|
576
|
+
: check.status === "failed"
|
|
577
|
+
? "error"
|
|
578
|
+
: check.status === "running"
|
|
579
|
+
? "info"
|
|
580
|
+
: "neutral"
|
|
581
|
+
}
|
|
582
|
+
motion={
|
|
583
|
+
check.status === "running" ? "bounce" : "none"
|
|
584
|
+
}
|
|
585
|
+
label={check.status}
|
|
586
|
+
/>
|
|
587
|
+
<span>{check.name}</span>
|
|
588
|
+
</div>
|
|
589
|
+
<p className="text-xs text-[var(--stipe-fg-muted)]">
|
|
590
|
+
<AgentName name={check.reporter} colors={colors} /> ·{" "}
|
|
591
|
+
{relativeTime(check.updatedAt)}
|
|
592
|
+
{check.head !== change.head
|
|
593
|
+
? ` · ${shortHash(check.head)} · before the latest push`
|
|
594
|
+
: ""}
|
|
595
|
+
</p>
|
|
596
|
+
{check.summary ? (
|
|
597
|
+
<p className="text-xs text-[var(--stipe-fg-muted)]">
|
|
598
|
+
{check.summary}
|
|
599
|
+
</p>
|
|
600
|
+
) : null}
|
|
601
|
+
{check.runUrl ? (
|
|
602
|
+
<a
|
|
603
|
+
className="text-xs"
|
|
604
|
+
href={check.runUrl}
|
|
605
|
+
target="_blank"
|
|
606
|
+
rel="noreferrer"
|
|
607
|
+
>
|
|
608
|
+
Run ›
|
|
609
|
+
</a>
|
|
610
|
+
) : null}
|
|
611
|
+
</div>
|
|
612
|
+
</DataRow>
|
|
613
|
+
))}
|
|
614
|
+
</DataList>
|
|
615
|
+
</section>
|
|
616
|
+
) : null}
|
|
617
|
+
{review.approvals.length > 0 ? (
|
|
618
|
+
<section>
|
|
619
|
+
<DataListHeading level={3}>Approvals</DataListHeading>
|
|
620
|
+
<DataList>
|
|
621
|
+
{review.approvals.map((approval) => (
|
|
622
|
+
<DataRow key={`${approval.actor}:${approval.head}`}>
|
|
623
|
+
{approval.head === change.head ? (
|
|
624
|
+
<span>
|
|
625
|
+
<AgentName name={approval.actor} colors={colors} /> ·{" "}
|
|
626
|
+
{approval.kind === "self" ? "self-approved · " : null}
|
|
627
|
+
{relativeTime(approval.at)}
|
|
628
|
+
</span>
|
|
629
|
+
) : (
|
|
630
|
+
<span className="text-xs text-[var(--stipe-fg-muted)]">
|
|
631
|
+
{approval.actor}{" "}
|
|
632
|
+
{approval.kind === "self"
|
|
633
|
+
? "self-approved"
|
|
634
|
+
: "approved"}{" "}
|
|
635
|
+
{shortHash(approval.head)} · before the latest push
|
|
636
|
+
</span>
|
|
637
|
+
)}
|
|
638
|
+
</DataRow>
|
|
639
|
+
))}
|
|
640
|
+
</DataList>
|
|
641
|
+
</section>
|
|
642
|
+
) : null}
|
|
643
|
+
</div>
|
|
644
|
+
),
|
|
645
|
+
},
|
|
646
|
+
];
|
|
647
|
+
const shown = tiles.filter(
|
|
648
|
+
(tile) =>
|
|
649
|
+
(tile.id !== "review" ||
|
|
650
|
+
review.checks.length > 0 ||
|
|
651
|
+
review.approvals.length > 0) &&
|
|
652
|
+
// Nothing real to show once settled: hide the whole tile (heading,
|
|
653
|
+
// zoom controls included), not just the body's inner spinner.
|
|
654
|
+
(tile.id !== "lane" || graph.isPending || graph.isError || hasLaneGraph),
|
|
655
|
+
);
|
|
656
|
+
const board = (ids: readonly string[]) => (
|
|
657
|
+
<BonsaiBoard
|
|
658
|
+
placements={ids
|
|
659
|
+
.filter((id) => shown.some((tile) => tile.id === id))
|
|
660
|
+
.map((id, row) => ({ id, col: 0, row, w: 1, h: 1 }))}
|
|
661
|
+
tiles={shown.filter((tile) => ids.includes(tile.id))}
|
|
662
|
+
columns={1}
|
|
663
|
+
rowHeight={0}
|
|
664
|
+
/>
|
|
665
|
+
);
|
|
666
|
+
return (
|
|
667
|
+
<div className="space-y-3">
|
|
668
|
+
{repoDetail.data ? (
|
|
669
|
+
<RepoNavigation
|
|
670
|
+
detail={repoDetail.data}
|
|
671
|
+
route={{
|
|
672
|
+
page: "repo",
|
|
673
|
+
repo,
|
|
674
|
+
tab: "changes",
|
|
675
|
+
ref: null,
|
|
676
|
+
path: "",
|
|
677
|
+
file: null,
|
|
678
|
+
}}
|
|
679
|
+
working={recentPushes.length}
|
|
680
|
+
changes={openChangeCount}
|
|
681
|
+
needsAttention={changesNeedAttention}
|
|
682
|
+
controlsTarget={setControlsTarget}
|
|
683
|
+
/>
|
|
684
|
+
) : null}
|
|
685
|
+
<nav
|
|
686
|
+
aria-label="Change neighbours"
|
|
687
|
+
className="flex flex-wrap gap-4 text-sm"
|
|
688
|
+
>
|
|
689
|
+
{previous ? (
|
|
690
|
+
<Link
|
|
691
|
+
to={href({
|
|
692
|
+
page: "change",
|
|
693
|
+
repo: previous.repo,
|
|
694
|
+
number: previous.number,
|
|
695
|
+
})}
|
|
696
|
+
>
|
|
697
|
+
Previous change #{previous.number}
|
|
698
|
+
</Link>
|
|
699
|
+
) : null}
|
|
700
|
+
{next ? (
|
|
701
|
+
<Link
|
|
702
|
+
to={href({ page: "change", repo: next.repo, number: next.number })}
|
|
703
|
+
>
|
|
704
|
+
Next change #{next.number}
|
|
705
|
+
</Link>
|
|
706
|
+
) : null}
|
|
707
|
+
</nav>
|
|
708
|
+
{board(["head"])}
|
|
709
|
+
{wide ? (
|
|
710
|
+
<div className="grid items-start gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,3fr)]">
|
|
711
|
+
<div className="min-w-0">{board(["lane", "commits", "files"])}</div>
|
|
712
|
+
<div className="min-w-0">{board(["diff", "review"])}</div>
|
|
713
|
+
</div>
|
|
714
|
+
) : (
|
|
715
|
+
board(["review", "diff", "lane", "commits", "files"])
|
|
716
|
+
)}
|
|
717
|
+
</div>
|
|
718
|
+
);
|
|
719
|
+
}
|