1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId, MergebotDeployments, MergebotMergedCommit } from "@t3tools/contracts";
3import { CircleDashedIcon, CircleXIcon, ExternalLinkIcon } from "lucide-react";
4import { useState } from "react";
5
6import { cn } from "../../lib/utils";
7import { useEnvironment } from "../../state/environments";
8import { mergebotEnvironment } from "../../state/mergebot";
9import { useAtomCommand } from "../../state/use-atom-command";
10import { useNowMinute } from "../../hooks/useNowMinute";
11import { formatRelativeTimeLabel } from "../../timestampFormat";
12import { PullRequestActorAvatar } from "../pullRequest/pullRequestPresentation";
13import {
14 AlertDialog,
15 AlertDialogClose,
16 AlertDialogDescription,
17 AlertDialogFooter,
18 AlertDialogHeader,
19 AlertDialogPopup,
20 AlertDialogTitle,
21} from "../ui/alert-dialog";
22import { Button } from "../ui/button";
23import { ScrollArea } from "../ui/scroll-area";
24import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
25import { ConfiguredDeployments } from "./ConfiguredDeployments";
26import { CommitTitle } from "../review/commitTitle";
27import {
28 buildDeploymentTimeline,
29 railCommitCount,
30 type DeploymentTimelineItem,
31} from "./deployments.logic";
32
33/** Mergebot's deploys pane: environment markers over the merged-commit rail. */
34export function DeploymentsPanel(props: { environmentId: EnvironmentId }) {
35 return (
36 <ConfiguredDeployments environmentId={props.environmentId}>
37 {(found) => <DeploymentsView environmentId={props.environmentId} found={found} />}
38 </ConfiguredDeployments>
39 );
40}
41
42function DeploymentsView(props: {
43 environmentId: EnvironmentId;
44 found: readonly MergebotDeployments[];
45}) {
46 const configuration = useEnvironment(props.environmentId)?.serverConfig?.mergebot;
47 const [selectedRepository, setSelectedRepository] = useState<string | null>(null);
48 const deployments =
49 props.found.find((entry) => entry.repository === selectedRepository) ?? props.found[0];
50
51 if (!configuration) {
52 return <PanelMessage>Mergebot is not configured on this environment.</PanelMessage>;
53 }
54 if (!deployments) {
55 return <PanelMessage>Waiting for deployment data from Mergebot…</PanelMessage>;
56 }
57
58 return (
59 <div className="@container flex h-full min-h-0 flex-col">
60 <div className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
61 {props.found.length > 1 ? (
62 <Select value={deployments.repository} onValueChange={setSelectedRepository}>
63 <SelectTrigger
64 aria-label="Repository"
65 size="compact"
66 variant="ghost"
67 className="w-auto min-w-0"
68 >
69 <SelectValue>{deployments.repository}</SelectValue>
70 </SelectTrigger>
71 <SelectPopup align="start" alignItemWithTrigger={false}>
72 {props.found.map((entry) => (
73 <SelectItem key={entry.repository} value={entry.repository}>
74 {entry.repository}
75 </SelectItem>
76 ))}
77 </SelectPopup>
78 </Select>
79 ) : (
80 <span className="min-w-0 truncate px-1 text-xs font-medium">
81 {deployments.repository}
82 </span>
83 )}
84 <Button
85 aria-label="Open in Mergebot"
86 className="ms-auto"
87 size="icon-xs"
88 variant="ghost-muted"
89 render={
90 <a
91 href={`${configuration.origin}/${deployments.repository}`}
92 rel="noopener noreferrer"
93 target="_blank"
94 />
95 }
96 >
97 <ExternalLinkIcon />
98 </Button>
99 </div>
100 <ScrollArea className="min-h-0 flex-1">
101 <DeploymentTimeline
102 key={deployments.repository}
103 environmentId={props.environmentId}
104 deployments={deployments}
105 />
106 </ScrollArea>
107 </div>
108 );
109}
110
111function PanelMessage(props: { children: string }) {
112 return (
113 <div className="flex h-full items-center justify-center p-6">
114 <p className="text-center text-sm text-muted-foreground">{props.children}</p>
115 </div>
116 );
117}
118
119function DeploymentTimeline(props: {
120 environmentId: EnvironmentId;
121 deployments: MergebotDeployments;
122}) {
123 const { deployments } = props;
124 const nowMinute = useNowMinute();
125 const [expanded, setExpanded] = useState(false);
126 const [confirming, setConfirming] = useState<MergebotMergedCommit | null>(null);
127 const [deploying, setDeploying] = useState(false);
128 const canDeploy = useAtomValue(
129 mergebotEnvironment.deployProduction.permissionAtom(props.environmentId),
130 );
131 const deployProduction = useAtomCommand(mergebotEnvironment.deployProduction);
132
133 const railCount = railCommitCount(deployments);
134 const hasMore = deployments.mergedCommits.length > railCount;
135 const items = buildDeploymentTimeline(
136 deployments,
137 Date.parse(`${nowMinute}Z`),
138 expanded ? undefined : railCount,
139 );
140
141 const confirm = async () => {
142 if (!confirming) return;
143 setDeploying(true);
144 const result = await deployProduction({
145 environmentId: props.environmentId,
146 input: { repository: deployments.repository, sha: confirming.sha },
147 });
148 setDeploying(false);
149 if (result._tag === "Success") setConfirming(null);
150 };
151
152 const confirmTitle = confirming ? firstLine(confirming.message) : "";
153
154 return (
155 <div className="flex flex-col gap-1.5 p-3">
156 {items.map((item) =>
157 item.kind === "marker" ? (
158 <TimelineMarker key={item.key} item={item} />
159 ) : (
160 <MergedCommitRow
161 key={item.key}
162 item={item}
163 onDeploy={canDeploy ? () => setConfirming(item.commit) : null}
164 />
165 ),
166 )}
167 {hasMore ? (
168 <Button
169 className="self-start"
170 size="xs"
171 variant="ghost-muted"
172 onClick={() => setExpanded((value) => !value)}
173 >
174 {expanded ? "Show less" : "Show timeline"}
175 </Button>
176 ) : null}
177
178 <AlertDialog
179 open={confirming !== null}
180 onOpenChange={(open) => {
181 if (!open && !deploying) setConfirming(null);
182 }}
183 >
184 <AlertDialogPopup>
185 <AlertDialogHeader>
186 <AlertDialogTitle>Deploy to production?</AlertDialogTitle>
187 <AlertDialogDescription>
188 Fast-forwards <code>{deployments.productionBranch}</code> to{" "}
189 <code>{confirming?.sha.slice(0, 7)}</code>
190 {` (${confirmTitle})`} and ships every commit up to it.
191 </AlertDialogDescription>
192 </AlertDialogHeader>
193 <AlertDialogFooter>
194 <AlertDialogClose render={<Button variant="outline" />} disabled={deploying}>
195 Cancel
196 </AlertDialogClose>
197 <Button
198 variant="destructive"
199 aria-busy={deploying}
200 disabled={deploying}
201 onClick={() => void confirm()}
202 >
203 {deploying ? "Deploying…" : "Deploy to production"}
204 </Button>
205 </AlertDialogFooter>
206 </AlertDialogPopup>
207 </AlertDialog>
208 </div>
209 );
210}
211
212const MARKER_TONE = {
213 default: { text: "text-muted-foreground", line: "bg-border" },
214 pending: { text: "text-warning-foreground", line: "bg-warning" },
215 failed: { text: "text-destructive-foreground", line: "bg-border" },
216} as const;
217
218function TimelineMarker(props: { item: Extract<DeploymentTimelineItem, { kind: "marker" }> }) {
219 const { label, tone, href } = props.item;
220 const content = (
221 <>
222 <span className={cn("h-px w-4 flex-none", MARKER_TONE[tone].line)} />
223 <span
224 className={cn(
225 "inline-flex min-w-0 items-center gap-1.5 text-xs font-medium",
226 MARKER_TONE[tone].text,
227 )}
228 >
229 {tone === "pending" ? <CircleDashedIcon aria-hidden className="size-3.5 shrink-0" /> : null}
230 {tone === "failed" ? <CircleXIcon aria-hidden className="size-3.5 shrink-0" /> : null}
231 <span>{label}</span>
232 </span>
233 <span className={cn("h-px min-w-4 flex-1", MARKER_TONE[tone].line)} />
234 </>
235 );
236 const className = "flex items-center gap-2 py-1";
237
238 return href ? (
239 <a
240 className={cn(className, "transition-opacity hover:opacity-80")}
241 href={href}
242 rel="noopener noreferrer"
243 target="_blank"
244 >
245 {content}
246 </a>
247 ) : (
248 <div className={className}>{content}</div>
249 );
250}
251
252const COMMIT_TONE = {
253 deploying: "border-warning/60 bg-warning-surface",
254 hotfix: "border-warning/40 bg-warning-surface",
255 unshipped: "border-success/50",
256 default: "border-border",
257} as const;
258
259function firstLine(message: string) {
260 return message.split("\n", 1)[0]?.trim() ?? "";
261}
262
263function MergedCommitRow(props: {
264 item: Extract<DeploymentTimelineItem, { kind: "commit" }>;
265 onDeploy: (() => void) | null;
266}) {
267 const { commit, tone, isDeployCandidate } = props.item;
268 const authorName = (commit.authorName ?? commit.authorLogin ?? "ghost").trim().split(/\s+/)[0];
269 const actor = {
270 login: commit.authorLogin ?? authorName ?? "ghost",
271 name: commit.authorName,
272 avatarUrl: commit.authorAvatarUrl,
273 };
274
275 return (
276 <div
277 className={cn(
278 "group relative flex items-center rounded-md border text-xs",
279 commit.status === "hotfix" && "ml-3",
280 COMMIT_TONE[tone],
281 )}
282 >
283 <a
284 className="flex min-w-0 flex-1 items-center gap-2 rounded-[inherit] px-2.5 py-1.5 transition-colors hover:bg-accent/50"
285 href={commit.url}
286 rel="noopener noreferrer"
287 target="_blank"
288 >
289 {commit.pullRequestNumber !== null ? (
290 <span className="shrink-0 font-semibold text-muted-foreground">
291 #{commit.pullRequestNumber}
292 </span>
293 ) : null}
294 <CommitTitle className="min-w-0 flex-1 font-medium" title={firstLine(commit.message)} />
295 <PullRequestActorAvatar actor={actor} />
296 <span className="hidden shrink-0 text-muted-foreground @md:inline">{authorName}</span>
297 <span className="shrink-0 text-muted-foreground tabular-nums">
298 {formatRelativeTimeLabel(commit.mergedAt)}
299 </span>
300 </a>
301 {isDeployCandidate && props.onDeploy ? (
302 <span className="absolute right-2 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100">
303 <Button size="micro" variant="outline" onClick={props.onDeploy}>
304 Deploy to here
305 </Button>
306 </span>
307 ) : null}
308 </div>
309 );
310}