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/issues.tsx

225 lines9,048 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { GitPullRequest, MessageSquare, Plus, Sparkles, X } from "lucide-react";
3import { Form, Link, useNavigation } from "react-router";
4
5import type { Route } from "./+types/issues";
6import { page } from "../../lib/meta";
7import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8import { Checkbox } from "../../components/ui/checkbox";
9import {
10 Assignee,
11 AssigneeStack,
12 IssueIcon,
13 Label,
14 StateTabs,
15} from "../../components/work";
16import { work } from "../../lib/services.server";
17import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
18
19/** As many sandboxes as one request may start. */
20const MAX_ASSIGNED_AT_ONCE = 10;
21
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Issues · ${params.owner}/${params.repo} · g1t` });
24}
25
26export async function loader({ params, context, request }: Route.LoaderArgs) {
27 const viewer = getViewer(context);
28 const path = { namespace: params.owner, name: params.repo };
29 const query = new URL(request.url).searchParams;
30 const state = query.get("state") === "closed" ? "closed" : "open";
31 const label = query.get("label") ?? "";
32 const [issues, labels, agentsEnabled] = await Promise.all([
33 work.listIssues(path, viewer, { state, label: label || undefined }),
34 work.listLabels(path, viewer),
35 env.RUNNER.enabled(viewer, path),
36 ]);
37 return {
38 issues: unwrap(issues),
39 labels: unwrap(labels),
40 state,
41 label,
42 agentsEnabled,
43 } as const;
44}
45
46/** Assigns each selected issue to a g1t agent of its own. */
47export async function action({ request, params, context }: Route.ActionArgs) {
48 assertSameOrigin(request);
49 const user = requireUser(context, request);
50 const path = { namespace: params.owner, name: params.repo };
51 const numbers = (await request.formData())
52 .getAll("issue")
53 .map(Number)
54 .filter((number) => Number.isInteger(number) && number > 0);
55 if (numbers.length === 0) return { error: "Select the issues to assign first." };
56 if (numbers.length > MAX_ASSIGNED_AT_ONCE) {
57 return { error: `Assign at most ${MAX_ASSIGNED_AT_ONCE} issues at a time.` };
58 }
59 const results = await Promise.all(
60 numbers.map((number) => env.RUNNER.run(user, path, number)),
61 );
62 const refused = results.find((result) => !result.ok);
63 const started = results.filter((result) => result.ok).length;
64 return {
65 started,
66 error:
67 refused && !refused.ok
68 ? `${started} of ${numbers.length} assigned. ${refused.error.message}`
69 : null,
70 };
71}
72
73export default function Issues({ loaderData, actionData, params }: Route.ComponentProps) {
74 const { issues, labels, state, label, agentsEnabled } = loaderData;
75 const repo = `/${params.owner}/${params.repo}`;
76 const base = `${repo}/issues`;
77 const stateQuery = state === "closed" ? "state=closed" : "";
78 const assignable = agentsEnabled && state === "open" && issues.length > 0;
79 const assigning = useNavigation().state === "submitting";
80 return (
81 <div>
82 <StateTabs
83 to={base}
84 state={state}
85 query={label ? `label=${encodeURIComponent(label)}` : ""}
86 action={
87 <ButtonLink to={`${base}/new`}>
88 <Plus size={15} />
89 New issue
90 </ButtonLink>
91 }
92 />
93 <div className="mt-3 flex flex-wrap items-center gap-1.5">
94 {labels.map((name) => {
95 const active = name === label;
96 const query = [stateQuery, active ? "" : `label=${encodeURIComponent(name)}`]
97 .filter(Boolean)
98 .join("&");
99 return (
100 <Link
101 key={name}
102 to={`${base}?${query}`}
103 className={`flex items-center gap-1 rounded-full transition-opacity ${
104 label && !active ? "opacity-45 hover:opacity-100" : ""
105 }`}
106 >
107 <Label name={name} />
108 {active && <X size={12} className="text-muted" />}
109 </Link>
110 );
111 })}
112 </div>
113 <Form method="post" className="mt-4">
114 {assignable && (
115 <div className="mb-3 flex flex-wrap items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-2.5">
116 <Sparkles size={15} className="shrink-0 text-accent" />
117 <p className="min-w-0 grow text-sm text-muted">
118 Tick the issues to hand over. Each gets a g1t agent of its own,
119 and they all work at once.
120 </p>
121 <Button variant="accent" type="submit" disabled={assigning}>
122 {assigning ? "Starting sandboxes…" : "Assign to g1t agent"}
123 </Button>
124 </div>
125 )}
126 {actionData?.error ? (
127 <div className="mb-3">
128 <ErrorText>{actionData.error}</ErrorText>
129 </div>
130 ) : (
131 actionData?.started != null && (
132 <p className="mb-3 text-sm text-muted">
133 {actionData.started}{" "}
134 {actionData.started === 1 ? "agent is" : "agents are"} starting. Each
135 issue shows its pull request as it appears.
136 </p>
137 )
138 )}
139 {issues.length === 0 ? (
140 <EmptyState
141 title={
142 label
143 ? `No ${state} issues labelled ${label}`
144 : state === "open"
145 ? "No open issues"
146 : "No closed issues"
147 }
148 >
149 An issue says what should change: a bug, a feature, a question.
150 Agents and people open pull requests against it.
151 </EmptyState>
152 ) : (
153 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
154 {issues.map((issue) => (
155 <li key={issue.id} className="flex items-start transition-colors hover:bg-surface">
156 {assignable && (
157 <Checkbox
158 name="issue"
159 value={String(issue.number)}
160 // Already being worked on: nothing more to hand over.
161 disabled={issue.agent != null || issue.queued}
162 aria-label={`Select issue #${issue.number}`}
163 className="mt-4 ml-4 disabled:opacity-30"
164 />
165 )}
166 <Link
167 prefetch="intent"
168 to={`${base}/${issue.number}`}
169 className="flex min-w-0 grow items-start gap-3 px-4 py-3"
170 >
171 <span className="mt-0.5">
172 <IssueIcon issue={issue} />
173 </span>
174 <span className="min-w-0 grow">
175 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
176 <span className="font-medium">{issue.title}</span>
177 {issue.labels.map((name) => (
178 <Label key={name} name={name} />
179 ))}
180 {issue.state === "open" && issue.agent && (
181 <Assignee agent={issue.agent} />
182 )}
183 {issue.state === "open" && issue.queued && !issue.agent && (
184 <span className="rounded-full border border-line px-2 py-px text-xs text-muted">
185 queued for g1t-agent
186 {issue.blockedBy.length > 0 &&
187 `, after ${issue.blockedBy.map((number) => `#${number}`).join(", ")}`}
188 </span>
189 )}
190 </span>
191 <span className="mt-0.5 block text-xs text-faint">
192 #{issue.number} opened <TimeAgo at={issue.createdAt} /> by{" "}
193 {issue.author.username}
194 {issue.resolvedBy != null && (
195 <span className="text-merged"> · resolved by #{issue.resolvedBy}</span>
196 )}
197 </span>
198 </span>
199 <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted">
200 <AssigneeStack people={issue.assignees} />
201 {issue.pullCount > 0 && (
202 <span
203 className="flex items-center gap-1"
204 title={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`}
205 >
206 <GitPullRequest size={13} />
207 {issue.pullCount}
208 </span>
209 )}
210 {issue.commentCount > 0 && (
211 <span className="flex items-center gap-1">
212 <MessageSquare size={13} />
213 {issue.commentCount}
214 </span>
215 )}
216 </span>
217 </Link>
218 </li>
219 ))}
220 </ul>
221 )}
222 </Form>
223 </div>
224 );
225}