Skip to content

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

231 linesCodeBlameRaw
1import { ArrowLeft, Bot, GitBranch, Milestone as MilestoneIcon, Plus, Tag } from "lucide-react";
2import { Link, useLocation } from "react-router";
3
4import type { Route } from "./+types/pulls";
5import { page } from "../../lib/meta";
6import { openedBy } from "../../lib/opened-by";
7import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
8import { Badge } from "../../components/ui/badge";
9import { Card } from "../../components/ui/card";
10import { Hint } from "../../components/ui/hint";
11import { CheckBadge } from "../../components/checks";
12import { ChangeSize, PullIcon, StateTabs } from "../../components/work";
13import { FilterMenu, LabelChip, Swatch } from "../../components/labels";
14import { colorsOf, listFilters, withFilter } from "../../lib/labels";
15import { repoFor } from "../../lib/access.server";
16import { work } from "../../lib/services.server";
17import { getViewer, unwrap } from "../../lib/session.server";
18import { repoPages } from "../../lib/repo-live";
19import { useRepoLive } from "../../lib/use-repo-live";
20import { isFailed, part } from "../../lib/partial";
21
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Pull requests · ${params.owner}/${params.repo} · g1t` });
24}
25
26export async function loader({ params, context, request }: Route.LoaderArgs) {
27 const path = { namespace: params.owner, name: params.repo };
28 const viewer = getViewer(context);
29 // `?label=bug&milestone=3&base=release`, or the same written in `q`.
30 const { state, label, milestone, base } = listFilters(new URL(request.url).searchParams);
31 const [pulls, labels, milestones, repo] = await Promise.all([
32 work.listPulls(path, viewer, state, {
33 label: label || undefined,
34 milestone: milestone ?? undefined,
35 base: base || undefined,
36 }),
37 // What the filters offer: each a part, whose menu says so when its service fails.
38 part(work.listLabels(path, viewer), "Labels"),
39 part(work.listMilestones(path, viewer, "open"), "Milestones"),
40 // The layout looks it up in this request too.
41 repoFor(context, params),
42 ]);
43 return {
44 pulls: unwrap(pulls),
45 state,
46 label,
47 milestone,
48 base,
49 labels: isFailed(labels) ? labels : labels.ok ? labels.value : [],
50 milestones: isFailed(milestones) ? milestones : milestones.ok ? milestones.value : [],
51 defaultBranch: repo.ok ? repo.value.defaultBranch : null,
52 } as const;
53}
54
55export default function Pulls({ loaderData, params }: Route.ComponentProps) {
56 const { pulls, state, label, milestone, defaultBranch } = loaderData;
57 // A filter whose choices could not be loaded offers none, and its menu says why.
58 const labels = isFailed(loaderData.labels) ? [] : loaderData.labels;
59 const milestones = isFailed(loaderData.milestones) ? [] : loaderData.milestones;
60 // Pull requests opened, updated, reviewed, checked, merged and closed show as it happens.
61 useRepoLive({ match: repoPages.pulls() });
62 const base = `/${params.owner}/${params.repo}`;
63 const list = `${base}/pulls`;
64 const current = new URLSearchParams(useLocation().search);
65 const colors = colorsOf(labels);
66 const milestoneTitle = milestones.find((m) => m.number === milestone)?.title ?? (milestone ? `#${milestone}` : null);
67 const filtered = [label, milestone, loaderData.base].some(Boolean);
68 const kept = new URLSearchParams(current);
69 kept.delete("state");
70 kept.delete("q");
71 return (
72 <div>
73 <StateTabs
74 to={list}
75 state={state}
76 query={kept.toString()}
77 action={
78 <ButtonLink to={`${base}/pulls/new`}>
79 <Plus size={15} />
80 New pull request
81 </ButtonLink>
82 }
83 />
84 <div className="mt-3 flex flex-wrap items-center gap-2">
85 <FilterMenu
86 label="Label"
87 active={label || undefined}
88 clearTo={label ? withFilter(list, current, "label", null) : undefined}
89 searchPlaceholder="Filter labels"
90 emptyText={isFailed(loaderData.labels) ? "Labels could not be loaded just now." : "No label matches."}
91 options={labels.map((each) => ({
92 key: each.name,
93 to: withFilter(list, current, "label", each.name),
94 keywords: `${each.name} ${each.description}`,
95 selected: each.name === label,
96 label: (
97 <>
98 <Swatch color={each.color} />
99 <span className="truncate">{each.name}</span>
100 </>
101 ),
102 }))}
103 />
104 <FilterMenu
105 label="Milestone"
106 active={milestoneTitle ?? undefined}
107 clearTo={milestone ? withFilter(list, current, "milestone", null) : undefined}
108 searchPlaceholder="Filter milestones"
109 emptyText={isFailed(loaderData.milestones) ? "Milestones could not be loaded just now." : "No open milestone matches."}
110 options={milestones.map((each) => ({
111 key: String(each.number),
112 to: withFilter(list, current, "milestone", String(each.number)),
113 keywords: `${each.title} ${each.number}`,
114 selected: each.number === milestone,
115 label: <span className="truncate">{each.title}</span>,
116 }))}
117 />
118 {loaderData.base && (
119 <Hint label="Show pull requests into every branch">
120 <Link
121 to={withFilter(list, current, "base", null)}
122 className="inline-flex h-8 items-center gap-1.5 rounded-md border border-accent/40 bg-accent/5 px-2.5 text-sm"
123 >
124 Into <span className="font-mono">{loaderData.base}</span>
125 <span className="sr-only">: show pull requests into every branch</span>
126 </Link>
127 </Hint>
128 )}
129 {filtered && (
130 <Link to={state === "closed" ? `${list}?state=closed` : list} className="text-xs text-muted hover:text-fg">
131 Clear filters
132 </Link>
133 )}
134 <span className="ml-auto flex items-center gap-1">
135 <Link
136 to={`${base}/labels`}
137 className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm text-muted transition-colors hover:bg-surface hover:text-fg"
138 >
139 <Tag size={14} />
140 Labels
141 </Link>
142 <Link
143 to={`${base}/milestones`}
144 className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm text-muted transition-colors hover:bg-surface hover:text-fg"
145 >
146 <MilestoneIcon size={14} />
147 Milestones
148 </Link>
149 </span>
150 </div>
151 <div className="mt-4">
152 {pulls.length === 0 ? (
153 <EmptyState
154 title={
155 filtered
156 ? `No ${state} pull requests match these filters`
157 : state === "open"
158 ? "No open pull requests"
159 : "No closed pull requests"
160 }
161 >
162 A pull request proposes a change. Push a branch and open one, or
163 have your coding agent open one for an issue.
164 </EmptyState>
165 ) : (
166 <Card asChild tone="plain" divided className="overflow-hidden">
167 <ul>
168 {pulls.map((pull) => (
169 <li key={pull.id}>
170 <Link
171 prefetch="intent"
172 to={`${base}/pull/${pull.number}`}
173 className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface"
174 >
175 <span className="mt-0.5">
176 <PullIcon status={pull.status} />
177 </span>
178 <span className="min-w-0 grow">
179 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
180 <span className="min-w-0 truncate font-medium">{pull.title}</span>
181 {pull.status === "draft" && (
182 <Badge className="px-1.5 font-normal text-faint">
183 draft
184 </Badge>
185 )}
186 {(pull.labels ?? []).map((name) => (
187 <LabelChip key={name} name={name} color={colors[name]} />
188 ))}
189 </span>
190 <span className="mt-0.5 block text-xs text-faint">
191 #{pull.number} opened <TimeAgo at={pull.createdAt} /> by{" "}
192 {openedBy(pull).name}
193 {pull.requestedBy && <> for {pull.requestedBy.username}</>}
194 {pull.issue != null && <> · for #{pull.issue}</>}
195 {pull.supersededBy != null && <> · superseded by #{pull.supersededBy}</>}
196 {pull.milestone && (
197 <>
198 {" "}
199 · <MilestoneIcon size={11} className="inline" /> {pull.milestone.title}
200 </>
201 )}
202 {/* Into a branch other than the default one: said. */}
203 {pull.base && defaultBranch && pull.base !== defaultBranch && (
204 <>
205 {" "}
206 · <ArrowLeft size={11} className="inline" /> into{" "}
207 <span className="font-mono">{pull.base}</span>
208 </>
209 )}
210 </span>
211 </span>
212 <span className="mt-0.5 hidden sm:block">
213 <ChangeSize files={pull.files} />
214 </span>
215 <span className="mt-0.5">
216 <CheckBadge status={pull.checkStatus} />
217 </span>
218 <span className="mt-0.5 flex shrink-0 items-center gap-1 font-mono text-xs text-muted">
219 {pull.branch ? <GitBranch size={13} /> : <Bot size={13} />}
220 {pull.branch ?? pull.agent}
221 </span>
222 </Link>
223 </li>
224 ))}
225 </ul>
226 </Card>
227 )}
228 </div>
229 </div>
230 );
231}