Stacked branches merge and get CI cleanly; a job's log report is constant work #369
Mergeds
syntaqx merged 365-stacked-ci-and-log-bytes into main
23 files+1925−1660/23 viewed
| 319 | 319 | ||
| 320 | 320 | Signed in without any workspace (you declined an invitation, or left the | |
| 321 | 321 | only workspace you were in), g1t shows **Create your workspace or ask to | |
| 322 | − | join one** in place of Home: the invitations waiting for you, if | |
| 322 | + | join one** in place of Mission control: the invitations waiting for you, if | |
| 323 | 323 | any, and the form to create a workspace. To join a team already on g1t, | |
| 324 | 324 | ask one of its owners to invite you by your username. | |
| 325 | 325 |
| 16 | 16 | someone else's workspace; see | |
| 17 | 17 | [your first workspace](/guides/authentication/#your-first-workspace). | |
| 18 | 18 | Signed in without a workspace, g1t shows **Create your workspace or ask to | |
| 19 | − | join one** in place of Home: the invitations waiting for you, | |
| 19 | + | join one** in place of Mission control: the invitations waiting for you, | |
| 20 | 20 | and the form below. | |
| 21 | 21 | ||
| 22 | 22 | To create another: | |
| ⋯ | |||
| 627 | 627 | ||
| 628 | 628 | ## The workspace's page | |
| 629 | 629 | ||
| 630 | − | A workspace's own page, `g1t.sh/<workspace>`, is Code's front page, as an | |
| 631 | − | organization's page is on GitHub, for members and visitors alike. It has | |
| 632 | − | the workspace's [profile](#the-workspaces-profile) at the top: its | |
| 633 | − | picture, name, address, description, location and links. Then how many | |
| 634 | − | repositories it has, how many are in production, its open work items and | |
| 635 | − | the pull requests in progress; your | |
| 630 | + | A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address | |
| 631 | + | and description at the top, then its overview: your | |
| 636 | 632 | [pinned repositories](#pinned-and-recent-repositories), then the most active ones, | |
| 637 | − | and **All repositories**; the pull requests in progress across them; and, | |
| 638 | − | beside them, its members. **Code** in the app bar opens it. Where you are | |
| 639 | − | needed and what happened while you were away are on [Home](/guides/home/). | |
| 633 | + | the pull requests in progress across them, and **All repositories**. Members | |
| 634 | + | also see a **Usage** card with this month's spend, and who belongs. | |
| 640 | 635 | ||
| 641 | 636 | The workspace's other pages each have a heading of their own and a row in | |
| 642 | 637 | [the sidebar](#the-sidebar), lit while you are on them. The trail in the | |
| ⋯ | |||
| 1006 | 1001 | **Explore**, **Docs**, **Sign in** and **Sign up**; a repository's pages run in | |
| 1007 | 1002 | the row under its name. | |
| 1008 | 1003 | ||
| 1009 | − | ## Code's front page | |
| 1004 | + | ## Mission control | |
| 1005 | + | ||
| 1006 | + | Mission control is Code's **Overview**, at `g1t.sh/<workspace>/-/overview`. | |
| 1007 | + | It shows where you are needed in the workspace's code and what landed, | |
| 1008 | + | then the week and the activity. What the workspace's work says is on | |
| 1009 | + | [Home](#the-workspace-overview). | |
| 1010 | + | Signed in, `g1t.sh` itself opens [Home](/guides/home/) in the workspace | |
| 1011 | + | you are in. | |
| 1012 | + | ||
| 1013 | + | **Review N that need you** jumps to the list, and **New work item** opens | |
| 1014 | + | a new [work item](/work/work-items/) of the workspace, filed from the | |
| 1015 | + | repository you pick. | |
| 1016 | + | ||
| 1017 | + | | Tab | What it lists | | |
| 1018 | + | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 1019 | + | | **Needs you** | Reviews asked of you, changes ready for you to merge, failed checks, failed production builds, repository invitations and a usage limit that is close or reached. | | |
| 1020 | + | | **Landed today** | Pull requests merged today in your time zone, and whether a person merged them. | | |
| 1021 | + | ||
| 1022 | + | Each row in **Needs you** carries the reason it needs you: | |
| 1023 | + | ||
| 1024 | + | | Reason | Means | | |
| 1025 | + | | ---------------- | ------------------------------------------------------------------------------------------------------------- | | |
| 1026 | + | | `BLOCKING` | Nothing moves until a person acts: a failed production build, a merge g1t could not make, or the usage limit. | | |
| 1027 | + | | `ASKED FOR YOU` | A review or an invitation addressed to you by name. | | |
| 1028 | + | | `CHECKS FAILING` | A required check fails. | | |
| 1029 | + | | `NEEDS REVIEW` | The repository wants a person's approval. | | |
| 1030 | + | | `READY TO MERGE` | Checks passed and it was approved; the repository lands changes only when a person merges them. | | |
| 1010 | 1031 | ||
| 1011 | − | Code opens on [the workspace's page](#the-workspaces-page), | |
| 1012 | − | `g1t.sh/<workspace>`: the workspace as an organization, its repositories | |
| 1013 | − | and its people. What needs you in the workspace's code (reviews asked of | |
| 1014 | − | you, changes ready to merge, failing checks, failed production builds, | |
| 1015 | − | repository invitations and a usage limit that is close or reached) is in | |
| 1016 | − | [Home's Your move](/guides/home/#your-move), with everything else waiting | |
| 1017 | − | on you; what landed is under | |
| 1018 | − | [Since you were last here](/guides/home/). Signed in, `g1t.sh` itself | |
| 1019 | − | opens [Home](/guides/home/) in the workspace you are in. | |
| 1032 | + | From a row, **Review and respond** opens it, and where it can be done | |
| 1033 | + | without leaving the page you can approve the change, merge it or re-run | |
| 1034 | + | its failed jobs. **By impact** puts the most urgent first; **Newest** sorts | |
| 1035 | + | by time. | |
| 1020 | 1036 | ||
| 1021 | 1037 | ## Workspace access tokens | |
| 1022 | 1038 | ||
| 188 | 188 | ))} | |
| 189 | 189 | </ul> | |
| 190 | 190 | )} | |
| 191 | − | <Link to={`/${slug}/-/home#needed`} className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 191 | + | <Link to={`/${slug}/-/overview?tab=needs#work`} className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 192 | 192 | The review queue <ArrowRight size={11} /> | |
| 193 | 193 | </Link> | |
| 194 | 194 | </div> |
| 1 | + | import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, Plus } from "lucide-react"; | |
| 2 | + | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | + | import { Link, useFetcher, useLocation, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 4 | + | ||
| 5 | + | import { type ActivityGroup, type Verb, isAgent } from "../lib/mission"; | |
| 6 | + | import { | |
| 7 | + | BLOCKING, | |
| 8 | + | type Fact, | |
| 9 | + | type LandedRow, | |
| 10 | + | type NeedRow, | |
| 11 | + | type QuickAction, | |
| 12 | + | REASON_LABEL, | |
| 13 | + | type Reason, | |
| 14 | + | type Sort, | |
| 15 | + | type Tab, | |
| 16 | + | type Week, | |
| 17 | + | type Who, | |
| 18 | + | change, | |
| 19 | + | dateLine, | |
| 20 | + | parseSort, | |
| 21 | + | parseTab, | |
| 22 | + | signedPercent, | |
| 23 | + | sortRows, | |
| 24 | + | } from "../lib/mission-control"; | |
| 25 | + | import { cn } from "../lib/cn"; | |
| 26 | + | import { money } from "../lib/money"; | |
| 27 | + | import { InboxNeedsCard } from "./inbox"; | |
| 28 | + | import type { ShellData } from "./shell"; | |
| 29 | + | import { Unavailable } from "./mission"; | |
| 30 | + | import { SubmitButton, TimeAgo } from "./ui"; | |
| 31 | + | import { Avatar } from "./ui/avatar"; | |
| 32 | + | import { UserAvatar } from "./user-card"; | |
| 33 | + | import { PersonLink } from "./work"; | |
| 34 | + | import { Card } from "./ui/card"; | |
| 35 | + | import { Hint } from "./ui/hint"; | |
| 36 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 37 | + | import type { Loaded } from "../lib/mission-control.server"; | |
| 38 | + | import { itemPath, newItemPath } from "../lib/work-items"; | |
| 39 | + | ||
| 40 | + | /* | |
| 41 | + | * Code's Overview: where the viewer is needed in the workspace's code, | |
| 42 | + | * what landed, and the week. It is a chunk of its own, loaded only for | |
| 43 | + | * those signed in. routes/workspace/code-overview.tsx loads its data; | |
| 44 | + | * lib/mission-control.ts shapes it. | |
| 45 | + | */ | |
| 46 | + | ||
| 47 | + | /** The viewer's time zone, set by the page itself, so the date fits their day. */ | |
| 48 | + | const TZ_COOKIE = "g1t_tz"; | |
| 49 | + | /** Rows a tab shows before "See all". */ | |
| 50 | + | const ROWS = 6; | |
| 51 | + | ||
| 52 | + | const dollars = (micros: number) => money(Math.max(0, micros)); | |
| 53 | + | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 54 | + | ||
| 55 | + | // --- Small pieces ----------------------------------------------------------------- | |
| 56 | + | ||
| 57 | + | const CHIP_TONE: Record<Reason, string> = { | |
| 58 | + | blocking: "border-danger/35 bg-danger/10 text-danger", | |
| 59 | + | checks_failing: "border-danger/35 bg-danger/10 text-danger", | |
| 60 | + | stalled: "border-warn/35 bg-warn/10 text-warn", | |
| 61 | + | invited: "border-accent/35 bg-accent/10 text-accent", | |
| 62 | + | needs_review: "border-info/35 bg-info/10 text-info", | |
| 63 | + | ready: "border-success/35 bg-success/10 text-success", | |
| 64 | + | }; | |
| 65 | + | ||
| 66 | + | function Chip({ children, tone }: { children: ReactNode; tone: string }) { | |
| 67 | + | return ( | |
| 68 | + | <span | |
| 69 | + | className={cn( | |
| 70 | + | "inline-flex shrink-0 items-center rounded border px-1.5 py-px font-mono text-[0.625rem] font-medium tracking-wider whitespace-nowrap uppercase", | |
| 71 | + | tone, | |
| 72 | + | )} | |
| 73 | + | > | |
| 74 | + | {children} | |
| 75 | + | </span> | |
| 76 | + | ); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /** Someone, by name, with their avatar. */ | |
| 80 | + | function Person({ who, size = 16, className }: { who: Who; size?: number; className?: string }) { | |
| 81 | + | return ( | |
| 82 | + | <span className={cn("inline-flex min-w-0 items-center gap-1.5", className)}> | |
| 83 | + | <Avatar name={who.name} size={size} /> | |
| 84 | + | <span className="truncate">{who.name}</span> | |
| 85 | + | </span> | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | function Eyebrow({ children }: { children: ReactNode }) { | |
| 90 | + | return <p className="font-mono text-[0.625rem] font-medium tracking-[0.12em] text-faint uppercase">{children}</p>; | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | const FACT_TONE = { good: "text-success", warn: "text-warn", bad: "text-danger" } as const; | |
| 94 | + | ||
| 95 | + | function Facts({ facts }: { facts: Fact[] }) { | |
| 96 | + | if (facts.length === 0) return <p className="text-sm text-muted">Nothing more is known about it yet.</p>; | |
| 97 | + | return ( | |
| 98 | + | <dl className="grid grid-cols-2 gap-x-4 gap-y-2.5"> | |
| 99 | + | {facts.map((fact) => ( | |
| 100 | + | <div key={fact.label} className={cn("min-w-0", fact.wide && "col-span-2")}> | |
| 101 | + | <dt className="text-xs text-faint">{fact.label}</dt> | |
| 102 | + | <dd | |
| 103 | + | className={cn("mt-0.5 text-sm font-medium tabular-nums", fact.wide ? "leading-5" : "truncate", fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft")} | |
| 104 | + | > | |
| 105 | + | {fact.value} | |
| 106 | + | </dd> | |
| 107 | + | </div> | |
| 108 | + | ))} | |
| 109 | + | </dl> | |
| 110 | + | ); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /** | |
| 114 | + | * A form that acts on a pull request from here: working until the action | |
| 115 | + | * has answered and the page has reloaded what it changed, then done, or | |
| 116 | + | * the error beside it. | |
| 117 | + | */ | |
| 118 | + | function QuickForm({ quick, variant = "outline" }: { quick: QuickAction; variant?: "outline" | "accent" }) { | |
| 119 | + | const fetcher = useFetcher<{ error?: string } | null>(); | |
| 120 | + | const [sent, setSent] = useState(false); | |
| 121 | + | const done = sent && fetcher.state === "idle" && !fetcher.data?.error; | |
| 122 | + | return ( | |
| 123 | + | <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents"> | |
| 124 | + | {Object.entries(quick.fields).map(([name, value]) => ( | |
| 125 | + | <input key={name} type="hidden" name={name} value={value} /> | |
| 126 | + | ))} | |
| 127 | + | <SubmitButton | |
| 128 | + | fetcher={fetcher} | |
| 129 | + | disabled={done} | |
| 130 | + | className={cn( | |
| 131 | + | "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default", | |
| 132 | + | variant === "accent" ? "bg-accent text-bg hover:bg-accent-hover" : "border border-line-strong text-fg/90 hover:bg-raised hover:text-fg", | |
| 133 | + | done && "border-success/40 text-success", | |
| 134 | + | )} | |
| 135 | + | > | |
| 136 | + | {done ? <Check size={14} /> : null} | |
| 137 | + | {done ? quick.done : quick.label} | |
| 138 | + | </SubmitButton> | |
| 139 | + | {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>} | |
| 140 | + | </fetcher.Form> | |
| 141 | + | ); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | function ProjectMark({ repo }: { repo: { namespace: string; name: string } | null }) { | |
| 145 | + | return <Avatar name={repo ? repo.name : "g1t"} size={32} square />; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /** A row that opens to what is known about it; the first one starts open. */ | |
| 149 | + | function Row({ | |
| 150 | + | open, | |
| 151 | + | repo, | |
| 152 | + | by, | |
| 153 | + | title, | |
| 154 | + | sub, | |
| 155 | + | chip, | |
| 156 | + | at, | |
| 157 | + | children, | |
| 158 | + | }: { | |
| 159 | + | open: boolean; | |
| 160 | + | repo: { namespace: string; name: string } | null; | |
| 161 | + | by: Who | null; | |
| 162 | + | title: ReactNode; | |
| 163 | + | sub: ReactNode; | |
| 164 | + | chip: ReactNode; | |
| 165 | + | at: number; | |
| 166 | + | children: ReactNode; | |
| 167 | + | }) { | |
| 168 | + | return ( | |
| 169 | + | <li> | |
| 170 | + | <details open={open} className="group"> | |
| 171 | + | <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-3 transition-colors select-none hover:bg-raised/60 sm:px-5 [&::-webkit-details-marker]:hidden"> | |
| 172 | + | <ProjectMark repo={repo} /> | |
| 173 | + | <span className="min-w-0 grow"> | |
| 174 | + | <span className="block truncate text-sm">{title}</span> | |
| 175 | + | <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted"> | |
| 176 | + | <span className="shrink-0 font-medium text-fg-soft">{repo ? repo.name : "Workspace"}</span> | |
| 177 | + | {by && ( | |
| 178 | + | <> | |
| 179 | + | <span className="text-faint">·</span> | |
| 180 | + | <Person who={by} size={14} className={cn("shrink-0", by.agent ? "text-accent" : "")} /> | |
| 181 | + | </> | |
| 182 | + | )} | |
| 183 | + | {sub && ( | |
| 184 | + | <> | |
| 185 | + | <span className="text-faint">—</span> | |
| 186 | + | <span className="truncate">{sub}</span> | |
| 187 | + | </> | |
| 188 | + | )} | |
| 189 | + | </span> | |
| 190 | + | </span> | |
| 191 | + | <span className="hidden sm:inline-flex">{chip}</span> | |
| 192 | + | <span className="hidden w-16 shrink-0 text-right text-xs text-faint sm:block"> | |
| 193 | + | <TimeAgo at={at} /> | |
| 194 | + | </span> | |
| 195 | + | <ChevronDown size={16} className="shrink-0 text-faint transition-transform group-open:rotate-180" /> | |
| 196 | + | </summary> | |
| 197 | + | <div className="px-4 pb-4 sm:px-5 sm:pb-5"> | |
| 198 | + | <div className="mb-3 flex items-center gap-2 sm:hidden"> | |
| 199 | + | {chip} | |
| 200 | + | <span className="text-xs text-faint"> | |
| 201 | + | <TimeAgo at={at} /> | |
| 202 | + | </span> | |
| 203 | + | </div> | |
| 204 | + | {children} | |
| 205 | + | </div> | |
| 206 | + | </details> | |
| 207 | + | </li> | |
| 208 | + | ); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | function NeedCard({ row, first }: { row: NeedRow; first: boolean }) { | |
| 212 | + | const isPull = row.to.includes("/pull/"); | |
| 213 | + | return ( | |
| 214 | + | <Row | |
| 215 | + | open={first} | |
| 216 | + | repo={row.repo} | |
| 217 | + | by={row.by} | |
| 218 | + | title={ | |
| 219 | + | <> | |
| 220 | + | <span className="font-medium">{row.title}</span> | |
| 221 | + | {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>} | |
| 222 | + | </> | |
| 223 | + | } | |
| 224 | + | sub={row.ask} | |
| 225 | + | chip={<Chip tone={CHIP_TONE[row.reason]}>{REASON_LABEL[row.reason]}</Chip>} | |
| 226 | + | at={row.at} | |
| 227 | + | > | |
| 228 | + | <Card tone="plain" className="grid gap-4 bg-bg/50 p-4 md:grid-cols-2 md:gap-x-6 2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,0.95fr)]"> | |
| 229 | + | <div className="min-w-0"> | |
| 230 | + | <Eyebrow>The ask</Eyebrow> | |
| 231 | + | <p className="mt-2 text-sm leading-6 text-fg-soft">{row.ask}</p> | |
| 232 | + | <p className="mt-2 text-xs leading-5 text-muted"> | |
| 233 | + | <Link to={row.to} className="font-medium text-fg hover:underline"> | |
| 234 | + | {row.repo ? `${row.repo.name}${row.ref ?? ""}` : row.title} | |
| 235 | + | </Link> | |
| 236 | + | </p> | |
| 237 | + | </div> | |
| 238 | + | <div className="min-w-0"> | |
| 239 | + | <Eyebrow>What is known</Eyebrow> | |
| 240 | + | <div className="mt-2"> | |
| 241 | + | <Facts facts={row.facts} /> | |
| 242 | + | </div> | |
| 243 | + | </div> | |
| 244 | + | <div className="rounded-lg border border-warn/20 bg-warn/6 p-3.5 md:col-span-2 2xl:col-span-1"> | |
| 245 | + | <p className="text-xs font-semibold text-warn">Why this needs you</p> | |
| 246 | + | <p className="mt-1.5 text-sm leading-6 text-fg-soft">{row.why}</p> | |
| 247 | + | <p className="mt-2 text-xs text-faint"> | |
| 248 | + | Started waiting <TimeAgo at={row.at} /> | |
| 249 | + | </p> | |
| 250 | + | </div> | |
| 251 | + | </Card> | |
| 252 | + | <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| 253 | + | <Link | |
| 254 | + | to={row.to} | |
| 255 | + | prefetch="intent" | |
| 256 | + | className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-fg-hover" | |
| 257 | + | > | |
| 258 | + | Review and respond <ArrowUpRight size={14} /> | |
| 259 | + | </Link> | |
| 260 | + | {row.quick && <QuickForm quick={row.quick} />} | |
| 261 | + | {row.link && ( | |
| 262 | + | <Link | |
| 263 | + | to={row.link.to} | |
| 264 | + | className="inline-flex items-center rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg" | |
| 265 | + | > | |
| 266 | + | {row.link.label} | |
| 267 | + | </Link> | |
| 268 | + | )} | |
| 269 | + | {row.open !== row.to && ( | |
| 270 | + | <Link to={row.open} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg"> | |
| 271 | + | {row.open.includes("/-/work/") ? "Open work item" : isPull ? "See the changes" : "Open pull request"} | |
| 272 | + | </Link> | |
| 273 | + | )} | |
| 274 | + | </div> | |
| 275 | + | </Row> | |
| 276 | + | ); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | function LandedCard({ row, first }: { row: LandedRow; first: boolean }) { | |
| 280 | + | return ( | |
| 281 | + | <Row | |
| 282 | + | open={first} | |
| 283 | + | repo={row.repo} | |
| 284 | + | by={row.by} | |
| 285 | + | title={ | |
| 286 | + | <> | |
| 287 | + | <span className="font-medium">{row.title}</span> | |
| 288 | + | <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span> | |
| 289 | + | </> | |
| 290 | + | } | |
| 291 | + | sub={row.automatic ? "merged automatically" : `merged by ${row.by?.name ?? "a person"}`} | |
| 292 | + | chip={ | |
| 293 | + | row.automatic ? ( | |
| 294 | + | <Chip tone="border-accent/35 bg-accent/10 text-accent">Auto-merged</Chip> | |
| 295 | + | ) : ( | |
| 296 | + | <Chip tone="border-line-strong bg-raised text-muted">Merged</Chip> | |
| 297 | + | ) | |
| 298 | + | } | |
| 299 | + | at={row.at} | |
| 300 | + | > | |
| 301 | + | <Card tone="plain" className="grid gap-4 bg-bg/50 p-4 md:grid-cols-2 md:gap-6"> | |
| 302 | + | <div className="min-w-0"> | |
| 303 | + | <Eyebrow>How it landed</Eyebrow> | |
| 304 | + | <p className="mt-2 text-sm leading-6 text-fg-soft"> | |
| 305 | + | {row.automatic | |
| 306 | + | ? "g1t merged it once everything the repository asks for was met, with no one pressing merge." | |
| 307 | + | : `${row.by?.name ?? "A person"} merged it.`} | |
| 308 | + | </p> | |
| 309 | + | </div> | |
| 310 | + | <div className="min-w-0"> | |
| 311 | + | <Eyebrow>What changed</Eyebrow> | |
| 312 | + | <div className="mt-2"> | |
| 313 | + | <Facts facts={row.facts} /> | |
| 314 | + | </div> | |
| 315 | + | </div> | |
| 316 | + | </Card> | |
| 317 | + | <div className="mt-3"> | |
| 318 | + | <Link | |
| 319 | + | to={row.to} | |
| 320 | + | prefetch="intent" | |
| 321 | + | className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg" | |
| 322 | + | > | |
| 323 | + | Open pull request <ArrowUpRight size={14} /> | |
| 324 | + | </Link> | |
| 325 | + | </div> | |
| 326 | + | </Row> | |
| 327 | + | ); | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | function List({ children }: { children: ReactNode }) { | |
| 331 | + | return <ul className="divide-y divide-line">{children}</ul>; | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | function Empty({ children, action }: { children: ReactNode; action?: ReactNode }) { | |
| 335 | + | return ( | |
| 336 | + | <div className="px-5 py-10 text-center"> | |
| 337 | + | <div className="mx-auto max-w-md text-sm leading-6 text-muted">{children}</div> | |
| 338 | + | {action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>} | |
| 339 | + | </div> | |
| 340 | + | ); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // --- The stat strip ----------------------------------------------------------------- | |
| 344 | + | ||
| 345 | + | function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) { | |
| 346 | + | return ( | |
| 347 | + | <Hint label={title}> | |
| 348 | + | <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4"> | |
| 349 | + | <p className="flex items-center gap-1.5 truncate text-xs text-muted"> | |
| 350 | + | {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />} | |
| 351 | + | {label} | |
| 352 | + | </p> | |
| 353 | + | <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p> | |
| 354 | + | <p className="mt-0.5 truncate text-xs text-faint">{hint}</p> | |
| 355 | + | </div> | |
| 356 | + | </Hint> | |
| 357 | + | ); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // --- This week ---------------------------------------------------------------------- | |
| 361 | + | ||
| 362 | + | /** Seven days of landed changes: merged pull requests and commits pushed straight to a default branch. */ | |
| 363 | + | function WeekChart({ week }: { week: Week }) { | |
| 364 | + | const max = Math.max(1, ...week.days.map((d) => d.count)); | |
| 365 | + | const height = 112; | |
| 366 | + | const delta = change(week.total, week.previous); | |
| 367 | + | return ( | |
| 368 | + | <div> | |
| 369 | + | <div className="flex items-end justify-between gap-3"> | |
| 370 | + | <div> | |
| 371 | + | <p className="font-display text-3xl font-semibold tracking-tight tabular-nums">{week.total.toLocaleString("en-US")}</p> | |
| 372 | + | <p className="text-xs text-muted">changes landed in 7 days</p> | |
| 373 | + | </div> | |
| 374 | + | {delta != null ? ( | |
| 375 | + | <span | |
| 376 | + | className={cn( | |
| 377 | + | "rounded-full px-2 py-0.5 text-xs font-medium tabular-nums ring-1", | |
| 378 | + | delta >= 0 ? "text-success ring-success/30" : "text-warn ring-warn/30", | |
| 379 | + | )} | |
| 380 | + | > | |
| 381 | + | {signedPercent(delta)} vs last week | |
| 382 | + | </span> | |
| 383 | + | ) : week.previous === 0 && week.total > 0 ? ( | |
| 384 | + | <span className="text-xs text-faint">none the week before</span> | |
| 385 | + | ) : null} | |
| 386 | + | </div> | |
| 387 | + | {week.total === 0 ? ( | |
| 388 | + | <Card asChild tone="plain" radius="lg" className="mt-4 border-dashed px-4 py-6 text-center text-sm leading-6 text-muted"> | |
| 389 | + | <p>Nothing landed in the last 7 days. Each merged pull request and each push to a default branch shows here, by day.</p> | |
| 390 | + | </Card> | |
| 391 | + | ) : ( | |
| 392 | + | <div className="relative mt-5" style={{ height: height + 20 }}> | |
| 393 | + | <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} /> | |
| 394 | + | <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}> | |
| 395 | + | {week.days.map((day, index) => { | |
| 396 | + | const today = index === week.days.length - 1; | |
| 397 | + | return ( | |
| 398 | + | <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end"> | |
| 399 | + | {day.count > 0 && ( | |
| 400 | + | <span | |
| 401 | + | className={cn("mb-1 text-[0.625rem] tabular-nums", today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100 max-md:hidden")} | |
| 402 | + | > | |
| 403 | + | {day.count} | |
| 404 | + | </span> | |
| 405 | + | )} | |
| 406 | + | <div className="flex w-full max-w-6 flex-col items-stretch"> | |
| 407 | + | {day.count > 0 ? ( | |
| 408 | + | <span className="block rounded-t bg-accent" style={{ height: Math.max(3, Math.round((day.count / max) * (height - 4))) }} /> | |
| 409 | + | ) : ( | |
| 410 | + | <span className="block h-0.5 rounded-full bg-line-strong" /> | |
| 411 | + | )} | |
| 412 | + | </div> | |
| 413 | + | <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 md:group-hover:block"> | |
| 414 | + | <p className="font-medium text-fg">{day.label}</p> | |
| 415 | + | <p className="mt-0.5 text-muted">{plural(day.count, "change")} landed</p> | |
| 416 | + | </div> | |
| 417 | + | </div> | |
| 418 | + | ); | |
| 419 | + | })} | |
| 420 | + | </div> | |
| 421 | + | <div className="absolute inset-x-0 bottom-0 flex justify-between gap-1"> | |
| 422 | + | {week.days.map((day, index) => ( | |
| 423 | + | <span | |
| 424 | + | key={day.key} | |
| 425 | + | className={cn("flex-1 text-center text-[0.6875rem]", index === week.days.length - 1 ? "font-medium text-fg-soft" : "text-faint")} | |
| 426 | + | > | |
| 427 | + | {day.label} | |
| 428 | + | </span> | |
| 429 | + | ))} | |
| 430 | + | </div> | |
| 431 | + | </div> | |
| 432 | + | )} | |
| 433 | + | {/* A table does not shrink to sr-only's 1px; its wrapper does. */} | |
| 434 | + | <div className="sr-only"> | |
| 435 | + | <table> | |
| 436 | + | <caption>Changes landed each day</caption> | |
| 437 | + | <thead> | |
| 438 | + | <tr> | |
| 439 | + | <th>Day</th> | |
| 440 | + | <th>Changes</th> | |
| 441 | + | </tr> | |
| 442 | + | </thead> | |
| 443 | + | <tbody> | |
| 444 | + | {week.days.map((day) => ( | |
| 445 | + | <tr key={day.key}> | |
| 446 | + | <td>{day.key}</td> | |
| 447 | + | <td>{day.count}</td> | |
| 448 | + | </tr> | |
| 449 | + | ))} | |
| 450 | + | </tbody> | |
| 451 | + | </table> | |
| 452 | + | </div> | |
| 453 | + | </div> | |
| 454 | + | ); | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // --- Activity ----------------------------------------------------------------------- | |
| 458 | + | ||
| 459 | + | const VERB: Record<Verb, string> = { | |
| 460 | + | landed: "landed", | |
| 461 | + | opened_item: "opened", | |
| 462 | + | closed_item: "closed", | |
| 463 | + | started: "started on", | |
| 464 | + | ready: "marked ready", | |
| 465 | + | checks_passed: "checks passed on", | |
| 466 | + | checks_failed: "checks failed on", | |
| 467 | + | approved: "approved", | |
| 468 | + | changes_requested: "asked for changes on", | |
| 469 | + | commented: "commented on", | |
| 470 | + | deployed: "deployed production", | |
| 471 | + | deploy_failed: "production build failed", | |
| 472 | + | }; | |
| 473 | + | ||
| 474 | + | /** One line of the feed: who, what, and the one thing it was about, by name. */ | |
| 475 | + | function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) { | |
| 476 | + | const part = group.parts[0]; | |
| 477 | + | const base = `/${group.repo.namespace}/${group.repo.name}`; | |
| 478 | + | const actor = group.actor ?? "g1t"; | |
| 479 | + | const agent = group.actor == null || isAgent(group.actor); | |
| 480 | + | const number = part.numbers[0]; | |
| 481 | + | const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined; | |
| 482 | + | const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1); | |
| 483 | + | return ( | |
| 484 | + | <li className="flex gap-2.5 py-2"> | |
| 485 | + | <span className="mt-px">{agent ? <Avatar name={actor} image={null} size={22} /> : <UserAvatar username={actor} size={22} />}</span> | |
| 486 | + | <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted"> | |
| 487 | + | {agent ? <span className="font-medium text-accent">{actor}</span> : <PersonLink name={actor} className="font-medium text-fg hover:text-accent" />}{" "} | |
| 488 | + | {VERB[part.verb]}{" "} | |
| 489 | + | {number != null ? ( | |
| 490 | + | <Link | |
| 491 | + | to={part.verb === "opened_item" || part.verb === "closed_item" ? itemPath(group.repo.namespace, number) : `${base}/pull/${number}`} | |
| 492 | + | prefetch="intent" | |
| 493 | + | className="text-fg-soft hover:text-fg" | |
| 494 | + | > | |
| 495 | + | <span className="font-mono text-xs">#{number}</span> | |
| 496 | + | {title && <> {title}</>} | |
| 497 | + | </Link> | |
| 498 | + | ) : part.to ? ( | |
| 499 | + | <Link to={part.to} className="text-fg-soft hover:text-fg"> | |
| 500 | + | {group.repo.name} | |
| 501 | + | </Link> | |
| 502 | + | ) : null} | |
| 503 | + | {more > 0 && <span className="text-faint"> and {more} more</span>} | |
| 504 | + | <span className="block text-xs text-faint"> | |
| 505 | + | <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} /> | |
| 506 | + | </span> | |
| 507 | + | </span> | |
| 508 | + | </li> | |
| 509 | + | ); | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // --- The page ----------------------------------------------------------------------- | |
| 513 | + | ||
| 514 | + | const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", landed: "Landed today" }; | |
| 515 | + | const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", landed: "Today" }; | |
| 516 | + | const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" }; | |
| 517 | + | ||
| 518 | + | export default function MissionControl({ loaderData }: { loaderData: Loaded }) { | |
| 519 | + | const { pathname } = useLocation(); | |
| 520 | + | const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined; | |
| 521 | + | const loaded: Loaded = loaderData; | |
| 522 | + | const [params] = useSearchParams(); | |
| 523 | + | const [date, setDate] = useState(loaded.date); | |
| 524 | + | useEffect(() => { | |
| 525 | + | // The date follows the viewer's own clock, and the server learns their zone for next time. | |
| 526 | + | try { | |
| 527 | + | const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; | |
| 528 | + | setDate(dateLine(Date.now(), zone ?? null)); | |
| 529 | + | if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`; | |
| 530 | + | } catch { | |
| 531 | + | // Nothing to remember. | |
| 532 | + | } | |
| 533 | + | }, []); | |
| 534 | + | ||
| 535 | + | const { viewer, repos, landed, week, groups, titles, stats } = loaded; | |
| 536 | + | const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null; | |
| 537 | + | ||
| 538 | + | // The usage limit, as the sidebar knows it, heads the list when it bites. | |
| 539 | + | const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null; | |
| 540 | + | const limitRow: NeedRow[] = limit | |
| 541 | + | ? [ | |
| 542 | + | (() => { | |
| 543 | + | const detail = | |
| 544 | + | limit.state === "stopped" | |
| 545 | + | ? "Paid usage is paused until the limit is raised or a card is added." | |
| 546 | + | : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`; | |
| 547 | + | return { | |
| 548 | + | key: "limit", | |
| 549 | + | reason: "blocking" as const, | |
| 550 | + | repo: null, | |
| 551 | + | ref: null, | |
| 552 | + | title: limit.state === "stopped" ? "The usage limit was reached" : "Usage is nearing its limit", | |
| 553 | + | ask: detail, | |
| 554 | + | by: null, | |
| 555 | + | at: Date.now(), | |
| 556 | + | to: `/${workspace}/-/settings/billing`, | |
| 557 | + | open: `/${workspace}/-/settings/billing`, | |
| 558 | + | facts: [{ label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const }], | |
| 559 | + | why: "Workflows, checks and deployments that are paid for stop at the workspace's usage limit. Only an owner can raise it or add a card.", | |
| 560 | + | quick: null, | |
| 561 | + | link: null, | |
| 562 | + | }; | |
| 563 | + | })(), | |
| 564 | + | ] | |
| 565 | + | : []; | |
| 566 | + | const needs = [...limitRow, ...loaded.needs]; | |
| 567 | + | const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length; | |
| 568 | + | ||
| 569 | + | const counts: Record<Tab, number> = { needs: needs.length, landed: landed.length }; | |
| 570 | + | const tab = parseTab(params.get("tab")) ?? "needs"; | |
| 571 | + | const sort = parseSort(params.get("sort")); | |
| 572 | + | const all = params.get("all") === "1"; | |
| 573 | + | const everyActivity = params.get("activity") === "all"; | |
| 574 | + | const link = (changes: Record<string, string | null>) => { | |
| 575 | + | const next = new URLSearchParams(params); | |
| 576 | + | for (const [key, value] of Object.entries(changes)) { | |
| 577 | + | if (value == null) next.delete(key); | |
| 578 | + | else next.set(key, value); | |
| 579 | + | } | |
| 580 | + | const text = next.toString(); | |
| 581 | + | return text ? `?${text}` : pathname; | |
| 582 | + | }; | |
| 583 | + | ||
| 584 | + | const noProjects = repos.length === 0; | |
| 585 | + | ||
| 586 | + | const rows = tab === "needs" ? sortRows(needs, sort) : sortRows(landed, sort); | |
| 587 | + | const shown = all ? rows : rows.slice(0, ROWS); | |
| 588 | + | const delta = change(week.total, week.previous); | |
| 589 | + | const feed = everyActivity ? groups : groups.slice(0, 8); | |
| 590 | + | ||
| 591 | + | const newItem = | |
| 592 | + | repos.length > 0 ? ( | |
| 593 | + | <DropdownMenu> | |
| 594 | + | <DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg"> | |
| 595 | + | <Plus size={14} /> New work item | |
| 596 | + | </DropdownMenuTrigger> | |
| 597 | + | <DropdownMenuContent align="start" className="max-h-80 overflow-y-auto"> | |
| 598 | + | <DropdownMenuLabel>Filed from which repository?</DropdownMenuLabel> | |
| 599 | + | {repos.map((repo) => ( | |
| 600 | + | <DropdownMenuItem key={repo.name} asChild> | |
| 601 | + | <Link to={newItemPath(repo.namespace, repo.name)}> | |
| 602 | + | <Avatar name={repo.name} size={18} square /> | |
| 603 | + | {repo.name} | |
| 604 | + | </Link> | |
| 605 | + | </DropdownMenuItem> | |
| 606 | + | ))} | |
| 607 | + | </DropdownMenuContent> | |
| 608 | + | </DropdownMenu> | |
| 609 | + | ) : ( | |
| 610 | + | <Link | |
| 611 | + | to={"/new"} | |
| 612 | + | className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg" | |
| 613 | + | > | |
| 614 | + | <Plus size={14} /> New repository | |
| 615 | + | </Link> | |
| 616 | + | ); | |
| 617 | + | ||
| 618 | + | return ( | |
| 619 | + | <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10"> | |
| 620 | + | <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4"> | |
| 621 | + | <div className="min-w-0"> | |
| 622 | + | <h1 className="text-2xl leading-tight font-semibold tracking-tight">Overview</h1> | |
| 623 | + | <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning> | |
| 624 | + | <span className="text-fg-soft">{date}</span> · {loaded.summary} | |
| 625 | + | </p> | |
| 626 | + | </div> | |
| 627 | + | </header> | |
| 628 | + | ||
| 629 | + | <section aria-label="Start work" className="flex flex-wrap items-center gap-2"> | |
| 630 | + | {newItem} | |
| 631 | + | {needs.length > 0 && ( | |
| 632 | + | <Link | |
| 633 | + | to={`${link({ tab: "needs", all: null })}#work`} | |
| 634 | + | preventScrollReset | |
| 635 | + | className="inline-flex items-center gap-1.5 rounded-md bg-accent px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-accent-hover" | |
| 636 | + | > | |
| 637 | + | Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} /> | |
| 638 | + | </Link> | |
| 639 | + | )} | |
| 640 | + | </section> | |
| 641 | + | ||
| 642 | + | <Card asChild tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4"> | |
| 643 | + | <section aria-label="At a glance"> | |
| 644 | + | <Stat | |
| 645 | + | label="Repositories" | |
| 646 | + | value={stats.projects == null ? "—" : String(stats.projects)} | |
| 647 | + | hint={ | |
| 648 | + | stats.projectsThisMonth == null | |
| 649 | + | ? "in this workspace" | |
| 650 | + | : stats.projectsThisMonth > 0 | |
| 651 | + | ? `+${stats.projectsThisMonth} this month` | |
| 652 | + | : "none new this month" | |
| 653 | + | } | |
| 654 | + | /> | |
| 655 | + | <Stat label="Open pull requests" value={stats.openPulls == null ? "—" : String(stats.openPulls)} hint="drafts included" /> | |
| 656 | + | <Stat | |
| 657 | + | label="Changes this week" | |
| 658 | + | value={loaded.perRepoLoaded ? String(week.total) : "—"} | |
| 659 | + | hint={delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merges and direct pushes"} | |
| 660 | + | /> | |
| 661 | + | <Stat | |
| 662 | + | label="Needs you" | |
| 663 | + | dot="bg-warn" | |
| 664 | + | value={String(needs.length)} | |
| 665 | + | hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"} | |
| 666 | + | /> | |
| 667 | + | </section> | |
| 668 | + | </Card> | |
| 669 | + | ||
| 670 | + | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]"> | |
| 671 | + | <Card asChild className="min-w-0 scroll-mt-[calc(var(--topbar-h)+1rem)] self-start overflow-hidden"> | |
| 672 | + | <section id="work"> | |
| 673 | + | <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3"> | |
| 674 | + | <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto scrollbar-none" aria-label="Overview"> | |
| 675 | + | {(["needs", "landed"] as const).map((value) => ( | |
| 676 | + | <Link | |
| 677 | + | key={value} | |
| 678 | + | to={link({ tab: value, all: null })} | |
| 679 | + | preventScrollReset | |
| 680 | + | aria-current={tab === value ? "page" : undefined} | |
| 681 | + | className={cn( | |
| 682 | + | "flex shrink-0 items-center gap-1.5 border-b-2 px-2 py-3 text-sm whitespace-nowrap transition-colors sm:px-2.5", | |
| 683 | + | tab === value ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg", | |
| 684 | + | )} | |
| 685 | + | > | |
| 686 | + | <span className="sm:hidden">{TAB_SHORT[value]}</span> | |
| 687 | + | <span className="hidden sm:inline">{TAB_LABEL[value]}</span> | |
| 688 | + | <span | |
| 689 | + | className={cn( | |
| 690 | + | "rounded-full px-1.5 text-[0.6875rem] tabular-nums", | |
| 691 | + | tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted", | |
| 692 | + | )} | |
| 693 | + | > | |
| 694 | + | {counts[value]} | |
| 695 | + | </span> | |
| 696 | + | </Link> | |
| 697 | + | ))} | |
| 698 | + | </nav> | |
| 699 | + | <DropdownMenu> | |
| 700 | + | <DropdownMenuTrigger | |
| 701 | + | className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted hover:bg-raised hover:text-fg" | |
| 702 | + | aria-label="Sort" | |
| 703 | + | > | |
| 704 | + | <ArrowDownWideNarrow size={14} /> | |
| 705 | + | <span className="hidden sm:inline">{SORT_LABEL[sort]}</span> | |
| 706 | + | </DropdownMenuTrigger> | |
| 707 | + | <DropdownMenuContent align="end"> | |
| 708 | + | {(["impact", "newest"] as const).map((value) => ( | |
| 709 | + | <DropdownMenuItem key={value} asChild> | |
| 710 | + | <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset> | |
| 711 | + | {sort === value ? <Check /> : <span className="size-4" />} | |
| 712 | + | {SORT_LABEL[value]} | |
| 713 | + | </Link> | |
| 714 | + | </DropdownMenuItem> | |
| 715 | + | ))} | |
| 716 | + | </DropdownMenuContent> | |
| 717 | + | </DropdownMenu> | |
| 718 | + | </div> | |
| 719 | + | ||
| 720 | + | {!loaded.perRepoLoaded ? ( | |
| 721 | + | <div className="p-4"> | |
| 722 | + | <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} /> | |
| 723 | + | </div> | |
| 724 | + | ) : null} | |
| 725 | + | ||
| 726 | + | {tab === "needs" && | |
| 727 | + | (needs.length === 0 ? ( | |
| 728 | + | noProjects ? ( | |
| 729 | + | <Empty | |
| 730 | + | action={ | |
| 731 | + | <Link to={"/new"} className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover"> | |
| 732 | + | Create or import a repository | |
| 733 | + | </Link> | |
| 734 | + | } | |
| 735 | + | > | |
| 736 | + | <p className="font-medium text-fg">No repositories yet</p> | |
| 737 | + | <p className="mt-1">Create a repository or import one. Reviews, failed deploys and invitations for you show up here.</p> | |
| 738 | + | </Empty> | |
| 739 | + | ) : ( | |
| 740 | + | <> | |
| 741 | + | <Empty> | |
| 742 | + | <p className="font-medium text-fg">Nothing needs you</p> | |
| 743 | + | <p className="mt-1">{landed.length > 0 ? "Here's what landed today." : "Reviews, failed deploys and invitations show up here first."}</p> | |
| 744 | + | </Empty> | |
| 745 | + | {landed.length > 0 && ( | |
| 746 | + | <div className="border-t border-line"> | |
| 747 | + | <List> | |
| 748 | + | {landed.slice(0, ROWS).map((row) => ( | |
| 749 | + | <LandedCard key={row.key} row={row} first={false} /> | |
| 750 | + | ))} | |
| 751 | + | </List> | |
| 752 | + | </div> | |
| 753 | + | )} | |
| 754 | + | </> | |
| 755 | + | ) | |
| 756 | + | ) : ( | |
| 757 | + | <List> | |
| 758 | + | {(shown as NeedRow[]).map((row, index) => ( | |
| 759 | + | <NeedCard key={row.key} row={row} first={index === 0} /> | |
| 760 | + | ))} | |
| 761 | + | </List> | |
| 762 | + | ))} | |
| 763 | + | ||
| 764 | + | {tab === "landed" && | |
| 765 | + | loaded.perRepoLoaded && | |
| 766 | + | (landed.length === 0 ? ( | |
| 767 | + | <Empty> | |
| 768 | + | <p className="font-medium text-fg">Nothing has landed today yet</p> | |
| 769 | + | <p className="mt-1"> | |
| 770 | + | {week.total > 0 ? `${plural(week.total, "change")} landed in the last 7 days.` : "Merged pull requests show up here the moment they land."} | |
| 771 | + | </p> | |
| 772 | + | </Empty> | |
| 773 | + | ) : ( | |
| 774 | + | <List> | |
| 775 | + | {(shown as LandedRow[]).map((row, index) => ( | |
| 776 | + | <LandedCard key={row.key} row={row} first={index === 0} /> | |
| 777 | + | ))} | |
| 778 | + | </List> | |
| 779 | + | ))} | |
| 780 | + | ||
| 781 | + | {rows.length > ROWS && ( | |
| 782 | + | <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5"> | |
| 783 | + | <span> | |
| 784 | + | Showing {shown.length} of {rows.length} | |
| 785 | + | </span> | |
| 786 | + | <Link to={link({ all: all ? null : "1" })} preventScrollReset className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg"> | |
| 787 | + | {all ? "Show fewer" : "See all"} <ChevronRight size={12} /> | |
| 788 | + | </Link> | |
| 789 | + | </div> | |
| 790 | + | )} | |
| 791 | + | </section> | |
| 792 | + | </Card> | |
| 793 | + | ||
| 794 | + | <aside className="min-w-0 space-y-6"> | |
| 795 | + | <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} /> | |
| 796 | + | <Card asChild className="p-5"> | |
| 797 | + | <section> | |
| 798 | + | <h2 className="text-base font-semibold tracking-tight">This week</h2> | |
| 799 | + | <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div> | |
| 800 | + | </section> | |
| 801 | + | </Card> | |
| 802 | + | ||
| 803 | + | <Card asChild className="scroll-mt-[calc(var(--topbar-h)+1rem)] p-5"> | |
| 804 | + | <section id="activity"> | |
| 805 | + | <div className="flex items-center justify-between"> | |
| 806 | + | <h2 className="text-base font-semibold tracking-tight">Activity</h2> | |
| 807 | + | {groups.length > 8 && ( | |
| 808 | + | <Link | |
| 809 | + | to={`${link({ activity: everyActivity ? null : "all" })}#activity`} | |
| 810 | + | preventScrollReset | |
| 811 | + | className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg" | |
| 812 | + | > | |
| 813 | + | {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} /> | |
| 814 | + | </Link> | |
| 815 | + | )} | |
| 816 | + | </div> | |
| 817 | + | {!loaded.perRepoLoaded ? ( | |
| 818 | + | <div className="mt-3"> | |
| 819 | + | <Unavailable what="Activity" /> | |
| 820 | + | </div> | |
| 821 | + | ) : feed.length === 0 ? ( | |
| 822 | + | <p className="mt-3 text-sm leading-6 text-muted">Nothing has moved yet. Merges, deploys, checks and reviews show up here.</p> | |
| 823 | + | ) : ( | |
| 824 | + | <ol className="mt-2 divide-y divide-line/70"> | |
| 825 | + | {feed.map((group) => ( | |
| 826 | + | <FeedLine key={group.id} group={group} titles={titles} /> | |
| 827 | + | ))} | |
| 828 | + | </ol> | |
| 829 | + | )} | |
| 830 | + | </section> | |
| 831 | + | </Card> | |
| 832 | + | </aside> | |
| 833 | + | </div> | |
| 834 | + | </main> | |
| 835 | + | ); | |
| 836 | + | } |
| 1228 | 1228 | ? { to: repositoriesPath(home.slug), label: "All repositories" } | |
| 1229 | 1229 | : browsing | |
| 1230 | 1230 | ? { to: repositoriesPath(menuRepo.namespace), label: "Repositories", context: menuRepo.namespace } | |
| 1231 | − | : { to: ws ? `/${ws.slug}` : "/explore", label: "Code" }; | |
| 1231 | + | : { to: ws ? `/${ws.slug}/-/overview` : "/explore", label: "Code" }; | |
| 1232 | 1232 | trail.push({ | |
| 1233 | 1233 | key, | |
| 1234 | 1234 | node: ( | |
| ⋯ | |||
| 1426 | 1426 | return ( | |
| 1427 | 1427 | <nav aria-label="Code" className={PANEL}> | |
| 1428 | 1428 | <NavRows> | |
| 1429 | − | <SidebarLink to={`/${slug}`} icon={<LayoutDashboard size={15} />} current={path === `/${slug}`}> | |
| 1429 | + | <SidebarLink to={`/${slug}/-/overview`} icon={<LayoutDashboard size={15} />} current={at("overview")}> | |
| 1430 | 1430 | Overview | |
| 1431 | 1431 | </SidebarLink> | |
| 1432 | 1432 | </NavRows> | |
| ⋯ | |||
| 2077 | 2077 | { label: "Labels", hint: "Work", to: workPath(ws.slug, "labels"), icon: <ListTodo size={15} /> }, | |
| 2078 | 2078 | { label: "Milestones", hint: "Work", to: workPath(ws.slug, "milestones"), icon: <ListTodo size={15} /> }, | |
| 2079 | 2079 | { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> }, | |
| 2080 | − | ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}`, icon: <Code2 size={15} /> }] : []), | |
| 2080 | + | ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []), | |
| 2081 | 2081 | { label: "Library", to: `/${ws.slug}/-/library/artifacts`, icon: <LibraryBig size={15} /> }, | |
| 2082 | 2082 | { label: "Routines", hint: "Standing work for @g1t (soon)", to: `/${ws.slug}/-/routines`, icon: <Repeat size={15} /> }, | |
| 2083 | 2083 | { label: "Apps", hint: "Everything installed that you can use", to: `/${ws.slug}/-/apps`, icon: <LayoutGrid size={15} /> }, | |
| 45 | 45 | { key: "work", name: "Work", path: under("work") }, | |
| 46 | 46 | { key: "chat", name: "Chat", path: under("chat") }, | |
| 47 | 47 | { key: "notifications", name: "Notifications", path: () => "/notifications" }, | |
| 48 | − | // The workspace's own page, as an organization's is on GitHub. | |
| 49 | − | { key: "code", name: "Code", path: (slug) => `/${slug}`, code: true }, | |
| 48 | + | { key: "code", name: "Code", path: under("overview"), code: true }, | |
| 50 | 49 | // Artifacts are the Library's: docs now, and slides, designs and dashboards next. | |
| 51 | 50 | { key: "artifacts", name: "Library", path: under("library/artifacts") }, | |
| 52 | 51 | { key: "routines", name: "Routines", path: under("routines") }, |
| 323 | 323 | series: weeks.map(agentChanges), | |
| 324 | 324 | points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} merged, ${week.agentCommits ?? 0} pushed directly, of ${allChanges(week)} changes`), | |
| 325 | 325 | hint: "Agents' merged pull requests, and the commits their sessions pushed outside one", | |
| 326 | − | to: `/${slug}/-/home?window=7d#since`, | |
| 326 | + | to: `/${slug}/-/overview?tab=landed`, | |
| 327 | 327 | }, | |
| 328 | 328 | { | |
| 329 | 329 | key: "spend", | |
| ⋯ | |||
| 343 | 343 | series: weeks.map((week) => week.stalled), | |
| 344 | 344 | points: weeks.map((week, i) => `${labels[i]}: ${week.stalled} with nothing for three days`), | |
| 345 | 345 | hint: "Open pull requests with no push, comment, review, check or session for three days", | |
| 346 | − | to: `/${slug}/-/home#needed`, | |
| 346 | + | to: `/${slug}/-/overview?tab=needs#work`, | |
| 347 | 347 | }, | |
| 348 | 348 | { | |
| 349 | 349 | key: "rework", | |
| ⋯ | |||
| 453 | 453 | caption: "Time to first review", | |
| 454 | 454 | range: `${short(first)} → ${short(wait)} over ${weeks.length} weeks`, | |
| 455 | 455 | }; | |
| 456 | − | const link = { label: "See the review queue", to: `/${ctx.slug}/-/home#needed` }; | |
| 456 | + | const link = { label: "See the review queue", to: `/${ctx.slug}/-/overview?tab=needs#work` }; | |
| 457 | 457 | if (draft != null && wait >= 4 * draft && wait >= 4 * 3600) { | |
| 458 | 458 | return { | |
| 459 | 459 | key: "review", | |
| ⋯ | |||
| 611 | 611 | key: "stalled", | |
| 612 | 612 | headline: `${countWord(now.stalled)} open changes have gone three days with nothing happening, ${more} more than last week.`, | |
| 613 | 613 | facts: [`${before.stalled} last week`, `${now.stalled} now`], | |
| 614 | − | link: { label: "See what's stalled", to: `/${ctx.slug}/-/home#needed` }, | |
| 614 | + | link: { label: "See what's stalled", to: `/${ctx.slug}/-/overview?tab=needs#work` }, | |
| 615 | 615 | trend: { | |
| 616 | 616 | values: ctx.weeks.map((week) => week.stalled), | |
| 617 | 617 | points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`), | |
| 84 | 84 | test("the rail's mode follows the address", async () => { | |
| 85 | 85 | const { modeOf, modeHome, homePath } = await import("./workspace-nav.ts"); | |
| 86 | 86 | assert.equal(modeOf("/", "acme"), "home"); | |
| 87 | − | // The workspace's own page is Code's front page. | |
| 88 | − | assert.equal(modeOf("/acme", "acme"), "code"); | |
| 87 | + | assert.equal(modeOf("/acme", "acme"), "home"); | |
| 89 | 88 | assert.equal(modeOf("/acme/-/home", "acme"), "home"); | |
| 90 | 89 | // g1t's own public pages light no mode. | |
| 91 | 90 | assert.equal(modeOf("/explore", "acme"), "site"); | |
| ⋯ | |||
| 128 | 127 | assert.equal(modeHome("notifications", "acme"), "/notifications"); | |
| 129 | 128 | assert.equal(modeHome("people", "acme"), "/acme/-/people"); | |
| 130 | 129 | assert.equal(modeHome("apps", "acme"), "/acme/-/apps"); | |
| 131 | − | assert.equal(modeHome("code", "acme"), "/acme"); | |
| 130 | + | assert.equal(modeHome("code", "acme"), "/acme/-/overview"); | |
| 132 | 131 | assert.equal(modeHome("workspace", "acme"), "/acme/-/settings/profile"); | |
| 133 | 132 | assert.equal(homePath("acme"), "/acme/-/home"); | |
| 134 | − | assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/home"); | |
| 133 | + | assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed"); | |
| 135 | 134 | }); | |
| 136 | 135 | ||
| 137 | 136 | test("Workspace mode's sidebar knows which group and page an address is", async () => { | |
| 347 | 347 | const parts = path.split("/").filter(Boolean); | |
| 348 | 348 | if (SITE_PAGES.has(parts[0] ?? "")) return "site"; | |
| 349 | 349 | if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) { | |
| 350 | − | // The workspace's own page is Code's front page, as an organization's is on GitHub. | |
| 351 | − | if (parts.length === 1) return "code"; | |
| 350 | + | if (parts.length === 1) return "home"; | |
| 352 | 351 | if (parts[1] === "-") { | |
| 353 | 352 | // The workspace's integrations are its apps: Apps is lit on them, beside the settings' sidebar. | |
| 354 | 353 | if (parts[2] === "settings" && parts[3] === "integrations") return "apps"; | |
| ⋯ | |||
| 382 | 381 | case "routines": | |
| 383 | 382 | return `/${slug}/-/routines`; | |
| 384 | 383 | case "code": | |
| 385 | − | return `/${slug}`; | |
| 384 | + | return `/${slug}/-/overview`; | |
| 386 | 385 | case "people": | |
| 387 | 386 | return `/${slug}/-/people`; | |
| 388 | 387 | case "workspace": | |
| ⋯ | |||
| 516 | 515 | params.delete("_routes"); | |
| 517 | 516 | params.delete("index"); | |
| 518 | 517 | const query = params.toString(); | |
| 518 | + | if (params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`; | |
| 519 | 519 | return homePagePath(slug); | |
| 520 | 520 | } | |
| 521 | 521 | ||
| ⋯ | |||
| 528 | 528 | const WORK_REDIRECTS = new Set(["issues", "labels", "milestones", "ref"]); | |
| 529 | 529 | ||
| 530 | 530 | /** The workspace pages under `-/` that are Code's: closed to a member without Code access. */ | |
| 531 | − | const CODE_PAGES = new Set(["code", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]); | |
| 531 | + | const CODE_PAGES = new Set(["overview", "code", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]); | |
| 532 | 532 | ||
| 533 | 533 | /** The workspace's settings pages (under `-/settings/`) that are Code's, closed as Code's pages are. */ | |
| 534 | 534 | const CODE_SETTINGS = new Set(["repositories", "rules", "runners", "actions", "security"]); | |
| 143 | 143 | // it or skipping a step (components/onboarding.tsx). | |
| 144 | 144 | route("-/onboarding", "routes/workspace/onboarding.ts"), | |
| 145 | 145 | // Code's Overview: Mission control's code panels, for this workspace. | |
| 146 | + | route("-/overview", "routes/workspace/code-overview.tsx"), | |
| 146 | 147 | // The workspace itself, at a glance: members, plan and spend. | |
| 147 | 148 | // The workspace's settings, every page under `-/settings/`, open on its Profile. | |
| 148 | 149 | route("-/settings", "routes/workspace/settings-index.ts"), |
| 1 | + | import { Suspense, lazy } from "react"; | |
| 2 | + | import { type ShouldRevalidateFunctionArgs, data } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/code-overview"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 6 | + | import { loadMissionControl } from "../../lib/mission-control.server"; | |
| 7 | + | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 8 | + | import { Card } from "../../components/ui/card"; | |
| 9 | + | import { Skeleton, SkeletonCard, SkeletonLine, SkeletonStat } from "../../components/ui/skeleton"; | |
| 10 | + | ||
| 11 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 12 | + | return page(args, { title: `Overview · Code · ${params.owner} · g1t` }); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | export function headers({ loaderHeaders }: Route.HeadersArgs) { | |
| 16 | + | const out = new Headers(); | |
| 17 | + | const timing = loaderHeaders.get("Server-Timing"); | |
| 18 | + | if (timing) out.set("Server-Timing", timing); | |
| 19 | + | return out; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * Code's Overview, for this workspace: what needs you and what landed, | |
| 24 | + | * the week's numbers, activity, and a way to open a work item. | |
| 25 | + | */ | |
| 26 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 27 | + | const viewer = requireUser(context, request); | |
| 28 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 29 | + | const { value, timing } = await loadMissionControl(viewer, request, params.owner); | |
| 30 | + | return data(value, { headers: { "Server-Timing": timing } }); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** | |
| 34 | + | * Switching tabs, the sort or the activity list changes only the address: | |
| 35 | + | * everything is already loaded. Marking an inbox item read changes nothing | |
| 36 | + | * else here. | |
| 37 | + | */ | |
| 38 | + | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { | |
| 39 | + | if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false; | |
| 40 | + | if (formAction === "/notifications") return false; | |
| 41 | + | return defaultShouldRevalidate; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** Loaded once the browser is idle (components/shell.tsx), so the skeleton below is rarely seen. */ | |
| 45 | + | const MissionControl = lazy(() => import("../../components/mission-control")); | |
| 46 | + | ||
| 47 | + | export default function CodeOverview({ loaderData }: Route.ComponentProps) { | |
| 48 | + | return ( | |
| 49 | + | <Suspense fallback={<MissionControlSkeleton />}> | |
| 50 | + | <MissionControl loaderData={loaderData} /> | |
| 51 | + | </Suspense> | |
| 52 | + | ); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** | |
| 56 | + | * The Overview's outline while its code arrives, piece for piece as | |
| 57 | + | * components/mission-control.tsx lays it out: the title, the actions | |
| 58 | + | * under it, the stats, then the work list beside the week. Each | |
| 59 | + | * piece has the padding and line heights of the real one, so nothing moves | |
| 60 | + | * when it arrives. | |
| 61 | + | */ | |
| 62 | + | export function MissionControlSkeleton() { | |
| 63 | + | return ( | |
| 64 | + | <main aria-busy="true" className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10"> | |
| 65 | + | <span role="status" className="sr-only"> | |
| 66 | + | Loading… | |
| 67 | + | </span> | |
| 68 | + | <header> | |
| 69 | + | <SkeletonLine className="w-72 max-w-full text-[1.75rem] leading-tight sm:text-[2rem]" barClassName="h-8" /> | |
| 70 | + | {/* The date and the week's summary: one line wide, three on a phone. */} | |
| 71 | + | <div className="mt-1.5 text-sm"> | |
| 72 | + | <SkeletonLine className="w-xl max-w-full" barClassName="h-3.5" /> | |
| 73 | + | <SkeletonLine className="w-full sm:hidden" barClassName="h-3.5" /> | |
| 74 | + | <SkeletonLine className="w-2/3 sm:hidden" barClassName="h-3.5" /> | |
| 75 | + | </div> | |
| 76 | + | </header> | |
| 77 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 78 | + | <Skeleton className="h-9.5 w-28 rounded-md" /> | |
| 79 | + | <Skeleton className="h-9.5 w-44 rounded-md" /> | |
| 80 | + | </div> | |
| 81 | + | <Card tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4"> | |
| 82 | + | {Array.from({ length: 4 }, (_, index) => ( | |
| 83 | + | <SkeletonStat key={index} /> | |
| 84 | + | ))} | |
| 85 | + | </Card> | |
| 86 | + | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]"> | |
| 87 | + | <Card className="self-start overflow-hidden"> | |
| 88 | + | <div className="flex items-center gap-4 border-b border-line px-4 py-3 text-sm sm:px-5"> | |
| 89 | + | <SkeletonLine className="w-24" /> | |
| 90 | + | <SkeletonLine className="w-32" /> | |
| 91 | + | <SkeletonLine className="hidden w-28 sm:flex" /> | |
| 92 | + | </div> | |
| 93 | + | <div className="divide-y divide-line"> | |
| 94 | + | {Array.from({ length: 5 }, (_, index) => ( | |
| 95 | + | <div key={index}> | |
| 96 | + | <div className="flex items-center gap-3 px-4 py-3 sm:px-5"> | |
| 97 | + | <Skeleton className="size-8 shrink-0 rounded-md" /> | |
| 98 | + | <span className="min-w-0 grow"> | |
| 99 | + | <SkeletonLine className="text-sm" width={`${70 - ((index * 17) % 30)}%`} /> | |
| 100 | + | <SkeletonLine className="mt-0.5 text-xs" width={`${50 - ((index * 11) % 20)}%`} /> | |
| 101 | + | </span> | |
| 102 | + | <Skeleton className="hidden h-4 w-24 shrink-0 sm:block" /> | |
| 103 | + | </div> | |
| 104 | + | {/* The first row stands open, as the real list's does. */} | |
| 105 | + | {index === 0 && ( | |
| 106 | + | <div className="px-4 pb-4 sm:px-5 sm:pb-5"> | |
| 107 | + | <Skeleton className="h-68 rounded-xl" /> | |
| 108 | + | <Skeleton className="mt-3 h-8 w-44 rounded-md" /> | |
| 109 | + | </div> | |
| 110 | + | )} | |
| 111 | + | </div> | |
| 112 | + | ))} | |
| 113 | + | </div> | |
| 114 | + | </Card> | |
| 115 | + | <div className="space-y-6"> | |
| 116 | + | <SkeletonCard lines={0} className="h-80 p-5" /> | |
| 117 | + | <SkeletonCard lines={6} className="p-5" /> | |
| 118 | + | </div> | |
| 119 | + | </div> | |
| 120 | + | </main> | |
| 121 | + | ); | |
| 122 | + | } |
| 246 | 246 | // are columns of their own (components/chat/channel.tsx). Its sessions | |
| 247 | 247 | // are pages like any other, with the page's margins. | |
| 248 | 248 | if (parts[0] === "chat" && parts[1] !== "sessions" && !next) return <Outlet />; | |
| 249 | + | // Code's Overview lays itself out, as Mission control did. | |
| 250 | + | if (parts[0] === "overview" && parts.length === 1 && !next) return <Outlet />; | |
| 249 | 251 | // An open artifact, or a repository's doc, fills the panel: its header runs edge to edge right under | |
| 250 | 252 | // the top bar (components/folios/shell.tsx), and its page sets its own width. | |
| 251 | 253 | if (isArtifactPage(parts) && !next) { |
| 1 | − | import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, Lock, Pin, Plus, Rocket } from "lucide-react"; | |
| 2 | − | import { Link } from "react-router"; | |
| 1 | + | import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Pin, Plus, Rocket } from "lucide-react"; | |
| 2 | + | import { Link, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { PackageSummary, Project, ProjectDeploys } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/overview"; | |
| 7 | 7 | import { DeployLink, host, StatusDot } from "../../components/deploy"; | |
| 8 | 8 | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { Avatar } from "../../components/ui/avatar"; | |
| 9 | 10 | import { Badge } from "../../components/ui/badge"; | |
| 10 | 11 | import { Card } from "../../components/ui/card"; | |
| 12 | + | import { UsageCard } from "../../components/usage-card"; | |
| 11 | 13 | import { PullIcon } from "../../components/work-icons"; | |
| 12 | 14 | import { openedBy } from "../../lib/opened-by"; | |
| 13 | 15 | import { kindLabel, libraryPackages, packageLine, packagePath, primaryLink } from "../../lib/project-kind"; | |
| 14 | 16 | import { PinButton } from "../../components/pin-button"; | |
| 15 | 17 | import { sortProjects } from "../../lib/project-list"; | |
| 18 | + | import { usageFor } from "../../lib/workspace-usage.server"; | |
| 16 | 19 | import { deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server"; | |
| 17 | 20 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 18 | 21 | import { workspaceProjects } from "../../lib/workspace-projects.server"; | |
| 19 | 22 | import { repositoriesPath } from "../../lib/workspace-nav"; | |
| 20 | 23 | import { Unavailable } from "../../components/boundary"; | |
| 21 | − | import { MembersCard } from "../../components/members-card"; | |
| 22 | 24 | import { isFailed, part } from "../../lib/partial"; | |
| 23 | 25 | ||
| 24 | 26 | /** Projects whose open work items and pull requests are counted: the most active. */ | |
| ⋯ | |||
| 28 | 30 | /** Projects whose pull requests are listed under "In progress". */ | |
| 29 | 31 | const MAX_LISTED = 8; | |
| 30 | 32 | const MAX_PULLS = 8; | |
| 31 | − | ||
| 32 | − | /** | |
| 33 | − | * The workspace's own page, and Code's front page, as an organization's is | |
| 34 | − | * on GitHub: under its profile (the layout's header), how many | |
| 35 | − | * repositories it has, what is live, open and in progress; then the | |
| 36 | − | * viewer's pinned repositories, the recently active ones and every one; | |
| 37 | − | * the pull requests in progress; and who is in it. The same page for a | |
| 38 | − | * member and a visitor, who sees what is public and nothing to pin. | |
| 39 | − | * Where you're needed and what happened while you were away are Home's. | |
| 40 | − | */ | |
| 33 | + | const MAX_FACES = 8; | |
| 41 | 34 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 42 | 35 | const viewer = getViewer(context); | |
| 43 | 36 | const slug = params.owner.toLowerCase(); | |
| 44 | 37 | const role = roleIn(viewer, slug); | |
| 45 | − | const [listed, members, deploys, published, shortcuts, items] = await Promise.all([ | |
| 38 | + | // A member's way in is Home; this page | |
| 39 | + | // is the workspace as a visitor sees it. | |
| 40 | + | if (role) throw redirect(`/${slug}/-/home`); | |
| 41 | + | const [listed, members, deploys, usage, published, shortcuts, items] = await Promise.all([ | |
| 46 | 42 | // The listing the sidebar and the Projects tab share. | |
| 47 | 43 | workspaceProjects(slug, viewer), | |
| 48 | 44 | role ? identity.listMembers(slug, viewer) : null, | |
| 49 | 45 | role ? deployments.overview(slug, viewer) : null, | |
| 46 | + | role ? usageFor(slug, viewer) : null, | |
| 50 | 47 | // One listing for every card: a library's card shows its package where an app's shows production. | |
| 51 | 48 | packages.list(slug, viewer).catch(() => null), | |
| 52 | 49 | // The viewer's pins lead the page, as they lead the sidebar. | |
| ⋯ | |||
| 121 | 118 | openItems: !isFailed(items) && items.ok ? items.value.length : 0, | |
| 122 | 119 | pullsFailed, | |
| 123 | 120 | members: members?.ok ? members.value : null, | |
| 121 | + | usage, | |
| 124 | 122 | }; | |
| 125 | 123 | } | |
| 126 | 124 | ||
| ⋯ | |||
| 247 | 245 | } | |
| 248 | 246 | ||
| 249 | 247 | export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) { | |
| 250 | − | const { slug, role, total, pinned, active, open, deploys, pulls, members } = loaderData; | |
| 248 | + | const { slug, role, total, pinned, active, open, deploys, pulls, members, usage } = loaderData; | |
| 251 | 249 | const card = (project: Project, isPinned: boolean) => ( | |
| 252 | 250 | <ProjectCard | |
| 253 | 251 | key={project.id} | |
| ⋯ | |||
| 372 | 370 | </div> | |
| 373 | 371 | ||
| 374 | 372 | <aside className="space-y-6"> | |
| 373 | + | {role && usage && <UsageCard slug={slug} glance={usage} owner={role === "owner"} />} | |
| 374 | + | ||
| 375 | 375 | {members ? ( | |
| 376 | − | <MembersCard slug={slug} owner={role === "owner"} members={members} /> | |
| 376 | + | <section> | |
| 377 | + | <div className="flex items-center justify-between"> | |
| 378 | + | <h2 className="text-sm font-medium">Members</h2> | |
| 379 | + | <Link to={role === "owner" ? `/${slug}/-/settings/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg"> | |
| 380 | + | {role === "owner" ? "Manage" : "See all"} | |
| 381 | + | </Link> | |
| 382 | + | </div> | |
| 383 | + | <ul className="mt-3 space-y-1.5"> | |
| 384 | + | {members.slice(0, MAX_FACES).map((member) => ( | |
| 385 | + | <li key={member.username} className="flex items-center gap-2 text-sm"> | |
| 386 | + | <Avatar name={member.username} /> | |
| 387 | + | <span className="grow truncate font-mono">{member.username}</span> | |
| 388 | + | {member.role === "owner" && <Badge size="md">owner</Badge>} | |
| 389 | + | </li> | |
| 390 | + | ))} | |
| 391 | + | </ul> | |
| 392 | + | {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>} | |
| 393 | + | </section> | |
| 377 | 394 | ) : ( | |
| 378 | 395 | <p className="text-sm text-muted"> | |
| 379 | 396 | Members of this workspace build its repositories. Anyone can file a work item or open a pull request on a public one. | |
| 380 | 397 | </p> | |
| 381 | 398 | )} | |
| 399 | + | ||
| 400 | + | {role && ( | |
| 401 | + | <Card asChild className="p-5"> | |
| 402 | + | <section> | |
| 403 | + | <h2 className="flex items-center gap-2 font-medium"> | |
| 404 | + | <Rocket size={15} className="text-faint" /> | |
| 405 | + | Deploy on g1t.page | |
| 406 | + | </h2> | |
| 407 | + | <p className="mt-1.5 text-sm text-muted"> | |
| 408 | + | Off until you turn them on for a repository, under its Settings. Then production builds from its default branch, with a live preview for every | |
| 409 | + | pull request. Apps cost nothing while no one visits. | |
| 410 | + | </p> | |
| 411 | + | <Link prefetch="intent" to={`/${slug}/-/settings/billing`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"> | |
| 412 | + | Billing and plans <ArrowRight size={13} /> | |
| 413 | + | </Link> | |
| 414 | + | </section> | |
| 415 | + | </Card> | |
| 416 | + | )} | |
| 417 | + | ||
| 418 | + | {role && ( | |
| 419 | + | <Card asChild className="p-5"> | |
| 420 | + | <section> | |
| 421 | + | <h2 className="flex items-center gap-2 font-medium"> | |
| 422 | + | <KeyRound size={15} className="text-faint" /> | |
| 423 | + | Automate without a service account | |
| 424 | + | </h2> | |
| 425 | + | <p className="mt-1.5 text-sm text-muted"> | |
| 426 | + | A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace, not as a person. | |
| 427 | + | </p> | |
| 428 | + | <Link prefetch="intent" to={`/${slug}/-/settings/tokens`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"> | |
| 429 | + | Access tokens <ArrowRight size={13} /> | |
| 430 | + | </Link> | |
| 431 | + | </section> | |
| 432 | + | </Card> | |
| 433 | + | )} | |
| 382 | 434 | </aside> | |
| 383 | 435 | </div> | |
| 384 | 436 | ); | |
| 1 | + | -- How many bytes of log a job has kept so far, added to as each batch is | |
| 2 | + | -- kept (src/plan.rs `LOG_COUNTED`), so a report checks the 4 MB limit | |
| 3 | + | -- against this rather than adding up the whole log again each time. A job | |
| 4 | + | -- run again starts from 0 (its log moves aside). | |
| 5 | + | -- | |
| 6 | + | -- Only jobs still to finish are counted here: a finished job is sent no | |
| 7 | + | -- more log, and counting every finished job's would read every log kept. | |
| 8 | + | ALTER TABLE jobs ADD COLUMN log_bytes INTEGER NOT NULL DEFAULT 0; | |
| 9 | + | UPDATE jobs SET log_bytes = (SELECT COALESCE(SUM(LENGTH(CAST(text AS BLOB))), 0) FROM logs WHERE logs.job_id = jobs.id) | |
| 10 | + | WHERE finished_at IS NULL; |
| 30 | 30 | use worker::Result; | |
| 31 | 31 | use worker::wasm_bindgen::JsValue; | |
| 32 | 32 | ||
| 33 | − | use crate::plan::LOG_INSERT; | |
| 33 | + | use crate::plan::{LOG_COUNTED, LOG_INSERT}; | |
| 34 | 34 | use crate::{Actions, Count, check, fail}; | |
| 35 | 35 | ||
| 36 | 36 | /// The most hosts one brokered secret names. | |
| ⋯ | |||
| 202 | 202 | } | |
| 203 | 203 | ||
| 204 | 204 | /// Adds g1t's own lines to a job's log, under `step` (0 for its setup). | |
| 205 | + | /// They count toward its log's size, though they are kept past it. | |
| 205 | 206 | pub(crate) async fn note_log(&self, job_id: &str, step: u32, text: &str) -> Result<()> { | |
| 207 | + | let text = stamped(text); | |
| 206 | 208 | self.db | |
| 207 | − | .prepare(LOG_INSERT) | |
| 208 | − | .bind(&[job_id.into(), step.into(), stamped(text).into(), JsValue::NULL])? | |
| 209 | − | .run() | |
| 209 | + | .batch(vec![ | |
| 210 | + | self.db.prepare(LOG_COUNTED).bind(&[ | |
| 211 | + | job_id.into(), | |
| 212 | + | (text.len() as u32).into(), | |
| 213 | + | JsValue::NULL, | |
| 214 | + | JsValue::NULL, | |
| 215 | + | ])?, | |
| 216 | + | self.db.prepare(LOG_INSERT).bind(&[ | |
| 217 | + | job_id.into(), | |
| 218 | + | step.into(), | |
| 219 | + | text.into(), | |
| 220 | + | JsValue::NULL, | |
| 221 | + | ])?, | |
| 222 | + | ]) | |
| 210 | 223 | .await?; | |
| 211 | 224 | Ok(()) | |
| 212 | 225 | } | |
| 51 | 51 | pub(crate) const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk) | |
| 52 | 52 | SELECT ?1, COALESCE(MAX(seq), 0) + 1, ?2, ?3, ?4 FROM logs WHERE job_id = ?1 | |
| 53 | 53 | ON CONFLICT (job_id, chunk) DO NOTHING"; | |
| 54 | + | /// Adds a batch of `?2` bytes to the job `?1`'s count of its log (migration | |
| 55 | + | /// 0027), and with `?3` marks it seen. Sent in the same batch as, and just | |
| 56 | + | /// ahead of, [`LOG_INSERT`] with the same `?4`: a batch sent again, which | |
| 57 | + | /// the insert does not keep twice, is not counted twice either. | |
| 58 | + | pub(crate) const LOG_COUNTED: &str = "UPDATE jobs SET seen_at = COALESCE(?3, seen_at), | |
| 59 | + | log_bytes = log_bytes + CASE WHEN ?4 IS NOT NULL AND EXISTS (SELECT 1 FROM logs WHERE job_id = ?1 AND chunk = ?4) THEN 0 ELSE ?2 END | |
| 60 | + | WHERE id = ?1"; | |
| 61 | + | ||
| 62 | + | /// Whether a batch of log is kept by a job that has `used` bytes of it | |
| 63 | + | /// already: the one that reaches [`MAX_LOG_BYTES`] is kept whole, with a | |
| 64 | + | /// line to say the rest is not, and none after it. | |
| 65 | + | fn log_room(used: usize, text: &mut String) -> bool { | |
| 66 | + | if used >= MAX_LOG_BYTES { | |
| 67 | + | return false; | |
| 68 | + | } | |
| 69 | + | if used + text.len() >= MAX_LOG_BYTES { | |
| 70 | + | text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n"); | |
| 71 | + | } | |
| 72 | + | true | |
| 73 | + | } | |
| 54 | 74 | /// The most one step's job summary keeps, in bytes, and how many steps of a | |
| 55 | 75 | /// job may have one, as on GitHub. | |
| 56 | 76 | pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024; | |
| ⋯ | |||
| 257 | 277 | /// (migration 0025). | |
| 258 | 278 | #[serde(default)] | |
| 259 | 279 | pub waiting_since: Option<String>, | |
| 280 | + | /// How many bytes of log it has kept so far (migration 0027). | |
| 281 | + | #[serde(default)] | |
| 282 | + | pub log_bytes: u32, | |
| 260 | 283 | } | |
| 261 | 284 | ||
| 262 | 285 | /// How long a cancelled job has to run its `if: always()` and `cancelled()` | |
| ⋯ | |||
| 2358 | 2381 | "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL, | |
| 2359 | 2382 | matrix = NULL, call = NULL, token_hash = NULL, first_token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL, | |
| 2360 | 2383 | labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL, | |
| 2361 | − | concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?", | |
| 2384 | + | concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL, log_bytes = 0 WHERE run_id = ? AND key = ?", | |
| 2362 | 2385 | ) | |
| 2363 | 2386 | .bind(&[run.id.as_str().into(), key.as_str().into()])?, | |
| 2364 | 2387 | ); | |
| ⋯ | |||
| 2790 | 2813 | } | |
| 2791 | 2814 | text.truncate(cut); | |
| 2792 | 2815 | } | |
| 2793 | − | #[derive(Deserialize)] | |
| 2794 | − | struct Size { | |
| 2795 | − | n: Option<f64>, | |
| 2796 | − | } | |
| 2797 | − | let size = self | |
| 2798 | − | .db | |
| 2799 | − | .prepare("SELECT SUM(LENGTH(text)) AS n FROM logs WHERE job_id = ?") | |
| 2800 | − | .bind(&[job.id.as_str().into()])? | |
| 2801 | − | .first::<Size>(None) | |
| 2802 | − | .await?; | |
| 2803 | − | let used = size.and_then(|s| s.n).unwrap_or(0.0) as usize; | |
| 2804 | − | if used < MAX_LOG_BYTES { | |
| 2805 | − | if used + text.len() >= MAX_LOG_BYTES { | |
| 2806 | − | text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n"); | |
| 2807 | − | } | |
| 2816 | + | // Checked against the count kept on the job, read with it | |
| 2817 | + | // just now, rather than the log added up again: the count | |
| 2818 | + | // and the batch are written together, so the count is | |
| 2819 | + | // always the log's own size. Two reports at once may both | |
| 2820 | + | // find room, as before, and take the log one batch past. | |
| 2821 | + | if log_room(job.log_bytes as usize, &mut text) { | |
| 2822 | + | let chunk = report["chunk"] | |
| 2823 | + | .as_u64() | |
| 2824 | + | .map_or(worker::wasm_bindgen::JsValue::NULL, |n| (n as f64).into()); | |
| 2808 | 2825 | // The next `seq` is taken in the insert itself, so two | |
| 2809 | 2826 | // batches at once (a report sent again while the first | |
| 2810 | 2827 | // was still being written) never reach for the same one | |
| 2811 | 2828 | // and lose a chunk to the primary key. | |
| 2812 | 2829 | self.db | |
| 2813 | − | .prepare(LOG_INSERT) | |
| 2814 | − | .bind(&[ | |
| 2815 | − | job.id.as_str().into(), | |
| 2816 | − | (report["step"].as_u64().unwrap_or(0) as u32).into(), | |
| 2817 | − | text.into(), | |
| 2818 | − | report["chunk"].as_u64().map_or(worker::wasm_bindgen::JsValue::NULL, |n| (n as f64).into()), | |
| 2819 | − | ])? | |
| 2830 | + | .batch(vec![ | |
| 2831 | + | self.db.prepare(LOG_COUNTED).bind(&[ | |
| 2832 | + | job.id.as_str().into(), | |
| 2833 | + | (text.len() as u32).into(), | |
| 2834 | + | at.as_str().into(), | |
| 2835 | + | chunk.clone(), | |
| 2836 | + | ])?, | |
| 2837 | + | self.db.prepare(LOG_INSERT).bind(&[ | |
| 2838 | + | job.id.as_str().into(), | |
| 2839 | + | (report["step"].as_u64().unwrap_or(0) as u32).into(), | |
| 2840 | + | text.into(), | |
| 2841 | + | chunk, | |
| 2842 | + | ])?, | |
| 2843 | + | ]) | |
| 2844 | + | .await?; | |
| 2845 | + | } else { | |
| 2846 | + | self.db | |
| 2847 | + | .prepare("UPDATE jobs SET seen_at = ? WHERE id = ?") | |
| 2848 | + | .bind(&[at.into(), job.id.as_str().into()])? | |
| 2820 | 2849 | .run() | |
| 2821 | 2850 | .await?; | |
| 2822 | 2851 | } | |
| 2823 | − | self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?; | |
| 2824 | 2852 | } | |
| 2825 | 2853 | "summary" => { | |
| 2826 | 2854 | // `$GITHUB_STEP_SUMMARY`, masked by the runner: added to the | |
| ⋯ | |||
| 3084 | 3112 | } | |
| 3085 | 3113 | ||
| 3086 | 3114 | #[cfg(test)] | |
| 3115 | + | mod log_bytes { | |
| 3116 | + | use rusqlite::{Connection, params}; | |
| 3117 | + | ||
| 3118 | + | use super::{LOG_COUNTED, LOG_INSERT, MAX_LOG_BYTES, log_room}; | |
| 3119 | + | ||
| 3120 | + | fn migrate(db: &Connection, names: &[&str]) { | |
| 3121 | + | for name in names { | |
| 3122 | + | let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR")); | |
| 3123 | + | db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap(); | |
| 3124 | + | } | |
| 3125 | + | } | |
| 3126 | + | ||
| 3127 | + | fn add_job(db: &Connection, id: &str, finished: Option<&str>) { | |
| 3128 | + | db.execute( | |
| 3129 | + | "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, status, finished_at) VALUES (?1, 'run_1', 'repo_1', 'acme', ?1, ?1, 'in_progress', ?2)", | |
| 3130 | + | params![id, finished], | |
| 3131 | + | ) | |
| 3132 | + | .unwrap(); | |
| 3133 | + | } | |
| 3134 | + | ||
| 3135 | + | fn bytes(db: &Connection, job: &str) -> i64 { | |
| 3136 | + | db.query_row("SELECT log_bytes FROM jobs WHERE id = ?", [job], |row| { | |
| 3137 | + | row.get(0) | |
| 3138 | + | }) | |
| 3139 | + | .unwrap() | |
| 3140 | + | } | |
| 3141 | + | ||
| 3142 | + | /// A report's batch as `job_report` writes it: counted, then kept. | |
| 3143 | + | fn report(db: &Connection, job: &str, text: &str, chunk: Option<i64>, at: Option<&str>) { | |
| 3144 | + | let tx = db.unchecked_transaction().unwrap(); | |
| 3145 | + | tx.execute(LOG_COUNTED, params![job, text.len() as i64, at, chunk]) | |
| 3146 | + | .unwrap(); | |
| 3147 | + | tx.execute(LOG_INSERT, params![job, 0, text, chunk]) | |
| 3148 | + | .unwrap(); | |
| 3149 | + | tx.commit().unwrap(); | |
| 3150 | + | } | |
| 3151 | + | ||
| 3152 | + | #[test] | |
| 3153 | + | fn the_migration_counts_the_jobs_still_running_in_bytes() { | |
| 3154 | + | let db = Connection::open_in_memory().unwrap(); | |
| 3155 | + | migrate(&db, &["0001_init.sql", "0016_log_chunks.sql"]); | |
| 3156 | + | add_job(&db, "running", None); | |
| 3157 | + | add_job(&db, "quiet", None); | |
| 3158 | + | add_job(&db, "done", Some("2026-01-01T00:00:00Z")); | |
| 3159 | + | for (job, text) in [ | |
| 3160 | + | ("running", "héllo\n"), | |
| 3161 | + | ("running", "two\n"), | |
| 3162 | + | ("done", "old\n"), | |
| 3163 | + | ] { | |
| 3164 | + | db.execute(LOG_INSERT, params![job, 0, text, None::<i64>]) | |
| 3165 | + | .unwrap(); | |
| 3166 | + | } | |
| 3167 | + | migrate(&db, &["0027_log_bytes.sql"]); | |
| 3168 | + | // Bytes, as the limit is: é is two. | |
| 3169 | + | assert_eq!( | |
| 3170 | + | bytes(&db, "running"), | |
| 3171 | + | ("héllo\n".len() + "two\n".len()) as i64 | |
| 3172 | + | ); | |
| 3173 | + | assert_eq!(bytes(&db, "quiet"), 0); | |
| 3174 | + | // A finished job is sent no more log, so is not counted. | |
| 3175 | + | assert_eq!(bytes(&db, "done"), 0); | |
| 3176 | + | } | |
| 3177 | + | ||
| 3178 | + | #[test] | |
| 3179 | + | fn each_batch_kept_adds_to_the_count_once() { | |
| 3180 | + | let db = Connection::open_in_memory().unwrap(); | |
| 3181 | + | migrate( | |
| 3182 | + | &db, | |
| 3183 | + | &["0001_init.sql", "0016_log_chunks.sql", "0027_log_bytes.sql"], | |
| 3184 | + | ); | |
| 3185 | + | add_job(&db, "job_1", None); | |
| 3186 | + | report(&db, "job_1", "one\n", Some(1), Some("2026-01-01T00:00:01Z")); | |
| 3187 | + | assert_eq!(bytes(&db, "job_1"), 4); | |
| 3188 | + | // Sent again after an answer the runner did not hear: neither kept | |
| 3189 | + | // nor counted twice, though the job was still heard from. | |
| 3190 | + | report(&db, "job_1", "one\n", Some(1), Some("2026-01-01T00:00:02Z")); | |
| 3191 | + | assert_eq!(bytes(&db, "job_1"), 4); | |
| 3192 | + | report( | |
| 3193 | + | &db, | |
| 3194 | + | "job_1", | |
| 3195 | + | "three\n", | |
| 3196 | + | Some(2), | |
| 3197 | + | Some("2026-01-01T00:00:03Z"), | |
| 3198 | + | ); | |
| 3199 | + | // g1t's own notes number nothing, are always kept, and leave when | |
| 3200 | + | // it was last heard from alone. | |
| 3201 | + | report(&db, "job_1", "note\n", None, None); | |
| 3202 | + | report(&db, "job_1", "note\n", None, None); | |
| 3203 | + | let (counted, seen): (i64, String) = db | |
| 3204 | + | .query_row( | |
| 3205 | + | "SELECT log_bytes, seen_at FROM jobs WHERE id = 'job_1'", | |
| 3206 | + | [], | |
| 3207 | + | |row| Ok((row.get(0)?, row.get(1)?)), | |
| 3208 | + | ) | |
| 3209 | + | .unwrap(); | |
| 3210 | + | let kept: i64 = db | |
| 3211 | + | .query_row( | |
| 3212 | + | "SELECT SUM(LENGTH(CAST(text AS BLOB))) FROM logs WHERE job_id = 'job_1'", | |
| 3213 | + | [], | |
| 3214 | + | |row| row.get(0), | |
| 3215 | + | ) | |
| 3216 | + | .unwrap(); | |
| 3217 | + | assert_eq!(counted, kept); | |
| 3218 | + | assert_eq!(counted, 4 + 6 + 5 + 5); | |
| 3219 | + | assert_eq!(seen, "2026-01-01T00:00:03Z"); | |
| 3220 | + | } | |
| 3221 | + | ||
| 3222 | + | #[test] | |
| 3223 | + | fn the_batch_that_reaches_the_limit_is_the_last_kept() { | |
| 3224 | + | let mut text = "a".repeat(10); | |
| 3225 | + | assert!(log_room(0, &mut text)); | |
| 3226 | + | assert_eq!(text, "a".repeat(10)); | |
| 3227 | + | let mut text = "a".repeat(10); | |
| 3228 | + | assert!(log_room(MAX_LOG_BYTES - 10, &mut text)); | |
| 3229 | + | assert!( | |
| 3230 | + | text.starts_with(&"a".repeat(10)) && text.contains("limit of 4 MB"), | |
| 3231 | + | "{text}" | |
| 3232 | + | ); | |
| 3233 | + | let mut text = "a".repeat(10); | |
| 3234 | + | assert!(!log_room(MAX_LOG_BYTES, &mut text)); | |
| 3235 | + | assert!(!log_room(MAX_LOG_BYTES + 100, &mut text)); | |
| 3236 | + | } | |
| 3237 | + | } | |
| 3238 | + | ||
| 3239 | + | #[cfg(test)] | |
| 3087 | 3240 | mod token_rotation { | |
| 3088 | 3241 | use g1t_secrets::sha256_hex; | |
| 3089 | 3242 | use rusqlite::{Connection, OptionalExtension, params}; | |
| 9 | 9 | use g1t_contracts::events::{Event, caused_by_job}; | |
| 10 | 10 | use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs}; | |
| 11 | 11 | use g1t_contracts::repos::{Commit, CompareArgs, Comparison, FileStatus, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath}; | |
| 12 | − | use g1t_contracts::work::{Label, PullDetail, ViewArgs, WorkItemDetail, WorkItemViewArgs, WorkspaceViewArgs}; | |
| 12 | + | use g1t_contracts::work::{Label, Mergeable, PullDetail, ViewArgs, WorkItemDetail, WorkItemViewArgs, WorkspaceViewArgs}; | |
| 13 | 13 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; | |
| 14 | 14 | use g1t_contracts::time::rfc3339; | |
| 15 | 15 | use g1t_kit::now_ms; | |
| ⋯ | |||
| 116 | 116 | Nothing, | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | − | fn pull_build(event_name: &str, action: Option<&str>, made: &PullMergeRef) -> PullBuild { | |
| 119 | + | /// What a pull request's run builds, given the test merge `made` and | |
| 120 | + | /// whether the pull request is known to conflict (`mergeable`, worked out | |
| 121 | + | /// by work's mergeability check, which merges with git itself). | |
| 122 | + | /// | |
| 123 | + | /// A conflict only the test merge found is not shown on the pull request, | |
| 124 | + | /// which then waits on its required checks with nothing saying why they | |
| 125 | + | /// never report: it builds the head instead. Until 2026-10-22 it ran | |
| 126 | + | /// nothing, and a branch made from a pull request that merged with a merge | |
| 127 | + | /// commit, whose test merge went wrong (repos merge_base.rs), sat at | |
| 128 | + | /// "expected" (flagon-io/g1t#365). | |
| 129 | + | fn pull_build( | |
| 130 | + | event_name: &str, | |
| 131 | + | action: Option<&str>, | |
| 132 | + | made: &PullMergeRef, | |
| 133 | + | mergeable: Mergeable, | |
| 134 | + | ) -> PullBuild { | |
| 120 | 135 | match made { | |
| 121 | 136 | PullMergeRef::Merged { merge, .. } => PullBuild::Merge(merge.clone()), | |
| 122 | − | PullMergeRef::Conflict { .. } if event_name == "pull_request" && action != Some("closed") => PullBuild::Nothing, | |
| 137 | + | PullMergeRef::Conflict { .. } | |
| 138 | + | if event_name == "pull_request" | |
| 139 | + | && action != Some("closed") | |
| 140 | + | && mergeable == Mergeable::Conflicting => | |
| 141 | + | { | |
| 142 | + | PullBuild::Nothing | |
| 143 | + | } | |
| 123 | 144 | PullMergeRef::Conflict { .. } | PullMergeRef::Unavailable { .. } => PullBuild::Head, | |
| 124 | 145 | } | |
| 125 | 146 | } | |
| ⋯ | |||
| 524 | 545 | Some(_) => None, | |
| 525 | 546 | None => { | |
| 526 | 547 | let made = self.test_merge(repo, pull, &sha, &base_ref).await; | |
| 527 | − | match pull_build(event_name, action, &made) { | |
| 548 | + | match pull_build(event_name, action, &made, detail.mergeable) { | |
| 528 | 549 | PullBuild::Merge(merge) => Some(merge), | |
| 529 | 550 | PullBuild::Nothing => { | |
| 530 | 551 | worker::console_log!("actions: pull request #{} conflicts with {base_ref}: {made:?}; no pull_request workflows run", pull.number); | |
| 531 | 552 | return Ok(None); | |
| 532 | 553 | } | |
| 533 | 554 | PullBuild::Head => { | |
| 534 | − | worker::console_log!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number); | |
| 555 | + | worker::console_warn!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number); | |
| 535 | 556 | None | |
| 536 | 557 | } | |
| 537 | 558 | } | |
| ⋯ | |||
| 1594 | 1615 | let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() }; | |
| 1595 | 1616 | let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] }; | |
| 1596 | 1617 | let unavailable = PullMergeRef::Unavailable { reason: "too large".into() }; | |
| 1597 | − | assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into())); | |
| 1598 | − | assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into())); | |
| 1618 | + | let conflicting = Mergeable::Conflicting; | |
| 1619 | + | assert_eq!(pull_build("pull_request", Some("synchronize"), &merged, Mergeable::Clean), PullBuild::Merge("m".into())); | |
| 1620 | + | assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged, Mergeable::Clean), PullBuild::Merge("m".into())); | |
| 1599 | 1621 | // GitHub runs no pull_request workflow while it conflicts. | |
| 1600 | 1622 | for action in ["opened", "synchronize", "reopened", "edited"] { | |
| 1601 | − | assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing); | |
| 1623 | + | assert_eq!(pull_build("pull_request", Some(action), &conflict, conflicting), PullBuild::Nothing); | |
| 1602 | 1624 | } | |
| 1603 | 1625 | // Closing it, or reviewing it, still runs, on the head. | |
| 1604 | − | assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head); | |
| 1605 | − | assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head); | |
| 1626 | + | assert_eq!(pull_build("pull_request", Some("closed"), &conflict, conflicting), PullBuild::Head); | |
| 1627 | + | assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict, conflicting), PullBuild::Head); | |
| 1606 | 1628 | // What g1t could not merge itself runs on the head. | |
| 1607 | − | assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head); | |
| 1629 | + | assert_eq!(pull_build("pull_request", Some("opened"), &unavailable, Mergeable::Unknown), PullBuild::Head); | |
| 1630 | + | } | |
| 1631 | + | ||
| 1632 | + | #[test] | |
| 1633 | + | fn a_conflict_the_pull_request_does_not_show_still_builds_its_head() { | |
| 1634 | + | // flagon-io/g1t#362: the test merge conflicted where git did not, the | |
| 1635 | + | // pull request showed none, and its required check waited for good. | |
| 1636 | + | let conflict = PullMergeRef::Conflict { | |
| 1637 | + | head: "h".into(), | |
| 1638 | + | base: "b".into(), | |
| 1639 | + | paths: vec!["a".into()], | |
| 1640 | + | }; | |
| 1641 | + | for mergeable in [Mergeable::Clean, Mergeable::Checking, Mergeable::Unknown] { | |
| 1642 | + | assert_eq!( | |
| 1643 | + | pull_build("pull_request", Some("synchronize"), &conflict, mergeable), | |
| 1644 | + | PullBuild::Head | |
| 1645 | + | ); | |
| 1646 | + | } | |
| 1608 | 1647 | } | |
| 1609 | 1648 | ||
| 1610 | 1649 | fn repo() -> Repo { | |
| 33 | 33 | use crate::registry::{can_read, can_write, store_key}; | |
| 34 | 34 | use crate::store::{GitRepo, GitStore, Scope}; | |
| 35 | 35 | use crate::merge_methods::{Applied, Reader, apply}; | |
| 36 | − | use crate::{MAX_ANCESTRY, Repos, UNVERIFIED, descends_from, land, nearest_ancestor_in, not_found}; | |
| 36 | + | use crate::merge_base::{MergeBase, merge_base}; | |
| 37 | + | use crate::{MAX_ANCESTRY, Repos, UNVERIFIED, descends_from, land, not_found}; | |
| 37 | 38 | ||
| 38 | 39 | /// The mode git writes for an entry of each kind. | |
| 39 | 40 | fn mode(kind: EntryKind) -> &'static str { | |
| ⋯ | |||
| 488 | 489 | })); | |
| 489 | 490 | } | |
| 490 | 491 | let shared: HashSet<String> = target_history.iter().map(|commit| commit.hash.clone()).collect(); | |
| 491 | − | let merge_base = nearest_ancestor_in(&source_git, &history, &shared).await?; | |
| 492 | − | let Some((merge_base, merge_base_tree)) = merge_base.and_then(|hash| { | |
| 493 | − | target_history | |
| 494 | − | .iter() | |
| 495 | − | .find(|commit| commit.hash == hash) | |
| 496 | − | .map(|commit| (hash, commit.tree_hash.clone())) | |
| 497 | − | }) else { | |
| 492 | + | let found = merge_base(&source_git, &history, &target_git, &target_history, &shared).await?; | |
| 493 | + | let Some(MergeBase { hash: merge_base, tree: merge_base_tree, .. }) = found else { | |
| 498 | 494 | return Ok(needs_agent( | |
| 499 | 495 | NeedsAgentReason::Unsupported, | |
| 500 | 496 | format!("g1t could not find where this pull request left {base_branch}."), | |
| 30 | 30 | mod lifecycle; | |
| 31 | 31 | mod limits; | |
| 32 | 32 | mod listing; | |
| 33 | + | mod merge_base; | |
| 33 | 34 | mod merge_methods; | |
| 34 | 35 | mod meters; | |
| 35 | 36 | mod moves; | |
| ⋯ | |||
| 69 | 70 | use g1t_contracts::time::rfc3339; | |
| 70 | 71 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, is_valid_repo_name, new_id}; | |
| 71 | 72 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 72 | − | use std::collections::{HashMap, HashSet, VecDeque}; | |
| 73 | + | use std::collections::{HashMap, HashSet}; | |
| 73 | 74 | use std::rc::Rc; | |
| 74 | 75 | ||
| 75 | 76 | use serde::Serialize; | |
| ⋯ | |||
| 171 | 172 | } | |
| 172 | 173 | } | |
| 173 | 174 | Ok(false) | |
| 174 | − | } | |
| 175 | − | ||
| 176 | − | /// The commit closest to the newest in `history` that is also in `shared`: | |
| 177 | − | /// where a fork and the repository it came from last agreed. | |
| 178 | − | async fn nearest_ancestor_in<R: GitRepo>( | |
| 179 | − | repo: &R, | |
| 180 | − | history: &[Commit], | |
| 181 | − | shared: &HashSet<String>, | |
| 182 | − | ) -> Result<Option<String>> { | |
| 183 | − | let known: HashMap<&str, &[String]> = history | |
| 184 | − | .iter() | |
| 185 | − | .map(|commit| (commit.hash.as_str(), commit.parents.as_slice())) | |
| 186 | − | .collect(); | |
| 187 | − | let mut seen = HashSet::new(); | |
| 188 | − | let mut queue: VecDeque<String> = history | |
| 189 | − | .first() | |
| 190 | − | .map(|c| c.hash.clone()) | |
| 191 | − | .into_iter() | |
| 192 | − | .collect(); | |
| 193 | − | while let Some(hash) = queue.pop_front() { | |
| 194 | − | if shared.contains(&hash) { | |
| 195 | − | return Ok(Some(hash)); | |
| 196 | − | } | |
| 197 | − | if !seen.insert(hash.clone()) || seen.len() > MAX_ANCESTRY as usize { | |
| 198 | − | continue; | |
| 199 | − | } | |
| 200 | − | match known.get(hash.as_str()) { | |
| 201 | − | Some(parents) => queue.extend(parents.iter().cloned()), | |
| 202 | − | None => queue.extend(repo.parents(&hash).await?.unwrap_or_default()), | |
| 203 | − | } | |
| 204 | − | } | |
| 205 | − | Ok(None) | |
| 206 | 175 | } | |
| 207 | 176 | ||
| 208 | 177 | thread_local! { | |
| ⋯ | |||
| 1356 | 1325 | return Ok(None); | |
| 1357 | 1326 | }; | |
| 1358 | 1327 | let behind = !descends_from(&source_git, &history, &base.hash).await?; | |
| 1359 | − | let merge_base = nearest_ancestor_in(&source_git, &history, &side.shared).await?; | |
| 1328 | + | let found = merge_base::merge_base( | |
| 1329 | + | &source_git, | |
| 1330 | + | &history, | |
| 1331 | + | &target_git, | |
| 1332 | + | target_history, | |
| 1333 | + | &side.shared, | |
| 1334 | + | ) | |
| 1335 | + | .await?; | |
| 1360 | 1336 | let mut divergence = Divergence { | |
| 1361 | 1337 | head: head.hash.clone(), | |
| 1362 | 1338 | base: base.hash.clone(), | |
| 1363 | − | merge_base: merge_base.clone(), | |
| 1339 | + | merge_base: found.as_ref().map(|found| found.hash.clone()), | |
| 1364 | 1340 | behind, | |
| 1365 | 1341 | ..Divergence::default() | |
| 1366 | 1342 | }; | |
| 1367 | − | let merge_base_tree = match &merge_base { | |
| 1368 | − | Some(hash) => target_history | |
| 1369 | − | .iter() | |
| 1370 | − | .find(|commit| commit.hash == *hash) | |
| 1371 | − | .map(|commit| commit.tree_hash.clone()), | |
| 1372 | − | None => None, | |
| 1373 | − | }; | |
| 1374 | − | let Some(merge_base_tree) = merge_base_tree else { | |
| 1343 | + | let Some(merge_base_tree) = found.map(|found| found.tree) else { | |
| 1375 | 1344 | // No common history to compare from: say nothing is known. | |
| 1376 | 1345 | divergence.truncated = true; | |
| 1377 | 1346 | return Ok(Some(divergence)); | |
| ⋯ | |||
| 1881 | 1850 | let shared_with = async |against: &Repo| -> Result<Option<String>> { | |
| 1882 | 1851 | let against_git = self.read_git(against).await?; | |
| 1883 | 1852 | let branch = base_branch.as_deref().unwrap_or(&against.default_branch); | |
| 1884 | − | let shared: HashSet<String> = against_git | |
| 1885 | − | .log(branch, MAX_ANCESTRY) | |
| 1886 | − | .await? | |
| 1887 | − | .into_iter() | |
| 1888 | − | .map(|commit| commit.hash) | |
| 1853 | + | let against_history = against_git.log(branch, MAX_ANCESTRY).await?; | |
| 1854 | + | let shared: HashSet<String> = against_history | |
| 1855 | + | .iter() | |
| 1856 | + | .map(|commit| commit.hash.clone()) | |
| 1889 | 1857 | .collect(); | |
| 1890 | − | nearest_ancestor_in(&git, &history, &shared).await | |
| 1858 | + | let found = | |
| 1859 | + | merge_base::merge_base(&git, &history, &against_git, &against_history, &shared) | |
| 1860 | + | .await?; | |
| 1861 | + | Ok(found.map(|found| found.hash)) | |
| 1891 | 1862 | }; | |
| 1892 | 1863 | // A fork's branch, compared from the repository it would merge into. | |
| 1893 | 1864 | let target = match a.target_id.as_deref().filter(|id| a.base.is_none() && *id != repo.id) { | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…