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

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