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.

218 linesCodeBlameRaw
1import { GitPullRequest, MessageSquare, Milestone as MilestoneIcon, Plus, Tag } from "lucide-react";
2import { Link, useLocation, useRouteLoaderData } from "react-router";
3
4import type { Route } from "./+types/issues";
5import { page } from "../../lib/meta";
6import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
7import { Card } from "../../components/ui/card";
8import { Hint } from "../../components/ui/hint";
9import {
10 Assignee,
11 AssigneeStack,
12 IssueIcon,
13 StateTabs,
14} from "../../components/work";
15import { FilterMenu, LabelChip, Swatch } from "../../components/labels";
16import { colorsOf, listFilters, withFilter } from "../../lib/labels";
17import { work } from "../../lib/services.server";
18import { getViewer, unwrap } from "../../lib/session.server";
19import { repoPages } from "../../lib/repo-live";
20import { useRepoLive } from "../../lib/use-repo-live";
21import { isFailed, part } from "../../lib/partial";
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `Issues · ${params.owner}/${params.repo} · g1t` });
25}
26
27export async function loader({ params, context, request }: Route.LoaderArgs) {
28 const viewer = getViewer(context);
29 const path = { namespace: params.owner, name: params.repo };
30 // `?label=bug&milestone=3`, or `label:bug milestone:3` written in `q`.
31 const { state, label, milestone } = listFilters(new URL(request.url).searchParams);
32 const [issues, labels, milestones] = await Promise.all([ work.listIssues(path, viewer, { state, label: label || undefined, milestone: milestone ?? undefined }),
33 // What the filters offer: each a part, whose menu says so when its service fails.
34 part(work.listLabels(path, viewer), "Labels"),
35 part(work.listMilestones(path, viewer, "open"), "Milestones"),
36 ]);
37 return {
38 issues: unwrap(issues),
39 labels: isFailed(labels) ? labels : unwrap(labels),
40 milestones: isFailed(milestones) ? milestones : milestones.ok ? milestones.value : [],
41 state,
42 label,
43 milestone,
44 } as const;
45}
46
47export default function Issues({ loaderData, params }: Route.ComponentProps) {
48 const { issues, state, label, milestone } = loaderData;
49 // A filter whose choices could not be loaded offers none, and its menu says why.
50 const labels = isFailed(loaderData.labels) ? [] : loaderData.labels;
51 const milestones = isFailed(loaderData.milestones) ? [] : loaderData.milestones;
52 // Issues opened, commented on and closed show as it happens.
53 useRepoLive({ match: repoPages.issues() });
54 const repo = `/${params.owner}/${params.repo}`;
55 const base = `${repo}/issues`;
56 const current = new URLSearchParams(useLocation().search);
57 const colors = colorsOf(labels);
58 const milestoneTitle = milestones.find((m) => m.number === milestone)?.title ?? (milestone ? `#${milestone}` : null);
59 const filtered = [label, milestone].some(Boolean);
60 // The tabs keep the filters; each filter keeps the state and the other.
61 const kept = new URLSearchParams(current);
62 kept.delete("state");
63 kept.delete("q");
64
65 // An archived repository's issues are locked: no new ones.
66 const layout = useRouteLoaderData("routes/repo/layout") as { repo?: { archivedAt?: string | null } } | undefined;
67 const archived = Boolean(layout?.repo?.archivedAt);
68 return (
69 <div>
70 <StateTabs
71 to={base}
72 state={state}
73 query={kept.toString()}
74 action={
75 archived ? undefined : (
76 <ButtonLink to={`${base}/new`}>
77 <Plus size={15} />
78 New issue
79 </ButtonLink>
80 )
81 }
82 />
83 <div className="mt-3 flex flex-wrap items-center gap-2">
84 <FilterMenu
85 label="Label"
86 active={label || undefined}
87 clearTo={label ? withFilter(base, current, "label", null) : undefined}
88 searchPlaceholder="Filter labels"
89 emptyText={isFailed(loaderData.labels) ? "Labels could not be loaded just now." : "No label matches."}
90 options={labels.map((each) => ({
91 key: each.name,
92 to: withFilter(base, current, "label", each.name),
93 keywords: `${each.name} ${each.description}`,
94 selected: each.name === label,
95 label: (
96 <>
97 <Swatch color={each.color} />
98 <span className="truncate">{each.name}</span>
99 </>
100 ),
101 }))}
102 />
103 <FilterMenu
104 label="Milestone"
105 active={milestoneTitle ?? undefined}
106 clearTo={milestone ? withFilter(base, current, "milestone", null) : undefined}
107 searchPlaceholder="Filter milestones"
108 emptyText={isFailed(loaderData.milestones) ? "Milestones could not be loaded just now." : "No open milestone matches."}
109 options={milestones.map((each) => ({
110 key: String(each.number),
111 to: withFilter(base, current, "milestone", String(each.number)),
112 keywords: `${each.title} ${each.number}`,
113 selected: each.number === milestone,
114 label: <span className="truncate">{each.title}</span>,
115 }))}
116 />
117 {filtered && (
118 <Link to={state === "closed" ? `${base}?state=closed` : base} className="text-xs text-muted hover:text-fg">
119 Clear filters
120 </Link>
121 )}
122 <span className="ml-auto flex items-center gap-1">
123 <Link
124 to={`${repo}/labels`}
125 className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm text-muted transition-colors hover:bg-surface hover:text-fg"
126 >
127 <Tag size={14} />
128 Labels
129 </Link>
130 </span>
131 </div>
132 <div className="mt-4">
133 {issues.length === 0 ? (
134 <EmptyState
135 title={
136 filtered
137 ? `No ${state} issues match these filters`
138 : state === "open"
139 ? "No open issues"
140 : "No closed issues"
141 }
142 >
143 An issue says what should change: a bug, a feature, a question.
144 People and their coding agents open pull requests against it.
145 </EmptyState>
146 ) : (
147 <Card asChild tone="plain" divided className="overflow-hidden">
148 <ul>
149 {issues.map((issue) => (
150 <li key={issue.id} className="flex items-start transition-colors hover:bg-surface">
151 <Link
152 prefetch="intent"
153 to={`${base}/${issue.number}`}
154 className="flex min-w-0 grow items-start gap-3 px-4 py-3"
155 >
156 <span className="mt-0.5">
157 <IssueIcon issue={issue} />
158 </span>
159 <span className="min-w-0 grow">
160 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
161 <span className="font-medium">{issue.title}</span>
162 {issue.labels.map((name) => (
163 <LabelChip key={name} name={name} color={colors[name]} />
164 ))}
165 {issue.state === "open" && issue.agent && (
166 <Assignee agent={issue.agent} />
167 )}
168 </span>
169 <span className="mt-0.5 block text-xs text-faint">
170 #{issue.number} opened <TimeAgo at={issue.createdAt} /> by{" "}
171 {issue.author.username}
172 {issue.requestedBy && <> for {issue.requestedBy.username}</>}
173 {issue.resolvedBy != null ? (
174 <span className="text-merged"> · resolved by #{issue.resolvedBy}</span>
175 ) : (
176 issue.resolvedCommit && (
177 <span className="text-merged">
178 {" "}
179 · closed by <span className="font-mono">{issue.resolvedCommit.slice(0, 7)}</span>
180 </span>
181 )
182 )}
183 {issue.milestone && (
184 <span className="inline-flex items-center gap-1">
185 {" "}
186 · <MilestoneIcon size={11} className="inline" /> {issue.milestone.title}
187 </span>
188 )}
189 </span>
190 </span>
191 <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted">
192 <AssigneeStack people={issue.assignees} />
193 {issue.pullCount > 0 && (
194 <Hint label={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`}>
195 <span className="flex items-center gap-1">
196 <GitPullRequest size={13} />
197 {issue.pullCount}
198 <span className="sr-only">pull {issue.pullCount === 1 ? "request" : "requests"}</span>
199 </span>
200 </Hint>
201 )}
202 {issue.commentCount > 0 && (
203 <span className="flex items-center gap-1">
204 <MessageSquare size={13} />
205 {issue.commentCount}
206 </span>
207 )}
208 </span>
209 </Link>
210 </li>
211 ))}
212 </ul>
213 </Card>
214 )}
215 </div>
216 </div>
217 );
218}