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.

315 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2import { GitPullRequest, MessageSquare, Milestone as MilestoneIcon, Plus, Sparkles, Tag } from "lucide-react";
3import { useEffect, useRef } from "react";
4import { Form, Link, useLocation, useRouteLoaderData } from "react-router";
5
6import type { Route } from "./+types/issues";
7import { page } from "../../lib/meta";
8import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
9import { Badge } from "../../components/ui/badge";
10import { Button } from "../../components/ui/button";
11import { Card } from "../../components/ui/card";
12import { Checkbox } from "../../components/ui/checkbox";
13import { Hint } from "../../components/ui/hint";
14import {
15 Assignee,
16 AssigneeStack,
17 IssueIcon,
18 StateTabs,
19} from "../../components/work";
20import { FilterMenu, LabelChip, Swatch } from "../../components/labels";
21import { colorsOf, listFilters, withFilter } from "../../lib/labels";
22import { computeNoteFor } from "../../lib/compute.server";
23import { isWaitingMessage } from "../../lib/compute";
24import { work } from "../../lib/services.server";
25import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
26import { accessTo, refusal } from "../../lib/access.server";
27import { repoPages } from "../../lib/repo-live";
28import { useRepoLive } from "../../lib/use-repo-live";
29
30/** As many sandboxes as one request may start. */
31const MAX_ASSIGNED_AT_ONCE = 10;
32
33export function meta({ params, ...args }: Route.MetaArgs) {
34 return page(args, { title: `Issues · ${params.owner}/${params.repo} · g1t` });
35}
36
37export async function loader({ params, context, request }: Route.LoaderArgs) {
38 const viewer = getViewer(context);
39 const path = { namespace: params.owner, name: params.repo };
40 // `?label=bug&milestone=3`, or `label:bug milestone:3` written in `q`.
41 const { state, label, milestone } = listFilters(new URL(request.url).searchParams);
42 // Assigning agents needs Write: Read cannot spend compute. All at once:
43 // only the plan's note waits for the viewer's role.
44 const access = accessTo(context, params);
45 const [{ can }, issues, labels, milestones, agentsEnabled, computeNote] = await Promise.all([
46 access,
47 work.listIssues(path, viewer, { state, label: label || undefined, milestone: milestone ?? undefined }),
48 work.listLabels(path, viewer),
49 work.listMilestones(path, viewer, "open"),
50 env.RUNNER.enabled(viewer, path),
51 // Before a member assigns: whether the workspace's plan lets agents start.
52 access.then(({ can }) => (can.run ? computeNoteFor(params.owner, "agent") : null)),
53 ]);
54 return {
55 issues: unwrap(issues),
56 labels: unwrap(labels),
57 milestones: milestones.ok ? milestones.value : [],
58 state,
59 label,
60 milestone,
61 agentsEnabled: agentsEnabled && can.run,
62 computeNote,
63 } as const;
64}
65
66/** Assigns each selected issue to g1t, one run of its own each. */
67export async function action({ request, params, context }: Route.ActionArgs) {
68 assertSameOrigin(request);
69 const user = requireUser(context, request);
70 const path = { namespace: params.owner, name: params.repo };
71 const numbers = (await request.formData())
72 .getAll("issue")
73 .map(Number)
74 .filter((number) => Number.isInteger(number) && number > 0);
75 if (numbers.length === 0) return { error: "Select the issues to assign first." };
76 const denied = await refusal(context, params, "run");
77 if (denied) return { error: denied };
78 if (numbers.length > MAX_ASSIGNED_AT_ONCE) {
79 return { error: `Assign at most ${MAX_ASSIGNED_AT_ONCE} issues at a time.` };
80 }
81 const results = await Promise.all(
82 numbers.map((number) => env.RUNNER.run(user, path, number)),
83 );
84 // Over the workspace's agents-at-once cap, the rest wait in the queue.
85 const waiting = results.filter((result) => !result.ok && isWaitingMessage(result.error.message)).length;
86 const refused = results.find((result) => !result.ok && !isWaitingMessage(result.error.message));
87 const started = results.filter((result) => result.ok).length;
88 return {
89 started,
90 waiting,
91 error:
92 refused && !refused.ok
93 ? `${started} of ${numbers.length} assigned. ${refused.error.message}`
94 : null,
95 };
96}
97
98export default function Issues({ loaderData, actionData, params }: Route.ComponentProps) {
99 const { issues, labels, milestones, state, label, milestone, agentsEnabled } = loaderData;
100 // Issues opened, commented on and closed show as it happens.
101 useRepoLive({ match: repoPages.issues() });
102 const repo = `/${params.owner}/${params.repo}`;
103 const base = `${repo}/issues`;
104 const assignable = agentsEnabled && state === "open" && issues.length > 0;
105 const current = new URLSearchParams(useLocation().search);
106 const colors = colorsOf(labels);
107 const milestoneTitle = milestones.find((m) => m.number === milestone)?.title ?? (milestone ? `#${milestone}` : null);
108 const filtered = [label, milestone].some(Boolean);
109 // The tabs keep the filters; each filter keeps the state and the other.
110 const kept = new URLSearchParams(current);
111 kept.delete("state");
112 kept.delete("q");
113
114 // An archived repository's issues are locked: no new ones.
115 const layout = useRouteLoaderData("routes/repo/layout") as { repo?: { archivedAt?: string | null } } | undefined;
116 const archived = Boolean(layout?.repo?.archivedAt);
117 // Handed over: the ticks are cleared. Refused: they stay, to try again.
118 const form = useRef<HTMLFormElement>(null);
119 useEffect(() => {
120 if (actionData && !actionData.error) form.current?.reset();
121 }, [actionData]);
122 return (
123 <div>
124 <StateTabs
125 to={base}
126 state={state}
127 query={kept.toString()}
128 action={
129 archived ? undefined : (
130 <ButtonLink to={`${base}/new`}>
131 <Plus size={15} />
132 New issue
133 </ButtonLink>
134 )
135 }
136 />
137 <div className="mt-3 flex flex-wrap items-center gap-2">
138 <FilterMenu
139 label="Label"
140 active={label || undefined}
141 clearTo={label ? withFilter(base, current, "label", null) : undefined}
142 searchPlaceholder="Filter labels"
143 emptyText="No label matches."
144 options={labels.map((each) => ({
145 key: each.name,
146 to: withFilter(base, current, "label", each.name),
147 keywords: `${each.name} ${each.description}`,
148 selected: each.name === label,
149 label: (
150 <>
151 <Swatch color={each.color} />
152 <span className="truncate">{each.name}</span>
153 </>
154 ),
155 }))}
156 />
157 <FilterMenu
158 label="Milestone"
159 active={milestoneTitle ?? undefined}
160 clearTo={milestone ? withFilter(base, current, "milestone", null) : undefined}
161 searchPlaceholder="Filter milestones"
162 emptyText="No open milestone matches."
163 options={milestones.map((each) => ({
164 key: String(each.number),
165 to: withFilter(base, current, "milestone", String(each.number)),
166 keywords: `${each.title} ${each.number}`,
167 selected: each.number === milestone,
168 label: <span className="truncate">{each.title}</span>,
169 }))}
170 />
171 {filtered && (
172 <Link to={state === "closed" ? `${base}?state=closed` : base} className="text-xs text-muted hover:text-fg">
173 Clear filters
174 </Link>
175 )}
176 <span className="ml-auto flex items-center gap-1">
177 <Link
178 to={`${repo}/labels`}
179 className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm text-muted transition-colors hover:bg-surface hover:text-fg"
180 >
181 <Tag size={14} />
182 Labels
183 </Link>
184 </span>
185 </div>
186 <Form ref={form} method="post" className="mt-4">
187 {assignable && (
188 <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">
189 <Sparkles size={15} className="shrink-0 text-accent" />
190 <p className="min-w-0 grow text-sm text-muted">
191 Tick the issues to hand over. g1t takes each one in a run of its own,
192 and they all work at once.
193 </p>
194 <SubmitButton variant="accent" pending="Starting sandboxes…">
195 Assign to g1t
196 </SubmitButton>
197 {loaderData.computeNote && (
198 <div className="basis-full">
199 <ComputeNote note={loaderData.computeNote} />
200 </div>
201 )}
202 </div>
203 )}
204 {actionData?.error ? (
205 <div className="mb-3">
206 <ErrorText>{actionData.error}</ErrorText>
207 </div>
208 ) : (
209 actionData?.started != null && (
210 <p className="mb-3 text-sm text-muted">
211 {actionData.started}{" "}
212 {actionData.started === 1 ? "agent is" : "agents are"} starting. Each
213 issue shows its pull request as it appears.
214 {actionData.waiting
215 ? ` ${actionData.waiting} more ${actionData.waiting === 1 ? "waits" : "wait"} for a free slot and start by themselves.`
216 : ""}
217 </p>
218 )
219 )}
220 {issues.length === 0 ? (
221 <EmptyState
222 title={
223 filtered
224 ? `No ${state} issues match these filters`
225 : state === "open"
226 ? "No open issues"
227 : "No closed issues"
228 }
229 >
230 An issue says what should change: a bug, a feature, a question.
231 Agents and people open pull requests against it.
232 </EmptyState>
233 ) : (
234 <Card asChild tone="plain" divided className="overflow-hidden">
235 <ul>
236 {issues.map((issue) => (
237 <li key={issue.id} className="flex items-start transition-colors hover:bg-surface">
238 {assignable && (
239 <Checkbox
240 name="issue"
241 value={String(issue.number)}
242 // Already being worked on: nothing more to hand over.
243 disabled={issue.agent != null || issue.queued}
244 aria-label={`Select issue #${issue.number}`}
245 className="mt-4 ml-4 disabled:opacity-30"
246 />
247 )}
248 <Link
249 prefetch="intent"
250 to={`${base}/${issue.number}`}
251 className="flex min-w-0 grow items-start gap-3 px-4 py-3"
252 >
253 <span className="mt-0.5">
254 <IssueIcon issue={issue} />
255 </span>
256 <span className="min-w-0 grow">
257 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
258 <span className="font-medium">{issue.title}</span>
259 {issue.labels.map((name) => (
260 <LabelChip key={name} name={name} color={colors[name]} />
261 ))}
262 {issue.state === "open" && issue.agent && (
263 <Assignee agent={issue.agent} />
264 )}
265 {issue.state === "open" && issue.queued && !issue.agent && (
266 <Badge size="md" className="py-px">
267 queued for g1t
268 {issue.blockedBy.length > 0 &&
269 `, after ${issue.blockedBy.map((number) => `#${number}`).join(", ")}`}
270 </Badge>
271 )}
272 </span>
273 <span className="mt-0.5 block text-xs text-faint">
274 #{issue.number} opened <TimeAgo at={issue.createdAt} /> by{" "}
275 {issue.author.username}
276 {issue.requestedBy && <> for {issue.requestedBy.username}</>}
277 {issue.resolvedBy != null && (
278 <span className="text-merged"> · resolved by #{issue.resolvedBy}</span>
279 )}
280 {issue.milestone && (
281 <span className="inline-flex items-center gap-1">
282 {" "}
283 · <MilestoneIcon size={11} className="inline" /> {issue.milestone.title}
284 </span>
285 )}
286 </span>
287 </span>
288 <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted">
289 <AssigneeStack people={issue.assignees} />
290 {issue.pullCount > 0 && (
291 <Hint label={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`}>
292 <span className="flex items-center gap-1">
293 <GitPullRequest size={13} />
294 {issue.pullCount}
295 <span className="sr-only">pull {issue.pullCount === 1 ? "request" : "requests"}</span>
296 </span>
297 </Hint>
298 )}
299 {issue.commentCount > 0 && (
300 <span className="flex items-center gap-1">
301 <MessageSquare size={13} />
302 {issue.commentCount}
303 </span>
304 )}
305 </span>
306 </Link>
307 </li>
308 ))}
309 </ul>
310 </Card>
311 )}
312 </Form>
313 </div>
314 );
315}