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.

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