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.

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