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

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