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.

273 linesCodeBlameRaw
1import { Box, GitBranch, GitFork } from "lucide-react";
2import { Form, redirect, useNavigate } from "react-router";
3
4import type { Route } from "./+types/pull-new";
5import { page } from "../../lib/meta";
6import { cloneUrl, useAddresses } from "../../lib/addresses";
7import { Combobox } from "../../components/ui/combobox";
8import { Hint } from "../../components/ui/hint";
9import {
10 SubmitButton,
11 CopyLine,
12 EmptyState,
13 ErrorText,
14 Field,
15 Input,
16 Textarea,
17} from "../../components/ui";
18import { Card } from "../../components/ui/card";
19import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
20import { accessFor } from "../../lib/access.server";
21import { repos, work } from "../../lib/services.server";
22import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
23import { Unavailable } from "../../components/boundary";
24import { type Failed, isFailed, part } from "../../lib/partial";
25
26export function meta({ params, ...args }: Route.MetaArgs) {
27 return page(args, { title: `New pull request · ${params.owner}/${params.repo} · g1t` });
28}
29
30/**
31 * `?head=` names where the change is: a branch of this repository
32 * (`branch`), or a branch of a fork of it (`namespace/name:branch`, or
33 * `namespace/name:` for its branches), as a fork's Contribute link asks.
34 */
35function parseHead(head: string): { repo: string | null; branch: string } {
36 const colon = head.indexOf(":");
37 if (colon < 0) return { repo: null, branch: head };
38 return { repo: head.slice(0, colon).toLowerCase() || null, branch: head.slice(colon + 1) };
39}
40
41export async function loader({ request, params, context }: Route.LoaderArgs) {
42 const user = requireUser(context, request);
43 const path = { namespace: params.owner, name: params.repo };
44 const query = new URL(request.url).searchParams;
45 const head = parseHead(query.get("head") ?? "");
46 // The repository and its branches are the form. The rest are parts of it:
47 // the open pull requests only take branches that have one off the list
48 // (opening another is refused anyway), and the forks say so when they fail.
49 const [repo, branches, open, forks] = await Promise.all([
50 repos.get(path, user),
51 repos.branches(path, user),
52 part(work.listPulls(path, user, "open"), "Open pull requests"),
53 // Forks of it the viewer can push to: pull requests can come from their branches.
54 part(repos.forks(path, user, 100).then((found) => (found.ok ? found.value : [])), "Your forks"),
55 ]);
56 const { defaultBranch } = unwrap(repo);
57 const all = unwrap(branches).filter((branch) => !branch.name.startsWith("g1t-queue/"));
58 const theirs = isFailed(forks) ? [] : forks.filter((fork) => accessFor(user, fork).can.push);
59 const self = `${params.owner}/${params.repo}`.toLowerCase();
60 // From a fork when one of theirs is asked for; this repository otherwise.
61 // One asked for by name is read itself, so an older fork, or a fork of a
62 // fork, is found too; opening the pull request checks it is one of this.
63 const listed = head.repo && head.repo !== self ? (theirs.find((f) => `${f.namespace}/${f.name}`.toLowerCase() === head.repo) ?? null) : null;
64 const [askedNamespace, askedName] = (head.repo ?? "").split("/");
65 const lookedUp =
66 listed == null && head.repo && head.repo !== self && askedNamespace && askedName
67 ? await part(
68 repos
69 .get({ namespace: askedNamespace, name: askedName }, user)
70 .then((found) => (found.ok && found.value.parentId && accessFor(user, found.value).can.push ? found.value : null)),
71 "The fork",
72 )
73 : null;
74 const fork = listed ?? (isFailed(lookedUp) ? null : lookedUp);
75 if (fork && !theirs.some((f) => f.id === fork.id)) theirs.unshift(fork);
76 const headRepo = fork ? { namespace: fork.namespace, name: fork.name } : null;
77 const headFound = headRepo
78 ? await part(repos.branches(headRepo, user).then((found) => (found.ok ? found.value : [])), "The fork's branches")
79 : all;
80 // A fork asked for whose branches could not be read: the form says so,
81 // rather than offering this repository's branches or none.
82 const headFailed: Failed | null = isFailed(lookedUp) ? lookedUp : isFailed(headFound) ? headFound : null;
83 const headBranches = isFailed(headFound) ? [] : headFound;
84 // Into the default branch unless another is asked for (Compare's base).
85 const asked = query.get("base") ?? "";
86 const base = all.some((branch) => branch.name === asked) ? asked : defaultBranch;
87 const baseHead = all.find((branch) => branch.name === base)?.hash;
88 // A branch that already has an open pull request into this base cannot
89 // have another: from the same repository, this one or the fork.
90 const key = (p: { namespace: string; name: string } | null | undefined) => (p ? `${p.namespace}/${p.name}`.toLowerCase() : "");
91 const taken = new Set(
92 !isFailed(open) && open.ok
93 ? open.value.filter((pull) => (pull.base ?? defaultBranch) === base && key(pull.headRepo) === key(headRepo)).map((pull) => pull.branch)
94 : [],
95 );
96 return {
97 defaultBranch,
98 base,
99 bases: all.map((branch) => branch.name),
100 branches: headBranches
101 .filter((branch) => !branch.name.startsWith("g1t-queue/"))
102 // A branch at the same commit as the base has nothing to merge.
103 .filter((branch) => (headRepo || branch.name !== base) && branch.hash !== baseHead)
104 .map((branch) => branch.name)
105 .filter((name) => !taken.has(name)),
106 headRepo,
107 headFailed,
108 forks: theirs.map((f) => ({ namespace: f.namespace, name: f.name })),
109 forksFailed: isFailed(forks) ? forks : null,
110 selected: query.get("branch") ?? head.branch,
111 item: query.get("item") ?? "",
112 };
113}
114
115export async function action({ request, params, context }: Route.ActionArgs) {
116 assertSameOrigin(request);
117 const user = requireUser(context, request);
118 const form = await request.formData();
119 const item = Number(form.get("item"));
120 // From a fork: `namespace/name`; absent for a branch of this repository.
121 const [headNamespace, headName] = String(form.get("headRepo") ?? "").split("/");
122 const result = await work.openPull(
123 user,
124 { namespace: params.owner, name: params.repo },
125 {
126 branch: String(form.get("branch") ?? ""),
127 headRepo: headNamespace && headName ? { namespace: headNamespace, name: headName } : undefined,
128 title: String(form.get("title") ?? ""),
129 body: String(form.get("body") ?? ""),
130 item: item > 0 ? item : undefined,
131 agent: user.username,
132 runtime: "external",
133 base: String(form.get("base") ?? "") || undefined,
134 draft: form.get("intent") === "draft",
135 },
136 );
137 if (!result.ok) return { error: result.error.message };
138 throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
139}
140
141export default function NewPull({ loaderData, actionData, params }: Route.ComponentProps) {
142 const { defaultBranch, base, bases, branches, selected, item, headRepo, headFailed, forks, forksFailed } = loaderData;
143 const navigate = useNavigate();
144 const self = `${params.owner}/${params.repo}`;
145 const from = headRepo ? `${headRepo.namespace}/${headRepo.name}` : self;
146 const remote = cloneUrl(useAddresses(), from);
147 // Another repository's branches come with the page: choosing one loads them.
148 const choose = (repo: string) => {
149 const next = new URLSearchParams({ base });
150 if (repo !== self) next.set("head", `${repo}:`);
151 if (item) next.set("item", item);
152 navigate(`?${next}`, { replace: true });
153 };
154 const picker =
155 forks.length > 0 ? (
156 <Select value={from} onValueChange={choose}>
157 <SelectTrigger aria-label="Repository the change is in" size="sm" className="w-auto max-w-full min-w-40 font-mono">
158 <SelectValue />
159 </SelectTrigger>
160 <SelectContent>
161 <SelectItem value={self} icon={<Box />} className="font-mono">
162 {self}
163 </SelectItem>
164 {forks.map((fork) => (
165 <SelectItem key={`${fork.namespace}/${fork.name}`} value={`${fork.namespace}/${fork.name}`} icon={<GitFork />} className="font-mono">
166 {fork.namespace}/{fork.name}
167 </SelectItem>
168 ))}
169 </SelectContent>
170 </Select>
171 ) : null;
172
173 // Forks that could not be read: it says so under the repository the change is in.
174 const forksNote = forksFailed ? <Unavailable what={forksFailed.what} compact /> : null;
175
176 if (headFailed) {
177 return (
178 <div className="max-w-2xl space-y-4">
179 {picker && (
180 <Card className="flex flex-wrap items-center gap-2 px-4 py-3 text-sm">
181 <span className="text-muted">From</span>
182 {picker}
183 </Card>
184 )}
185 <Unavailable what={headFailed.what} />
186 </div>
187 );
188 }
189
190 if (branches.length === 0) {
191 return (
192 <div className="max-w-2xl">
193 {picker && (
194 <Card className="mb-4 flex flex-wrap items-center gap-2 px-4 py-3 text-sm">
195 <span className="text-muted">From</span>
196 {picker}
197 </Card>
198 )}
199 {forksNote && <div className="mb-4">{forksNote}</div>}
200 <EmptyState title="No branch to open a pull request from">
201 {headRepo ? `Push a branch with your change to ${from}, then come back.` : "Push a branch to this repository, then come back."}
202 </EmptyState>
203 <div className="mt-4 space-y-2">
204 <CopyLine prompt text="git switch -c my-change" />
205 <CopyLine prompt text={`git push ${remote} my-change`} />
206 </div>
207 <p className="mt-4 text-sm text-muted">
208 A coding agent connected through the MCP server does not need a branch: it can
209 open a draft pull request for a work item and get a fork of its own to push to.
210 </p>
211 </div>
212 );
213 }
214
215 return (
216 <Form method="post" className="max-w-2xl space-y-4">
217 <Card className="flex flex-wrap items-center gap-2 px-4 py-3 text-sm">
218 <GitBranch size={15} className="text-faint" />
219 <span className="text-muted">Merge</span>
220 {picker}
221 {headRepo && <input type="hidden" name="headRepo" value={from} />}
222 <Combobox
223 key={from}
224 name="branch"
225 defaultValue={branches.includes(selected) ? selected : branches[0]}
226 aria-label="Branch to merge"
227 searchPlaceholder="Find a branch"
228 emptyText="No branch by that name."
229 options={branches.map((name) => ({ value: name, label: name, icon: <GitBranch /> }))}
230 className="h-8 w-auto max-w-full min-w-40 font-mono"
231 />
232 <span className="text-muted">into</span>
233 <Combobox
234 name="base"
235 defaultValue={base}
236 aria-label="Branch to merge into"
237 searchPlaceholder="Find a branch"
238 emptyText="No branch by that name."
239 options={bases.map((name) => ({
240 value: name,
241 label: name,
242 icon: <GitBranch />,
243 description: name === defaultBranch ? "default" : undefined,
244 }))}
245 className="h-8 w-auto max-w-full min-w-32 font-mono"
246 />
247 </Card>
248 {forksNote}
249 <Field label="Title">
250 <Input name="title" required autoFocus maxLength={200} />
251 </Field>
252 <Field label="Description" hint="What changed and why. Markdown works.">
253 <Textarea name="body" rows={8} />
254 </Field>
255 <Field
256 label="Work item (optional)"
257 hint={`The number of the workspace's work item this is for. Merging the pull request into ${defaultBranch} lands it here.`}
258 >
259 <Input name="item" type="number" inputMode="numeric" min={1} defaultValue={item} placeholder="12" />
260 </Field>
261 <ErrorText>{actionData?.error}</ErrorText>
262 <div className="flex flex-wrap items-center gap-2">
263 <SubmitButton name="intent" value="open" match={{ intent: "open" }} pending="Opening…">
264 Open pull request
265 </SubmitButton>
266 <SubmitButton name="intent" value="draft" match={{ intent: "draft" }} variant="outline" pending="Opening…">
267 Open as draft
268 </SubmitButton>
269 <span className="text-xs text-faint">A draft can't merge until you mark it ready.</span>
270 </div>
271 </Form>
272 );
273}