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.

239 linesCodeBlameRaw
1import { Download, Info } from "lucide-react";
2import { Form, data, redirect } from "react-router";
3
4import { PROVIDERS } from "@g1t/contracts";
5
6import type { Route } from "./+types/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 { Unavailable } from "../../../components/boundary";
14import { integrations, repos, work } from "../../../lib/services.server";
15import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
16import { canItem } from "../../../lib/work-access";
17import { isFailed, part } from "../../../lib/partial";
18import { itemPath } from "../../../lib/work-items";
19import { workspaceProjects } from "../../../lib/workspace-projects.server";
20
21export function meta({ params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `New work item · ${params.owner} · g1t` });
23}
24
25/** The repository a new item is filed from, from `?repo=web` (or `acme/web`): only one of this workspace. */
26function originOf(workspace: string, value: string | null): string | null {
27 const parts = (value ?? "").split("/").filter(Boolean);
28 if (parts.length === 1) return parts[0]!;
29 if (parts.length === 2 && parts[0]!.toLowerCase() === workspace) return parts[1]!;
30 return null;
31}
32
33export async function loader({ request, params, context }: Route.LoaderArgs) {
34 const user = requireUser(context, request);
35 const workspace = params.owner.toLowerCase();
36 const name = originOf(workspace, new URL(request.url).searchParams.get("repo"));
37 // Filed from a repository, it is that repository's (its origin): anyone
38 // who may take part there may. Filed in the workspace alone, a member.
39 const origin = name ? await repos.get({ namespace: workspace, name }, user) : null;
40 if (name && !origin?.ok) throw data(null, { status: 404 });
41 const ref = origin?.ok ? { id: origin.value.id, namespace: origin.value.namespace, isPrivate: origin.value.isPrivate } : null;
42 const member = roleIn(user, workspace) != null;
43 if (!member && !canItem(user, workspace, ref, "participate")) throw data(null, { status: 404 });
44 const triage = canItem(user, workspace, ref, "triage");
45 // The form is what this page is about; what fills its choices is a part
46 // each, which says so in its own place when its service fails.
47 const [labels, milestones, projects, connections] = await Promise.all([
48 part(work.listLabels(workspace, user), "Labels"),
49 triage ? part(work.listMilestones(workspace, user, "open"), "Milestones") : null,
50 // Where it may land: the workspace's repositories, for a member.
51 member ? part(workspaceProjects(workspace, user), "Repositories") : null,
52 // Only a hint at what can be imported: the page does without it.
53 member ? integrations.list(workspace, user).catch(() => null) : null,
54 ]);
55 const sources = connections?.ok
56 ? [...new Set(connections.value.filter((c) => c.kind === "tracker" || c.provider === "sentry").map((c) => c.provider))]
57 : [];
58 return {
59 workspace,
60 origin: name,
61 labels: isFailed(labels) ? labels : labels.ok ? labels.value : [],
62 milestones: isFailed(milestones) ? milestones : milestones?.ok ? milestones.value : [],
63 repos:
64 projects == null ? [] : isFailed(projects) ? projects : projects.ok ? projects.value.filter((p) => p.workspace.toLowerCase() === workspace).map((p) => p.slug).sort() : [],
65 canTriage: triage,
66 // Making labels needs Write in the workspace; choosing them and a milestone, Triage.
67 canMakeLabels: canItem(user, workspace, ref, "manage_labels"),
68 sources,
69 };
70}
71
72export async function action({ request, params, context }: Route.ActionArgs) {
73 assertSameOrigin(request);
74 const user = requireUser(context, request);
75 const workspace = params.owner.toLowerCase();
76 const form = await request.formData();
77 const origin = originOf(workspace, String(form.get("origin") ?? "") || null);
78 if (form.get("intent") === "import") {
79 const imported = await integrations.import(user, workspace, String(form.get("reference") ?? ""), {
80 repo: origin ? { namespace: workspace, name: origin } : undefined,
81 });
82 if (!imported.ok) return { importError: imported.error.message };
83 throw redirect(itemPath(imported.value.workspace, imported.value.number));
84 }
85 const result = await work.openWorkItem(user, {
86 workspace,
87 repo: origin ? { namespace: workspace, name: origin } : undefined,
88 targets: [...new Set(form.getAll("target").map(String).filter(Boolean))].map((name) => ({ namespace: workspace, name })),
89 title: String(form.get("title") ?? ""),
90 body: String(form.get("body") ?? ""),
91 labels: [...form.getAll("label").map(String), ...String(form.get("labels") ?? "").split(",")].map((label) => label.trim()).filter(Boolean),
92 milestone: Number(form.get("milestone")) || undefined,
93 });
94 if (!result.ok) return { error: result.error.message };
95 throw redirect(itemPath(result.value.workspace, result.value.number));
96}
97
98export default function NewWorkItem({ loaderData, actionData }: Route.ComponentProps) {
99 const { origin, workspace } = loaderData;
100 const names = loaderData.sources.map((source) => PROVIDERS[source].label);
101 return (
102 <div className="max-w-2xl">
103 {origin && (
104 <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">
105 <Info size={15} className="mt-0.5 shrink-0 text-faint" />
106 <span>
107 Filed from <span className="font-mono text-fg">{workspace}/{origin}</span>, its origin: anyone who can read it sees this work
108 item.
109 </span>
110 </p>
111 )}
112 {names.length > 0 && (
113 <Card asChild className="mb-8 p-4">
114 <Form method="post">
115 <input type="hidden" name="intent" value="import" />
116 <input type="hidden" name="origin" value={origin ?? ""} />
117 <p className="flex items-center gap-2 text-sm font-medium">
118 <Download size={15} className="text-muted" />
119 Bring one in from {names.join(" or ")}
120 </p>
121 <p className="mt-1 text-xs text-muted">
122 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
123 work lands.
124 </p>
125 <div className="mt-3 flex flex-wrap items-center gap-2">
126 <div className="min-w-56 grow">
127 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" autoCapitalize="characters" />
128 </div>
129 <SubmitButton variant="outline" match={{ intent: "import" }} pending="Importing…">
130 Import
131 </SubmitButton>
132 </div>
133 {actionData && "importError" in actionData && (
134 <div className="mt-2">
135 <ErrorText>{actionData.importError}</ErrorText>
136 </div>
137 )}
138 </Form>
139 </Card>
140 )}
141 <Form method="post" className="space-y-5">
142 <input type="hidden" name="origin" value={origin ?? ""} />
143 <Field label="Title">
144 <Input
145 name="title"
146 required
147 autoFocus
148 maxLength={200}
149 autoCapitalize="sentences"
150 enterKeyHint="next"
151 placeholder="Sign-in times out on slow networks"
152 />
153 </Field>
154 <Field
155 label="Description"
156 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."
157 >
158 <Textarea name="body" rows={8} autoCapitalize="sentences" />
159 </Field>
160 {isFailed(loaderData.repos) ? (
161 <Field label="Where it lands">
162 <Unavailable what="The workspace's repositories" compact />
163 </Field>
164 ) : (
165 loaderData.repos.length > 0 && (
166 <fieldset>
167 <legend className="mb-1 text-sm font-medium text-muted">Where it lands</legend>
168 <p className="mb-2 text-xs text-muted">
169 Its targets: none, one or several. It closes once what closes it has landed in every one, or with none, once anything does.
170 </p>
171 <div className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
172 {loaderData.repos.map((name) => (
173 <CheckboxOption
174 key={name}
175 name="target"
176 value={name}
177 defaultChecked={name === origin}
178 label={<span className="font-mono text-sm">{name}</span>}
179 className="min-h-10 items-center gap-2"
180 />
181 ))}
182 </div>
183 </fieldset>
184 )
185 )}
186 <fieldset>
187 <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend>
188 {isFailed(loaderData.labels) ? (
189 <Unavailable what="The workspace's labels" compact />
190 ) : (
191 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
192 {loaderData.labels.map((label) => (
193 <CheckboxOption
194 key={label.name}
195 name="label"
196 value={label.name}
197 label={<LabelChip name={label.name} color={label.color} />}
198 className="min-h-8 items-center gap-1.5"
199 />
200 ))}
201 </div>
202 )}
203 {loaderData.canMakeLabels && (
204 <div className="mt-2">
205 <Input name="labels" aria-label="New labels" placeholder="New labels, separated by commas: performance, area: cli" autoCapitalize="none" />
206 </div>
207 )}
208 </fieldset>
209 {loaderData.canTriage && isFailed(loaderData.milestones) && (
210 <Field label="Milestone">
211 <Unavailable what="Milestones" compact />
212 </Field>
213 )}
214 {loaderData.canTriage && !isFailed(loaderData.milestones) && loaderData.milestones.length > 0 && (
215 <Field label="Milestone">
216 <SelectField
217 name="milestone"
218 defaultValue=""
219 options={[
220 { value: "", label: "None" },
221 ...loaderData.milestones.map((milestone) => ({
222 value: String(milestone.number),
223 label: milestone.dueOn ? `${milestone.title} (due ${milestone.dueOn})` : milestone.title,
224 })),
225 ]}
226 />
227 </Field>
228 )}
229 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
230 {/* Within reach on a phone, above the keyboard when it is up. */}
231 <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">
232 <SubmitButton name="intent" value="open" pending="Opening…" className="max-sm:h-11">
233 Open work item
234 </SubmitButton>
235 </div>
236 </Form>
237 </div>
238 );
239}