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