| 1 | import { Box, GitBranch, GitFork } from "lucide-react"; |
| 2 | import { Form, redirect, useNavigate } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/pull-new"; |
| 5 | import { page } from "../../lib/meta"; |
| 6 | import { cloneUrl, useAddresses } from "../../lib/addresses"; |
| 7 | import { Combobox } from "../../components/ui/combobox"; |
| 8 | import { Hint } from "../../components/ui/hint"; |
| 9 | import { |
| 10 | SubmitButton, |
| 11 | CopyLine, |
| 12 | EmptyState, |
| 13 | ErrorText, |
| 14 | Field, |
| 15 | Input, |
| 16 | Textarea, |
| 17 | } from "../../components/ui"; |
| 18 | import { Card } from "../../components/ui/card"; |
| 19 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| 20 | import { accessFor } from "../../lib/access.server"; |
| 21 | import { repos, work } from "../../lib/services.server"; |
| 22 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; |
| 23 | |
| 24 | export 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 | */ |
| 33 | function 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 | |
| 39 | export 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 | |
| 102 | export 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 | |
| 128 | export 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 | } |