Skip to content
164 linesCodeBlameRaw
1import { GitBranch } from "lucide-react";
2import { Form, redirect } 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 { useMirrorReason } from "../../components/mirror";
10import {
11 SubmitButton,
12 CopyLine,
13 EmptyState,
14 ErrorText,
15 Field,
16 Input,
17 Textarea,
18} from "../../components/ui";
19import { Card } from "../../components/ui/card";
20import { repos, work } from "../../lib/services.server";
21import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
22import { requireWritableRepo } from "../../lib/access.server";
23
24export function meta({ params, ...args }: Route.MetaArgs) {
25 return page(args, { title: `New pull request · ${params.owner}/${params.repo} · g1t` });
26}
27
28export async function loader({ request, params, context }: Route.LoaderArgs) {
29 const user = requireUser(context, request);
30 const path = { namespace: params.owner, name: params.repo };
31 const [repo, branches, open] = await Promise.all([
32 repos.get(path, user),
33 repos.branches(path, user),
34 work.listPulls(path, user, "open"),
35 ]);
36 const { defaultBranch } = unwrap(repo);
37 const all = unwrap(branches).filter((branch) => !branch.name.startsWith("g1t-queue/"));
38 const query = new URL(request.url).searchParams;
39 // Into the default branch unless another is asked for (Compare's base).
40 const asked = query.get("base") ?? "";
41 const base = all.some((branch) => branch.name === asked) ? asked : defaultBranch;
42 const baseHead = all.find((branch) => branch.name === base)?.hash;
43 // A branch that already has an open pull request into this base cannot
44 // have another.
45 const taken = new Set(open.ok ? open.value.filter((pull) => (pull.base ?? defaultBranch) === base).map((pull) => pull.branch) : []);
46 return {
47 defaultBranch,
48 base,
49 bases: all.map((branch) => branch.name),
50 branches: all
51 // A branch at the same commit as the base has nothing to merge.
52 .filter((branch) => branch.name !== base && branch.hash !== baseHead)
53 .map((branch) => branch.name)
54 .filter((name) => !taken.has(name)),
55 selected: query.get("branch") ?? "",
56 issue: query.get("issue") ?? "",
57 };
58}
59
60export async function action({ request, params, context }: Route.ActionArgs) {
61 assertSameOrigin(request);
62 const user = requireUser(context, request);
63 await requireWritableRepo(context, params);
64 const form = await request.formData();
65 const issue = Number(form.get("issue"));
66 const result = await work.openPull(
67 user,
68 { namespace: params.owner, name: params.repo },
69 {
70 branch: String(form.get("branch") ?? ""),
71 title: String(form.get("title") ?? ""),
72 body: String(form.get("body") ?? ""),
73 issue: issue > 0 ? issue : undefined,
74 agent: user.username,
75 runtime: "external",
76 base: String(form.get("base") ?? "") || undefined,
77 draft: form.get("intent") === "draft",
78 },
79 );
80 if (!result.ok) return { error: result.error.message };
81 throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
82}
83
84export default function NewPull({ loaderData, actionData, params }: Route.ComponentProps) {
85 const { defaultBranch, base, bases, branches, selected, issue } = loaderData;
86 const remote = cloneUrl(useAddresses(), `${params.owner}/${params.repo}`);
87 // A mirror takes no pull requests until someone takes over.
88 const mirrorBlocked = useMirrorReason();
89
90 if (branches.length === 0) {
91 return (
92 <div className="max-w-2xl">
93 <EmptyState title="No branch to open a pull request from">
94 Push a branch to this repository, then come back.
95 </EmptyState>
96 <div className="mt-4 space-y-2">
97 <CopyLine prompt text="git switch -c my-change" />
98 <CopyLine prompt text={`git push ${remote} my-change`} />
99 </div>
100 <p className="mt-4 text-sm text-muted">
101 Agents do not need a branch. Open an issue and assign agents to it,
102 and each gets a pull request with its own fork.
103 </p>
104 </div>
105 );
106 }
107
108 return (
109 <Form method="post" className="max-w-2xl space-y-4">
110 <Card className="flex flex-wrap items-center gap-2 px-4 py-3 text-sm">
111 <GitBranch size={15} className="text-faint" />
112 <span className="text-muted">Merge</span>
113 <Combobox
114 name="branch"
115 defaultValue={branches.includes(selected) ? selected : branches[0]}
116 aria-label="Branch to merge"
117 searchPlaceholder="Find a branch"
118 emptyText="No branch by that name."
119 options={branches.map((name) => ({ value: name, label: name, icon: <GitBranch /> }))}
120 className="h-8 w-auto max-w-full min-w-40 font-mono"
121 />
122 <span className="text-muted">into</span>
123 <Combobox
124 name="base"
125 defaultValue={base}
126 aria-label="Branch to merge into"
127 searchPlaceholder="Find a branch"
128 emptyText="No branch by that name."
129 options={bases.map((name) => ({
130 value: name,
131 label: name,
132 icon: <GitBranch />,
133 description: name === defaultBranch ? "default" : undefined,
134 }))}
135 className="h-8 w-auto max-w-full min-w-32 font-mono"
136 />
137 </Card>
138 <Field label="Title">
139 <Input name="title" required autoFocus maxLength={200} />
140 </Field>
141 <Field label="Description" hint="What changed and why. Markdown works.">
142 <Textarea name="body" rows={8} />
143 </Field>
144 <Field
145 label="Issue (optional)"
146 hint={`The number of the issue this resolves. Merging the pull request into ${defaultBranch} closes it.`}
147 >
148 <Input name="issue" type="number" min={1} defaultValue={issue} placeholder="12" />
149 </Field>
150 <ErrorText>{actionData?.error}</ErrorText>
151 <div className="flex flex-wrap items-center gap-2">
152 <Hint label={mirrorBlocked} disabled={mirrorBlocked != null}>
153 <SubmitButton name="intent" value="open" match={{ intent: "open" }} pending="Opening…" disabled={mirrorBlocked != null}>
154 Open pull request
155 </SubmitButton>
156 </Hint>
157 <SubmitButton name="intent" value="draft" match={{ intent: "draft" }} variant="outline" pending="Opening…" disabled={mirrorBlocked != null}>
158 Open as draft
159 </SubmitButton>
160 <span className="text-xs text-faint">A draft can't merge, and agents' review routines wait, until you mark it ready.</span>
161 </div>
162 </Form>
163 );
164}