| 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 { SubmitButton, CopyLine, EmptyState, ErrorText, Field, Input } from "../../components/ui"; |
| 10 | import { Card } from "../../components/ui/card"; |
| 11 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| 12 | import { accessFor } from "../../lib/access.server"; |
| 13 | import { repos, work } from "../../lib/services.server"; |
| 14 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; |
| 15 | import { Unavailable } from "../../components/boundary"; |
| 16 | import { AttachImages } from "../../components/attach-images"; |
| 17 | import { MentionTextarea } from "../../components/mention-textarea"; |
| 18 | import { type Failed, isFailed, part } from "../../lib/partial"; |
| 19 | |
| 20 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 21 | return page(args, { title: `New pull request · ${params.owner}/${params.repo} · g1t` }); |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * `?head=` names where the change is: a branch of this repository |
| 26 | * (`branch`), or a branch of a fork of it (`namespace/name:branch`, or |
| 27 | * `namespace/name:` for its branches), as a fork's Contribute link asks. |
| 28 | */ |
| 29 | function parseHead(head: string): { repo: string | null; branch: string } { |
| 30 | const colon = head.indexOf(":"); |
| 31 | if (colon < 0) return { repo: null, branch: head }; |
| 32 | return { repo: head.slice(0, colon).toLowerCase() || null, branch: head.slice(colon + 1) }; |
| 33 | } |
| 34 | |
| 35 | export async function loader({ request, params, context }: Route.LoaderArgs) { |
| 36 | const user = requireUser(context, request); |
| 37 | const path = { namespace: params.owner, name: params.repo }; |
| 38 | const query = new URL(request.url).searchParams; |
| 39 | const head = parseHead(query.get("head") ?? ""); |
| 40 | // The repository and its branches are the form. The rest are parts of it: |
| 41 | // the open pull requests only take branches that have one off the list |
| 42 | // (opening another is refused anyway), and the forks say so when they fail. |
| 43 | const [repo, branches, open, forks] = await Promise.all([ |
| 44 | repos.get(path, user), |
| 45 | repos.branches(path, user), |
| 46 | part(work.listPulls(path, user, "open"), "Open pull requests"), |
| 47 | // Forks of it the viewer can push to: pull requests can come from their branches. |
| 48 | part( |
| 49 | repos.forks(path, user, 100).then((found) => (found.ok ? found.value : [])), |
| 50 | "Your forks", |
| 51 | ), |
| 52 | ]); |
| 53 | const { defaultBranch } = unwrap(repo); |
| 54 | const all = unwrap(branches).filter((branch) => !branch.name.startsWith("g1t-queue/")); |
| 55 | const theirs = isFailed(forks) ? [] : forks.filter((fork) => accessFor(user, fork).can.push); |
| 56 | const self = `${params.owner}/${params.repo}`.toLowerCase(); |
| 57 | // From a fork when one of theirs is asked for; this repository otherwise. |
| 58 | // One asked for by name is read itself, so an older fork, or a fork of a |
| 59 | // fork, is found too; opening the pull request checks it is one of this. |
| 60 | const listed = head.repo && head.repo !== self ? (theirs.find((f) => `${f.namespace}/${f.name}`.toLowerCase() === head.repo) ?? null) : null; |
| 61 | const [askedNamespace, askedName] = (head.repo ?? "").split("/"); |
| 62 | const lookedUp = |
| 63 | listed == null && head.repo && head.repo !== self && askedNamespace && askedName |
| 64 | ? await part( |
| 65 | repos |
| 66 | .get({ namespace: askedNamespace, name: askedName }, user) |
| 67 | .then((found) => (found.ok && found.value.parentId && accessFor(user, found.value).can.push ? found.value : null)), |
| 68 | "The fork", |
| 69 | ) |
| 70 | : null; |
| 71 | const fork = listed ?? (isFailed(lookedUp) ? null : lookedUp); |
| 72 | if (fork && !theirs.some((f) => f.id === fork.id)) theirs.unshift(fork); |
| 73 | const headRepo = fork ? { namespace: fork.namespace, name: fork.name } : null; |
| 74 | const headFound = headRepo |
| 75 | ? await part( |
| 76 | repos.branches(headRepo, user).then((found) => (found.ok ? found.value : [])), |
| 77 | "The fork's branches", |
| 78 | ) |
| 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 | |
| 115 | export 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 | |
| 141 | export 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 open a draft pull request for a work item and get a fork of its own to |
| 209 | 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" htmlFor="pull-body" hint="What changed and why. Markdown works."> |
| 253 | <AttachImages to={{ repo: self }}> |
| 254 | <MentionTextarea id="pull-body" name="body" rows={8} /> |
| 255 | </AttachImages> |
| 256 | </Field> |
| 257 | <Field |
| 258 | label="Work item (optional)" |
| 259 | hint={`The number of the workspace's work item this is for. Merging the pull request into ${defaultBranch} lands it here.`} |
| 260 | > |
| 261 | <Input name="item" type="number" inputMode="numeric" min={1} defaultValue={item} placeholder="12" /> |
| 262 | </Field> |
| 263 | <ErrorText>{actionData?.error}</ErrorText> |
| 264 | <div className="flex flex-wrap items-center gap-2"> |
| 265 | <SubmitButton name="intent" value="open" match={{ intent: "open" }} pending="Opening…"> |
| 266 | Open pull request |
| 267 | </SubmitButton> |
| 268 | <SubmitButton name="intent" value="draft" match={{ intent: "draft" }} variant="outline" pending="Opening…"> |
| 269 | Open as draft |
| 270 | </SubmitButton> |
| 271 | <span className="text-xs text-faint">A draft can't merge until you mark it ready.</span> |
| 272 | </div> |
| 273 | </Form> |
| 274 | ); |
| 275 | } |