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

653 lines27,281 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Bot, ExternalLink, 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, PROVIDERS } from "@g1t/contracts";
7
8import type { Route } from "./+types/issue";
9import { excerpt, page } from "../../lib/meta";
10import { Markdown } from "../../components/markdown";
11import { AgentStepLine } from "../../components/agents";
12import {
13 Avatar,
14 Button,
15 CopyLine,
16 EmptyState,
17 ComputeNote,
18 ErrorText,
19 Input,
20 Textarea,
21 TimeAgo,
22} from "../../components/ui";
23import { CheckboxOption } from "../../components/ui/checkbox";
24import { CheckBadge } from "../../components/checks";
25import {
26 Assignee,
27 AssigneeStack,
28 ChangeSize,
29 CommentForm,
30 CommentList,
31 PeoplePicker,
32 IssueState,
33 Label,
34 PersonLink,
35 PullIcon,
36 plainText,
37} from "../../components/work";
38import { notFound } from "../../lib/not-found.server";
39import { computeNoteFor } from "../../lib/compute.server";
40import { identity, integrations, work } from "../../lib/services.server";
41import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
42import { accessTo, refusal } from "../../lib/access.server";
43
44const REFRESH_MS = 4000;
45
46export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
47 const issue = loaderData?.issue;
48 const title = issue ? `${issue.title} · Issue #${issue.number} · ` : "";
49 const state = issue?.state === "open" ? "Open" : issue?.reason === "not_planned" ? "Closed as not planned" : "Closed";
50 const body = excerpt(issue?.body);
51 return page(args, {
52 title: `${title}${params.owner}/${params.repo} · g1t`,
53 description: issue
54 ? `${state} issue #${issue.number} on ${params.owner}/${params.repo}, opened by ${issue.author.username}.${body ? ` ${body}` : ""}`
55 : null,
56 // The card shows the title and the state.
57 version: issue ? [issue.title, issue.state, issue.reason] : undefined,
58 type: "article",
59 });
60}
61
62export async function loader({ params, context }: Route.LoaderArgs) {
63 const viewer = getViewer(context);
64 const path = { namespace: params.owner, name: params.repo };
65 const number = Number(params.number);
66 // At once: none of these depends on another.
67 const { can } = await accessTo(context, params);
68 // Putting an agent on it needs Write: Read cannot spend compute.
69 const member = can.run;
70 const [found, labels, agentsEnabled, members, links, computeNote] = await Promise.all([
71 work.getIssue(path, number, viewer),
72 work.listLabels(path, viewer),
73 env.RUNNER.enabled(viewer, path),
74 // A member picks assignees from the workspace's people.
75 roleIn(viewer, params.owner) ? identity.listMembers(params.owner, viewer) : null,
76 // What it is tied to outside g1t. Shown only once the issue is known visible.
77 integrations.links(path, number).catch(() => []),
78 // Before a member assigns g1t-agent: whether the workspace's plan lets it start.
79 member ? computeNoteFor(params.owner, "agent") : null,
80 ]);
81 if (!found.ok) {
82 // Issues and pull requests share numbers; this one may be a pull request.
83 const pull = await work.getPull(path, number, viewer);
84 if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`);
85 throw notFound("issue");
86 }
87 const { issue } = found.value;
88 return {
89 ...found.value,
90 viewer,
91 labels: labels.ok ? labels.value : [],
92 agentsEnabled,
93 computeNote,
94 links,
95 members: members?.ok ? members.value.map((member) => member.username) : [],
96 // The author can close and reopen their own issue; Triage and up, anyone's.
97 canManage: viewer != null && (viewer.id === issue.author.id || can.triage),
98 can,
99 };
100}
101
102export async function action({ request, params, context }: Route.ActionArgs) {
103 assertSameOrigin(request);
104 const user = requireUser(context, request);
105 const form = await request.formData();
106 const path = { namespace: params.owner, name: params.repo };
107 const number = Number(params.number);
108 // What each form needs; closing your own issue is checked by work.
109 const needed = form.get("action") === "run-hosted" ? "run" : form.get("action") === "assign" || form.get("action") === "labels" ? "triage" : null;
110 const refused = needed ? await refusal(context, params, needed) : null;
111 if (refused) return { error: refused, action: String(form.get("action")) };
112
113 switch (form.get("action")) {
114 case "run-hosted": {
115 const result = await env.RUNNER.run(user, path, number, {
116 instructions: String(form.get("instructions") ?? ""),
117 });
118 return result.ok ? null : { error: result.error.message, action: "run-hosted" };
119 }
120 case "open-pull": {
121 const result = await work.openPull(user, path, {
122 issue: number,
123 agent: String(form.get("agent") ?? ""),
124 runtime: "external",
125 });
126 if (!result.ok) return { error: result.error.message };
127 throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
128 }
129 case "comment": {
130 const result = await work.addComment(user, path, number, {
131 body: String(form.get("body") ?? ""),
132 });
133 return result.ok ? null : { error: result.error.message };
134 }
135 case "assign": {
136 const result = await work.updateIssue(user, path, number, {
137 assignees: [
138 ...form.getAll("assignee").map(String),
139 ...String(form.get("others") ?? "").split(/[\s,]+/),
140 ],
141 });
142 return result.ok ? null : { error: result.error.message };
143 }
144 case "labels": {
145 const result = await work.updateIssue(user, path, number, {
146 labels: [
147 ...form.getAll("label").map(String),
148 ...String(form.get("labels") ?? "").split(","),
149 ],
150 });
151 return result.ok ? null : { error: result.error.message };
152 }
153 case "reopen": {
154 const result = await work.reopenIssue(user, path, number);
155 return result.ok ? null : { error: result.error.message };
156 }
157 default: {
158 const result = await work.closeIssue(
159 user,
160 path,
161 number,
162 form.get("action") === "close-not-planned" ? "not_planned" : "completed",
163 );
164 return result.ok ? null : { error: result.error.message };
165 }
166 }
167}
168
169/** What became of a pull request, in a few words. */
170function outcome(pull: Pull): string {
171 if (pull.status === "merged") return `Merged by ${pull.mergedBy ?? "someone"}`;
172 if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`;
173 if (pull.status === "closed") return "Closed without merging";
174 if (pull.status === "draft") return "Draft · in progress";
175 return "Ready for review";
176}
177
178function PullRow({ pull, base }: { pull: Pull; base: string }) {
179 const merged = pull.status === "merged";
180 return (
181 <li>
182 <Link
183 to={`${base}/pull/${pull.number}`}
184 className={`block rounded-xl border p-4 transition-colors ${
185 merged
186 ? "border-merged/40 bg-merged/5 hover:border-merged/70"
187 : "border-line bg-surface hover:border-line-strong"
188 } ${pull.status === "closed" ? "opacity-70" : ""}`}
189 >
190 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
191 <PullIcon status={pull.status} />
192 <span className="font-medium">
193 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
194 </span>
195 {pull.status === "draft" && (
196 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
197 )}
198 <span className="ml-auto flex items-center gap-3 text-xs text-faint">
199 <ChangeSize files={pull.files} />
200 <CheckBadge status={pull.checkStatus} />
201 <span className="flex items-center gap-1 font-mono">
202 <GitCommitHorizontal size={13} />
203 {pull.headCommit?.slice(0, 7) ?? "no commits"}
204 </span>
205 <TimeAgo at={pull.updatedAt} />
206 </span>
207 </div>
208 <p className="mt-1.5 flex flex-wrap items-center gap-x-2 text-xs text-muted">
209 <span className={merged ? "font-medium text-merged" : ""}>{outcome(pull)}</span>
210 <span className="text-faint">·</span>
211 <span className="flex items-center gap-1 font-mono">
212 <Bot size={12} />
213 {pull.agent}
214 </span>
215 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
216 <span className="text-faint">· opened by {pull.author.username}</span>
217 </p>
218 {pull.body && (
219 <p className="mt-2 line-clamp-2 text-sm text-muted">{plainText(pull.body)}</p>
220 )}
221 </Link>
222 </li>
223 );
224}
225
226export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) {
227 const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData;
228
229 // Follow agents at work without a manual reload.
230 const revalidator = useRevalidator();
231 const navigation = useNavigation();
232 const running = pulls.some(
233 (pull) =>
234 pull.status === "draft" ||
235 pull.checkStatus === "queued" ||
236 pull.checkStatus === "running",
237 );
238 // The pull request g1t's agent has in progress for this issue, if any.
239 const assigned = [...pulls]
240 .reverse()
241 .find(
242 (pull) =>
243 pull.agent === "g1t-agent" &&
244 pull.runtime === "hosted" &&
245 (pull.status === "draft" || pull.status === "open"),
246 );
247 useEffect(() => {
248 if (!running && !assigned) return;
249 const timer = setInterval(() => {
250 if (document.visibilityState === "visible") revalidator.revalidate();
251 }, REFRESH_MS);
252 return () => clearInterval(timer);
253 }, [running, assigned, revalidator]);
254
255 const starting = navigation.formData?.get("action") === "run-hosted";
256 const base = `/${params.owner}/${params.repo}`;
257 const open = issue.state === "open";
258 const reference = `${params.owner}/${params.repo}#${issue.number}`;
259 const resolver = pulls.find((pull) => pull.number === issue.resolvedBy);
260 // The merged pull request first, then the ones still in play, then the rest.
261 const rank = { merged: 0, open: 1, draft: 2, closed: 3 } as const;
262 const ordered = [...pulls].sort(
263 (a, b) => rank[a.status] - rank[b.status] || a.number - b.number,
264 );
265
266 return (
267 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
268 <div className="min-w-0">
269 <h2 className="text-2xl font-semibold tracking-tight text-balance">
270 {issue.title} <span className="font-normal text-faint">#{issue.number}</span>
271 </h2>
272 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
273 <IssueState issue={issue} />
274 <span className="flex items-center gap-2">
275 <Avatar name={issue.author.username} size={18} />
276 <span>
277 <PersonLink name={issue.author.username} className="font-medium text-fg hover:underline" /> opened this{" "}
278 <TimeAgo at={issue.createdAt} />
279 </span>
280 </span>
281 {issue.labels.map((name) => (
282 <Label key={name} name={name} />
283 ))}
284 {open && issue.agent && <Assignee agent={issue.agent} />}
285 </div>
286
287 {issue.resolvedBy != null && (
288 <Link
289 to={`${base}/pull/${issue.resolvedBy}`}
290 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"
291 >
292 <GitMerge size={18} className="shrink-0 text-merged" />
293 <span>
294 Resolved by{" "}
295 <span className="font-medium">
296 {resolver?.title ?? "pull request"} #{issue.resolvedBy}
297 </span>
298 {resolver?.mergedBy && (
299 <span className="text-muted">
300 , merged by {resolver.mergedBy}{" "}
301 {resolver.mergedAt && <TimeAgo at={resolver.mergedAt} />}
302 </span>
303 )}
304 </span>
305 </Link>
306 )}
307
308 {issue.body && (
309 <div className="mt-5 rounded-xl border border-line bg-surface p-5">
310 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
311 </div>
312 )}
313
314 <div className="mt-10 flex items-baseline justify-between">
315 <h3 className="font-semibold tracking-tight">Pull requests</h3>
316 <p className="text-sm text-muted">
317 {pulls.length === 0
318 ? "None yet"
319 : `${pulls.length} for this issue${
320 pulls.some((pull) => pull.status === "merged") ? "" : ", none merged"
321 }`}
322 </p>
323 </div>
324 <div className="mt-3">
325 {pulls.length === 0 ? (
326 <EmptyState title="Nobody has worked on this yet">
327 Put g1t agents on it, or point your own agent at{" "}
328 <code className="font-mono">{reference}</code>.
329 </EmptyState>
330 ) : (
331 <ol className="space-y-3">
332 {ordered.map((pull) => (
333 <PullRow key={pull.id} pull={pull} base={base} />
334 ))}
335 </ol>
336 )}
337 </div>
338
339 <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
340 <div className="mt-3">
341 <div className="space-y-4">
342 <CommentList comments={comments} base={base} />
343 <CommentForm author={viewer?.username ?? null} resetKey={comments.length} />
344 {canManage && (
345 <Form method="post" className="flex flex-wrap justify-end gap-2">
346 {open ? (
347 <>
348 <Button variant="quiet" type="submit" name="action" value="close-not-planned">
349 Close as not planned
350 </Button>
351 <Button variant="quiet" type="submit" name="action" value="close-completed">
352 Close issue
353 </Button>
354 </>
355 ) : (
356 <Button variant="quiet" type="submit" name="action" value="reopen">
357 Reopen issue
358 </Button>
359 )}
360 </Form>
361 )}
362 </div>
363 </div>
364 <div className="mt-2">
365 {!(actionData && "action" in actionData) && <ErrorText>{actionData?.error}</ErrorText>}
366 </div>
367 </div>
368
369 <aside className="space-y-6">
370 {loaderData.links.length > 0 && (
371 <section>
372 <h3 className="text-sm font-medium">From outside g1t</h3>
373 <ul className="mt-2 space-y-1.5 text-sm">
374 {loaderData.links.map((link) => (
375 <li key={`${link.connectionId}-${link.key}`}>
376 <a
377 href={link.url}
378 target="_blank"
379 rel="noreferrer"
380 className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong"
381 >
382 <span className="min-w-0 grow">
383 <span className="block truncate font-medium">
384 {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span>
385 </span>
386 <span className="block text-xs text-faint">
387 {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "}
388 <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} />
389 </span>
390 </span>
391 <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" />
392 </a>
393 </li>
394 ))}
395 </ul>
396 </section>
397 )}
398 <section>
399 <h3 className="text-sm font-medium">Assignees</h3>
400 <ul className="mt-2 space-y-1.5 text-sm">
401 {open && assigned && (
402 <li>
403 <Link
404 to={`${base}/pull/${assigned.number}`}
405 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"
406 >
407 <Sparkles size={15} className="shrink-0 text-accent" />
408 <span className="min-w-0 grow">
409 <span className="block font-mono text-xs font-medium">g1t-agent</span>
410 <span className="block truncate text-xs text-muted">
411 {assigned.status === "draft"
412 ? "Making the change"
413 : "Seeing it through checks and review"}{" "}
414 · #{assigned.number}
415 </span>
416 <AgentStepLine owner={params.owner} repo={params.repo} number={assigned.number} />
417 </span>
418 <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-accent" />
419 </Link>
420 </li>
421 )}
422 {issue.assignees.map((name) => (
423 <li key={name} className="flex items-center gap-2 px-1">
424 <Avatar name={name} size={20} />
425 <span className="grow truncate font-mono text-xs">{name}</span>
426 </li>
427 ))}
428 {open && issue.queued && !assigned && (
429 <li className="flex items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2">
430 <Sparkles size={15} className="shrink-0 text-faint" />
431 <span className="min-w-0 grow">
432 <span className="block font-mono text-xs font-medium">g1t-agent</span>
433 <span className="block text-xs text-muted">
434 {issue.blockedBy.length > 0
435 ? "Queued. Starts when what this depends on has merged."
436 : "Queued. Starts as soon as there is room."}
437 </span>
438 </span>
439 </li>
440 )}
441 {!open && resolver && resolver.agent === "g1t-agent" && (
442 <li>
443 <Link
444 to={`${base}/pull/${resolver.number}`}
445 className="flex items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2 transition-colors hover:border-line-strong"
446 >
447 <Sparkles size={15} className="shrink-0 text-merged" />
448 <span className="min-w-0 grow">
449 <span className="block font-mono text-xs font-medium">g1t-agent</span>
450 <span className="block truncate text-xs text-muted">
451 Resolved it with #{resolver.number}
452 </span>
453 </span>
454 </Link>
455 </li>
456 )}
457 {issue.assignees.length === 0 &&
458 !(open && (assigned || issue.queued)) &&
459 !(!open && resolver?.agent === "g1t-agent") && (
460 <li className="px-1 text-xs text-faint">No one yet.</li>
461 )}
462 </ul>
463 {issue.blockedBy.length > 0 && (
464 <p className="mt-3 text-xs text-muted">
465 Depends on{" "}
466 {issue.blockedBy.map((number, index) => (
467 <span key={number}>
468 {index > 0 && ", "}
469 <Link
470 to={`${base}/issues/${number}`}
471 className="font-medium text-fg hover:underline"
472 >
473 #{number}
474 </Link>
475 </span>
476 ))}
477 {open ? ", which has to merge first." : "."}
478 </p>
479 )}
480
481 {open && agentsEnabled && can.run && !assigned && !issue.queued && (
482 <Form method="post" className="mt-3 space-y-2">
483 <input type="hidden" name="action" value="run-hosted" />
484 <div className="*:w-full">
485 <Button variant="accent" type="submit" disabled={starting}>
486 <Sparkles size={14} />
487 {starting ? "Starting a sandbox…" : "Assign to g1t agent"}
488 </Button>
489 </div>
490 <details>
491 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
492 Add guidance for this run
493 </summary>
494 <div className="mt-2">
495 <Textarea
496 name="instructions"
497 rows={2}
498 placeholder="On top of the issue's description"
499 />
500 </div>
501 </details>
502 <p className="text-xs text-muted">
503 It opens a pull request and sees it through checks, a review by another
504 agent and fixes. You get it back ready to merge.
505 </p>
506 <ComputeNote note={loaderData.computeNote} />
507 {actionData && "action" in actionData && actionData.action === "run-hosted" && (
508 <ErrorText>{actionData.error}</ErrorText>
509 )}
510 </Form>
511 )}
512
513 {open && !agentsEnabled && can.run && !assigned && !issue.queued && (
514 // Where g1t's agent would be, and what makes it appear.
515 <div className="mt-3 rounded-lg border border-dashed border-line p-3 text-sm">
516 <p className="flex items-center gap-1.5 font-medium">
517 <Sparkles size={14} className="text-accent" />
518 g1t agent
519 </p>
520 <p className="mt-1 text-xs text-muted">
521 Connect a model provider and g1t's agent can take this issue: it opens a pull request and sees it
522 through checks, review and fixes. Agents run on a paid workspace, or on the free trial.
523 </p>
524 <Link
525 to={`/${params.owner}/-/integrations`}
526 className="mt-2 inline-block text-xs text-accent hover:underline"
527 >
528 Connect a model
529 </Link>
530 </div>
531 )}
532
533 {viewer && can.triage && open && (
534 <div className="mt-3 space-y-2">
535 {!issue.assignees.includes(viewer.username) && (
536 <Form method="post">
537 <input type="hidden" name="action" value="assign" />
538 {issue.assignees.map((name) => (
539 <input key={name} type="hidden" name="assignee" value={name} />
540 ))}
541 <input type="hidden" name="assignee" value={viewer.username} />
542 <div className="*:w-full">
543 <Button variant="quiet" type="submit">
544 Assign yourself
545 </Button>
546 </div>
547 </Form>
548 )}
549 <details className="group">
550 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
551 Assign people
552 </summary>
553 <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}>
554 <input type="hidden" name="action" value="assign" />
555 <PeoplePicker name="assignee" members={members} chosen={issue.assignees} />
556 <Button variant="quiet" type="submit">
557 Save assignees
558 </Button>
559 </Form>
560 </details>
561 </div>
562 )}
563 </section>
564
565 {open && (
566 <section className="rounded-xl border border-line bg-surface p-4">
567 <h3 className="text-sm font-medium">Bring your own agent</h3>
568 <p className="mt-1 text-xs text-muted">
569 With g1t connected to your agent, ask it to work on this issue.
570 </p>
571 <div className="mt-3">
572 <CopyLine text={reference} />
573 </div>
574 {viewer ? (
575 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
576 <input type="hidden" name="action" value="open-pull" />
577 <p className="text-xs text-muted">
578 Or open a draft pull request yourself and get a fork to push to.
579 </p>
580 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
581 <div className="*:w-full">
582 <Button type="submit">Open pull request</Button>
583 </div>
584 <p className="text-xs text-muted">
585 Already pushed a branch?{" "}
586 <Link
587 to={`${base}/pulls/new?issue=${issue.number}`}
588 className="text-fg underline underline-offset-4"
589 >
590 Open a pull request from it
591 </Link>
592 .
593 </p>
594 </Form>
595 ) : (
596 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
597 <Link to="/login" className="text-fg underline underline-offset-4">
598 Sign in
599 </Link>{" "}
600 to open a pull request.
601 </p>
602 )}
603 </section>
604 )}
605
606 {issue.checks.length > 0 && (
607 <section>
608 <h3 className="text-sm font-medium">Acceptance checks</h3>
609 <ul className="mt-2 space-y-1.5">
610 {issue.checks.map((check) => (
611 <li
612 key={check}
613 className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs"
614 >
615 <Terminal size={13} className="shrink-0 text-faint" />
616 <span className="truncate">{check}</span>
617 </li>
618 ))}
619 </ul>
620 </section>
621 )}
622
623 {can.triage && (
624 <details className="group">
625 <summary className="cursor-pointer list-none text-sm font-medium">
626 Labels <span className="text-xs font-normal text-faint group-open:hidden">Edit</span>
627 </summary>
628 <Form method="post" className="mt-3 space-y-3" key={issue.labels.join()}>
629 <input type="hidden" name="action" value="labels" />
630 <div className="flex flex-wrap gap-x-3 gap-y-2">
631 {labels.map((name) => (
632 <CheckboxOption
633 key={name}
634 name="label"
635 value={name}
636 defaultChecked={issue.labels.includes(name)}
637 label={<Label name={name} />}
638 className="items-center gap-1.5"
639 />
640 ))}
641 </div>
642 <Input name="labels" placeholder="New labels, comma separated" />
643 <Button variant="quiet" type="submit">
644 Save labels
645 </Button>
646 </Form>
647 </details>
648 )}
649
650 </aside>
651 </div>
652 );
653}