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

572 lines22,941 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Issues and pull requests replace intents and attempts1import { 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";
Acceptance checks in sandboxes, line comments and review verdicts20import { CheckBadge } from "../../components/checks";
Agents as a team: lifecycle, merge queue, billing and a new shell21import {
22 Assignee,
23 AssigneeStack,
24 ChangeSize,
25 CommentForm,
26 CommentList,
27 PeoplePicker,
28 IssueState,
29 Label,
30 PullIcon,
31 plainText,
32} from "../../components/work";
33import { identity, work } from "../../lib/services.server";
34import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
Issues and pull requests replace intents and attempts35
36const REFRESH_MS = 4000;
37
38export function meta({ loaderData, params }: Route.MetaArgs) {
39 const title = loaderData ? `${loaderData.issue.title} · Issue #${loaderData.issue.number} · ` : "";
40 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
41}
42
43export async function loader({ params, context }: Route.LoaderArgs) {
44 const viewer = getViewer(context);
45 const path = { namespace: params.owner, name: params.repo };
46 const number = Number(params.number);
Agents as a team: lifecycle, merge queue, billing and a new shell47 // At once: none of these depends on another.
48 const [found, labels, agentsEnabled, members] = await Promise.all([
49 work.getIssue(path, number, viewer),
50 work.listLabels(path, viewer),
51 env.RUNNER.enabled(viewer),
52 // A member picks assignees from the workspace's people.
53 roleIn(viewer, params.owner) ? identity.listMembers(params.owner, viewer) : null,
54 ]);
Issues and pull requests replace intents and attempts55 if (!found.ok) {
56 // Issues and pull requests share numbers; this one may be a pull request.
57 const pull = await work.getPull(path, number, viewer);
58 if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`);
59 throw new Response("Issue not found.", { status: 404 });
60 }
61 const { issue } = found.value;
62 return {
63 ...found.value,
64 viewer,
65 labels: labels.ok ? labels.value : [],
Agents as a team: lifecycle, merge queue, billing and a new shell66 agentsEnabled,
67 members: members?.ok ? members.value.map((member) => member.username) : [],
Issues and pull requests replace intents and attempts68 // The author and members of the workspace can change an issue.
69 canManage:
70 viewer != null &&
71 (viewer.id === issue.author.id ||
72 (viewer.workspaces ?? []).some((membership) => membership.slug === params.owner)),
73 };
74}
75
76export async function action({ request, params, context }: Route.ActionArgs) {
77 assertSameOrigin(request);
78 const user = requireUser(context, request);
79 const form = await request.formData();
80 const path = { namespace: params.owner, name: params.repo };
81 const number = Number(params.number);
82
83 switch (form.get("action")) {
84 case "run-hosted": {
85 const result = await env.RUNNER.run(user, path, number, {
86 instructions: String(form.get("instructions") ?? ""),
87 });
88 return result.ok ? null : { error: result.error.message };
89 }
90 case "open-pull": {
91 const result = await work.openPull(user, path, {
92 issue: number,
93 agent: String(form.get("agent") ?? ""),
94 runtime: "external",
95 });
96 if (!result.ok) return { error: result.error.message };
97 throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
98 }
99 case "comment": {
Acceptance checks in sandboxes, line comments and review verdicts100 const result = await work.addComment(user, path, number, {
101 body: String(form.get("body") ?? ""),
102 });
Issues and pull requests replace intents and attempts103 return result.ok ? null : { error: result.error.message };
104 }
Agents as a team: lifecycle, merge queue, billing and a new shell105 case "assign": {
106 const result = await work.updateIssue(user, path, number, {
107 assignees: [
108 ...form.getAll("assignee").map(String),
109 ...String(form.get("others") ?? "").split(/[\s,]+/),
110 ],
111 });
112 return result.ok ? null : { error: result.error.message };
113 }
Issues and pull requests replace intents and attempts114 case "labels": {
115 const result = await work.updateIssue(user, path, number, {
116 labels: [
117 ...form.getAll("label").map(String),
118 ...String(form.get("labels") ?? "").split(","),
119 ],
120 });
121 return result.ok ? null : { error: result.error.message };
122 }
123 case "reopen": {
124 const result = await work.reopenIssue(user, path, number);
125 return result.ok ? null : { error: result.error.message };
126 }
127 default: {
128 const result = await work.closeIssue(
129 user,
130 path,
131 number,
132 form.get("action") === "close-not-planned" ? "not_planned" : "completed",
133 );
134 return result.ok ? null : { error: result.error.message };
135 }
136 }
137}
138
139/** What became of a pull request, in a few words. */
140function outcome(pull: Pull): string {
141 if (pull.status === "merged") return `Merged by ${pull.mergedBy ?? "someone"}`;
142 if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`;
143 if (pull.status === "closed") return "Closed without merging";
144 if (pull.status === "draft") return "Draft · in progress";
145 return "Ready for review";
146}
147
148function PullRow({ pull, base }: { pull: Pull; base: string }) {
149 const merged = pull.status === "merged";
150 return (
151 <li>
152 <Link
153 to={`${base}/pull/${pull.number}`}
154 className={`block rounded-xl border p-4 transition-colors ${
155 merged
156 ? "border-merged/40 bg-merged/5 hover:border-merged/70"
157 : "border-line bg-surface hover:border-line-strong"
158 } ${pull.status === "closed" ? "opacity-70" : ""}`}
159 >
160 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
161 <PullIcon status={pull.status} />
162 <span className="font-medium">
163 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
164 </span>
165 {pull.status === "draft" && (
166 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
167 )}
168 <span className="ml-auto flex items-center gap-3 text-xs text-faint">
Agents as a team: lifecycle, merge queue, billing and a new shell169 <ChangeSize files={pull.files} />
Acceptance checks in sandboxes, line comments and review verdicts170 <CheckBadge status={pull.checkStatus} />
Issues and pull requests replace intents and attempts171 <span className="flex items-center gap-1 font-mono">
172 <GitCommitHorizontal size={13} />
173 {pull.headCommit?.slice(0, 7) ?? "no commits"}
174 </span>
175 <TimeAgo at={pull.updatedAt} />
176 </span>
177 </div>
178 <p className="mt-1.5 flex flex-wrap items-center gap-x-2 text-xs text-muted">
179 <span className={merged ? "font-medium text-merged" : ""}>{outcome(pull)}</span>
180 <span className="text-faint">·</span>
181 <span className="flex items-center gap-1 font-mono">
182 <Bot size={12} />
183 {pull.agent}
184 </span>
185 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
186 <span className="text-faint">· opened by {pull.author.username}</span>
187 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell188 {pull.body && (
189 <p className="mt-2 line-clamp-2 text-sm text-muted">{plainText(pull.body)}</p>
190 )}
Issues and pull requests replace intents and attempts191 </Link>
192 </li>
193 );
194}
195
196export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) {
Agents as a team: lifecycle, merge queue, billing and a new shell197 const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage } = loaderData;
Issues and pull requests replace intents and attempts198
199 // Follow agents at work without a manual reload.
200 const revalidator = useRevalidator();
201 const navigation = useNavigation();
Acceptance checks in sandboxes, line comments and review verdicts202 const running = pulls.some(
203 (pull) =>
204 pull.status === "draft" ||
205 pull.checkStatus === "queued" ||
206 pull.checkStatus === "running",
207 );
Agents as a team: lifecycle, merge queue, billing and a new shell208 // The pull request g1t's agent has in progress for this issue, if any.
209 const assigned = [...pulls]
210 .reverse()
211 .find(
212 (pull) =>
213 pull.agent === "g1t-agent" &&
214 pull.runtime === "hosted" &&
215 (pull.status === "draft" || pull.status === "open"),
216 );
Issues and pull requests replace intents and attempts217 useEffect(() => {
Agents as a team: lifecycle, merge queue, billing and a new shell218 if (!running && !assigned) return;
Issues and pull requests replace intents and attempts219 const timer = setInterval(() => {
220 if (document.visibilityState === "visible") revalidator.revalidate();
221 }, REFRESH_MS);
222 return () => clearInterval(timer);
Agents as a team: lifecycle, merge queue, billing and a new shell223 }, [running, assigned, revalidator]);
Issues and pull requests replace intents and attempts224
225 const starting = navigation.formData?.get("action") === "run-hosted";
226 const base = `/${params.owner}/${params.repo}`;
227 const open = issue.state === "open";
228 const reference = `${params.owner}/${params.repo}#${issue.number}`;
229 const resolver = pulls.find((pull) => pull.number === issue.resolvedBy);
230 // The merged pull request first, then the ones still in play, then the rest.
231 const rank = { merged: 0, open: 1, draft: 2, closed: 3 } as const;
232 const ordered = [...pulls].sort(
233 (a, b) => rank[a.status] - rank[b.status] || a.number - b.number,
234 );
235
236 return (
237 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
238 <div className="min-w-0">
239 <h2 className="text-2xl font-semibold tracking-tight text-balance">
240 {issue.title} <span className="font-normal text-faint">#{issue.number}</span>
241 </h2>
242 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
243 <IssueState issue={issue} />
244 <span className="flex items-center gap-2">
245 <Avatar name={issue.author.username} size={18} />
246 <span>
247 <span className="font-medium text-fg">{issue.author.username}</span> opened this{" "}
248 <TimeAgo at={issue.createdAt} />
249 </span>
250 </span>
251 {issue.labels.map((name) => (
252 <Label key={name} name={name} />
253 ))}
Agents as a team: lifecycle, merge queue, billing and a new shell254 {open && issue.agent && <Assignee agent={issue.agent} />}
Issues and pull requests replace intents and attempts255 </div>
256
257 {issue.resolvedBy != null && (
258 <Link
259 to={`${base}/pull/${issue.resolvedBy}`}
260 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"
261 >
262 <GitMerge size={18} className="shrink-0 text-merged" />
263 <span>
264 Resolved by{" "}
265 <span className="font-medium">
266 {resolver?.title ?? "pull request"} #{issue.resolvedBy}
267 </span>
268 {resolver?.mergedBy && (
269 <span className="text-muted">
270 , merged by {resolver.mergedBy}{" "}
271 {resolver.mergedAt && <TimeAgo at={resolver.mergedAt} />}
272 </span>
273 )}
274 </span>
275 </Link>
276 )}
277
278 {issue.body && (
279 <div className="mt-5 rounded-xl border border-line bg-surface p-5">
Agents as a team: lifecycle, merge queue, billing and a new shell280 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
Issues and pull requests replace intents and attempts281 </div>
282 )}
283
284 <div className="mt-10 flex items-baseline justify-between">
285 <h3 className="font-semibold tracking-tight">Pull requests</h3>
286 <p className="text-sm text-muted">
287 {pulls.length === 0
288 ? "None yet"
289 : `${pulls.length} for this issue${
290 pulls.some((pull) => pull.status === "merged") ? "" : ", none merged"
291 }`}
292 </p>
293 </div>
294 <div className="mt-3">
295 {pulls.length === 0 ? (
296 <EmptyState title="Nobody has worked on this yet">
297 Put g1t agents on it, or point your own agent at{" "}
298 <code className="font-mono">{reference}</code>.
299 </EmptyState>
300 ) : (
301 <ol className="space-y-3">
302 {ordered.map((pull) => (
303 <PullRow key={pull.id} pull={pull} base={base} />
304 ))}
305 </ol>
306 )}
307 </div>
308
309 <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
310 <div className="mt-3">
Agents as a team: lifecycle, merge queue, billing and a new shell311 <div className="space-y-4">
312 <CommentList comments={comments} base={base} />
313 <CommentForm author={viewer?.username ?? null} resetKey={comments.length} />
314 {canManage && (
315 <Form method="post" className="flex flex-wrap justify-end gap-2">
316 {open ? (
317 <>
318 <Button variant="quiet" type="submit" name="action" value="close-not-planned">
319 Close as not planned
320 </Button>
321 <Button variant="quiet" type="submit" name="action" value="close-completed">
322 Close issue
323 </Button>
324 </>
325 ) : (
326 <Button variant="quiet" type="submit" name="action" value="reopen">
327 Reopen issue
328 </Button>
329 )}
330 </Form>
331 )}
332 </div>
Issues and pull requests replace intents and attempts333 </div>
334 <div className="mt-2">
335 <ErrorText>{actionData?.error}</ErrorText>
336 </div>
337 </div>
338
339 <aside className="space-y-6">
Agents as a team: lifecycle, merge queue, billing and a new shell340 <section>
341 <h3 className="text-sm font-medium">Assignees</h3>
342 <ul className="mt-2 space-y-1.5 text-sm">
343 {open && assigned && (
344 <li>
345 <Link
346 to={`${base}/pull/${assigned.number}`}
347 className="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/5 px-2.5 py-2 transition-colors hover:border-accent/60"
348 >
349 <Sparkles size={15} className="shrink-0 text-accent" />
350 <span className="min-w-0 grow">
351 <span className="block font-mono text-xs font-medium">g1t-agent</span>
352 <span className="block truncate text-xs text-muted">
353 {assigned.status === "draft"
354 ? "Making the change"
355 : "Seeing it through checks and review"}{" "}
356 · #{assigned.number}
357 </span>
358 </span>
359 <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-accent" />
360 </Link>
361 </li>
362 )}
363 {issue.assignees.map((name) => (
364 <li key={name} className="flex items-center gap-2 px-1">
365 <Avatar name={name} size={20} />
366 <span className="grow truncate font-mono text-xs">{name}</span>
367 </li>
368 ))}
369 {open && issue.queued && !assigned && (
370 <li className="flex items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2">
371 <Sparkles size={15} className="shrink-0 text-faint" />
372 <span className="min-w-0 grow">
373 <span className="block font-mono text-xs font-medium">g1t-agent</span>
374 <span className="block text-xs text-muted">
375 {issue.blockedBy.length > 0
376 ? "Queued. Starts when what this depends on has merged."
377 : "Queued. Starts as soon as there is room."}
378 </span>
379 </span>
380 </li>
381 )}
382 {!open && resolver && resolver.agent === "g1t-agent" && (
383 <li>
384 <Link
385 to={`${base}/pull/${resolver.number}`}
386 className="flex items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2 transition-colors hover:border-line-strong"
387 >
388 <Sparkles size={15} className="shrink-0 text-merged" />
389 <span className="min-w-0 grow">
390 <span className="block font-mono text-xs font-medium">g1t-agent</span>
391 <span className="block truncate text-xs text-muted">
392 Resolved it with #{resolver.number}
393 </span>
394 </span>
395 </Link>
396 </li>
397 )}
398 {issue.assignees.length === 0 &&
399 !(open && (assigned || issue.queued)) &&
400 !(!open && resolver?.agent === "g1t-agent") && (
401 <li className="px-1 text-xs text-faint">No one yet.</li>
402 )}
403 </ul>
404 {issue.blockedBy.length > 0 && (
405 <p className="mt-3 text-xs text-muted">
406 Depends on{" "}
407 {issue.blockedBy.map((number, index) => (
408 <span key={number}>
409 {index > 0 && ", "}
410 <Link
411 to={`${base}/issues/${number}`}
412 className="font-medium text-fg hover:underline"
413 >
414 #{number}
415 </Link>
416 </span>
417 ))}
418 {open ? ", which has to merge first." : "."}
Issues and pull requests replace intents and attempts419 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell420 )}
421
422 {open && agentsEnabled && !assigned && !issue.queued && (
Issues and pull requests replace intents and attempts423 <Form method="post" className="mt-3 space-y-2">
424 <input type="hidden" name="action" value="run-hosted" />
425 <div className="*:w-full">
426 <Button variant="accent" type="submit" disabled={starting}>
Agents as a team: lifecycle, merge queue, billing and a new shell427 <Sparkles size={14} />
428 {starting ? "Starting a sandbox…" : "Assign to g1t agent"}
Issues and pull requests replace intents and attempts429 </Button>
430 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell431 <details>
432 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
433 Add guidance for this run
434 </summary>
435 <div className="mt-2">
436 <Textarea
437 name="instructions"
438 rows={2}
439 placeholder="On top of the issue's description"
440 />
441 </div>
442 </details>
443 <p className="text-xs text-muted">
444 It opens a pull request and sees it through checks, a review by another
445 agent and fixes. You get it back ready to merge.
446 </p>
Issues and pull requests replace intents and attempts447 </Form>
Agents as a team: lifecycle, merge queue, billing and a new shell448 )}
449
450 {viewer && canManage && open && (
451 <div className="mt-3 space-y-2">
452 {!issue.assignees.includes(viewer.username) && (
453 <Form method="post">
454 <input type="hidden" name="action" value="assign" />
455 {issue.assignees.map((name) => (
456 <input key={name} type="hidden" name="assignee" value={name} />
457 ))}
458 <input type="hidden" name="assignee" value={viewer.username} />
459 <div className="*:w-full">
460 <Button variant="quiet" type="submit">
461 Assign yourself
462 </Button>
463 </div>
464 </Form>
465 )}
466 <details className="group">
467 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
468 Assign people
469 </summary>
470 <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}>
471 <input type="hidden" name="action" value="assign" />
472 <PeoplePicker name="assignee" members={members} chosen={issue.assignees} />
473 <Button variant="quiet" type="submit">
474 Save assignees
475 </Button>
476 </Form>
477 </details>
478 </div>
479 )}
480 </section>
Issues and pull requests replace intents and attempts481
482 {open && (
483 <section className="rounded-xl border border-line bg-surface p-4">
484 <h3 className="text-sm font-medium">Bring your own agent</h3>
485 <p className="mt-1 text-xs text-muted">
486 With g1t connected to your agent, ask it to work on this issue.
487 </p>
488 <div className="mt-3">
489 <CopyLine text={reference} />
490 </div>
491 {viewer ? (
492 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
493 <input type="hidden" name="action" value="open-pull" />
494 <p className="text-xs text-muted">
495 Or open a draft pull request yourself and get a fork to push to.
496 </p>
497 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
498 <div className="*:w-full">
499 <Button type="submit">Open pull request</Button>
500 </div>
Pull requests from branches501 <p className="text-xs text-muted">
502 Already pushed a branch?{" "}
503 <Link
504 to={`${base}/pulls/new?issue=${issue.number}`}
505 className="text-fg underline underline-offset-4"
506 >
507 Open a pull request from it
508 </Link>
509 .
510 </p>
Issues and pull requests replace intents and attempts511 </Form>
512 ) : (
513 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
514 <Link to="/login" className="text-fg underline underline-offset-4">
515 Sign in
516 </Link>{" "}
517 to open a pull request.
518 </p>
519 )}
520 </section>
521 )}
522
523 {issue.checks.length > 0 && (
524 <section>
525 <h3 className="text-sm font-medium">Acceptance checks</h3>
526 <ul className="mt-2 space-y-1.5">
527 {issue.checks.map((check) => (
528 <li
529 key={check}
530 className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs"
531 >
532 <Terminal size={13} className="shrink-0 text-faint" />
533 <span className="truncate">{check}</span>
534 </li>
535 ))}
536 </ul>
537 </section>
538 )}
539
540 {canManage && (
541 <details className="group">
542 <summary className="cursor-pointer list-none text-sm font-medium">
543 Labels <span className="text-xs font-normal text-faint group-open:hidden">Edit</span>
544 </summary>
545 <Form method="post" className="mt-3 space-y-3" key={issue.labels.join()}>
546 <input type="hidden" name="action" value="labels" />
547 <div className="flex flex-wrap gap-x-3 gap-y-2">
548 {labels.map((name) => (
549 <label key={name} className="flex cursor-pointer items-center gap-1.5">
550 <input
551 type="checkbox"
552 name="label"
553 value={name}
554 defaultChecked={issue.labels.includes(name)}
555 className="accent-accent"
556 />
557 <Label name={name} />
558 </label>
559 ))}
560 </div>
561 <Input name="labels" placeholder="New labels, comma separated" />
562 <Button variant="quiet" type="submit">
563 Save labels
564 </Button>
565 </Form>
566 </details>
567 )}
568
569 </aside>
570 </div>
571 );
572}