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

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