flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

137 lines5,656 bytesCodeBlame
1import { Download } from "lucide-react";
2import { Form, redirect, useNavigation } from "react-router";
3
4import { PROVIDERS } from "@g1t/contracts";
5
6import type { Route } from "./+types/issue-new";
7import { page } from "../../lib/meta";
8import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui";
9import { CheckboxOption } from "../../components/ui/checkbox";
10import { Label } from "../../components/work";
11import { integrations, work } from "../../lib/services.server";
12import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
13
14export function meta({ params, ...args }: Route.MetaArgs) {
15 return page(args, { title: `New issue · ${params.owner}/${params.repo} · g1t` });
16}
17
18export async function loader({ request, params, context }: Route.LoaderArgs) {
19 const user = requireUser(context, request);
20 const path = { namespace: params.owner, name: params.repo };
21 const [labels, connections] = await Promise.all([
22 work.listLabels(path, user),
23 integrations.list(params.owner.toLowerCase(), user),
24 ]);
25 // Systems a ticket can be imported from.
26 const sources = connections.ok
27 ? [...new Set(connections.value.filter((c) => c.kind === "tracker" || c.provider === "sentry").map((c) => c.provider))]
28 : [];
29 return { labels: unwrap(labels), sources };
30}
31
32export async function action({ request, params, context }: Route.ActionArgs) {
33 assertSameOrigin(request);
34 const user = requireUser(context, request);
35 const form = await request.formData();
36 if (form.get("intent") === "import") {
37 const imported = await integrations.import(
38 user,
39 { namespace: params.owner, name: params.repo },
40 String(form.get("reference") ?? ""),
41 form.get("assign") === "on",
42 );
43 if (!imported.ok) return { importError: imported.error.message };
44 throw redirect(`/${params.owner}/${params.repo}/issues/${imported.value.number}`);
45 }
46 const result = await work.openIssue(
47 user,
48 { namespace: params.owner, name: params.repo },
49 {
50 title: String(form.get("title") ?? ""),
51 body: String(form.get("body") ?? ""),
52 labels: [
53 ...form.getAll("label").map(String),
54 ...String(form.get("labels") ?? "").split(","),
55 ],
56 checks: String(form.get("checks") ?? "").split("\n"),
57 },
58 );
59 if (!result.ok) return { error: result.error.message };
60 throw redirect(`/${params.owner}/${params.repo}/issues/${result.value.number}`);
61}
62
63export default function NewIssue({ loaderData, actionData }: Route.ComponentProps) {
64 const busy = useNavigation().state === "submitting";
65 const names = loaderData.sources.map((source) => PROVIDERS[source].label);
66 return (
67 <div className="max-w-2xl">
68 {names.length > 0 && (
69 <Form method="post" className="mb-8 rounded-xl border border-line bg-surface p-4">
70 <input type="hidden" name="intent" value="import" />
71 <p className="flex items-center gap-2 text-sm font-medium">
72 <Download size={15} className="text-muted" />
73 Bring one in from {names.join(" or ")}
74 </p>
75 <p className="mt-1 text-xs text-muted">
76 Give a key such as TECH-1234 or paste its address. The issue stays linked: agents read the
77 original, and it hears back when the work lands.
78 </p>
79 <div className="mt-3 flex flex-wrap items-center gap-2">
80 <div className="min-w-56 grow">
81 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" />
82 </div>
83 <CheckboxOption name="assign" label="Put an agent on it" className="items-center" labelClassName="text-muted" />
84 <Button type="submit" variant="quiet" disabled={busy}>
85 Import
86 </Button>
87 </div>
88 {actionData && "importError" in actionData && (
89 <div className="mt-2">
90 <ErrorText>{actionData.importError}</ErrorText>
91 </div>
92 )}
93 </Form>
94 )}
95 <Form method="post" className="space-y-4">
96 <Field label="Title">
97 <Input
98 name="title"
99 required
100 autoFocus
101 maxLength={200}
102 placeholder="Parser drops the last line of a file without a trailing newline"
103 />
104 </Field>
105 <Field
106 label="Description"
107 hint="What is wrong or wanted, and anything needed to act on it. An agent given this issue works from this text."
108 >
109 <Textarea name="body" rows={8} />
110 </Field>
111 <fieldset>
112 <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend>
113 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
114 {loaderData.labels.map((name) => (
115 <CheckboxOption key={name} name="label" value={name} label={<Label name={name} />} className="items-center gap-1.5" />
116 ))}
117 </div>
118 <div className="mt-2">
119 <Input
120 name="labels"
121 aria-label="Other labels"
122 placeholder="Others, separated by commas: performance, good first issue"
123 />
124 </div>
125 </fieldset>
126 <Field
127 label="Acceptance checks (optional)"
128 hint="One command per line. A pull request for this issue should make them all pass."
129 >
130 <Textarea name="checks" rows={3} placeholder="cargo test" />
131 </Field>
132 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
133 <Button type="submit">Open issue</Button>
134 </Form>
135 </div>
136 );
137}