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.

287 linesCodeBlameRaw
1import { Download, Info } from "lucide-react";
2import { Form, data, redirect } from "react-router";
3
4import { PROVIDERS, type User, type WorkItem } from "@g1t/contracts";
5
6import type { Route } from "./+types/new";
7import { page } from "../../../lib/meta";
8import { ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
9import { AttachImages } from "../../../components/attach-images";
10import { MentionTextarea } from "../../../components/mention-textarea";
11import { Card } from "../../../components/ui/card";
12import { CheckboxOption } from "../../../components/ui/checkbox";
13import { SelectField } from "../../../components/ui/select";
14import { LabelChip } from "../../../components/labels";
15import { Unavailable } from "../../../components/boundary";
16import { integrations, repos, work } from "../../../lib/services.server";
17import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
18import { canItem } from "../../../lib/work-access";
19import { isFailed, part } from "../../../lib/partial";
20import { itemPath } from "../../../lib/work-items";
21import { workspaceProjects } from "../../../lib/workspace-projects.server";
22import { addresses } from "../../../lib/addresses.server";
23import { DEFERRED_FILE_FIELD, DEFERRED_REF_FIELD, deferredRefs, resolveDeferred } from "../../../lib/attachments";
24import { toBase64 } from "../../../lib/avatar-upload";
25
26export function meta({ params, ...args }: Route.MetaArgs) {
27 return page(args, { title: `New work item · ${params.owner} · g1t` });
28}
29
30/** The repository a new item is filed from, from `?repo=web` (or `acme/web`): only one of this workspace. */
31function originOf(workspace: string, value: string | null): string | null {
32 const parts = (value ?? "").split("/").filter(Boolean);
33 if (parts.length === 1) return parts[0]!;
34 if (parts.length === 2 && parts[0]!.toLowerCase() === workspace) return parts[1]!;
35 return null;
36}
37
38export async function loader({ request, params, context }: Route.LoaderArgs) {
39 const user = requireUser(context, request);
40 const workspace = params.owner.toLowerCase();
41 const name = originOf(workspace, new URL(request.url).searchParams.get("repo"));
42 // Filed from a repository, it is that repository's (its origin): anyone
43 // who may take part there may. Filed in the workspace alone, a member.
44 const origin = name ? await repos.get({ namespace: workspace, name }, user) : null;
45 if (name && !origin?.ok) throw data(null, { status: 404 });
46 const ref = origin?.ok ? { id: origin.value.id, namespace: origin.value.namespace, isPrivate: origin.value.isPrivate } : null;
47 const member = roleIn(user, workspace) != null;
48 if (!member && !canItem(user, workspace, ref, "participate")) throw data(null, { status: 404 });
49 const triage = canItem(user, workspace, ref, "triage");
50 // The form is what this page is about; what fills its choices is a part
51 // each, which says so in its own place when its service fails.
52 const [labels, milestones, projects, connections] = await Promise.all([
53 part(work.listLabels(workspace, user), "Labels"),
54 triage ? part(work.listMilestones(workspace, user, "open"), "Milestones") : null,
55 // Where it may land: the workspace's repositories, for a member.
56 member ? part(workspaceProjects(workspace, user), "Repositories") : null,
57 // Only a hint at what can be imported: the page does without it.
58 member ? integrations.list(workspace, user).catch(() => null) : null,
59 ]);
60 const sources = connections?.ok ? [...new Set(connections.value.filter((c) => c.kind === "tracker" || c.provider === "sentry").map((c) => c.provider))] : [];
61 return {
62 workspace,
63 origin: name,
64 labels: isFailed(labels) ? labels : labels.ok ? labels.value : [],
65 milestones: isFailed(milestones) ? milestones : milestones?.ok ? milestones.value : [],
66 repos:
67 projects == null
68 ? []
69 : isFailed(projects)
70 ? projects
71 : projects.ok
72 ? projects.value
73 .filter((p) => p.workspace.toLowerCase() === workspace)
74 .map((p) => p.slug)
75 .sort()
76 : [],
77 canTriage: triage,
78 // Making labels needs Write in the workspace; choosing them and a milestone, Triage.
79 canMakeLabels: canItem(user, workspace, ref, "manage_labels"),
80 sources,
81 };
82}
83
84export async function action({ request, params, context }: Route.ActionArgs) {
85 assertSameOrigin(request);
86 const user = requireUser(context, request);
87 const workspace = params.owner.toLowerCase();
88 const form = await request.formData();
89 const origin = originOf(workspace, String(form.get("origin") ?? "") || null);
90 if (form.get("intent") === "import") {
91 const imported = await integrations.import(user, workspace, String(form.get("reference") ?? ""), {
92 repo: origin ? { namespace: workspace, name: origin } : undefined,
93 });
94 if (!imported.ok) return { importError: imported.error.message };
95 throw redirect(itemPath(imported.value.workspace, imported.value.number));
96 }
97 const result = await work.openWorkItem(user, {
98 workspace,
99 repo: origin ? { namespace: workspace, name: origin } : undefined,
100 targets: [...new Set(form.getAll("target").map(String).filter(Boolean))].map((name) => ({ namespace: workspace, name })),
101 title: String(form.get("title") ?? ""),
102 body: String(form.get("body") ?? ""),
103 labels: [...form.getAll("label").map(String), ...String(form.get("labels") ?? "").split(",")].map((label) => label.trim()).filter(Boolean),
104 milestone: Number(form.get("milestone")) || undefined,
105 });
106 if (!result.ok) return { error: result.error.message };
107 await attachHeld(user, result.value, form);
108 throw redirect(itemPath(result.value.workspace, result.value.number));
109}
110
111/**
112 * Images put in the description of a work item filed with no repository
113 * (components/attach-images.tsx, `later`): with nothing to keep them until
114 * it was open, they came with the form as `![name](attachment:<ref>)`.
115 * Now the work item keeps them, and its description gets their addresses,
116 * as `g1t work new --attach` does. One that fails to upload is taken out
117 * of the description; the box checked its type and size before it was sent.
118 */
119async function attachHeld(user: User, item: WorkItem, form: FormData) {
120 const files = form.getAll(DEFERRED_FILE_FIELD);
121 const refs = form.getAll(DEFERRED_REF_FIELD).map(String);
122 const body = item.body ?? "";
123 const wanted = deferredRefs(body);
124 if (wanted.size === 0) return;
125 const urls = new Map<string, string | null>();
126 for (const [index, ref] of refs.entries()) {
127 const file = files[index];
128 if (!wanted.has(ref) || !file || typeof file === "string") continue;
129 const bytes = new Uint8Array(await file.arrayBuffer());
130 const kept = await work
131 .uploadAttachment(user, { workspace: item.workspace, number: item.number }, { name: file.name || "image.png", data: toBase64(bytes) })
132 .catch(() => null);
133 urls.set(ref, kept?.ok ? `${addresses().usercontent}${kept.value.path}` : null);
134 }
135 // Any still not uploaded (its file did not arrive) goes too.
136 for (const ref of wanted) if (!urls.has(ref)) urls.set(ref, null);
137 await work.updateWorkItem(user, item.workspace, item.number, { body: resolveDeferred(body, urls) }).catch(() => null);
138}
139
140export default function NewWorkItem({ loaderData, actionData }: Route.ComponentProps) {
141 const { origin, workspace } = loaderData;
142 const names = loaderData.sources.map((source) => PROVIDERS[source].label);
143 return (
144 <div className="max-w-2xl">
145 {origin && (
146 <p className="mb-6 flex items-start gap-2 rounded-lg border border-line bg-surface px-3 py-2.5 text-sm text-muted">
147 <Info size={15} className="mt-0.5 shrink-0 text-faint" />
148 <span>
149 Filed from{" "}
150 <span className="font-mono text-fg">
151 {workspace}/{origin}
152 </span>
153 , its origin: anyone who can read it sees this work item.
154 </span>
155 </p>
156 )}
157 {names.length > 0 && (
158 <Card asChild className="mb-8 p-4">
159 <Form method="post">
160 <input type="hidden" name="intent" value="import" />
161 <input type="hidden" name="origin" value={origin ?? ""} />
162 <p className="flex items-center gap-2 text-sm font-medium">
163 <Download size={15} className="text-muted" />
164 Bring one in from {names.join(" or ")}
165 </p>
166 <p className="mt-1 text-xs text-muted">
167 Give a key such as TECH-1234 or paste its address. The work item stays linked to the original, and it hears back when the work lands.
168 </p>
169 <div className="mt-3 flex flex-wrap items-center gap-2">
170 <div className="min-w-56 grow">
171 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" autoCapitalize="characters" />
172 </div>
173 <SubmitButton variant="outline" match={{ intent: "import" }} pending="Importing…">
174 Import
175 </SubmitButton>
176 </div>
177 {actionData && "importError" in actionData && (
178 <div className="mt-2">
179 <ErrorText>{actionData.importError}</ErrorText>
180 </div>
181 )}
182 </Form>
183 </Card>
184 )}
185 <Form method="post" encType="multipart/form-data" className="space-y-5">
186 <input type="hidden" name="origin" value={origin ?? ""} />
187 <Field label="Title">
188 <Input
189 name="title"
190 required
191 autoFocus
192 maxLength={200}
193 autoCapitalize="sentences"
194 enterKeyHint="next"
195 placeholder="Sign-in times out on slow networks"
196 />
197 </Field>
198 <Field
199 label="Description"
200 htmlFor="item-body"
201 hint="What is wrong or wanted, and anything needed to act on it. Whoever works on it, person or agent, works from this text. To say what done looks like, add a list under a “## Definition of done” heading."
202 >
203 {/* Filed from a repository, it keeps the images; with none, the work item does, once it is open. */}
204 <AttachImages to={origin ? { repo: `${workspace}/${origin}` } : "later"}>
205 <MentionTextarea id="item-body" name="body" rows={8} autoCapitalize="sentences" />
206 </AttachImages>
207 </Field>
208 {isFailed(loaderData.repos) ? (
209 <Field label="Where it lands">
210 <Unavailable what="The workspace's repositories" compact />
211 </Field>
212 ) : (
213 loaderData.repos.length > 0 && (
214 <fieldset>
215 <legend className="mb-1 text-sm font-medium text-muted">Where it lands</legend>
216 <p className="mb-2 text-xs text-muted">
217 Its targets: none, one or several. It closes once what closes it has landed in every one, or with none, once anything does.
218 </p>
219 <div className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
220 {loaderData.repos.map((name) => (
221 <CheckboxOption
222 key={name}
223 name="target"
224 value={name}
225 defaultChecked={name === origin}
226 label={<span className="font-mono text-sm">{name}</span>}
227 className="min-h-10 items-center gap-2"
228 />
229 ))}
230 </div>
231 </fieldset>
232 )
233 )}
234 <fieldset>
235 <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend>
236 {isFailed(loaderData.labels) ? (
237 <Unavailable what="The workspace's labels" compact />
238 ) : (
239 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
240 {loaderData.labels.map((label) => (
241 <CheckboxOption
242 key={label.name}
243 name="label"
244 value={label.name}
245 label={<LabelChip name={label.name} color={label.color} />}
246 className="min-h-8 items-center gap-1.5"
247 />
248 ))}
249 </div>
250 )}
251 {loaderData.canMakeLabels && (
252 <div className="mt-2">
253 <Input name="labels" aria-label="New labels" placeholder="New labels, separated by commas: performance, area: cli" autoCapitalize="none" />
254 </div>
255 )}
256 </fieldset>
257 {loaderData.canTriage && isFailed(loaderData.milestones) && (
258 <Field label="Milestone">
259 <Unavailable what="Milestones" compact />
260 </Field>
261 )}
262 {loaderData.canTriage && !isFailed(loaderData.milestones) && loaderData.milestones.length > 0 && (
263 <Field label="Milestone">
264 <SelectField
265 name="milestone"
266 defaultValue=""
267 options={[
268 { value: "", label: "None" },
269 ...loaderData.milestones.map((milestone) => ({
270 value: String(milestone.number),
271 label: milestone.dueOn ? `${milestone.title} (due ${milestone.dueOn})` : milestone.title,
272 })),
273 ]}
274 />
275 </Field>
276 )}
277 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
278 {/* Within reach on a phone, above the keyboard when it is up. */}
279 <div className="sticky bottom-(--tabbar-h) -mx-4 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur in-data-[keyboard=open]:bottom-0 sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:p-0 sm:backdrop-blur-none max-sm:*:w-full">
280 <SubmitButton name="intent" value="open" pending="Opening…" className="max-sm:h-11">
281 Open work item
282 </SubmitButton>
283 </div>
284 </Form>
285 </div>
286 );
287}