flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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