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.

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