Skip to content

g1t/apps/web/app/routes/repo/issue-new.tsx

224 lines9,724 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Download, Sparkles } from "lucide-react";
3import { Form, Link, redirect, useSearchParams } from "react-router";
4
5import { PROVIDERS } from "@g1t/contracts";
6
7import type { Route } from "./+types/issue-new";
8import { page } from "../../lib/meta";
9import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui";
10import { CheckboxOption } from "../../components/ui/checkbox";
11import { LabelChip } from "../../components/labels";
12import { integrations, work } from "../../lib/services.server";
13import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
14import { accessTo } from "../../lib/access.server";
15import { computeNoteFor } from "../../lib/compute.server";
16import { delegateForm, issuePath, notStarted } from "../../lib/delegate";
17
18export function meta({ params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `New issue · ${params.owner}/${params.repo} · g1t` });
20}
21
22export async function loader({ request, params, context }: Route.LoaderArgs) {
23 const user = requireUser(context, request);
24 const path = { namespace: params.owner, name: params.repo };
25 // Putting an agent on it needs Write (Run): Read cannot spend compute.
26 const { can } = await accessTo(context, params);
27 const [labels, milestones, connections, agents, computeNote] = await Promise.all([
28 work.listLabels(path, user),
29 can.triage ? work.listMilestones(path, user, "open") : null,
30 integrations.list(params.owner.toLowerCase(), user),
31 can.run ? env.RUNNER.enabled(user, path) : false,
32 can.run ? computeNoteFor(params.owner, "agent") : null,
33 ]);
34 // Systems a ticket can be imported from.
35 const sources = connections.ok
36 ? [...new Set(connections.value.filter((c) => c.kind === "tracker" || c.provider === "sentry").map((c) => c.provider))]
37 : [];
38 return {
39 labels: unwrap(labels),
40 milestones: milestones?.ok ? milestones.value : [],
41 // Making labels, and choosing a milestone, need Triage.
42 canTriage: can.triage,
43 sources,
44 canAssign: can.run,
45 agents,
46 computeNote,
47 };
48}
49
50export async function action({ request, params, context }: Route.ActionArgs) {
51 assertSameOrigin(request);
52 const user = requireUser(context, request);
53 const form = await request.formData();
54 if (form.get("intent") === "import") {
55 const imported = await integrations.import(
56 user,
57 { namespace: params.owner, name: params.repo },
58 String(form.get("reference") ?? ""),
59 form.get("assign") === "on",
60 );
61 if (!imported.ok) return { importError: imported.error.message };
62 throw redirect(`/${params.owner}/${params.repo}/issues/${imported.value.number}`);
63 }
64 const path = { namespace: params.owner, name: params.repo };
65 // Assigned to g1t as it opens: one step, and the agent starts.
66 if (form.get("agent") === "on") {
67 const delegated = await env.RUNNER.delegate(user, path, delegateForm(form));
68 if (!delegated.ok) return { error: delegated.error.message };
69 const { issue, agent } = delegated.value;
70 const refused = notStarted(agent, path, issue.number);
71 if (!refused) throw redirect(issuePath(path, issue.number));
72 return { notStarted: refused };
73 }
74 const result = await work.openIssue(
75 user,
76 path,
77 {
78 title: String(form.get("title") ?? ""),
79 body: String(form.get("body") ?? ""),
80 labels: [
81 ...form.getAll("label").map(String),
82 ...String(form.get("labels") ?? "").split(","),
83 ],
84 milestone: Number(form.get("milestone")) || undefined,
85 },
86 );
87 if (!result.ok) return { error: result.error.message };
88 throw redirect(`/${params.owner}/${params.repo}/issues/${result.value.number}`);
89}
90
91export default function NewIssue({ loaderData, actionData }: Route.ComponentProps) {
92 // "Put an agent on …" in the palette arrives with ?agent=1.
93 const [params] = useSearchParams();
94 const refused = actionData && "notStarted" in actionData ? actionData.notStarted : null;
95 const names = loaderData.sources.map((source) => PROVIDERS[source].label);
96 return (
97 <div className="max-w-2xl">
98 {names.length > 0 && (
99 <Form method="post" className="mb-8 rounded-xl border border-line bg-surface p-4">
100 <input type="hidden" name="intent" value="import" />
101 <p className="flex items-center gap-2 text-sm font-medium">
102 <Download size={15} className="text-muted" />
103 Bring one in from {names.join(" or ")}
104 </p>
105 <p className="mt-1 text-xs text-muted">
106 Give a key such as TECH-1234 or paste its address. The issue stays linked: agents read the
107 original, and it hears back when the work lands.
108 </p>
109 <div className="mt-3 flex flex-wrap items-center gap-2">
110 <div className="min-w-56 grow">
111 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" />
112 </div>
113 <CheckboxOption name="assign" label="Put an agent on it" className="items-center" labelClassName="text-muted" />
114 <SubmitButton variant="quiet" match={{ intent: "import" }} pending="Importing…">
115 Import
116 </SubmitButton>
117 </div>
118 {actionData && "importError" in actionData && (
119 <div className="mt-2">
120 <ErrorText>{actionData.importError}</ErrorText>
121 </div>
122 )}
123 </Form>
124 )}
125 <Form method="post" className="space-y-4">
126 <Field label="Title">
127 <Input
128 name="title"
129 required
130 autoFocus
131 maxLength={200}
132 placeholder="Parser drops the last line of a file without a trailing newline"
133 />
134 </Field>
135 <Field
136 label="Description"
137 hint="What is wrong or wanted, and anything needed to act on it. An agent given this issue works from this text. To say what done looks like, add a list under a “## Definition of done” heading. Pull requests for it merge once the checks the default branch requires pass."
138 >
139 <Textarea name="body" rows={8} />
140 </Field>
141 <fieldset>
142 <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend>
143 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
144 {loaderData.labels.map((label) => (
145 <CheckboxOption
146 key={label.name}
147 name="label"
148 value={label.name}
149 label={<LabelChip name={label.name} color={label.color} />}
150 className="items-center gap-1.5"
151 />
152 ))}
153 </div>
154 {loaderData.canTriage && (
155 <div className="mt-2">
156 <Input
157 name="labels"
158 aria-label="New labels"
159 placeholder="New labels, separated by commas: performance, area: cli"
160 />
161 </div>
162 )}
163 </fieldset>
164 {loaderData.canTriage && loaderData.milestones.length > 0 && (
165 <Field label="Milestone">
166 <select
167 name="milestone"
168 defaultValue=""
169 className="h-9 w-full rounded-md border border-line bg-bg px-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
170 >
171 <option value="">None</option>
172 {loaderData.milestones.map((milestone) => (
173 <option key={milestone.number} value={milestone.number}>
174 {milestone.title}
175 {milestone.dueOn ? ` (due ${milestone.dueOn})` : ""}
176 </option>
177 ))}
178 </select>
179 </Field>
180 )}
181 {loaderData.canAssign && (
182 <div className="rounded-xl border border-accent/25 bg-accent/[0.04] px-3.5 py-3">
183 <CheckboxOption
184 name="agent"
185 defaultChecked={params.get("agent") === "1"}
186 label={
187 <span className="flex items-center gap-1.5 font-medium">
188 <Sparkles size={14} className="text-accent" />
189 Assign g1t now
190 </span>
191 }
192 description="It opens a pull request for this issue in a sandbox of its own and sees it through: this repository's workflows run on it as its checks, and it revises until the required ones pass and the review approves. There is no model or agent count to choose."
193 />
194 {(!loaderData.agents || loaderData.computeNote) && (
195 <p className="mt-2 pl-6 text-xs text-warn">
196 {loaderData.computeNote ?? "This workspace's agents have no model yet. The issue still opens, and says why the agent did not start."}
197 </p>
198 )}
199 </div>
200 )}
201 {refused && (
202 <div className="rounded-lg border border-warn/30 bg-warn/[0.06] p-3 text-sm">
203 <p>
204 Opened{" "}
205 <Link to={refused.to} className="font-medium text-fg hover:underline">
206 #{refused.number}
207 </Link>
208 , but g1t did not start. {refused.message}
209 </p>
210 {refused.fix && (
211 <Link to={refused.fix.to} className="mt-2 inline-block text-sm font-medium text-fg hover:underline">
212 {refused.fix.label}
213 </Link>
214 )}
215 </div>
216 )}
217 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
218 <SubmitButton name="intent" value="open" pending="Opening…">
219 Open issue
220 </SubmitButton>
221 </Form>
222 </div>
223 );
224}