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.

169 linesCodeBlameRaw
1import { Download } from "lucide-react";
2import { Form, redirect } from "react-router";
3
4import { PROVIDERS } from "@g1t/contracts";
5
6import type { Route } from "./+types/issue-new";
7import { page } from "../../lib/meta";
8import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui";
9import { Card } from "../../components/ui/card";
10import { CheckboxOption } from "../../components/ui/checkbox";
11import { SelectField } from "../../components/ui/select";
12import { LabelChip } from "../../components/labels";
13import { integrations, work } from "../../lib/services.server";
14import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
15import { accessTo } from "../../lib/access.server";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `New issue · ${params.owner}/${params.repo} · g1t` });
19}
20
21export async function loader({ request, params, context }: Route.LoaderArgs) {
22 const user = requireUser(context, request);
23 const path = { namespace: params.owner, name: params.repo };
24 const { can } = await accessTo(context, params);
25 const [labels, milestones, connections] = await Promise.all([
26 work.listLabels(path, user),
27 can.triage ? work.listMilestones(path, user, "open") : null,
28 integrations.list(params.owner.toLowerCase(), user),
29 ]);
30 // Systems a ticket can be imported from.
31 const sources = connections.ok
32 ? [...new Set(connections.value.filter((c) => c.kind === "tracker" || c.provider === "sentry").map((c) => c.provider))]
33 : [];
34 return {
35 labels: unwrap(labels),
36 milestones: milestones?.ok ? milestones.value : [],
37 // Making labels, and choosing a milestone, need Triage.
38 canTriage: can.triage,
39 // Making labels needs Write; choosing them and a milestone, Triage.
40 canMakeLabels: can.manage_labels,
41 sources,
42 };
43}
44
45export async function action({ request, params, context }: Route.ActionArgs) {
46 assertSameOrigin(request);
47 const user = requireUser(context, request);
48 const form = await request.formData();
49 if (form.get("intent") === "import") {
50 const imported = await integrations.import(
51 user,
52 { namespace: params.owner, name: params.repo },
53 String(form.get("reference") ?? ""),
54 );
55 if (!imported.ok) return { importError: imported.error.message };
56 throw redirect(`/${params.owner}/${params.repo}/issues/${imported.value.number}`);
57 }
58 const path = { namespace: params.owner, name: params.repo };
59 const result = await work.openIssue(
60 user,
61 path,
62 {
63 title: String(form.get("title") ?? ""),
64 body: String(form.get("body") ?? ""),
65 labels: [
66 ...form.getAll("label").map(String),
67 ...String(form.get("labels") ?? "").split(","),
68 ],
69 milestone: Number(form.get("milestone")) || undefined,
70 },
71 );
72 if (!result.ok) return { error: result.error.message };
73 throw redirect(`/${params.owner}/${params.repo}/issues/${result.value.number}`);
74}
75
76export default function NewIssue({ loaderData, actionData }: Route.ComponentProps) {
77 const names = loaderData.sources.map((source) => PROVIDERS[source].label);
78 return (
79 <div className="max-w-2xl">
80 {names.length > 0 && (
81 <Card asChild className="mb-8 p-4">
82 <Form method="post">
83 <input type="hidden" name="intent" value="import" />
84 <p className="flex items-center gap-2 text-sm font-medium">
85 <Download size={15} className="text-muted" />
86 Bring one in from {names.join(" or ")}
87 </p>
88 <p className="mt-1 text-xs text-muted">
89 Give a key such as TECH-1234 or paste its address. The issue stays linked to the
90 original, and it hears back when the work lands.
91 </p>
92 <div className="mt-3 flex flex-wrap items-center gap-2">
93 <div className="min-w-56 grow">
94 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" />
95 </div>
96 <SubmitButton variant="outline" match={{ intent: "import" }} pending="Importing…">
97 Import
98 </SubmitButton>
99 </div>
100 {actionData && "importError" in actionData && (
101 <div className="mt-2">
102 <ErrorText>{actionData.importError}</ErrorText>
103 </div>
104 )}
105 </Form>
106 </Card>
107 )}
108 <Form method="post" className="space-y-4">
109 <Field label="Title">
110 <Input
111 name="title"
112 required
113 autoFocus
114 maxLength={200}
115 placeholder="Parser drops the last line of a file without a trailing newline"
116 />
117 </Field>
118 <Field
119 label="Description"
120 hint="What is wrong or wanted, and anything needed to act on it. A coding 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."
121 >
122 <Textarea name="body" rows={8} />
123 </Field>
124 <fieldset>
125 <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend>
126 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
127 {loaderData.labels.map((label) => (
128 <CheckboxOption
129 key={label.name}
130 name="label"
131 value={label.name}
132 label={<LabelChip name={label.name} color={label.color} />}
133 className="items-center gap-1.5"
134 />
135 ))}
136 </div>
137 {loaderData.canMakeLabels && (
138 <div className="mt-2">
139 <Input
140 name="labels"
141 aria-label="New labels"
142 placeholder="New labels, separated by commas: performance, area: cli"
143 />
144 </div>
145 )}
146 </fieldset>
147 {loaderData.canTriage && loaderData.milestones.length > 0 && (
148 <Field label="Milestone">
149 <SelectField
150 name="milestone"
151 defaultValue=""
152 options={[
153 { value: "", label: "None" },
154 ...loaderData.milestones.map((milestone) => ({
155 value: String(milestone.number),
156 label: milestone.dueOn ? `${milestone.title} (due ${milestone.dueOn})` : milestone.title,
157 })),
158 ]}
159 />
160 </Field>
161 )}
162 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
163 <SubmitButton name="intent" value="open" pending="Opening…">
164 Open issue
165 </SubmitButton>
166 </Form>
167 </div>
168 );
169}