g1t/apps/web/app/routes/repo/queue.tsx

232 lines9,788 bytesCodeBlame
1import {
2 Check,
3 CircleDashed,
4 GitCommitHorizontal,
5 GitMerge,
6 Layers,
7 Loader2,
8 Minus,
9 X,
10} from "lucide-react";
11import { useEffect } from "react";
12import { Link, useRevalidator } from "react-router";
13
14import type { QueueEntry, QueueState } from "@g1t/contracts";
15
16import type { Route } from "./+types/queue";
17import { page } from "../../lib/meta";
18import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
19import { work } from "../../lib/services.server";
20import { getViewer, unwrap } from "../../lib/session.server";
21import { accessFor, repoFor } from "../../lib/access.server";
22
23const REFRESH_MS = 4000;
24
25export function meta({ params, ...args }: Route.MetaArgs) {
26 return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` });
27}
28
29export async function loader({ params, context }: Route.LoaderArgs) {
30 const viewer = getViewer(context);
31 const path = { namespace: params.owner, name: params.repo };
32 const [queue, repo] = await Promise.all([work.queue(path, viewer), repoFor(context, params)]);
33 return {
34 queue: unwrap(queue),
35 defaultBranch: unwrap(repo).defaultBranch,
36 // Turning the queue on is a setting: Maintain and up.
37 member: repo.ok && accessFor(viewer, repo.value).can.manage_settings,
38 };
39}
40
41const STATE: Record<QueueState, { label: string; tone: string; icon: React.ReactNode }> = {
42 waiting: { label: "Waiting", tone: "text-muted border-line", icon: <CircleDashed size={13} /> },
43 testing: {
44 label: "Testing",
45 tone: "text-accent border-accent/40 bg-accent/10",
46 icon: <Loader2 size={13} className="animate-spin" />,
47 },
48 passed: { label: "Passed", tone: "text-accent border-accent/40 bg-accent/10", icon: <Check size={13} /> },
49 failed: { label: "Failed", tone: "text-danger border-danger/40 bg-danger/10", icon: <X size={13} /> },
50 landed: { label: "Landed", tone: "text-merged border-merged/40 bg-merged/10", icon: <GitMerge size={13} /> },
51 removed: { label: "Removed", tone: "text-faint border-line", icon: <Minus size={13} /> },
52};
53
54function StateBadge({ state }: { state: QueueState }) {
55 const { label, tone, icon } = STATE[state];
56 return (
57 <span className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs ${tone}`}>
58 {icon}
59 {label}
60 </span>
61 );
62}
63
64/** `main + #12 + #14`: the state an entry is being tested in. */
65function TestedAs({ entry, branch }: { entry: QueueEntry; branch: string }) {
66 return (
67 <span className="flex flex-wrap items-center gap-1 font-mono text-xs text-muted">
68 <span className="rounded border border-line px-1.5 py-px">{branch}</span>
69 {[...entry.ahead, entry.number].map((number) => (
70 <span key={number} className="flex items-center gap-1">
71 <span className="text-faint">+</span>
72 <span
73 className={`rounded border px-1.5 py-px ${
74 number === entry.number ? "border-accent/40 text-fg" : "border-line"
75 }`}
76 >
77 #{number}
78 </span>
79 </span>
80 ))}
81 </span>
82 );
83}
84
85function Entry({ entry, base, branch, position }: { entry: QueueEntry; base: string; branch: string; position?: number }) {
86 const ran = entry.results.length;
87 const passed = entry.results.filter((result) => result.passed).length;
88 return (
89 <li className="relative pl-10">
90 {/* Its place on the rail. */}
91 <span
92 className={`absolute top-4 left-[13px] size-3 rounded-full border-2 ${
93 entry.state === "testing"
94 ? "animate-pulse border-accent bg-accent/30"
95 : entry.state === "passed" || entry.state === "landed"
96 ? "border-accent bg-accent"
97 : entry.state === "failed"
98 ? "border-danger bg-danger"
99 : "border-line-strong bg-bg"
100 }`}
101 />
102 <div className="rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
103 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
104 {position != null && <span className="font-mono text-xs text-faint">{position}</span>}
105 <Link to={`${base}/pull/${entry.number}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
106 {entry.title} <span className="font-normal text-faint">#{entry.number}</span>
107 </Link>
108 <StateBadge state={entry.state} />
109 </div>
110 <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
111 {(entry.state === "testing" || entry.state === "passed" || entry.state === "failed") && (
112 <TestedAs entry={entry} branch={branch} />
113 )}
114 {ran > 0 && (
115 <span className={passed === ran ? "text-accent" : "text-danger"}>
116 {passed}/{ran} checks passed
117 </span>
118 )}
119 <span className="flex items-center gap-1.5">
120 <Avatar name={entry.agent} size={14} />
121 {entry.agent}
122 </span>
123 <span>
124 queued by {entry.enqueuedBy} <TimeAgo at={entry.createdAt} />
125 </span>
126 {entry.combinedCommit && (
127 <span className="flex items-center gap-1 font-mono">
128 <GitCommitHorizontal size={12} />
129 {entry.combinedCommit.slice(0, 7)}
130 </span>
131 )}
132 </div>
133 {entry.error && <p className="mt-2.5 text-xs text-danger">{entry.error}</p>}
134 {entry.state === "failed" &&
135 entry.results
136 .filter((result) => !result.passed)
137 .slice(0, 2)
138 .map((result) => (
139 <details key={result.command} className="mt-2">
140 <summary className="cursor-pointer font-mono text-xs text-danger">{result.command}</summary>
141 <pre className="mt-1.5 max-h-48 overflow-auto rounded-lg bg-bg p-3 text-xs text-muted"><code>{result.output}</code></pre>
142 </details>
143 ))}
144 </div>
145 </li>
146 );
147}
148
149export default function Queue({ loaderData, params }: Route.ComponentProps) {
150 const { queue, defaultBranch, member } = loaderData;
151 const base = `/${params.owner}/${params.repo}`;
152 const revalidator = useRevalidator();
153 const moving = queue.active.length > 0;
154 useEffect(() => {
155 if (!moving) return;
156 const timer = setInterval(() => {
157 if (document.visibilityState === "visible") revalidator.revalidate();
158 }, REFRESH_MS);
159 return () => clearInterval(timer);
160 }, [moving, revalidator]);
161
162 const testing = queue.active.filter((entry) => entry.state !== "waiting").length;
163 return (
164 <div className="max-w-4xl">
165 <div className="flex flex-wrap items-start justify-between gap-4">
166 <div>
167 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
168 <Layers size={19} className="text-accent" />
169 Merge queue
170 </h2>
171 <p className="mt-1.5 max-w-2xl text-sm text-muted">
172 Each pull request is tested together with everything ahead of it, several
173 combinations at once, and {defaultBranch} only ever moves to a combination that
174 passed. One that fails goes back to its author; the ones behind it are tested
175 again without it.
176 </p>
177 </div>
178 {member && (
179 <ButtonLink to={`${base}/settings`}>{queue.enabled ? "Settings" : "Turn it on"}</ButtonLink>
180 )}
181 </div>
182
183 {!queue.enabled && queue.active.length === 0 && queue.recent.length === 0 ? (
184 <div className="mt-8">
185 <EmptyState title="This repository merges directly">
186 Turn on <b>Merge through a queue</b> in the repository's settings, and merging a
187 pull request adds it here instead of changing {defaultBranch} at once.
188 </EmptyState>
189 </div>
190 ) : (
191 <>
192 <section className="mt-8">
193 <div className="flex items-baseline justify-between">
194 <h3 className="text-sm font-medium">In the queue</h3>
195 <span className="text-xs text-muted">
196 {queue.active.length} waiting to land{testing > 0 && `, ${testing} being tested now`}
197 </span>
198 </div>
199 <ol className="relative mt-3 space-y-3 before:absolute before:top-0 before:bottom-4 before:left-[18px] before:w-px before:bg-line">
200 <li className="relative flex items-center gap-2 pl-10 text-xs text-muted">
201 <span className="absolute left-[11px] flex size-4 items-center justify-center rounded-full bg-raised ring-1 ring-line-strong">
202 <GitCommitHorizontal size={10} />
203 </span>
204 <span className="font-mono text-fg">{defaultBranch}</span>
205 {queue.active[0]?.baseCommit && (
206 <span className="font-mono">at {queue.active[0].baseCommit.slice(0, 7)}</span>
207 )}
208 </li>
209 {queue.active.map((entry, index) => (
210 <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} position={index + 1} />
211 ))}
212 {queue.active.length === 0 && (
213 <li className="pl-10 text-sm text-muted">Nothing is waiting. Merged pull requests appear here.</li>
214 )}
215 </ol>
216 </section>
217
218 {queue.recent.length > 0 && (
219 <section className="mt-10">
220 <h3 className="text-sm font-medium">Recently</h3>
221 <ol className="mt-3 space-y-3">
222 {queue.recent.map((entry) => (
223 <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} />
224 ))}
225 </ol>
226 </section>
227 )}
228 </>
229 )}
230 </div>
231 );
232}