Skip to content
295 linesCodeBlameRaw
1import {
2 Check,
3 CircleDashed,
4 GitCommitHorizontal,
5 GitMerge,
6 Layers,
7 Loader2,
8 Minus,
9 X,
10} from "lucide-react";
11import { Suspense } from "react";
12import { Await, Link } from "react-router";
13
14import type { QueueEntry, QueueState, RepoPath, Viewer } from "@g1t/contracts";
15
16import type { Route } from "./+types/queue";
17import { page } from "../../lib/meta";
18import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
19import { Avatar } from "../../components/ui/avatar";
20import { Card } from "../../components/ui/card";
21import { actions, work } from "../../lib/services.server";
22import { getViewer, unwrap } from "../../lib/session.server";
23import { accessFor, repoFor } from "../../lib/access.server";
24import { useRefreshWhile } from "../../lib/refresh";
25
26
27/** A merge_group workflow run on a combined state. */
28type GroupRun = { id: string; name: string };
29
30export function meta({ params, ...args }: Route.MetaArgs) {
31 return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` });
32}
33
34export async function loader({ params, context }: Route.LoaderArgs) {
35 const viewer = getViewer(context);
36 const path = { namespace: params.owner, name: params.repo };
37 const [queue, repo] = await Promise.all([work.queue(path, viewer), repoFor(context, params)]);
38 const view = unwrap(queue);
39 return {
40 queue: view,
41 // The merge_group runs on each combined state, to link its checks to:
42 // streamed, so the queue shows at once.
43 groupRuns: mergeGroupRuns(path, viewer, [...view.active, ...view.recent]),
44 defaultBranch: unwrap(repo).defaultBranch,
45 // Turning the queue on is a setting: Maintain and up.
46 member: repo.ok && accessFor(viewer, repo.value).can.manage_settings,
47 };
48}
49
50/** The merge_group runs on each entry's combined state, by commit; empty where none could be read. */
51function mergeGroupRuns(path: RepoPath, viewer: Viewer, entries: QueueEntry[]): Promise<Record<string, GroupRun[]>> {
52 const commits = [...new Set(entries.map((entry) => entry.combinedCommit).filter((sha) => sha != null))].slice(0, 20);
53 return Promise.all(
54 commits.map((sha) =>
55 actions
56 .runs(path, viewer, { sha, event: "merge_group", limit: 10 })
57 .then((found) => [sha, found.ok ? found.value.map((run) => ({ id: run.id, name: run.name })) : []] as const)
58 .catch(() => [sha, []] as const),
59 ),
60 ).then((found) => Object.fromEntries(found));
61}
62
63const STATE: Record<QueueState, { label: string; tone: string; icon: React.ReactNode }> = {
64 waiting: { label: "Waiting", tone: "text-muted border-line", icon: <CircleDashed size={13} /> },
65 testing: {
66 label: "Testing",
67 tone: "text-accent border-accent/40 bg-accent/10",
68 icon: <Loader2 size={13} className="animate-spin" />,
69 },
70 passed: { label: "Passed", tone: "text-success border-success/40 bg-success/10", icon: <Check size={13} /> },
71 failed: { label: "Failed", tone: "text-danger border-danger/40 bg-danger/10", icon: <X size={13} /> },
72 landed: { label: "Landed", tone: "text-merged border-merged/40 bg-merged/10", icon: <GitMerge size={13} /> },
73 removed: { label: "Removed", tone: "text-faint border-line", icon: <Minus size={13} /> },
74};
75
76function StateBadge({ state }: { state: QueueState }) {
77 const { label, tone, icon } = STATE[state];
78 return (
79 <span className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs ${tone}`}>
80 {icon}
81 {label}
82 </span>
83 );
84}
85
86/** `main + #12 + #14`: the state an entry is being tested in. */
87function TestedAs({ entry, branch }: { entry: QueueEntry; branch: string }) {
88 return (
89 <span className="flex flex-wrap items-center gap-1 font-mono text-xs text-muted">
90 <span className="rounded border border-line px-1.5 py-px">{branch}</span>
91 {[...entry.ahead, entry.number].map((number) => (
92 <span key={number} className="flex items-center gap-1">
93 <span className="text-faint">+</span>
94 <span
95 className={`rounded border px-1.5 py-px ${
96 number === entry.number ? "border-accent/40 text-fg" : "border-line"
97 }`}
98 >
99 #{number}
100 </span>
101 </span>
102 ))}
103 </span>
104 );
105}
106
107/**
108 * How an entry's checks went, linked to the merge_group run on its combined
109 * state; with several runs, each is named and linked after the count.
110 */
111function GroupChecks({ entry, base, runs }: { entry: QueueEntry; base: string; runs: GroupRun[] }) {
112 const ran = entry.results.length;
113 const passed = entry.results.filter((result) => result.passed).length;
114 const [first, ...more] = runs;
115 const tone = passed === ran ? "text-success" : "text-danger";
116 const count = ran > 0 ? `${passed}/${ran} checks passed` : first ? "merge_group run" : null;
117 if (!count) return null;
118 return (
119 <span className="flex flex-wrap items-center gap-x-2">
120 {first ? (
121 <Link to={`${base}/actions/runs/${first.id}`} className={`underline-offset-2 hover:underline ${ran > 0 ? tone : "text-muted"}`}>
122 {count}
123 {more.length > 0 && ` · ${first.name}`}
124 </Link>
125 ) : (
126 <span className={tone}>{count}</span>
127 )}
128 {more.map((run) => (
129 <Link key={run.id} to={`${base}/actions/runs/${run.id}`} className="text-muted underline-offset-2 hover:text-fg hover:underline">
130 {run.name}
131 </Link>
132 ))}
133 </span>
134 );
135}
136
137function Entry({
138 entry,
139 base,
140 branch,
141 position,
142 groupRuns,
143}: {
144 entry: QueueEntry;
145 base: string;
146 branch: string;
147 position?: number;
148 groupRuns: Promise<Record<string, GroupRun[]>>;
149}) {
150 const runsOf = (found: Record<string, GroupRun[]>) => (entry.combinedCommit ? (found[entry.combinedCommit] ?? []) : []);
151 return (
152 <li className="relative pl-10">
153 {/* Its place on the rail. */}
154 <span
155 className={`absolute top-4 left-[13px] size-3 rounded-full border-2 ${
156 entry.state === "testing"
157 ? "animate-pulse border-accent bg-accent/30"
158 : entry.state === "passed" || entry.state === "landed"
159 ? "border-success bg-success"
160 : entry.state === "failed"
161 ? "border-danger bg-danger"
162 : "border-line-strong bg-bg"
163 }`}
164 />
165 <Card className="p-4 transition-colors hover:border-line-strong">
166 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
167 {position != null && <span className="font-mono text-xs text-faint">{position}</span>}
168 <Link to={`${base}/pull/${entry.number}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
169 {entry.title} <span className="font-normal text-faint">#{entry.number}</span>
170 </Link>
171 <StateBadge state={entry.state} />
172 </div>
173 <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
174 {(entry.state === "testing" || entry.state === "passed" || entry.state === "failed") && (
175 <TestedAs entry={entry} branch={branch} />
176 )}
177 <Suspense fallback={<GroupChecks entry={entry} base={base} runs={[]} />}>
178 <Await resolve={groupRuns} errorElement={<GroupChecks entry={entry} base={base} runs={[]} />}>
179 {(found) => <GroupChecks entry={entry} base={base} runs={runsOf(found)} />}
180 </Await>
181 </Suspense>
182 <span className="flex items-center gap-1.5">
183 <Avatar name={entry.agent} size={14} />
184 {entry.agent}
185 </span>
186 <span>
187 queued by {entry.enqueuedBy} <TimeAgo at={entry.createdAt} />
188 </span>
189 {entry.combinedCommit && (
190 <span className="flex items-center gap-1 font-mono">
191 <GitCommitHorizontal size={12} />
192 {entry.combinedCommit.slice(0, 7)}
193 </span>
194 )}
195 </div>
196 {entry.error && <p className="mt-2.5 text-xs text-danger">{entry.error}</p>}
197 {entry.state === "failed" &&
198 entry.results
199 .filter((result) => !result.passed)
200 .slice(0, 2)
201 .map((result) => (
202 <details key={result.command} className="mt-2">
203 <summary className="cursor-pointer font-mono text-xs text-danger">{result.command}</summary>
204 <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>
205 </details>
206 ))}
207 </Card>
208 </li>
209 );
210}
211
212export default function Queue({ loaderData, params }: Route.ComponentProps) {
213 const { queue, defaultBranch, member, groupRuns } = loaderData;
214 const base = `/${params.owner}/${params.repo}`;
215 const moving = queue.active.length > 0;
216 useRefreshWhile(moving);
217
218 const testing = queue.active.filter((entry) => entry.state !== "waiting").length;
219 return (
220 <div className="max-w-4xl">
221 <div className="flex flex-wrap items-start justify-between gap-4">
222 <div>
223 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
224 <Layers size={19} className="text-accent" />
225 Merge queue
226 </h2>
227 <p className="mt-1.5 max-w-2xl text-sm text-muted">
228 Each pull request is tested together with everything ahead of it, several
229 combinations at once, and {defaultBranch} only ever moves to a combination that
230 passed. One that fails goes back to its author; the ones behind it are tested
231 again without it.
232 </p>
233 </div>
234 {member && (
235 <ButtonLink to={`${base}/settings/rules`}>{queue.enabled ? "Settings" : "Turn it on"}</ButtonLink>
236 )}
237 </div>
238
239 {!queue.enabled && queue.active.length === 0 && queue.recent.length === 0 ? (
240 <div className="mt-8">
241 <EmptyState title="This repository merges directly">
242 Add the rule <b>Require the merge queue</b> for {defaultBranch} under{" "}
243 {member ? (
244 <Link to={`${base}/settings/rules`} className="text-fg underline underline-offset-2">
245 Settings → Rules
246 </Link>
247 ) : (
248 "Settings → Rules"
249 )}
250 , and merging a pull request adds it here instead of changing {defaultBranch} at once.
251 </EmptyState>
252 </div>
253 ) : (
254 <>
255 <section className="mt-8">
256 <div className="flex items-baseline justify-between">
257 <h3 className="text-sm font-medium">In the queue</h3>
258 <span className="text-xs text-muted">
259 {queue.active.length} waiting to land{testing > 0 && `, ${testing} being tested now`}
260 </span>
261 </div>
262 <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">
263 <li className="relative flex items-center gap-2 pl-10 text-xs text-muted">
264 <span className="absolute left-[11px] flex size-4 items-center justify-center rounded-full bg-raised ring-1 ring-line-strong">
265 <GitCommitHorizontal size={10} />
266 </span>
267 <span className="font-mono text-fg">{defaultBranch}</span>
268 {queue.active[0]?.baseCommit && (
269 <span className="font-mono">at {queue.active[0].baseCommit.slice(0, 7)}</span>
270 )}
271 </li>
272 {queue.active.map((entry, index) => (
273 <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} position={index + 1} groupRuns={groupRuns} />
274 ))}
275 {queue.active.length === 0 && (
276 <li className="pl-10 text-sm text-muted">Nothing is waiting. Merged pull requests appear here.</li>
277 )}
278 </ol>
279 </section>
280
281 {queue.recent.length > 0 && (
282 <section className="mt-10">
283 <h3 className="text-sm font-medium">Recently</h3>
284 <ol className="mt-3 space-y-3">
285 {queue.recent.map((entry) => (
286 <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} groupRuns={groupRuns} />
287 ))}
288 </ol>
289 </section>
290 )}
291 </>
292 )}
293 </div>
294 );
295}