Skip to content
117 linesCodeBlameRaw
1import { Layers } from "lucide-react";
2import { Link } from "react-router";
3
4import type { QueueEntry, RepoPath, Viewer } from "@g1t/contracts";
5
6import type { Route } from "./+types/queue";
7import { page } from "../../lib/meta";
8import { ButtonLink, EmptyState } from "../../components/ui";
9import { type GroupRun, QueueEntryCard, QueueTrain } from "../../components/merge-queue";
10import { actions, work } from "../../lib/services.server";
11import { getViewer, unwrap } from "../../lib/session.server";
12import { accessFor, repoFor } from "../../lib/access.server";
13import { useRefreshWhile } from "../../lib/refresh";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` });
17}
18
19export async function loader({ params, context }: Route.LoaderArgs) {
20 const viewer = getViewer(context);
21 const path = { namespace: params.owner, name: params.repo };
22 const [queue, repo] = await Promise.all([work.queue(path, viewer), repoFor(context, params)]);
23 const view = unwrap(queue);
24 return {
25 queue: view,
26 // The merge_group runs on each combined state, to link its checks to:
27 // streamed, so the queue shows at once.
28 groupRuns: mergeGroupRuns(path, viewer, [...view.active, ...view.recent]),
29 defaultBranch: unwrap(repo).defaultBranch,
30 // Turning the queue on is a setting: Maintain and up.
31 member: repo.ok && accessFor(viewer, repo.value).can.manage_settings,
32 };
33}
34
35/** The merge_group runs on each entry's combined state, by commit; empty where none could be read. */
36function mergeGroupRuns(path: RepoPath, viewer: Viewer, entries: QueueEntry[]): Promise<Record<string, GroupRun[]>> {
37 const commits = [...new Set(entries.map((entry) => entry.combinedCommit).filter((sha) => sha != null))].slice(0, 20);
38 return Promise.all(
39 commits.map((sha) =>
40 actions
41 .runs(path, viewer, { sha, event: "merge_group", limit: 10 })
42 .then((found) => [sha, found.ok ? found.value.map((run) => ({ id: run.id, name: run.name })) : []] as const)
43 .catch(() => [sha, []] as const),
44 ),
45 ).then((found) => Object.fromEntries(found));
46}
47
48export default function Queue({ loaderData, params }: Route.ComponentProps) {
49 const { queue, defaultBranch, member, groupRuns } = loaderData;
50 const base = `/${params.owner}/${params.repo}`;
51 const moving = queue.active.length > 0;
52 useRefreshWhile(moving);
53
54 const testing = queue.active.filter((entry) => entry.state !== "waiting").length;
55 return (
56 <div className="max-w-4xl">
57 <div className="flex flex-wrap items-start justify-between gap-4">
58 <div>
59 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
60 <Layers size={19} className="text-accent" />
61 Merge queue
62 </h2>
63 <p className="mt-1.5 max-w-2xl text-sm text-muted">
64 Each pull request is tested together with everything ahead of it, several
65 combinations at once, and {defaultBranch} only ever moves to a combination that
66 passed. One that fails goes back to its author; the ones behind it are tested
67 again without it.
68 </p>
69 </div>
70 {member && (
71 <ButtonLink to={`${base}/settings/rules`}>{queue.enabled ? "Settings" : "Turn it on"}</ButtonLink>
72 )}
73 </div>
74
75 {!queue.enabled && queue.active.length === 0 && queue.recent.length === 0 ? (
76 <div className="mt-8">
77 <EmptyState title="This repository merges directly">
78 Add the rule <b>Require the merge queue</b> for {defaultBranch} under{" "}
79 {member ? (
80 <Link to={`${base}/settings/rules`} className="text-fg underline underline-offset-2">
81 Settings → Rules
82 </Link>
83 ) : (
84 "Settings → Rules"
85 )}
86 , and merging a pull request adds it here instead of changing {defaultBranch} at once.
87 </EmptyState>
88 </div>
89 ) : (
90 <>
91 <section className="mt-8">
92 <div className="flex items-baseline justify-between">
93 <h3 className="text-sm font-medium">In the queue</h3>
94 <span className="text-xs text-muted">
95 {queue.active.length} waiting to land{testing > 0 && `, ${testing} being tested now`}
96 </span>
97 </div>
98 <div className="mt-3">
99 <QueueTrain active={queue.active} base={base} branch={defaultBranch} groupRuns={groupRuns} />
100 </div>
101 </section>
102
103 {queue.recent.length > 0 && (
104 <section className="mt-10">
105 <h3 className="text-sm font-medium">Recently</h3>
106 <ol className="mt-3 space-y-3">
107 {queue.recent.map((entry) => (
108 <QueueEntryCard key={entry.id} entry={entry} base={base} branch={defaultBranch} groupRuns={groupRuns} />
109 ))}
110 </ol>
111 </section>
112 )}
113 </>
114 )}
115 </div>
116 );
117}