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.

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