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

423 lines16,839 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Bot, GitCommitHorizontal, GitMerge, Play, Sparkles, Terminal } from "lucide-react";
3import { useEffect } from "react";
4import { Form, Link, redirect, useNavigation, useRevalidator } from "react-router";
5
6import type { Pull } from "@g1t/contracts";
7
8import type { Route } from "./+types/issue";
9import { Markdown } from "../../components/markdown";
10import {
11 Avatar,
12 Button,
13 CopyLine,
14 EmptyState,
15 ErrorText,
16 Input,
17 Textarea,
18 TimeAgo,
19} from "../../components/ui";
20import { Comments, IssueState, Label, PullIcon } from "../../components/work";
21import { work } from "../../lib/services.server";
22import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
23
24const REFRESH_MS = 4000;
25
26export function meta({ loaderData, params }: Route.MetaArgs) {
27 const title = loaderData ? `${loaderData.issue.title} · Issue #${loaderData.issue.number} · ` : "";
28 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
29}
30
31export async function loader({ params, context }: Route.LoaderArgs) {
32 const viewer = getViewer(context);
33 const path = { namespace: params.owner, name: params.repo };
34 const number = Number(params.number);
35 const found = await work.getIssue(path, number, viewer);
36 if (!found.ok) {
37 // Issues and pull requests share numbers; this one may be a pull request.
38 const pull = await work.getPull(path, number, viewer);
39 if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`);
40 throw new Response("Issue not found.", { status: 404 });
41 }
42 const [labels, agentModels] = await Promise.all([
43 work.listLabels(path, viewer),
44 env.RUNNER.models(viewer),
45 ]);
46 const { issue } = found.value;
47 return {
48 ...found.value,
49 viewer,
50 labels: labels.ok ? labels.value : [],
51 agentModels,
52 // The author and members of the workspace can change an issue.
53 canManage:
54 viewer != null &&
55 (viewer.id === issue.author.id ||
56 (viewer.workspaces ?? []).some((membership) => membership.slug === params.owner)),
57 };
58}
59
60export async function action({ request, params, context }: Route.ActionArgs) {
61 assertSameOrigin(request);
62 const user = requireUser(context, request);
63 const form = await request.formData();
64 const path = { namespace: params.owner, name: params.repo };
65 const number = Number(params.number);
66
67 switch (form.get("action")) {
68 case "run-hosted": {
69 const result = await env.RUNNER.run(user, path, number, {
70 count: Number(form.get("count")),
71 instructions: String(form.get("instructions") ?? ""),
72 model: String(form.get("model") ?? "") || undefined,
73 });
74 return result.ok ? null : { error: result.error.message };
75 }
76 case "open-pull": {
77 const result = await work.openPull(user, path, {
78 issue: number,
79 agent: String(form.get("agent") ?? ""),
80 runtime: "external",
81 });
82 if (!result.ok) return { error: result.error.message };
83 throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
84 }
85 case "comment": {
86 const result = await work.addComment(user, path, number, String(form.get("body") ?? ""));
87 return result.ok ? null : { error: result.error.message };
88 }
89 case "labels": {
90 const result = await work.updateIssue(user, path, number, {
91 labels: [
92 ...form.getAll("label").map(String),
93 ...String(form.get("labels") ?? "").split(","),
94 ],
95 });
96 return result.ok ? null : { error: result.error.message };
97 }
98 case "reopen": {
99 const result = await work.reopenIssue(user, path, number);
100 return result.ok ? null : { error: result.error.message };
101 }
102 default: {
103 const result = await work.closeIssue(
104 user,
105 path,
106 number,
107 form.get("action") === "close-not-planned" ? "not_planned" : "completed",
108 );
109 return result.ok ? null : { error: result.error.message };
110 }
111 }
112}
113
114/** What became of a pull request, in a few words. */
115function outcome(pull: Pull): string {
116 if (pull.status === "merged") return `Merged by ${pull.mergedBy ?? "someone"}`;
117 if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`;
118 if (pull.status === "closed") return "Closed without merging";
119 if (pull.status === "draft") return "Draft · in progress";
120 return "Ready for review";
121}
122
123function PullRow({ pull, base }: { pull: Pull; base: string }) {
124 const merged = pull.status === "merged";
125 return (
126 <li>
127 <Link
128 to={`${base}/pull/${pull.number}`}
129 className={`block rounded-xl border p-4 transition-colors ${
130 merged
131 ? "border-merged/40 bg-merged/5 hover:border-merged/70"
132 : "border-line bg-surface hover:border-line-strong"
133 } ${pull.status === "closed" ? "opacity-70" : ""}`}
134 >
135 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
136 <PullIcon status={pull.status} />
137 <span className="font-medium">
138 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
139 </span>
140 {pull.status === "draft" && (
141 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
142 )}
143 <span className="ml-auto flex items-center gap-3 text-xs text-faint">
144 <span className="flex items-center gap-1 font-mono">
145 <GitCommitHorizontal size={13} />
146 {pull.headCommit?.slice(0, 7) ?? "no commits"}
147 </span>
148 <TimeAgo at={pull.updatedAt} />
149 </span>
150 </div>
151 <p className="mt-1.5 flex flex-wrap items-center gap-x-2 text-xs text-muted">
152 <span className={merged ? "font-medium text-merged" : ""}>{outcome(pull)}</span>
153 <span className="text-faint">·</span>
154 <span className="flex items-center gap-1 font-mono">
155 <Bot size={12} />
156 {pull.agent}
157 </span>
158 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
159 <span className="text-faint">· opened by {pull.author.username}</span>
160 </p>
161 {pull.body && <p className="mt-2 line-clamp-2 text-sm text-muted">{pull.body}</p>}
162 </Link>
163 </li>
164 );
165}
166
167export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) {
168 const { issue, pulls, comments, viewer, labels, agentModels, canManage } = loaderData;
169
170 // Follow agents at work without a manual reload.
171 const revalidator = useRevalidator();
172 const navigation = useNavigation();
173 const running = pulls.some((pull) => pull.status === "draft");
174 useEffect(() => {
175 if (!running) return;
176 const timer = setInterval(() => {
177 if (document.visibilityState === "visible") revalidator.revalidate();
178 }, REFRESH_MS);
179 return () => clearInterval(timer);
180 }, [running, revalidator]);
181
182 const starting = navigation.formData?.get("action") === "run-hosted";
183 const base = `/${params.owner}/${params.repo}`;
184 const open = issue.state === "open";
185 const reference = `${params.owner}/${params.repo}#${issue.number}`;
186 const resolver = pulls.find((pull) => pull.number === issue.resolvedBy);
187 // The merged pull request first, then the ones still in play, then the rest.
188 const rank = { merged: 0, open: 1, draft: 2, closed: 3 } as const;
189 const ordered = [...pulls].sort(
190 (a, b) => rank[a.status] - rank[b.status] || a.number - b.number,
191 );
192
193 return (
194 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
195 <div className="min-w-0">
196 <h2 className="text-2xl font-semibold tracking-tight text-balance">
197 {issue.title} <span className="font-normal text-faint">#{issue.number}</span>
198 </h2>
199 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
200 <IssueState issue={issue} />
201 <span className="flex items-center gap-2">
202 <Avatar name={issue.author.username} size={18} />
203 <span>
204 <span className="font-medium text-fg">{issue.author.username}</span> opened this{" "}
205 <TimeAgo at={issue.createdAt} />
206 </span>
207 </span>
208 {issue.labels.map((name) => (
209 <Label key={name} name={name} />
210 ))}
211 </div>
212
213 {issue.resolvedBy != null && (
214 <Link
215 to={`${base}/pull/${issue.resolvedBy}`}
216 className="mt-5 flex items-center gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70"
217 >
218 <GitMerge size={18} className="shrink-0 text-merged" />
219 <span>
220 Resolved by{" "}
221 <span className="font-medium">
222 {resolver?.title ?? "pull request"} #{issue.resolvedBy}
223 </span>
224 {resolver?.mergedBy && (
225 <span className="text-muted">
226 , merged by {resolver.mergedBy}{" "}
227 {resolver.mergedAt && <TimeAgo at={resolver.mergedAt} />}
228 </span>
229 )}
230 </span>
231 </Link>
232 )}
233
234 {issue.body && (
235 <div className="mt-5 rounded-xl border border-line bg-surface p-5">
236 <Markdown source={issue.body} />
237 </div>
238 )}
239
240 <div className="mt-10 flex items-baseline justify-between">
241 <h3 className="font-semibold tracking-tight">Pull requests</h3>
242 <p className="text-sm text-muted">
243 {pulls.length === 0
244 ? "None yet"
245 : `${pulls.length} for this issue${
246 pulls.some((pull) => pull.status === "merged") ? "" : ", none merged"
247 }`}
248 </p>
249 </div>
250 <div className="mt-3">
251 {pulls.length === 0 ? (
252 <EmptyState title="Nobody has worked on this yet">
253 Put g1t agents on it, or point your own agent at{" "}
254 <code className="font-mono">{reference}</code>.
255 </EmptyState>
256 ) : (
257 <ol className="space-y-3">
258 {ordered.map((pull) => (
259 <PullRow key={pull.id} pull={pull} base={base} />
260 ))}
261 </ol>
262 )}
263 </div>
264
265 <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
266 <div className="mt-3">
267 <Comments comments={comments} canComment={Boolean(viewer)} />
268 </div>
269 <div className="mt-2">
270 <ErrorText>{actionData?.error}</ErrorText>
271 </div>
272 </div>
273
274 <aside className="space-y-6">
275 {open && agentModels.length > 0 && (
276 <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
277 <h3 className="flex items-center gap-2 text-sm font-medium">
278 <Sparkles size={15} className="text-accent" />
279 Assign g1t agents
280 </h3>
281 <p className="mt-1 text-xs text-muted">
282 Each agent opens its own pull request for this issue and works in
283 its own sandbox, in parallel. Merge the one you want.
284 </p>
285 <Form method="post" className="mt-3 space-y-2">
286 <input type="hidden" name="action" value="run-hosted" />
287 <label className="flex items-center justify-between gap-3 text-sm">
288 <span className="text-muted">Agents</span>
289 <select
290 name="count"
291 defaultValue="1"
292 className="rounded-md border border-line bg-bg px-2 py-1 text-sm"
293 >
294 {[1, 2, 3, 4, 5].map((count) => (
295 <option key={count} value={count}>
296 {count}
297 </option>
298 ))}
299 </select>
300 </label>
301 <label className="flex items-center justify-between gap-3 text-sm">
302 <span className="text-muted">Model</span>
303 <select
304 name="model"
305 className="rounded-md border border-line bg-bg px-2 py-1 text-sm"
306 >
307 {agentModels.map((model) => (
308 <option key={model.id} value={model.id} title={model.description}>
309 {model.label} · {model.modelName}
310 </option>
311 ))}
312 </select>
313 </label>
314 <Textarea name="instructions" rows={2} placeholder="Extra guidance (optional)" />
315 <div className="*:w-full">
316 <Button variant="accent" type="submit" disabled={starting}>
317 <Play size={14} />
318 {starting ? "Starting sandboxes…" : "Start"}
319 </Button>
320 </div>
321 </Form>
322 </section>
323 )}
324
325 {open && (
326 <section className="rounded-xl border border-line bg-surface p-4">
327 <h3 className="text-sm font-medium">Bring your own agent</h3>
328 <p className="mt-1 text-xs text-muted">
329 With g1t connected to your agent, ask it to work on this issue.
330 </p>
331 <div className="mt-3">
332 <CopyLine text={reference} />
333 </div>
334 {viewer ? (
335 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
336 <input type="hidden" name="action" value="open-pull" />
337 <p className="text-xs text-muted">
338 Or open a draft pull request yourself and get a fork to push to.
339 </p>
340 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
341 <div className="*:w-full">
342 <Button type="submit">Open pull request</Button>
343 </div>
344 </Form>
345 ) : (
346 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
347 <Link to="/login" className="text-fg underline underline-offset-4">
348 Sign in
349 </Link>{" "}
350 to open a pull request.
351 </p>
352 )}
353 </section>
354 )}
355
356 {issue.checks.length > 0 && (
357 <section>
358 <h3 className="text-sm font-medium">Acceptance checks</h3>
359 <ul className="mt-2 space-y-1.5">
360 {issue.checks.map((check) => (
361 <li
362 key={check}
363 className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs"
364 >
365 <Terminal size={13} className="shrink-0 text-faint" />
366 <span className="truncate">{check}</span>
367 </li>
368 ))}
369 </ul>
370 </section>
371 )}
372
373 {canManage && (
374 <details className="group">
375 <summary className="cursor-pointer list-none text-sm font-medium">
376 Labels <span className="text-xs font-normal text-faint group-open:hidden">Edit</span>
377 </summary>
378 <Form method="post" className="mt-3 space-y-3" key={issue.labels.join()}>
379 <input type="hidden" name="action" value="labels" />
380 <div className="flex flex-wrap gap-x-3 gap-y-2">
381 {labels.map((name) => (
382 <label key={name} className="flex cursor-pointer items-center gap-1.5">
383 <input
384 type="checkbox"
385 name="label"
386 value={name}
387 defaultChecked={issue.labels.includes(name)}
388 className="accent-accent"
389 />
390 <Label name={name} />
391 </label>
392 ))}
393 </div>
394 <Input name="labels" placeholder="New labels, comma separated" />
395 <Button variant="quiet" type="submit">
396 Save labels
397 </Button>
398 </Form>
399 </details>
400 )}
401
402 {canManage && (
403 <Form method="post" className="flex flex-wrap gap-2 border-t border-line pt-5">
404 {open ? (
405 <>
406 <Button variant="quiet" type="submit" name="action" value="close-completed">
407 Close issue
408 </Button>
409 <Button variant="quiet" type="submit" name="action" value="close-not-planned">
410 Close as not planned
411 </Button>
412 </>
413 ) : (
414 <Button variant="quiet" type="submit" name="action" value="reopen">
415 Reopen issue
416 </Button>
417 )}
418 </Form>
419 )}
420 </aside>
421 </div>
422 );
423}