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.

291 linesCodeBlameRaw
1import { Bot, Columns3, GitMerge, Plus } from "lucide-react";
2import { Link } from "react-router";
3
4import { LIST_PAGE } from "@g1t/contracts";
5
6import type { Route } from "./+types/board";
7import { HarnessMark, StateDot } from "../../../components/sessions";
8import { ButtonLink, EmptyState } from "../../../components/ui";
9import { Avatar } from "../../../components/ui/avatar";
10import { Card } from "../../../components/ui/card";
11import { Hint } from "../../../components/ui/hint";
12import { RepoChips } from "../../../components/work-items";
13import { ItemIcon } from "../../../components/work-icons";
14import {
15 type Board,
16 type BoardCard,
17 DONE_DAYS,
18 ITEM_PAGES,
19 PULL_PAGES,
20 type ReadPull,
21 STAGES,
22 STAGE_INFO,
23 type Stage,
24 boardOf,
25 cappedNotes,
26 pullRepos,
27 pullsByItem,
28 readPages,
29 shownStage,
30} from "../../../lib/board";
31import { cn } from "../../../lib/cn";
32import { page } from "../../../lib/meta";
33import { isFailed, part } from "../../../lib/partial";
34import { useRefreshWhile } from "../../../lib/refresh";
35import { workPages } from "../../../lib/repo-live";
36import { work } from "../../../lib/services.server";
37import { getViewer, roleIn, unwrap } from "../../../lib/session.server";
38import { onItemSessionTitle, runsIn, sessionHref } from "../../../lib/sessions";
39import { useWorkspaceLive } from "../../../lib/use-repo-live";
40import { itemPath, newItemPath, repoFilter, shortPath, workPath } from "../../../lib/work-items";
41
42export function meta({ params, location, ...args }: Route.MetaArgs) {
43 const repo = new URLSearchParams(location.search).get("repo");
44 return page({ location, ...args }, { title: `Board${repo ? ` · ${repo}` : ""} · Work · ${params.owner} · g1t` });
45}
46
47export async function loader({ params, context, request }: Route.LoaderArgs) {
48 const viewer = getViewer(context);
49 const workspace = params.owner.toLowerCase();
50 const query = new URL(request.url).searchParams;
51 const repoName = query.get("repo")?.trim() || null;
52 const repo = repoName ? repoFilter(workspace, repoName) : null;
53 const member = roleIn(viewer, workspace) != null;
54 const now = Date.now();
55 const since = new Date(now - DONE_DAYS * 86_400_000).toISOString();
56 // The open work items are what the board is, every page of them; what says where each stands, a part each.
57 const [open, closed, claims, live] = await Promise.all([
58 readPages((before) => work.listWorkItems(workspace, viewer, { state: "open", repo: repo ?? undefined, before }).then(unwrap), LIST_PAGE, ITEM_PAGES),
59 part(work.listWorkItems(workspace, viewer, { state: "closed", repo: repo ?? undefined, closedSince: since }), "Done"),
60 part(work.listClaims(workspace, viewer, { state: "active", repo: repo ?? undefined }), "Claims"),
61 part(work.listWorkspaceSessions(workspace, viewer, { shown: "live", sessionsOnly: true, repo: repoName ?? undefined, limit: 200 }), "Live sessions"),
62 ]);
63 const sessions = isFailed(live) || !live.ok ? [] : live.value.sessions;
64 // The open and recently merged pull requests in every repository an open item is linked to.
65 const repos = pullRepos(open.rows);
66 const read = (path: string, state: "open" | "closed") => {
67 const [namespace = "", name = ""] = path.split("/");
68 // Not found or refused is an answer: none the viewer can see there.
69 const page = (before: number | undefined) =>
70 work
71 .listPulls({ namespace, name }, viewer, state, { before, mergedSince: state === "closed" ? since : undefined })
72 .then((found) => (found.ok ? found.value : []));
73 return part(readPages(page, LIST_PAGE, PULL_PAGES), `Pull requests in ${path}`);
74 };
75 const pulls = await Promise.all(repos.map((path) => Promise.all([read(path, "open"), read(path, "closed")])));
76 const readPulls: ReadPull[] = [];
77 const unread: string[] = [];
78 const capped: string[] = [];
79 repos.forEach((path, index) => {
80 const both = pulls[index]!;
81 if (both.some(isFailed)) unread.push(path);
82 for (const found of both) {
83 if (isFailed(found)) continue;
84 if (found.capped) capped.push(path);
85 readPulls.push(...found.rows.map((pull) => ({ ...pull, repo: path })));
86 }
87 });
88 const done = isFailed(closed) || !closed.ok ? [] : closed.value;
89 const board = boardOf([...open.rows, ...done], { claims: isFailed(claims) || !claims.ok ? [] : claims.value, sessions, pulls: pullsByItem(readPulls) }, now);
90 return {
91 workspace,
92 member,
93 signedIn: viewer != null,
94 repo: repoName,
95 board,
96 stage: shownStage(query.get("stage"), board),
97 // A part that could not be read: its items may be missing, or stand a column early, and the board says so.
98 unavailable: [
99 ...[closed, claims, live].filter(isFailed).map((failed) => failed.what),
100 ...(unread.length === 0 ? [] : [unread.length === 1 ? `Pull requests in ${unread[0]}` : `Pull requests in ${unread.length} repositories`]),
101 ],
102 // What was read only in part, said plainly rather than left out silently.
103 capped: cappedNotes({ items: open.capped ? open.rows.length : null, done: done.length >= LIST_PAGE ? LIST_PAGE : null, pulls: [...new Set(capped)] }),
104 live: sessions.length,
105 };
106}
107
108/** Who is on a card: people's avatars, and the live sessions on it with what they run in. */
109function OnIt({ card, workspace }: { card: BoardCard; workspace: string }) {
110 const { people, sessions } = card.on;
111 if (people.length === 0 && sessions.length === 0) return null;
112 return (
113 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
114 {people.length > 0 && (
115 <Hint label={people.join(", ")}>
116 <span className="flex -space-x-1.5">
117 {people.slice(0, 3).map((name) => (
118 <span key={name} className="rounded-full ring-2 ring-surface">
119 <Avatar name={name} size={18} />
120 </span>
121 ))}
122 </span>
123 </Hint>
124 )}
125 {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>}
126 {sessions.slice(0, 2).map((session) => {
127 const to = sessionHref(workspace, session);
128 const label = (
129 <span className="inline-flex min-w-0 items-center gap-1 text-[0.6875rem] text-muted">
130 <StateDot state={session.state} />
131 <HarnessMark session={session} size={11} />
132 <span className="truncate">{session.actor ?? runsIn(session)}</span>
133 </span>
134 );
135 return (
136 <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}>
137 {to ? (
138 <Link to={to} className="min-w-0 hover:text-fg">
139 {label}
140 </Link>
141 ) : (
142 label
143 )}
144 </Hint>
145 );
146 })}
147 {sessions.length > 2 && (
148 <span className="inline-flex items-center gap-0.5 text-[0.6875rem] text-faint">
149 <Bot size={11} />+{sessions.length - 2}
150 </span>
151 )}
152 </div>
153 );
154}
155
156function ItemCard({ card, workspace }: { card: BoardCard; workspace: string }) {
157 const { item } = card;
158 return (
159 <li>
160 <Card className="flex flex-col gap-2 p-3 transition-colors hover:border-line-strong">
161 <Link to={itemPath(workspace, item.number)} prefetch="intent" className="flex min-w-0 items-start gap-2 text-sm font-medium hover:text-accent">
162 <span className="mt-0.5">
163 <ItemIcon item={item} size={14} />
164 </span>
165 <span className="min-w-0 wrap-break-word">
166 {item.title} <span className="font-normal text-faint">#{item.number}</span>
167 </span>
168 </Link>
169 {(item.repos.length > 0 || card.merged > 0) && (
170 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
171 <RepoChips repos={item.repos} workspace={workspace} />
172 {card.merged > 0 && (
173 <Hint label={`${card.merged === 1 ? "A pull request" : `${card.merged} pull requests`} for it merged in the last ${DONE_DAYS} days`}>
174 <span className="inline-flex shrink-0 items-center gap-1 text-[0.6875rem] text-merged">
175 <GitMerge size={11} />
176 {card.merged} merged
177 </span>
178 </Hint>
179 )}
180 </div>
181 )}
182 <OnIt card={card} workspace={workspace} />
183 </Card>
184 </li>
185 );
186}
187
188function Column({ stage, board, workspace }: { stage: Stage; board: Board; workspace: string }) {
189 const cards = board[stage];
190 return (
191 <section aria-label={STAGE_INFO[stage].label} className="flex min-w-0 flex-col">
192 <h2 className="mb-2 flex items-center justify-between gap-2 px-1 text-xs font-medium text-muted">
193 <Hint label={STAGE_INFO[stage].hint}>
194 <span>{STAGE_INFO[stage].label}</span>
195 </Hint>
196 <span className="tabular-nums text-faint">{cards.length}</span>
197 </h2>
198 {cards.length === 0 ? (
199 <p className="rounded-lg border border-dashed border-line px-3 py-4 text-center text-xs text-faint">Nothing here</p>
200 ) : (
201 <ol className="space-y-2">
202 {cards.map((card) => (
203 <ItemCard key={card.item.id} card={card} workspace={workspace} />
204 ))}
205 </ol>
206 )}
207 </section>
208 );
209}
210
211/**
212 * Work's Board: the workspace's work items as they move, in columns read
213 * from what is so. On a phone, one column at a time, picked from a row of
214 * stages that wraps rather than scrolling sideways.
215 */
216export default function BoardPage({ loaderData }: Route.ComponentProps) {
217 const { workspace, board, stage, repo, member, signedIn, unavailable, capped, live } = loaderData;
218 const total = STAGES.reduce((sum, one) => sum + board[one].length, 0);
219 const query = (more: Record<string, string>) => `?${new URLSearchParams({ ...(repo ? { repo } : {}), ...more }).toString()}`;
220 useWorkspaceLive({ workspace, member, match: workPages.list() });
221 // Sessions go idle and end with no event to say so.
222 useRefreshWhile(live > 0, 30_000);
223 return (
224 <div>
225 <div className="flex flex-wrap items-center justify-between gap-3">
226 <div className="min-w-0">
227 <h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
228 <Columns3 size={17} className="text-accent" />
229 Board
230 {repo && <span className="font-mono text-sm font-normal text-muted">{shortPath(repo, workspace)}</span>}
231 </h1>
232 <p className="mt-1 text-sm text-muted">Work items as they move: who is on each, what is in review, and what is landing where.</p>
233 </div>
234 {signedIn && (member || repo) && (
235 <ButtonLink to={newItemPath(workspace, repo)} className="max-sm:h-10">
236 <Plus size={15} />
237 New work item
238 </ButtonLink>
239 )}
240 </div>
241 {unavailable.length > 0 && (
242 <p className="mt-3 text-xs text-warn">
243 {unavailable.join(", ")} could not be loaded just now, so this board is partial: some work items may be missing, or stand a column early.
244 </p>
245 )}
246 {capped.length > 0 && <p className="mt-2 text-xs text-muted">{capped.join(" ")}</p>}
247
248 {total === 0 ? (
249 <div className="mt-5">
250 <EmptyState title={repo ? `No work items linked to ${shortPath(repo, workspace)}` : "No work items yet"}>
251 A work item moves across the board by itself: claimed, a session on it, a pull request opened, landed in its repositories, closed.{" "}
252 <Link to={workPath(workspace)} className="text-fg underline underline-offset-4">
253 The Work list
254 </Link>
255 .
256 </EmptyState>
257 </div>
258 ) : (
259 <>
260 {/* A phone: the stages as a wrapping row, one column under it. */}
261 <nav aria-label="Stages" className="mt-4 flex flex-wrap gap-1.5 md:hidden">
262 {STAGES.map((one) => (
263 <Link
264 key={one}
265 to={query({ stage: one })}
266 replace
267 preventScrollReset
268 aria-current={stage === one ? "page" : undefined}
269 className={cn(
270 "inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-sm transition-colors",
271 stage === one ? "border-accent/50 bg-accent/10 font-medium text-fg" : "border-line text-muted active:bg-raised",
272 )}
273 >
274 {STAGE_INFO[one].label}
275 <span className="text-xs tabular-nums text-faint">{board[one].length}</span>
276 </Link>
277 ))}
278 </nav>
279 <div className="mt-4 md:hidden">
280 <Column stage={stage} board={board} workspace={workspace} />
281 </div>
282 <div className="mt-5 hidden gap-3 md:grid md:grid-cols-5">
283 {STAGES.map((one) => (
284 <Column key={one} stage={one} board={board} workspace={workspace} />
285 ))}
286 </div>
287 </>
288 )}
289 </div>
290 );
291}