flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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