Skip to content

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

555 linesCodeBlameRaw
1import { Bot, ExternalLink, GitCommitHorizontal, GitMerge } from "lucide-react";
2import { Form, Link, redirect } from "react-router";
3
4import { type Pull, PROVIDERS, workOwner } from "@g1t/contracts";
5
6import type { Route } from "./+types/issue";
7import { excerpt, page } from "../../lib/meta";
8import { Markdown } from "../../components/markdown";
9import {
10 CopyLine,
11 EmptyState,
12 ErrorText,
13 Input,
14 SubmitButton,
15 TimeAgo,
16} from "../../components/ui";
17import { Avatar } from "../../components/ui/avatar";
18import { Card } from "../../components/ui/card";
19import { CheckBadge } from "../../components/checks";
20import { LabelChip, LabelsBox, MilestoneBox } from "../../components/labels";
21import {
22 Assignee,
23 ChangeSize,
24 CommentForm,
25 CommentList,
26 PeoplePicker,
27 IssueState,
28 PersonLink,
29 PullIcon,
30 plainText,
31} from "../../components/work";
32import { notFound } from "../../lib/not-found.server";
33import { openedBy } from "../../lib/opened-by";
34import { identity, inbox, integrations, work } from "../../lib/services.server";
35import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
36import { accessTo, refusal, repoFor } from "../../lib/access.server";
37import { SubscriptionBox } from "../../components/notifications";
38import { useRefreshWhile } from "../../lib/refresh";
39import { DetailsDisclosure, detailsSummary } from "../../components/details-disclosure";
40import { repoPages } from "../../lib/repo-live";
41import { useRepoLive } from "../../lib/use-repo-live";
42
43
44export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
45 const issue = loaderData?.issue;
46 const title = issue ? `${issue.title} · Issue #${issue.number} · ` : "";
47 const state = issue?.state === "open" ? "Open" : issue?.reason === "not_planned" ? "Closed as not planned" : "Closed";
48 const body = excerpt(issue?.body);
49 return page(args, {
50 title: `${title}${params.owner}/${params.repo} · g1t`,
51 description: issue
52 ? `${state} issue #${issue.number} on ${params.owner}/${params.repo}, opened by ${issue.author.username}${issue.requestedBy ? ` for ${issue.requestedBy.username}` : ""}.${body ? ` ${body}` : ""}`
53 : null,
54 // The card shows the title and the state.
55 version: issue ? [issue.title, issue.state, issue.reason] : undefined,
56 type: "article",
57 });
58}
59
60export async function loader({ params, context }: Route.LoaderArgs) {
61 const viewer = getViewer(context);
62 const path = { namespace: params.owner, name: params.repo };
63 const number = Number(params.number);
64 const access = accessTo(context, params);
65 const [{ can }, found, labels, milestones, members, links, subscription, teams] = await Promise.all([
66 access,
67 work.getIssue(path, number, viewer),
68 work.listLabels(path, viewer),
69 // Open ones to choose from, and the one it is in, with its progress.
70 work.listMilestones(path, viewer),
71 // A member picks assignees from the workspace's people.
72 roleIn(viewer, params.owner) ? identity.listMembers(params.owner, viewer) : null,
73 // What it is tied to outside g1t. Shown only once the issue is known visible.
74 integrations.links(path, number).catch(() => []),
75 // Whether the viewer hears of it, for the sidebar's Notifications.
76 viewer
77 ? repoFor(context, params).then((repo) =>
78 repo.ok ? inbox.subscription(viewer, { repoId: repo.value.id, number }).catch(() => null) : null,
79 )
80 : null,
81 // The teams a member can mention, for the comment box.
82 roleIn(viewer, params.owner) ? identity.listTeams(viewer, params.owner).catch(() => null) : null,
83 ]);
84 if (!found.ok) {
85 // Issues and pull requests share numbers; this one may be a pull request.
86 const pull = await work.getPull(path, number, viewer);
87 if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`);
88 throw notFound("issue");
89 }
90 const { issue } = found.value;
91 return {
92 ...found.value,
93 viewer,
94 labels: labels.ok ? labels.value : [],
95 milestones: milestones.ok ? milestones.value : [],
96 links,
97 subscription,
98 members: members?.ok ? members.value.map((member) => member.username) : [],
99 handles: teams?.ok ? teams.value.map((team) => `@${team.workspace}/${team.slug}`) : [],
100 // The author can close and reopen their own issue, and whoever g1t's
101 // agent filed one for, that one; Triage and up, anyone's.
102 canManage: viewer != null && (viewer.id === workOwner(issue).id || can.triage),
103 // Maintain and up edit and delete anyone's comments; everyone, their own.
104 canModerate: can.manage_settings,
105 can,
106 };
107}
108
109export async function action({ request, params, context }: Route.ActionArgs) {
110 assertSameOrigin(request);
111 const user = requireUser(context, request);
112 const form = await request.formData();
113 const path = { namespace: params.owner, name: params.repo };
114 const number = Number(params.number);
115 // What each form needs; closing your own issue is checked by work.
116 // Labels: its author may use the repository's on their own issue, and
117 // work says so; anyone else, and every milestone, needs Triage.
118 const needed = form.get("action") === "assign" || form.get("action") === "milestone" ? "triage" : null;
119 const refused = needed ? await refusal(context, params, needed) : null;
120 if (refused) return { error: refused, action: String(form.get("action")) };
121
122 switch (form.get("action")) {
123 case "open-pull": {
124 const result = await work.openPull(user, path, {
125 issue: number,
126 agent: String(form.get("agent") ?? ""),
127 runtime: "external",
128 });
129 if (!result.ok) return { error: result.error.message };
130 throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
131 }
132 case "comment": {
133 const result = await work.addComment(user, path, number, {
134 body: String(form.get("body") ?? ""),
135 });
136 return result.ok ? null : { error: result.error.message };
137 }
138 case "assign": {
139 const result = await work.updateIssue(user, path, number, {
140 assignees: [
141 ...form.getAll("assignee").map(String),
142 ...String(form.get("others") ?? "").split(/[\s,]+/),
143 ],
144 });
145 return result.ok ? null : { error: result.error.message };
146 }
147 case "labels": {
148 const result = await work.setLabels(user, path, number, form.getAll("label").map(String));
149 return result.ok ? null : { error: result.error.message, action: "labels" };
150 }
151 case "milestone": {
152 const result = await work.updateIssue(user, path, number, { milestone: Number(form.get("milestone")) || 0 });
153 return result.ok ? null : { error: result.error.message, action: "milestone" };
154 }
155 case "reopen": {
156 const result = await work.reopenIssue(user, path, number);
157 return result.ok ? null : { error: result.error.message };
158 }
159 // A comment's own text, changed or taken away: its author's to do, or
160 // a maintainer's, which work checks.
161 case "edit-comment":
162 case "delete-comment": {
163 const action = String(form.get("action"));
164 const id = String(form.get("comment") ?? "");
165 const result =
166 action === "edit-comment"
167 ? await work.editComment(user, path, id, String(form.get("body") ?? ""))
168 : await work.deleteComment(user, path, id);
169 return result.ok ? null : { error: result.error.message, action, comment: id };
170 }
171 default: {
172 const result = await work.closeIssue(
173 user,
174 path,
175 number,
176 form.get("action") === "close-not-planned" ? "not_planned" : "completed",
177 );
178 return result.ok ? null : { error: result.error.message };
179 }
180 }
181}
182
183/** What became of a pull request, in a few words. */
184function outcome(pull: Pull): string {
185 if (pull.status === "merged") return `Merged by ${pull.mergedBy ?? "someone"}`;
186 if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`;
187 if (pull.status === "closed") return "Closed without merging";
188 if (pull.status === "draft") return "Draft · in progress";
189 return "Ready for review";
190}
191
192function PullRow({ pull, base }: { pull: Pull; base: string }) {
193 const merged = pull.status === "merged";
194 const opener = openedBy(pull);
195 return (
196 <li>
197 <Link
198 to={`${base}/pull/${pull.number}`}
199 className={`block rounded-xl border p-4 transition-colors ${
200 merged
201 ? "border-merged/40 bg-merged/5 hover:border-merged/70"
202 : "border-line bg-surface hover:border-line-strong"
203 } ${pull.status === "closed" ? "opacity-70" : ""}`}
204 >
205 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
206 <PullIcon status={pull.status} />
207 <span className="font-medium">
208 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
209 </span>
210 {pull.status === "draft" && (
211 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
212 )}
213 <span className="ml-auto flex items-center gap-3 text-xs text-faint">
214 <ChangeSize files={pull.files} />
215 <CheckBadge status={pull.checkStatus} />
216 <span className="flex items-center gap-1 font-mono">
217 <GitCommitHorizontal size={13} />
218 {pull.headCommit?.slice(0, 7) ?? "no commits"}
219 </span>
220 <TimeAgo at={pull.updatedAt} />
221 </span>
222 </div>
223 <p className="mt-1.5 flex flex-wrap items-center gap-x-2 text-xs text-muted">
224 <span className={merged ? "font-medium text-merged" : ""}>{outcome(pull)}</span>
225 <span className="text-faint">·</span>
226 <span className="flex items-center gap-1 font-mono">
227 <Bot size={12} />
228 {pull.agent}
229 </span>
230 {opener.requestedBy ? (
231 <span className="text-faint">· requested by {opener.requestedBy}</span>
232 ) : (
233 <>
234 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
235 <span className="text-faint">· opened by {opener.name}</span>
236 </>
237 )}
238 </p>
239 {pull.body && (
240 <p className="mt-2 line-clamp-2 text-sm text-muted">{plainText(pull.body)}</p>
241 )}
242 </Link>
243 </li>
244 );
245}
246
247export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) {
248 const { issue, pulls, comments, viewer, labels, members, canManage, can } = loaderData;
249 // Follow pull requests at work without a manual reload.
250 const running = pulls.some(
251 (pull) =>
252 pull.status === "draft" ||
253 pull.checkStatus === "queued" ||
254 pull.checkStatus === "running",
255 );
256 useRefreshWhile(running);
257 // Comments, changes and its pull requests, as they happen; one reading at its end follows them.
258 useRepoLive({ match: repoPages.issue(issue.number, pulls.map((pull) => pull.number)), followBottom: true });
259
260 const base = `/${params.owner}/${params.repo}`;
261 const open = issue.state === "open";
262 const reference = `${params.owner}/${params.repo}#${issue.number}`;
263 const resolver = pulls.find((pull) => pull.number === issue.resolvedBy);
264 // The merged pull request first, then the ones still in play, then the rest.
265 const rank = { merged: 0, open: 1, draft: 2, closed: 3 } as const;
266 const ordered = [...pulls].sort(
267 (a, b) => rank[a.status] - rank[b.status] || a.number - b.number,
268 );
269
270 return (
271 // On a phone: the title, then the sidebar as a Details disclosure, then
272 // the issue; beside it from lg up.
273 <div className="grid gap-x-8 gap-y-5 lg:grid-cols-[1fr_19rem] lg:grid-rows-[auto_1fr] lg:gap-y-0">
274 <header className="min-w-0 lg:col-start-1">
275 <h2 className="text-xl font-semibold tracking-tight text-balance wrap-break-word sm:text-2xl">
276 {issue.title} <span className="font-normal text-faint">#{issue.number}</span>
277 </h2>
278 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
279 <IssueState issue={issue} />
280 <span className="flex items-center gap-2">
281 <Avatar name={issue.author.username} size={18} />
282 <span>
283 <PersonLink name={issue.author.username} className="font-medium text-fg hover:underline" /> opened this{" "}
284 {issue.requestedBy && (
285 <>
286 for{" "}
287 <PersonLink name={issue.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "}
288 </>
289 )}
290 <TimeAgo at={issue.createdAt} />
291 </span>
292 </span>
293 {issue.labels.map((name) => (
294 <LabelChip key={name} name={name} color={labels.find((label) => label.name === name)?.color} />
295 ))}
296 {open && issue.agent && <Assignee agent={issue.agent} />}
297 </div>
298 </header>
299
300 <div className="min-w-0 max-lg:order-1 lg:col-start-1">
301
302 {issue.resolvedBy != null && (
303 <Link
304 to={`${base}/pull/${issue.resolvedBy}`}
305 className="flex lg:mt-5 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"
306 >
307 <GitMerge size={18} className="shrink-0 text-merged" />
308 <span>
309 Resolved by{" "}
310 <span className="font-medium">
311 {resolver?.title ?? "pull request"} #{issue.resolvedBy}
312 </span>
313 {resolver?.mergedBy && (
314 <span className="text-muted">
315 , merged by {resolver.mergedBy}{" "}
316 {resolver.mergedAt && <TimeAgo at={resolver.mergedAt} />}
317 </span>
318 )}
319 </span>
320 </Link>
321 )}
322
323 {issue.body && (
324 <div className={`${issue.resolvedBy != null ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}>
325 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
326 </div>
327 )}
328
329 <div className="mt-10 flex items-baseline justify-between">
330 <h3 className="font-semibold tracking-tight">Pull requests</h3>
331 <p className="text-sm text-muted">
332 {pulls.length === 0
333 ? "None yet"
334 : `${pulls.length} for this issue${
335 pulls.some((pull) => pull.status === "merged") ? "" : ", none merged"
336 }`}
337 </p>
338 </div>
339 <div className="mt-3">
340 {pulls.length === 0 ? (
341 <EmptyState title="Nobody has worked on this yet">
342 Point your own agent at{" "}
343 <code className="font-mono">{reference}</code>.
344 </EmptyState>
345 ) : (
346 <ol className="space-y-3">
347 {ordered.map((pull) => (
348 <PullRow key={pull.id} pull={pull} base={base} />
349 ))}
350 </ol>
351 )}
352 </div>
353
354 <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
355 <div className="mt-3">
356 <div className="space-y-4">
357 <CommentList
358 comments={comments}
359 base={base}
360 viewerId={viewer?.id ?? null}
361 canModerate={loaderData.canModerate}
362 failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
363 />
364 <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} />
365 {canManage && (
366 <Form method="post" className="flex flex-wrap justify-end gap-2">
367 {open ? (
368 <>
369 <SubmitButton variant="outline" name="action" value="close-not-planned" pending="Closing…">
370 Close as not planned
371 </SubmitButton>
372 <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…">
373 Close issue
374 </SubmitButton>
375 </>
376 ) : (
377 <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…">
378 Reopen issue
379 </SubmitButton>
380 )}
381 </Form>
382 )}
383 </div>
384 </div>
385 <div className="mt-2">
386 {/* A refused comment edit says so under the comment. */}
387 {!(actionData && "comment" in actionData) && <ErrorText>{actionData?.error}</ErrorText>}
388 </div>
389 </div>
390
391 <DetailsDisclosure
392 className="lg:col-start-2 lg:row-span-2 lg:row-start-1"
393 summary={detailsSummary([
394 [issue.assignees.length, "assignee", "assignees"],
395 [issue.labels.length, "label", "labels"],
396 [issue.milestone ? 1 : 0, "milestone", "milestones"],
397 ])}
398 >
399 {loaderData.links.length > 0 && (
400 <section>
401 <h3 className="text-sm font-medium">From outside g1t</h3>
402 <ul className="mt-2 space-y-1.5 text-sm">
403 {loaderData.links.map((link) => (
404 <li key={`${link.connectionId}-${link.key}`}>
405 <a
406 href={link.url}
407 target="_blank"
408 rel="noreferrer"
409 className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong"
410 >
411 <span className="min-w-0 grow">
412 <span className="block truncate font-medium">
413 {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span>
414 </span>
415 <span className="block text-xs text-faint">
416 {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "}
417 <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} />
418 </span>
419 </span>
420 <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" />
421 </a>
422 </li>
423 ))}
424 </ul>
425 </section>
426 )}
427 <section>
428 <h3 className="text-sm font-medium">Assignees</h3>
429 <ul className="mt-2 space-y-1.5 text-sm">
430 {issue.assignees.map((name) => (
431 <li key={name} className="flex items-center gap-2 px-1">
432 <Avatar name={name} size={20} />
433 <span className="grow truncate font-mono text-xs">
434 <PersonLink name={name} className="hover:underline" />
435 </span>
436 </li>
437 ))}
438 </ul>
439 {issue.blockedBy.length > 0 && (
440 <p className="mt-3 text-xs text-muted">
441 Depends on{" "}
442 {issue.blockedBy.map((number, index) => (
443 <span key={number}>
444 {index > 0 && ", "}
445 <Link
446 to={`${base}/issues/${number}`}
447 className="font-medium text-fg hover:underline"
448 >
449 #{number}
450 </Link>
451 </span>
452 ))}
453 {open ? ", which has to merge first." : "."}
454 </p>
455 )}
456
457 {viewer && can.triage && open && (
458 <div className="mt-3 space-y-2">
459 {!issue.assignees.includes(viewer.username) && (
460 <Form method="post">
461 <input type="hidden" name="action" value="assign" />
462 {issue.assignees.map((name) => (
463 <input key={name} type="hidden" name="assignee" value={name} />
464 ))}
465 <input type="hidden" name="assignee" value={viewer.username} />
466 <div className="*:w-full">
467 <SubmitButton variant="outline" name="who" value="self" pending="Assigning…">
468 Assign yourself
469 </SubmitButton>
470 </div>
471 </Form>
472 )}
473 <details className="group">
474 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
475 Assign people
476 </summary>
477 <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}>
478 <input type="hidden" name="action" value="assign" />
479 <PeoplePicker name="assignee" members={members} chosen={issue.assignees} />
480 <SubmitButton variant="outline" name="who" value="picked" pending="Saving…">
481 Save assignees
482 </SubmitButton>
483 </Form>
484 </details>
485 </div>
486 )}
487 </section>
488
489 {/* Another way to start the work: not once a pull request is doing it. */}
490 {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && (
491 <Card asChild className="p-4">
492 <section>
493 <h3 className="text-sm font-medium">Bring your own agent</h3>
494 <p className="mt-1 text-xs text-muted">
495 With g1t connected to your agent, ask it to work on this issue.
496 </p>
497 <div className="mt-3">
498 <CopyLine text={reference} />
499 </div>
500 {viewer ? (
501 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
502 <input type="hidden" name="action" value="open-pull" />
503 <p className="text-xs text-muted">
504 Or open a draft pull request yourself and get a fork to push to.
505 </p>
506 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
507 <div className="*:w-full">
508 <SubmitButton match={{ action: "open-pull" }} pending="Opening…">
509 Open pull request
510 </SubmitButton>
511 </div>
512 <p className="text-xs text-muted">
513 Already pushed a branch?{" "}
514 <Link
515 to={`${base}/pulls/new?issue=${issue.number}`}
516 className="text-fg underline underline-offset-4"
517 >
518 Open a pull request from it
519 </Link>
520 .
521 </p>
522 </Form>
523 ) : (
524 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
525 <Link to="/login" className="text-fg underline underline-offset-4">
526 Sign in
527 </Link>{" "}
528 to open a pull request.
529 </p>
530 )}
531 </section>
532 </Card>
533 )}
534
535 <LabelsBox
536 labels={labels}
537 chosen={issue.labels}
538 canEdit={loaderData.canManage}
539 canCreate={can.manage_labels}
540 manageUrl={can.manage_labels ? `${base}/labels` : undefined}
541 />
542 <MilestoneBox
543 milestones={loaderData.milestones}
544 current={issue.milestone}
545 canEdit={can.triage}
546 base={base}
547 />
548
549 {viewer && (
550 <SubscriptionBox action={`${base}/notifications`} number={issue.number} kind="issue" subscription={loaderData.subscription} />
551 )}
552 </DetailsDisclosure>
553 </div>
554 );
555}