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

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