g1t/apps/web/app/routes/repo/issues.tsx

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