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.

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