| 1 | import { Activity, GitMerge, History, Layers, Radio } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { Link } from "react-router"; |
| 4 | |
| 5 | import type { EventType, Pull } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/work"; |
| 8 | import { CoordinationFeed, type LivePull, LiveNowList, StartHere } from "../../components/coordination"; |
| 9 | import { QueueTrain } from "../../components/merge-queue"; |
| 10 | import { EmptyState, TimeAgo } from "../../components/ui"; |
| 11 | import { Avatar } from "../../components/ui/avatar"; |
| 12 | import { Card } from "../../components/ui/card"; |
| 13 | import { PullIcon } from "../../components/work-icons"; |
| 14 | import { requireRepo } from "../../lib/access.server"; |
| 15 | import { COORDINATION_TYPES, awakeAndIdle, feedLine } from "../../lib/coordination"; |
| 16 | import { page } from "../../lib/meta"; |
| 17 | import { useRefreshWhile } from "../../lib/refresh"; |
| 18 | import { repoPages } from "../../lib/repo-live"; |
| 19 | import { events, work } from "../../lib/services.server"; |
| 20 | import { useRepoLive } from "../../lib/use-repo-live"; |
| 21 | import { Unavailable } from "../../components/boundary"; |
| 22 | import { type Failed, isFailed, part } from "../../lib/partial"; |
| 23 | |
| 24 | /** How many coordination events the feed shows. */ |
| 25 | const FEED = 30; |
| 26 | /** How many merged pull requests Just landed shows. */ |
| 27 | const LANDED = 6; |
| 28 | |
| 29 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 30 | return page(args, { title: `Work · ${params.owner}/${params.repo} · g1t` }); |
| 31 | } |
| 32 | |
| 33 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 34 | const { viewer, repo, access } = await requireRepo(context, params, "read"); |
| 35 | const path = { namespace: params.owner, name: params.repo }; |
| 36 | // One round: who is on what, the queue, what happened, what landed. |
| 37 | // Each section is a part of its own (lib/partial.ts): one whose service |
| 38 | // fails says so in its place, and the others stay. |
| 39 | const [claims, active, idle, queue, recent, open, closed, openPulls] = await Promise.all([ |
| 40 | part(work.listClaims(path, viewer), "Who is working"), |
| 41 | part(work.listWorkSessions(path, viewer, { state: "active" }), "Who is working"), |
| 42 | part(work.listWorkSessions(path, viewer, { state: "idle" }), "Who is working"), |
| 43 | part(work.queue(path, viewer), "The merge queue"), |
| 44 | part( |
| 45 | events.list({ |
| 46 | repoId: repo.id, |
| 47 | types: [...COORDINATION_TYPES] as EventType[], |
| 48 | limit: FEED, |
| 49 | }), |
| 50 | "What happened", |
| 51 | ), |
| 52 | // The titles of the issues being worked on: only labels for Live now's |
| 53 | // rows, which still name each by its number without them. |
| 54 | work.listIssues(path, viewer, { state: "open" }).catch(() => null), |
| 55 | part(work.listPulls(path, viewer, "closed"), "Just landed"), |
| 56 | // The titles of the pull requests sessions with no issue are on; the same. |
| 57 | work.listPulls(path, viewer, "open").catch(() => null), |
| 58 | ]); |
| 59 | const titles: Record<number, string> = {}; |
| 60 | for (const issue of open?.ok ? open.value : []) titles[issue.number] = issue.title; |
| 61 | const pulls: Record<number, LivePull> = {}; |
| 62 | for (const pull of openPulls?.ok ? openPulls.value : []) pulls[pull.number] = { title: pull.title, status: pull.status }; |
| 63 | const landed = isFailed(closed) |
| 64 | ? closed |
| 65 | : (closed.ok ? closed.value : []) |
| 66 | .filter((pull) => pull.status === "merged" && pull.mergedAt) |
| 67 | .sort((a, b) => Date.parse(b.mergedAt!) - Date.parse(a.mergedAt!)) |
| 68 | .slice(0, LANDED); |
| 69 | // Live now is one list of claims and sessions: it is whole or it is unavailable. |
| 70 | const live: Failed | null = [claims, active, idle].find(isFailed) ?? null; |
| 71 | return { |
| 72 | live, |
| 73 | claims: isFailed(claims) || !claims.ok ? [] : claims.value, |
| 74 | sessions: [...(isFailed(active) || !active.ok ? [] : active.value), ...(isFailed(idle) || !idle.ok ? [] : idle.value)], |
| 75 | queue: isFailed(queue) ? queue : queue.ok ? queue.value : null, |
| 76 | feed: isFailed(recent) ? recent : recent.flatMap((event) => feedLine(event) ?? []), |
| 77 | titles, |
| 78 | pulls, |
| 79 | landed, |
| 80 | defaultBranch: repo.defaultBranch, |
| 81 | // Who may end a session by hand from Live now: its own runner, or anyone with write access. |
| 82 | ender: { viewerId: viewer?.id ?? null, canPush: access.can.push }, |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | function Section({ icon, title, aside, children }: { icon: ReactNode; title: string; aside?: ReactNode; children: ReactNode }) { |
| 87 | return ( |
| 88 | <section className="mt-10"> |
| 89 | <div className="flex flex-wrap items-baseline justify-between gap-2"> |
| 90 | <h3 className="flex items-center gap-2 text-sm font-medium"> |
| 91 | {icon} |
| 92 | {title} |
| 93 | </h3> |
| 94 | {aside && <span className="text-xs text-muted">{aside}</span>} |
| 95 | </div> |
| 96 | <div className="mt-3">{children}</div> |
| 97 | </section> |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | function LandedRow({ pull, base }: { pull: Pull; base: string }) { |
| 102 | return ( |
| 103 | <li className="flex items-start gap-3 px-4 py-3 text-sm"> |
| 104 | <span className="mt-0.5"> |
| 105 | <PullIcon status={pull.status} /> |
| 106 | </span> |
| 107 | <span className="flex min-w-0 grow flex-wrap items-center gap-x-3 gap-y-1"> |
| 108 | <Link to={`${base}/pull/${pull.number}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent"> |
| 109 | {pull.title} <span className="font-normal text-faint">#{pull.number}</span> |
| 110 | </Link> |
| 111 | <span className="flex items-center gap-3 text-xs text-muted"> |
| 112 | {pull.issue != null && ( |
| 113 | <Link to={`${base}/issues/${pull.issue}`} className="hover:text-fg"> |
| 114 | for #{pull.issue} |
| 115 | </Link> |
| 116 | )} |
| 117 | <Link to={`${base}/pull/${pull.number}/session`} className="hover:text-fg"> |
| 118 | Why |
| 119 | </Link> |
| 120 | {pull.mergedBy && ( |
| 121 | <span className="flex items-center gap-1.5"> |
| 122 | <Avatar name={pull.mergedBy} size={14} /> |
| 123 | {pull.mergedBy} |
| 124 | </span> |
| 125 | )} |
| 126 | {pull.mergedAt && <TimeAgo at={pull.mergedAt} />} |
| 127 | </span> |
| 128 | </span> |
| 129 | </li> |
| 130 | ); |
| 131 | } |
| 132 | |
| 133 | export default function WorkPage({ loaderData, params }: Route.ComponentProps) { |
| 134 | const { live, claims, sessions, feed, titles, pulls, landed, defaultBranch, ender } = loaderData; |
| 135 | const queue = isFailed(loaderData.queue) ? null : loaderData.queue; |
| 136 | const base = `/${params.owner}/${params.repo}`; |
| 137 | const now = Date.now(); |
| 138 | // Idle sessions fold under the list (LiveNowList); the counts are of the rest. |
| 139 | const { awake, idle } = awakeAndIdle(sessions); |
| 140 | const busy = claims.length + awake.length > 0; |
| 141 | const working = busy || idle.length > 0; |
| 142 | // Claims, sessions, overlaps, integration results and merges as they happen. |
| 143 | useRepoLive({ match: repoPages.work() }); |
| 144 | // Claims lapse and sessions go idle with no event to say so. |
| 145 | useRefreshWhile(working || (queue?.active.length ?? 0) > 0, 15_000); |
| 146 | |
| 147 | return ( |
| 148 | <div className="max-w-4xl"> |
| 149 | <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight"> |
| 150 | <Activity size={19} className="text-accent" /> |
| 151 | Work |
| 152 | </h2> |
| 153 | <p className="mt-1.5 max-w-2xl text-sm text-muted"> |
| 154 | Who is working on what right now, each in their own harness: the issues they claimed, their sessions, the merge queue combining |
| 155 | their changes, and what happened along the way. |
| 156 | </p> |
| 157 | |
| 158 | <Section |
| 159 | icon={<Radio size={15} className={busy ? "text-success" : "text-faint"} />} |
| 160 | title="Live now" |
| 161 | aside={ |
| 162 | working && !live |
| 163 | ? `${claims.length} ${claims.length === 1 ? "claim" : "claims"}, ${awake.length} ${awake.length === 1 ? "session" : "sessions"}${idle.length > 0 ? `, ${idle.length} idle` : ""}` |
| 164 | : undefined |
| 165 | } |
| 166 | > |
| 167 | {live ? ( |
| 168 | <Unavailable what={live.what} /> |
| 169 | ) : !working ? ( |
| 170 | <StartHere title="Nobody is working on this repository right now" /> |
| 171 | ) : ( |
| 172 | <LiveNowList claims={claims} sessions={sessions} titles={titles} pulls={pulls} defaultBranch={defaultBranch} base={base} now={now} ender={ender} /> |
| 173 | )} |
| 174 | </Section> |
| 175 | |
| 176 | {isFailed(loaderData.queue) && ( |
| 177 | <Section icon={<Layers size={15} className="text-accent" />} title="Merge queue"> |
| 178 | <Unavailable what={loaderData.queue.what} /> |
| 179 | </Section> |
| 180 | )} |
| 181 | {queue && (queue.enabled || queue.active.length > 0) && ( |
| 182 | <Section |
| 183 | icon={<Layers size={15} className="text-accent" />} |
| 184 | title="Merge queue" |
| 185 | aside={ |
| 186 | <Link to={`${base}/queue`} className="hover:text-fg"> |
| 187 | {queue.active.length} waiting to land · the whole queue |
| 188 | </Link> |
| 189 | } |
| 190 | > |
| 191 | <QueueTrain active={queue.active} base={base} branch={defaultBranch} empty="Nothing is waiting to land." /> |
| 192 | </Section> |
| 193 | )} |
| 194 | |
| 195 | <Section icon={<History size={15} className="text-accent" />} title="What happened"> |
| 196 | {isFailed(feed) ? ( |
| 197 | <Unavailable what={feed.what} /> |
| 198 | ) : feed.length === 0 ? ( |
| 199 | <EmptyState title="Nothing yet"> |
| 200 | Claims, sessions, overlapping changes, dependencies and what the merge queue finds when it combines changes are listed here as |
| 201 | they happen. |
| 202 | </EmptyState> |
| 203 | ) : ( |
| 204 | <CoordinationFeed lines={feed} base={base} /> |
| 205 | )} |
| 206 | </Section> |
| 207 | |
| 208 | <Section |
| 209 | icon={<GitMerge size={15} className="text-merged" />} |
| 210 | title="Just landed" |
| 211 | aside={ |
| 212 | !isFailed(landed) && landed.length > 0 ? ( |
| 213 | <Link to={`${base}/pulls?state=closed`} className="hover:text-fg"> |
| 214 | All merged pull requests |
| 215 | </Link> |
| 216 | ) : undefined |
| 217 | } |
| 218 | > |
| 219 | {isFailed(landed) ? ( |
| 220 | <Unavailable what={landed.what} /> |
| 221 | ) : landed.length === 0 ? ( |
| 222 | <EmptyState title="Nothing merged yet">Merged pull requests show here, each with the session behind it.</EmptyState> |
| 223 | ) : ( |
| 224 | <Card asChild divided> |
| 225 | <ol> |
| 226 | {landed.map((pull) => ( |
| 227 | <LandedRow key={pull.id} pull={pull} base={base} /> |
| 228 | ))} |
| 229 | </ol> |
| 230 | </Card> |
| 231 | )} |
| 232 | </Section> |
| 233 | </div> |
| 234 | ); |
| 235 | } |