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.

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