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