Mission control shows where you are needed and what agents landed without you; git answers in about 200ms
- Mission control: what needs you, what agents are working on and what landed today, each with the ask, what the agent already knows and why it needs you, a week of landed-by-agents against needed-a-person, and activity that tells agents from people. - Git: the repository is read once, store credentials are kept across isolates (encrypted), and branch listings are cached per repository version, bumped by every ref writer; Server-Timing shows where a request's time went.
24 files+3654−8970/24 viewed
| 991 | 991 | "g1t-contracts", | |
| 992 | 992 | "g1t-kit", | |
| 993 | 993 | "g1t-scan", | |
| 994 | + | "g1t-secrets", | |
| 994 | 995 | "serde", | |
| 995 | 996 | "serde_json", | |
| 996 | 997 | "similar", |
| 125 | 125 | until the month turns. Counting starts on 2026-10-14. See | |
| 126 | 126 | [git operations](/guides/usage-and-billing/#git-operations). | |
| 127 | 127 | ||
| 128 | + | ## Where a slow request's time went | |
| 129 | + | ||
| 130 | + | Every answer g1t gives git carries a `Server-Timing` header: how many | |
| 131 | + | milliseconds each step of the request took. To see it, run git with its | |
| 132 | + | HTTP trace on: | |
| 133 | + | ||
| 134 | + | ```sh | |
| 135 | + | GIT_TRACE_CURL=1 git ls-remote https://g1t.sh/<owner>/<repo>.git 2>&1 | grep -i server-timing | |
| 136 | + | ``` | |
| 137 | + | ||
| 138 | + | | Step | What it is | | |
| 139 | + | | --- | --- | | |
| 140 | + | | `repo` | Finding the repository, and checking your credentials if you sent any | | |
| 141 | + | | `moved` | Only for an address with no repository: looking for a renamed workspace or a transferred repository to send you to | | |
| 142 | + | | `access` | Deciding whether you may fetch from or push to it | | |
| 143 | + | | `kept` | A free workspace's limits, and looking for a ref listing and a store credential made a moment ago | | |
| 144 | + | | `mint` | Only when no credential was kept: the git store making one for the request | | |
| 145 | + | | `store` | The git store's answer; for a push, checking it for secrets first | | |
| 146 | + | | `refs` | Only for a push: recording that the repository's refs changed | | |
| 147 | + | | `total` | Everything g1t did | | |
| 148 | + | | `repos` | The same, measured where your request arrived | | |
| 149 | + | ||
| 150 | + | Two entries say how a step went rather than how long it took: | |
| 151 | + | ||
| 152 | + | | Entry | Values | | |
| 153 | + | | --- | --- | | |
| 154 | + | | `refs;desc=` | `hit-colo` or `hit-shared` when the ref listing came from g1t's cache, `miss` when the git store was asked | | |
| 155 | + | | `cred;desc=` | `isolate` or `shared` for a store credential made a moment ago, `mint` for a new one | | |
| 156 | + | ||
| 157 | + | The ref listing git asks for first on every clone and fetch is kept for up | |
| 158 | + | to a minute, and only the same question about the same refs gets the same | |
| 159 | + | answer: a push, a merge or any other change to a repository's branches and | |
| 160 | + | tags makes the next fetch ask the git store again. A change can take up to | |
| 161 | + | 5 seconds to reach every fetch. | |
| 162 | + | ||
| 163 | + | Include the header when you report a slow clone, fetch or push. | |
| 164 | + | ||
| 128 | 165 | ## SSH | |
| 129 | 166 | ||
| 130 | 167 | Git over SSH is not available yet. Use HTTPS, which works for clone, |
| 269 | 269 | for the roles that can use it. A link straight to any of these pages opens | |
| 270 | 270 | the sidebar already there. | |
| 271 | 271 | ||
| 272 | + | ## Mission control | |
| 273 | + | ||
| 274 | + | Mission control, `g1t.sh` when you are signed in, is your home page. It | |
| 275 | + | shows where you are needed in the workspace you have chosen in the | |
| 276 | + | sidebar, what its agents are doing, and what landed without you. | |
| 277 | + | ||
| 278 | + | Under the greeting, one line sums up the week, such as *Agents landed 39 | |
| 279 | + | of 47 changes this week without you*. A change landed without you when | |
| 280 | + | g1t merged it, by auto-merge or from the [merge queue](/guides/merge-queue/), | |
| 281 | + | with no person pressing merge. **Review N that need you** jumps to the | |
| 282 | + | list, and **New issue** opens a new issue in the project you pick. | |
| 283 | + | ||
| 284 | + | | Across the top | What it counts | | |
| 285 | + | | --- | --- | | |
| 286 | + | | **Projects** | The workspace's projects, and how many were added this month. | | |
| 287 | + | | **Agents** | Agent runs going now, and the hours agents worked in the last 7 days. | | |
| 288 | + | | **Changes this week** | Pull requests merged in the last 7 days, and the change from the 7 days before. The change is left out when g1t cannot read far enough back to count it. | | |
| 289 | + | | **Landed without you** | The share of those changes that g1t merged with no person pressing merge. | | |
| 290 | + | | **Need you** | What is waiting on you, and how many of those block work. | | |
| 291 | + | ||
| 292 | + | The list has three tabs. Each row opens to say more; the first is open. | |
| 293 | + | ||
| 294 | + | | Tab | What it lists | | |
| 295 | + | | --- | --- | | |
| 296 | + | | **Needs you** | Pull requests g1t stopped seeing through, reviews asked of you, changes ready for you to merge, failed checks, quiet agents, failed production builds, repository invitations and a usage limit that is close or reached. | | |
| 297 | + | | **Waiting on agents** | Pull requests in an agent's hands (making the change, checking, reviewing, revising, catching up or in the merge queue), and runs going now. | | |
| 298 | + | | **Landed today** | Pull requests merged today in your time zone, and whether a person merged them. | | |
| 299 | + | ||
| 300 | + | Each row in **Needs you** carries the reason it needs you: | |
| 301 | + | ||
| 302 | + | | Reason | Means | | |
| 303 | + | | --- | --- | | |
| 304 | + | | `BLOCKING` | Nothing moves until a person acts: a failed production build, a merge g1t could not make, or the usage limit. | | |
| 305 | + | | `ASKED FOR YOU` | A review or an invitation addressed to you by name. | | |
| 306 | + | | `CHECKS FAILING` | The checks still fail after the agent revised, or could not run. | | |
| 307 | + | | `OUTSIDE GUARDRAILS` | A run reached a cost or time cap set in [Guardrails](/guides/guardrails/). | | |
| 308 | + | | `NEEDS REVIEW` | The repository wants a person's approval, or the review still asks for changes after the agent revised. | | |
| 309 | + | | `STALLED` | An agent stopped, or has reported nothing for 10 minutes. | | |
| 310 | + | | `READY TO MERGE` | Checks passed and it was approved; the repository lands changes only when a person merges them. | | |
| 311 | + | ||
| 312 | + | Opened, a row shows **The ask** (what g1t stopped with, and who the work | |
| 313 | + | was started for), **What the agent already knows** (its checks, the files | |
| 314 | + | and lines it changes, the test files it touches, how often the agent was | |
| 315 | + | sent back, and what its runs cost) and **Why this needs you**. From | |
| 316 | + | there, **Review and respond** opens it, and where it can be done without | |
| 317 | + | leaving the page you can approve the change, merge it or run its checks | |
| 318 | + | again. **By impact** puts the most urgent first; **Newest** sorts by time. | |
| 319 | + | ||
| 320 | + | On the right, **This week** charts the changes landed each day, split | |
| 321 | + | into those agents landed alone and those a person merged, with what | |
| 322 | + | agents and sandboxes cost over the same days. **Activity** lists what | |
| 323 | + | moved across the workspace, agents marked apart from people. The page | |
| 324 | + | refreshes itself while agents are at work. | |
| 325 | + | ||
| 272 | 326 | ## Workspace access tokens | |
| 273 | 327 | ||
| 274 | 328 | A workspace has access tokens of its own, for CI, integrations and agents |
| 1 | − | import { | |
| 2 | − | Activity as ActivityIcon, | |
| 3 | − | ArrowRight, | |
| 4 | − | ArrowUpRight, | |
| 5 | − | Bot, | |
| 6 | − | Box, | |
| 7 | − | CircleDot, | |
| 8 | − | Coins, | |
| 9 | − | GitPullRequest, | |
| 10 | − | Hand, | |
| 11 | − | Lock, | |
| 12 | − | Plus, | |
| 13 | − | Radio, | |
| 14 | − | Settings, | |
| 15 | − | Sparkles, | |
| 16 | − | Users, | |
| 17 | − | } from "lucide-react"; | |
| 1 | + | import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, LoaderCircle, Plus } from "lucide-react"; | |
| 18 | 2 | import { type ReactNode, useEffect, useState } from "react"; | |
| 19 | − | import { Link, useRouteLoaderData } from "react-router"; | |
| 3 | + | import { Link, useFetcher, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 20 | 4 | ||
| 21 | 5 | import { trialClosed } from "../lib/trial"; | |
| 22 | − | import { type Need, formatSpan, greetingFor, rankNeeds } from "../lib/mission"; | |
| 6 | + | import { type ActivityGroup, type Verb, greetingFor, isAgent } from "../lib/mission"; | |
| 7 | + | import { | |
| 8 | + | BLOCKING, | |
| 9 | + | type Fact, | |
| 10 | + | type LandedRow, | |
| 11 | + | type NeedRow, | |
| 12 | + | type QuickAction, | |
| 13 | + | REASON_LABEL, | |
| 14 | + | type Reason, | |
| 15 | + | type Sort, | |
| 16 | + | type Tab, | |
| 17 | + | type WaitingRow, | |
| 18 | + | type Week, | |
| 19 | + | type Who, | |
| 20 | + | change, | |
| 21 | + | dateLine, | |
| 22 | + | parseSort, | |
| 23 | + | parseTab, | |
| 24 | + | signedPercent, | |
| 25 | + | sortRows, | |
| 26 | + | usd, | |
| 27 | + | whyFor, | |
| 28 | + | } from "../lib/mission-control"; | |
| 29 | + | import { cn } from "../lib/cn"; | |
| 23 | 30 | import { AgentSetup } from "./agent-setup"; | |
| 24 | 31 | import type { ShellData } from "./shell"; | |
| 25 | − | import { STAGE_LABEL, StageDots } from "./lifecycle"; | |
| 26 | − | import { RunCard, formatCost, useLiveRefresh } from "./agents"; | |
| 27 | − | import { CheckBadge } from "./checks"; | |
| 28 | − | import { DEPLOY_STATUS, StatusDot, host } from "./deploy"; | |
| 29 | − | import { ActivityFeed, Meter, NeedsList, Panel, PulseTile, Quiet, Unavailable, percent } from "./mission"; | |
| 32 | + | import { useLiveRefresh } from "./agents"; | |
| 33 | + | import { Unavailable } from "./mission"; | |
| 30 | 34 | import { Avatar, TimeAgo } from "./ui"; | |
| 31 | − | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 32 | − | import { IssueIcon, PullIcon } from "./work-icons"; | |
| 35 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 33 | 36 | import type { Loaded } from "../routes/home"; | |
| 34 | 37 | ||
| 35 | 38 | /* | |
| 36 | − | * Mission control: the home page of someone signed in. It is a chunk of its | |
| 37 | − | * own, loaded only for them, so the signed-out home page does not carry it. | |
| 38 | − | * routes/home.tsx loads its data. | |
| 39 | + | * Mission control: the home page of someone signed in. It answers where | |
| 40 | + | * the viewer is needed, what agents are doing, and what landed without | |
| 41 | + | * them. It is a chunk of its own, loaded only for them, so the signed-out | |
| 42 | + | * home page does not carry it. routes/home.tsx loads its data; | |
| 43 | + | * lib/mission-control.ts shapes it. | |
| 39 | 44 | */ | |
| 40 | 45 | ||
| 41 | 46 | /** The viewer's time zone, set by the page itself, so the greeting fits their day. */ | |
| 42 | 47 | const TZ_COOKIE = "g1t_tz"; | |
| 48 | + | /** Rows a tab shows before "See all". */ | |
| 49 | + | const ROWS = 6; | |
| 43 | 50 | ||
| 44 | 51 | const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`; | |
| 52 | + | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 45 | 53 | ||
| 46 | − | // --- Pieces --------------------------------------------------------------------- | |
| 54 | + | // --- Small pieces ----------------------------------------------------------------- | |
| 47 | 55 | ||
| 48 | − | const TONE = { fg: "text-fg", warn: "text-warn", danger: "text-danger", accent: "text-accent" }; | |
| 56 | + | const CHIP_TONE: Record<Reason, string> = { | |
| 57 | + | blocking: "border-danger/35 bg-danger/10 text-danger", | |
| 58 | + | checks_failing: "border-danger/35 bg-danger/10 text-danger", | |
| 59 | + | outside_guardrails: "border-warn/35 bg-warn/10 text-warn", | |
| 60 | + | stalled: "border-warn/35 bg-warn/10 text-warn", | |
| 61 | + | asked_for_you: "border-merged/35 bg-merged/10 text-merged", | |
| 62 | + | needs_review: "border-info/35 bg-info/10 text-info", | |
| 63 | + | ready_to_merge: "border-accent/35 bg-accent/10 text-accent", | |
| 64 | + | }; | |
| 49 | 65 | ||
| 50 | − | function Digest({ parts, seenBefore }: { parts: Loaded["digest"]; seenBefore: number | null }) { | |
| 51 | − | const when = seenBefore ? ( | |
| 52 | − | <> | |
| 53 | − | Since you were last here, <TimeAgo at={seenBefore} /> | |
| 54 | − | </> | |
| 55 | − | ) : ( | |
| 56 | − | "In the last day" | |
| 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> | |
| 57 | 76 | ); | |
| 58 | − | if (parts.length === 0) { | |
| 59 | − | return <p className="mt-1 text-sm text-muted">{when}: nothing new landed, and nothing is waiting on you.</p>; | |
| 60 | − | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /** A person or an agent, by name, wearing the mark that tells them apart. */ | |
| 80 | + | function Person({ who, size = 16, className }: { who: Who; size?: number; className?: string }) { | |
| 61 | 81 | return ( | |
| 62 | − | <p className="mt-1 text-sm leading-6 text-muted"> | |
| 63 | − | {when}:{" "} | |
| 64 | − | {parts.map((part, index) => ( | |
| 65 | − | <span key={part.text}> | |
| 66 | − | {index > 0 && (index === parts.length - 1 ? " and " : ", ")} | |
| 67 | − | <a href={`#${part.anchor}`} className={`font-medium underline decoration-line-strong underline-offset-4 hover:decoration-current ${TONE[part.tone]}`}> | |
| 68 | − | {part.text} | |
| 69 | − | </a> | |
| 70 | − | </span> | |
| 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-accent", 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="min-w-0"> | |
| 101 | + | <dt className="text-xs text-faint">{fact.label}</dt> | |
| 102 | + | <dd className={cn("mt-0.5 truncate text-sm font-medium tabular-nums", fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft")}> | |
| 103 | + | {fact.value} | |
| 104 | + | </dd> | |
| 105 | + | </div> | |
| 71 | 106 | ))} | |
| 72 | − | . | |
| 73 | − | </p> | |
| 107 | + | </dl> | |
| 74 | 108 | ); | |
| 75 | 109 | } | |
| 76 | 110 | ||
| 77 | − | function PullList({ rows, empty }: { rows: Loaded["pullsTabs"]["authored"]; empty: ReactNode }) { | |
| 78 | − | if (rows.length === 0) return <Quiet>{empty}</Quiet>; | |
| 111 | + | /** A form that acts on a pull request from here, saying so before the page catches up. */ | |
| 112 | + | function QuickForm({ quick, variant = "quiet" }: { quick: QuickAction; variant?: "quiet" | "accent" }) { | |
| 113 | + | const fetcher = useFetcher<{ error?: string } | null>(); | |
| 114 | + | const [sent, setSent] = useState(false); | |
| 115 | + | const busy = fetcher.state !== "idle"; | |
| 116 | + | const done = sent && fetcher.state === "idle" && !fetcher.data?.error; | |
| 79 | 117 | return ( | |
| 80 | − | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 81 | − | {rows.map((row) => ( | |
| 82 | − | <li key={row.key}> | |
| 83 | − | <Link | |
| 84 | − | prefetch="intent" | |
| 85 | − | to={`/${row.repo.namespace}/${row.repo.name}/pull/${row.number}`} | |
| 86 | − | className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised" | |
| 87 | − | > | |
| 88 | − | <PullIcon status={row.status} /> | |
| 89 | − | <span className="min-w-0 grow"> | |
| 90 | − | <span className="block truncate text-sm font-medium">{row.title}</span> | |
| 91 | − | <span className="block truncate font-mono text-xs text-muted"> | |
| 92 | − | {row.repo.name}#{row.number} | |
| 93 | − | {row.agent && ` · ${row.agent}`} | |
| 94 | − | </span> | |
| 118 | + | <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents"> | |
| 119 | + | {Object.entries(quick.fields).map(([name, value]) => ( | |
| 120 | + | <input key={name} type="hidden" name={name} value={value} /> | |
| 121 | + | ))} | |
| 122 | + | <button | |
| 123 | + | type="submit" | |
| 124 | + | disabled={busy || done} | |
| 125 | + | className={cn( | |
| 126 | + | "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default", | |
| 127 | + | variant === "accent" | |
| 128 | + | ? "bg-merged text-bg hover:bg-[#c9bfff]" | |
| 129 | + | : "border border-line-strong text-fg/90 hover:bg-raised hover:text-fg", | |
| 130 | + | done && "border-accent/40 text-accent", | |
| 131 | + | )} | |
| 132 | + | > | |
| 133 | + | {busy ? <LoaderCircle size={14} className="animate-spin" /> : done ? <Check size={14} /> : null} | |
| 134 | + | {done ? quick.done : quick.label} | |
| 135 | + | </button> | |
| 136 | + | {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>} | |
| 137 | + | </fetcher.Form> | |
| 138 | + | ); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | function ProjectMark({ repo }: { repo: { namespace: string; name: string } | null }) { | |
| 142 | + | return <Avatar name={repo ? repo.name : "g1t"} size={32} square />; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /** A row that opens to what is known about it; the first one starts open. */ | |
| 146 | + | function Row({ | |
| 147 | + | open, | |
| 148 | + | repo, | |
| 149 | + | by, | |
| 150 | + | title, | |
| 151 | + | sub, | |
| 152 | + | chip, | |
| 153 | + | at, | |
| 154 | + | children, | |
| 155 | + | }: { | |
| 156 | + | open: boolean; | |
| 157 | + | repo: { namespace: string; name: string } | null; | |
| 158 | + | by: Who | null; | |
| 159 | + | title: ReactNode; | |
| 160 | + | sub: ReactNode; | |
| 161 | + | chip: ReactNode; | |
| 162 | + | at: number; | |
| 163 | + | children: ReactNode; | |
| 164 | + | }) { | |
| 165 | + | return ( | |
| 166 | + | <li> | |
| 167 | + | <details open={open} className="group"> | |
| 168 | + | <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"> | |
| 169 | + | <ProjectMark repo={repo} /> | |
| 170 | + | <span className="min-w-0 grow"> | |
| 171 | + | <span className="block truncate text-sm">{title}</span> | |
| 172 | + | <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted"> | |
| 173 | + | <span className="shrink-0 font-medium text-fg-soft">{repo ? repo.name : "Workspace"}</span> | |
| 174 | + | {by && ( | |
| 175 | + | <> | |
| 176 | + | <span className="text-faint">·</span> | |
| 177 | + | <Person who={by} size={14} className={cn("shrink-0", by.agent ? "text-merged" : "")} /> | |
| 178 | + | </> | |
| 179 | + | )} | |
| 180 | + | {sub && ( | |
| 181 | + | <> | |
| 182 | + | <span className="text-faint">—</span> | |
| 183 | + | <span className="truncate">{sub}</span> | |
| 184 | + | </> | |
| 185 | + | )} | |
| 95 | 186 | </span> | |
| 96 | − | {row.stage ? ( | |
| 97 | − | <span className="hidden shrink-0 items-center gap-2 text-xs text-muted sm:flex"> | |
| 98 | − | <StageDots stage={row.stage} /> | |
| 99 | − | <span className={row.stage === "needs_you" ? "text-warn" : ""}>{STAGE_LABEL[row.stage]}</span> | |
| 100 | − | </span> | |
| 101 | − | ) : ( | |
| 102 | − | <span className="hidden shrink-0 sm:block"> | |
| 103 | − | <CheckBadge status={row.checkStatus} /> | |
| 104 | − | </span> | |
| 105 | − | )} | |
| 106 | − | <span className="w-14 shrink-0 text-right text-xs text-faint"> | |
| 107 | − | <TimeAgo at={row.updatedAt} /> | |
| 187 | + | </span> | |
| 188 | + | <span className="hidden sm:inline-flex">{chip}</span> | |
| 189 | + | <span className="hidden w-16 shrink-0 text-right text-xs text-faint sm:block"> | |
| 190 | + | <TimeAgo at={at} /> | |
| 191 | + | </span> | |
| 192 | + | <ChevronDown size={16} className="shrink-0 text-faint transition-transform group-open:rotate-180" /> | |
| 193 | + | </summary> | |
| 194 | + | <div className="px-4 pb-4 sm:px-5 sm:pb-5"> | |
| 195 | + | <div className="mb-3 flex items-center gap-2 sm:hidden"> | |
| 196 | + | {chip} | |
| 197 | + | <span className="text-xs text-faint"> | |
| 198 | + | <TimeAgo at={at} /> | |
| 108 | 199 | </span> | |
| 109 | − | </Link> | |
| 110 | − | </li> | |
| 111 | − | ))} | |
| 112 | − | </ul> | |
| 200 | + | </div> | |
| 201 | + | {children} | |
| 202 | + | </div> | |
| 203 | + | </details> | |
| 204 | + | </li> | |
| 113 | 205 | ); | |
| 114 | 206 | } | |
| 115 | 207 | ||
| 116 | − | function IssueList({ rows, empty }: { rows: Loaded["issuesTabs"]["assigned"]; empty: ReactNode }) { | |
| 117 | − | if (rows.length === 0) return <Quiet>{empty}</Quiet>; | |
| 208 | + | function NeedCard({ row, first }: { row: NeedRow; first: boolean }) { | |
| 209 | + | const isPull = row.to.includes("/pull/"); | |
| 118 | 210 | return ( | |
| 119 | − | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 120 | − | {rows.map((row) => ( | |
| 121 | − | <li key={row.key}> | |
| 211 | + | <Row | |
| 212 | + | open={first} | |
| 213 | + | repo={row.repo} | |
| 214 | + | by={row.by} | |
| 215 | + | title={ | |
| 216 | + | <> | |
| 217 | + | <span className="font-medium">{row.title}</span> | |
| 218 | + | {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>} | |
| 219 | + | </> | |
| 220 | + | } | |
| 221 | + | sub={row.ask} | |
| 222 | + | chip={<Chip tone={CHIP_TONE[row.reason]}>{REASON_LABEL[row.reason]}</Chip>} | |
| 223 | + | at={row.at} | |
| 224 | + | > | |
| 225 | + | <div className="grid gap-4 rounded-xl border border-line 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)]"> | |
| 226 | + | <div className="min-w-0"> | |
| 227 | + | <Eyebrow>The ask</Eyebrow> | |
| 228 | + | <p className="mt-2 text-sm leading-6 text-fg-soft">{row.ask}</p> | |
| 229 | + | <p className="mt-2 text-xs leading-5 text-muted"> | |
| 230 | + | <Link to={row.to} className="font-medium text-fg hover:underline"> | |
| 231 | + | {row.repo ? `${row.repo.name}${row.ref ?? ""}` : row.title} | |
| 232 | + | </Link> | |
| 233 | + | {row.for && ( | |
| 234 | + | <> | |
| 235 | + | {" "} | |
| 236 | + | · started for <span className="text-fg-soft">{row.for}</span> | |
| 237 | + | </> | |
| 238 | + | )} | |
| 239 | + | </p> | |
| 240 | + | </div> | |
| 241 | + | <div className="min-w-0"> | |
| 242 | + | <Eyebrow>What the agent already knows</Eyebrow> | |
| 243 | + | <div className="mt-2"> | |
| 244 | + | <Facts facts={row.facts} /> | |
| 245 | + | </div> | |
| 246 | + | </div> | |
| 247 | + | <div className="rounded-lg border border-warn/20 bg-warn/[0.06] p-3.5 md:col-span-2 2xl:col-span-1"> | |
| 248 | + | <p className="text-xs font-semibold text-warn">Why this needs you</p> | |
| 249 | + | <p className="mt-1.5 text-sm leading-6 text-fg-soft">{row.why}</p> | |
| 250 | + | <p className="mt-2 text-xs text-faint"> | |
| 251 | + | Waiting <TimeAgo at={row.at} /> | |
| 252 | + | </p> | |
| 253 | + | </div> | |
| 254 | + | </div> | |
| 255 | + | <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| 256 | + | <Link | |
| 257 | + | to={row.to} | |
| 258 | + | prefetch="intent" | |
| 259 | + | 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-white" | |
| 260 | + | > | |
| 261 | + | Review and respond <ArrowUpRight size={14} /> | |
| 262 | + | </Link> | |
| 263 | + | {row.quick && <QuickForm quick={row.quick} />} | |
| 264 | + | {row.link && ( | |
| 122 | 265 | <Link | |
| 123 | − | prefetch="intent" | |
| 124 | − | to={`/${row.repo.namespace}/${row.repo.name}/issues/${row.number}`} | |
| 125 | − | className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised" | |
| 266 | + | to={row.link.to} | |
| 267 | + | 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" | |
| 126 | 268 | > | |
| 127 | − | <IssueIcon issue={{ state: "open", reason: null }} /> | |
| 128 | − | <span className="min-w-0 grow"> | |
| 129 | − | <span className="block truncate text-sm font-medium">{row.title}</span> | |
| 130 | − | <span className="block truncate font-mono text-xs text-muted"> | |
| 131 | − | {row.repo.name}#{row.number} | |
| 132 | − | </span> | |
| 133 | − | </span> | |
| 134 | − | {row.agent ? ( | |
| 135 | − | <span className="hidden shrink-0 items-center gap-1 text-xs text-merged sm:flex"> | |
| 136 | − | <Bot size={12} /> {row.agent} at work | |
| 137 | − | </span> | |
| 138 | − | ) : row.queued ? ( | |
| 139 | − | <span className="hidden shrink-0 text-xs text-muted sm:block">Queued for an agent</span> | |
| 140 | − | ) : null} | |
| 141 | − | <span className="w-14 shrink-0 text-right text-xs text-faint"> | |
| 142 | − | <TimeAgo at={row.updatedAt} /> | |
| 143 | − | </span> | |
| 269 | + | {row.link.label} | |
| 270 | + | </Link> | |
| 271 | + | )} | |
| 272 | + | {row.open !== row.to && ( | |
| 273 | + | <Link to={row.open} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg"> | |
| 274 | + | {row.open.includes("/issues/") ? "Open issue" : isPull ? "See the changes" : "Open pull request"} | |
| 144 | 275 | </Link> | |
| 145 | − | </li> | |
| 146 | − | ))} | |
| 147 | − | </ul> | |
| 276 | + | )} | |
| 277 | + | </div> | |
| 278 | + | </Row> | |
| 148 | 279 | ); | |
| 149 | 280 | } | |
| 150 | 281 | ||
| 151 | − | function ProjectCard({ project, workspace }: { project: Loaded["projects"][number]; workspace: string }) { | |
| 152 | − | const base = `/${workspace}/${project.slug}`; | |
| 153 | − | const latest = project.latest; | |
| 282 | + | function WaitingCard({ row, first }: { row: WaitingRow; first: boolean }) { | |
| 154 | 283 | return ( | |
| 155 | − | <li className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 156 | − | <div className="flex items-center gap-2.5"> | |
| 157 | − | <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line"> | |
| 158 | − | {project.private ? <Lock size={13} /> : <Box size={13} />} | |
| 159 | − | </span> | |
| 160 | − | <Link to={base} prefetch="intent" className="min-w-0 grow truncate font-medium hover:text-accent"> | |
| 161 | − | {project.name} | |
| 284 | + | <Row | |
| 285 | + | open={first} | |
| 286 | + | repo={row.repo} | |
| 287 | + | by={row.by} | |
| 288 | + | title={ | |
| 289 | + | <> | |
| 290 | + | <span className="font-medium">{row.title}</span> | |
| 291 | + | {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>} | |
| 292 | + | </> | |
| 293 | + | } | |
| 294 | + | sub={row.detail} | |
| 295 | + | chip={ | |
| 296 | + | <Chip tone="border-line-strong bg-raised text-muted"> | |
| 297 | + | {row.live && <span className="mr-1.5 size-1.5 animate-pulse rounded-full bg-accent" />} | |
| 298 | + | {row.chip} | |
| 299 | + | </Chip> | |
| 300 | + | } | |
| 301 | + | at={row.at} | |
| 302 | + | > | |
| 303 | + | <div className="grid gap-4 rounded-xl border border-line bg-bg/50 p-4 md:grid-cols-2 md:gap-6"> | |
| 304 | + | <div className="min-w-0"> | |
| 305 | + | <Eyebrow>{row.live ? "Doing now" : "Where it stands"}</Eyebrow> | |
| 306 | + | <p className="mt-2 text-sm leading-6 text-fg-soft">{row.detail}</p> | |
| 307 | + | </div> | |
| 308 | + | <div className="min-w-0"> | |
| 309 | + | <Eyebrow>What is known</Eyebrow> | |
| 310 | + | <div className="mt-2"> | |
| 311 | + | <Facts facts={row.facts} /> | |
| 312 | + | </div> | |
| 313 | + | </div> | |
| 314 | + | </div> | |
| 315 | + | <div className="mt-3 flex flex-wrap gap-2"> | |
| 316 | + | <Link | |
| 317 | + | to={row.to} | |
| 318 | + | prefetch="intent" | |
| 319 | + | 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" | |
| 320 | + | > | |
| 321 | + | {row.to.includes("/runs/") ? "Watch the run" : "Open pull request"} <ArrowUpRight size={14} /> | |
| 162 | 322 | </Link> | |
| 163 | − | {project.agents > 0 && ( | |
| 164 | − | <span className="inline-flex items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-[0.6875rem] text-merged"> | |
| 165 | − | <span className="size-1.5 animate-pulse rounded-full bg-merged" /> | |
| 166 | − | {project.agents} at work | |
| 167 | − | </span> | |
| 323 | + | {row.run && row.run !== row.to && ( | |
| 324 | + | <Link to={row.run} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg"> | |
| 325 | + | Watch the run | |
| 326 | + | </Link> | |
| 168 | 327 | )} | |
| 169 | 328 | </div> | |
| 170 | − | <div className="mt-3 min-h-10 text-xs"> | |
| 171 | − | {project.production ? ( | |
| 172 | − | <> | |
| 173 | − | <a href={project.production.url} className="flex items-center gap-1 truncate font-mono text-fg-soft hover:text-accent"> | |
| 174 | − | {host(project.production.url)} | |
| 175 | − | <ArrowUpRight size={12} className="shrink-0 text-faint" /> | |
| 176 | − | </a> | |
| 177 | − | <p className="mt-1 flex items-center gap-2 text-muted"> | |
| 178 | − | {latest && <StatusDot status={latest.status} />} | |
| 179 | − | <span> | |
| 180 | − | deployed <TimeAgo at={project.production.deployedAt} /> | |
| 181 | − | </span> | |
| 182 | − | </p> | |
| 183 | − | </> | |
| 184 | − | ) : project.deploys ? ( | |
| 185 | − | <p className="text-muted"> | |
| 186 | − | {latest ? <StatusDot status={latest.status} label={`${DEPLOY_STATUS[latest.status].label}, not live yet`} /> : "Deploys on the next push to the default branch."} | |
| 187 | − | </p> | |
| 329 | + | </Row> | |
| 330 | + | ); | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | function LandedCard({ row, first }: { row: LandedRow; first: boolean }) { | |
| 334 | + | return ( | |
| 335 | + | <Row | |
| 336 | + | open={first} | |
| 337 | + | repo={row.repo} | |
| 338 | + | by={row.byAgents ? { name: row.agent, agent: isAgent(row.agent) } : row.by} | |
| 339 | + | title={ | |
| 340 | + | <> | |
| 341 | + | <span className="font-medium">{row.title}</span> | |
| 342 | + | <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span> | |
| 343 | + | </> | |
| 344 | + | } | |
| 345 | + | sub={row.byAgents ? "landed without a person" : `merged by ${row.by?.name ?? "a person"}`} | |
| 346 | + | chip={ | |
| 347 | + | row.byAgents ? ( | |
| 348 | + | <Chip tone="border-merged/35 bg-merged/10 text-merged">By agents</Chip> | |
| 188 | 349 | ) : ( | |
| 189 | − | <p className="text-faint"> | |
| 190 | − | Deployments are off.{" "} | |
| 191 | − | <Link to={`${base}/settings/deployments`} className="text-muted hover:text-fg"> | |
| 192 | − | Turn on deployments | |
| 193 | − | </Link> | |
| 350 | + | <Chip tone="border-warn/35 bg-warn/10 text-warn">Needed a person</Chip> | |
| 351 | + | ) | |
| 352 | + | } | |
| 353 | + | at={row.at} | |
| 354 | + | > | |
| 355 | + | <div className="grid gap-4 rounded-xl border border-line bg-bg/50 p-4 md:grid-cols-2 md:gap-6"> | |
| 356 | + | <div className="min-w-0"> | |
| 357 | + | <Eyebrow>How it landed</Eyebrow> | |
| 358 | + | <p className="mt-2 text-sm leading-6 text-fg-soft"> | |
| 359 | + | {row.byAgents | |
| 360 | + | ? `g1t merged it once everything the repository asks for was met, with no one pressing merge.` | |
| 361 | + | : `${row.by?.name ?? "A person"} merged it.`} | |
| 362 | + | {row.agent && isAgent(row.agent) && ` ${row.agent} made the change.`} | |
| 194 | 363 | </p> | |
| 195 | − | )} | |
| 364 | + | </div> | |
| 365 | + | <div className="min-w-0"> | |
| 366 | + | <Eyebrow>What changed</Eyebrow> | |
| 367 | + | <div className="mt-2"> | |
| 368 | + | <Facts facts={row.facts} /> | |
| 369 | + | </div> | |
| 370 | + | </div> | |
| 371 | + | </div> | |
| 372 | + | <div className="mt-3"> | |
| 373 | + | <Link | |
| 374 | + | to={row.to} | |
| 375 | + | prefetch="intent" | |
| 376 | + | 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" | |
| 377 | + | > | |
| 378 | + | Open pull request <ArrowUpRight size={14} /> | |
| 379 | + | </Link> | |
| 196 | 380 | </div> | |
| 197 | − | <dl className="mt-3 grid grid-cols-3 gap-2 border-t border-line pt-3 text-xs"> | |
| 198 | − | <div> | |
| 199 | − | <dt className="text-faint">Open PRs</dt> | |
| 200 | − | <dd className="mt-0.5 font-medium tabular-nums"> | |
| 201 | − | <Link to={`${base}/pulls`} className="hover:text-accent"> | |
| 202 | − | {project.openPulls ?? "—"} | |
| 203 | − | </Link> | |
| 204 | − | </dd> | |
| 205 | − | </div> | |
| 381 | + | </Row> | |
| 382 | + | ); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | function List({ children }: { children: ReactNode }) { | |
| 386 | + | return <ul className="divide-y divide-line">{children}</ul>; | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | function Empty({ children, action }: { children: ReactNode; action?: ReactNode }) { | |
| 390 | + | return ( | |
| 391 | + | <div className="px-5 py-10 text-center"> | |
| 392 | + | <div className="mx-auto max-w-md text-sm leading-6 text-muted">{children}</div> | |
| 393 | + | {action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>} | |
| 394 | + | </div> | |
| 395 | + | ); | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // --- The stat strip ----------------------------------------------------------------- | |
| 399 | + | ||
| 400 | + | function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) { | |
| 401 | + | return ( | |
| 402 | + | <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4" title={title}> | |
| 403 | + | <p className="flex items-center gap-1.5 truncate text-xs text-muted"> | |
| 404 | + | {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />} | |
| 405 | + | {label} | |
| 406 | + | </p> | |
| 407 | + | <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p> | |
| 408 | + | <p className="mt-0.5 truncate text-xs text-faint">{hint}</p> | |
| 409 | + | </div> | |
| 410 | + | ); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | // --- This week ---------------------------------------------------------------------- | |
| 414 | + | ||
| 415 | + | /** Seven days of landed changes, each split into what agents landed alone and what a person merged. */ | |
| 416 | + | function WeekChart({ week }: { week: Week }) { | |
| 417 | + | const max = Math.max(1, ...week.days.map((d) => d.agents + d.people)); | |
| 418 | + | const height = 112; | |
| 419 | + | const delta = change(week.total, week.previous); | |
| 420 | + | return ( | |
| 421 | + | <div> | |
| 422 | + | <div className="flex items-end justify-between gap-3"> | |
| 206 | 423 | <div> | |
| 207 | − | <dt className="text-faint">Agents</dt> | |
| 208 | − | <dd className="mt-0.5 font-medium tabular-nums"> | |
| 209 | − | <Link to={`${base}/agents`} className="hover:text-accent"> | |
| 210 | − | {project.agents} | |
| 211 | − | </Link> | |
| 212 | − | </dd> | |
| 424 | + | <p className="font-display text-3xl font-semibold tracking-tight tabular-nums">{week.total.toLocaleString("en-US")}</p> | |
| 425 | + | <p className="text-xs text-muted">changes landed in 7 days</p> | |
| 213 | 426 | </div> | |
| 214 | − | <div title={project.checks ? `${project.checks} recent check runs` : "No recent check runs"}> | |
| 215 | − | <dt className="text-faint">Checks</dt> | |
| 216 | − | <dd className="mt-0.5 font-medium tabular-nums">{percent(project.passRate)}</dd> | |
| 217 | − | <dd className="mt-1"> | |
| 218 | − | <Meter value={project.passRate} tone={project.passRate != null && project.passRate < 0.7 ? "bg-warn" : "bg-accent"} /> | |
| 219 | − | </dd> | |
| 427 | + | {delta != null ? ( | |
| 428 | + | <span | |
| 429 | + | className={cn( | |
| 430 | + | "rounded-full px-2 py-0.5 text-xs font-medium tabular-nums ring-1", | |
| 431 | + | delta >= 0 ? "text-accent ring-accent/30" : "text-warn ring-warn/30", | |
| 432 | + | )} | |
| 433 | + | > | |
| 434 | + | {signedPercent(delta)} vs last week | |
| 435 | + | </span> | |
| 436 | + | ) : week.previous === 0 && week.total > 0 ? ( | |
| 437 | + | <span className="text-xs text-faint">none the week before</span> | |
| 438 | + | ) : null} | |
| 439 | + | </div> | |
| 440 | + | {week.total === 0 ? ( | |
| 441 | + | <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm leading-6 text-muted"> | |
| 442 | + | Nothing landed in the last 7 days. Each change that does shows here, by day, split by whether a person had to merge it. | |
| 443 | + | </p> | |
| 444 | + | ) : ( | |
| 445 | + | <div className="relative mt-5" style={{ height: height + 20 }}> | |
| 446 | + | <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} /> | |
| 447 | + | <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}> | |
| 448 | + | {week.days.map((day, index) => { | |
| 449 | + | const total = day.agents + day.people; | |
| 450 | + | const agentsH = Math.round((day.agents / max) * (height - 4)); | |
| 451 | + | const peopleH = Math.round((day.people / max) * (height - 4)); | |
| 452 | + | const today = index === week.days.length - 1; | |
| 453 | + | return ( | |
| 454 | + | <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end"> | |
| 455 | + | {total > 0 && ( | |
| 456 | + | <span | |
| 457 | + | className={cn( | |
| 458 | + | "mb-1 text-[0.625rem] tabular-nums", | |
| 459 | + | today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100", | |
| 460 | + | )} | |
| 461 | + | > | |
| 462 | + | {total} | |
| 463 | + | </span> | |
| 464 | + | )} | |
| 465 | + | <div className="flex w-full max-w-6 flex-col items-stretch gap-[2px]"> | |
| 466 | + | {day.people > 0 && <span className="block rounded-t bg-warn" style={{ height: Math.max(3, peopleH) }} />} | |
| 467 | + | {day.agents > 0 && ( | |
| 468 | + | <span className={cn("block bg-merged", day.people > 0 ? "" : "rounded-t")} style={{ height: Math.max(3, agentsH) }} /> | |
| 469 | + | )} | |
| 470 | + | {total === 0 && <span className="block h-[2px] rounded-full bg-line-strong" />} | |
| 471 | + | </div> | |
| 472 | + | <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 group-hover:block"> | |
| 473 | + | <p className="font-medium text-fg">{day.label}</p> | |
| 474 | + | <p className="mt-0.5 flex items-center gap-1.5 text-muted"> | |
| 475 | + | <span className="size-1.5 rounded-full bg-merged" /> {day.agents} by agents | |
| 476 | + | </p> | |
| 477 | + | <p className="flex items-center gap-1.5 text-muted"> | |
| 478 | + | <span className="size-1.5 rounded-full bg-warn" /> {day.people} needed a person | |
| 479 | + | </p> | |
| 480 | + | </div> | |
| 481 | + | </div> | |
| 482 | + | ); | |
| 483 | + | })} | |
| 484 | + | </div> | |
| 485 | + | <div className="absolute inset-x-0 bottom-0 flex justify-between gap-1"> | |
| 486 | + | {week.days.map((day, index) => ( | |
| 487 | + | <span | |
| 488 | + | key={day.key} | |
| 489 | + | className={cn( | |
| 490 | + | "flex-1 text-center text-[0.6875rem]", | |
| 491 | + | index === week.days.length - 1 ? "font-medium text-fg-soft" : "text-faint", | |
| 492 | + | )} | |
| 493 | + | > | |
| 494 | + | {day.label} | |
| 495 | + | </span> | |
| 496 | + | ))} | |
| 497 | + | </div> | |
| 220 | 498 | </div> | |
| 221 | − | </dl> | |
| 499 | + | )} | |
| 500 | + | <div className={cn("mt-3 flex-wrap gap-x-4 gap-y-1 text-xs text-muted", week.total === 0 ? "hidden" : "flex")}> | |
| 501 | + | <span className="inline-flex items-center gap-1.5"> | |
| 502 | + | <span className="size-2 rounded-sm bg-merged" /> Landed by agents | |
| 503 | + | </span> | |
| 504 | + | <span className="inline-flex items-center gap-1.5"> | |
| 505 | + | <span className="size-2 rounded-sm bg-warn" /> Needed a person | |
| 506 | + | </span> | |
| 507 | + | </div> | |
| 508 | + | <table className="sr-only"> | |
| 509 | + | <caption>Changes landed each day</caption> | |
| 510 | + | <thead> | |
| 511 | + | <tr> | |
| 512 | + | <th>Day</th> | |
| 513 | + | <th>Landed by agents</th> | |
| 514 | + | <th>Needed a person</th> | |
| 515 | + | </tr> | |
| 516 | + | </thead> | |
| 517 | + | <tbody> | |
| 518 | + | {week.days.map((day) => ( | |
| 519 | + | <tr key={day.key}> | |
| 520 | + | <td>{day.key}</td> | |
| 521 | + | <td>{day.agents}</td> | |
| 522 | + | <td>{day.people}</td> | |
| 523 | + | </tr> | |
| 524 | + | ))} | |
| 525 | + | </tbody> | |
| 526 | + | </table> | |
| 527 | + | </div> | |
| 528 | + | ); | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | // --- Activity ----------------------------------------------------------------------- | |
| 532 | + | ||
| 533 | + | const VERB: Record<Verb, string> = { | |
| 534 | + | landed: "landed", | |
| 535 | + | opened_issue: "opened", | |
| 536 | + | closed_issue: "closed", | |
| 537 | + | started: "started on", | |
| 538 | + | ready: "marked ready", | |
| 539 | + | checks_passed: "checks passed on", | |
| 540 | + | checks_failed: "checks failed on", | |
| 541 | + | approved: "approved", | |
| 542 | + | changes_requested: "asked for changes on", | |
| 543 | + | commented: "commented on", | |
| 544 | + | asked: "was asked about", | |
| 545 | + | deployed: "deployed production", | |
| 546 | + | deploy_failed: "production build failed", | |
| 547 | + | learned: "learned", | |
| 548 | + | }; | |
| 549 | + | ||
| 550 | + | /** One line of the feed: who, what, and the one thing it was about, by name. */ | |
| 551 | + | function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) { | |
| 552 | + | const part = group.parts[0]; | |
| 553 | + | const base = `/${group.repo.namespace}/${group.repo.name}`; | |
| 554 | + | const actor = group.actor ?? "g1t"; | |
| 555 | + | const agent = group.actor == null || isAgent(group.actor); | |
| 556 | + | const number = part.numbers[0]; | |
| 557 | + | const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined; | |
| 558 | + | const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1); | |
| 559 | + | return ( | |
| 560 | + | <li className="flex gap-2.5 py-2"> | |
| 561 | + | <span className="mt-px"> | |
| 562 | + | <Avatar name={actor} size={22} /> | |
| 563 | + | </span> | |
| 564 | + | <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted"> | |
| 565 | + | <span className={cn("font-medium", agent ? "text-merged" : "text-fg")}>{actor}</span> {VERB[part.verb]}{" "} | |
| 566 | + | {part.verb === "learned" ? ( | |
| 567 | + | <Link to={part.to ?? `${base}/memory`} className="text-fg-soft hover:text-fg"> | |
| 568 | + | “{part.texts[0]}” | |
| 569 | + | </Link> | |
| 570 | + | ) : number != null ? ( | |
| 571 | + | <Link to={`${base}/issues/${number}`} prefetch="intent" className="text-fg-soft hover:text-fg"> | |
| 572 | + | <span className="font-mono text-xs">#{number}</span> | |
| 573 | + | {title && <> {title}</>} | |
| 574 | + | </Link> | |
| 575 | + | ) : part.to ? ( | |
| 576 | + | <Link to={part.to} className="text-fg-soft hover:text-fg"> | |
| 577 | + | {group.repo.name} | |
| 578 | + | </Link> | |
| 579 | + | ) : null} | |
| 580 | + | {more > 0 && <span className="text-faint"> and {more} more</span>} | |
| 581 | + | <span className="block text-xs text-faint"> | |
| 582 | + | <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} /> | |
| 583 | + | </span> | |
| 584 | + | </span> | |
| 222 | 585 | </li> | |
| 223 | 586 | ); | |
| 224 | 587 | } | |
| 225 | 588 | ||
| 589 | + | // --- Get started -------------------------------------------------------------------- | |
| 590 | + | ||
| 226 | 591 | type Step = { done: boolean; title: string; about: string; to: string | null; action: string }; | |
| 227 | 592 | ||
| 228 | − | /** | |
| 229 | − | * The first things to do, ticked off as they are done, until work has been | |
| 230 | − | * handed to agents. | |
| 231 | − | */ | |
| 593 | + | /** The first things to do, ticked off as they are done, until work has been handed to agents. */ | |
| 232 | 594 | function GetStarted({ steps }: { steps: Step[] }) { | |
| 233 | 595 | const left = steps.filter((step) => !step.done).length; | |
| 234 | 596 | return ( | |
| 235 | − | <section className="rounded-2xl bg-surface p-5 ring-1 ring-line"> | |
| 597 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 236 | 598 | <div className="flex items-baseline justify-between gap-3"> | |
| 237 | − | <h2 className="font-semibold tracking-tight">Get started</h2> | |
| 599 | + | <h2 className="text-base font-semibold tracking-tight">Get started</h2> | |
| 238 | 600 | <span className="text-xs text-muted"> | |
| 239 | 601 | {steps.length - left} of {steps.length} done | |
| 240 | 602 | </span> | |
| 243 | 605 | {steps.map((step, index) => ( | |
| 244 | 606 | <li | |
| 245 | 607 | key={step.title} | |
| 246 | − | className={`flex items-start gap-3 rounded-xl px-3 py-2.5 ${step.done ? "" : "bg-bg/50 ring-1 ring-line"}`} | |
| 608 | + | className={cn("flex items-start gap-3 rounded-lg px-3 py-2.5", step.done ? "" : "bg-bg/50 ring-1 ring-line")} | |
| 247 | 609 | > | |
| 248 | 610 | <span | |
| 249 | − | className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium ${ | |
| 250 | − | step.done ? "bg-accent text-bg" : "text-muted ring-1 ring-line-strong" | |
| 251 | − | }`} | |
| 611 | + | className={cn( | |
| 612 | + | "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium", | |
| 613 | + | step.done ? "bg-accent text-bg" : "text-muted ring-1 ring-line-strong", | |
| 614 | + | )} | |
| 252 | 615 | > | |
| 253 | − | {step.done ? "✓" : index + 1} | |
| 616 | + | {step.done ? <Check size={12} /> : index + 1} | |
| 254 | 617 | </span> | |
| 255 | 618 | <span className="min-w-0 grow"> | |
| 256 | − | <span className={`block text-sm font-medium ${step.done ? "text-muted line-through decoration-faint" : ""}`}> | |
| 257 | − | {step.title} | |
| 258 | − | </span> | |
| 619 | + | <span className={cn("block text-sm font-medium", step.done && "text-muted line-through decoration-faint")}>{step.title}</span> | |
| 259 | 620 | {!step.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{step.about}</span>} | |
| 260 | 621 | </span> | |
| 261 | 622 | {!step.done && step.to && ( | |
| 273 | 634 | ); | |
| 274 | 635 | } | |
| 275 | 636 | ||
| 276 | − | /** The workspace being looked at: what it holds and what is moving in it. */ | |
| 277 | − | function WorkspaceCard({ | |
| 278 | − | slug, | |
| 279 | − | name, | |
| 280 | − | avatar, | |
| 281 | − | role, | |
| 282 | − | projects, | |
| 283 | − | needs, | |
| 284 | − | live, | |
| 285 | − | }: { | |
| 286 | − | slug: string; | |
| 287 | − | name: string; | |
| 288 | − | avatar?: string | null; | |
| 289 | − | role: string; | |
| 290 | − | projects: number; | |
| 291 | − | needs: number; | |
| 292 | − | live: number; | |
| 293 | − | }) { | |
| 294 | − | const stats = [ | |
| 295 | − | { value: projects, label: "projects" }, | |
| 296 | − | { value: live, label: "agents at work" }, | |
| 297 | − | { value: needs, label: "need you" }, | |
| 298 | − | ]; | |
| 299 | − | return ( | |
| 300 | − | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 301 | − | <div className="flex items-center gap-3"> | |
| 302 | − | <Avatar name={slug} image={avatar} size={32} square /> | |
| 303 | − | <div className="min-w-0"> | |
| 304 | − | <Link to={`/${slug}`} title={name} className="block truncate font-medium hover:underline"> | |
| 305 | − | {name} | |
| 306 | − | </Link> | |
| 307 | − | <p className="truncate text-xs text-muted"> | |
| 308 | − | <span className="font-mono">g1t.sh/{slug}</span> · <span className="capitalize">{role}</span> | |
| 309 | − | </p> | |
| 310 | − | </div> | |
| 311 | − | </div> | |
| 312 | − | <dl className="mt-4 grid grid-cols-3 gap-2"> | |
| 313 | − | {stats.map((stat) => ( | |
| 314 | − | <div key={stat.label} className="rounded-lg bg-bg/60 px-2.5 py-2 ring-1 ring-line"> | |
| 315 | − | <dt className="sr-only">{stat.label}</dt> | |
| 316 | − | <dd className="text-lg font-semibold tabular-nums tracking-tight">{stat.value}</dd> | |
| 317 | − | <dd className="text-[0.6875rem] leading-tight text-muted">{stat.label}</dd> | |
| 318 | − | </div> | |
| 319 | − | ))} | |
| 320 | − | </dl> | |
| 321 | − | <div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm"> | |
| 322 | − | <Link to={`/${slug}/-/people`} className="inline-flex items-center gap-1.5 text-muted hover:text-fg"> | |
| 323 | − | <Users size={13} /> | |
| 324 | − | Members | |
| 325 | − | </Link> | |
| 326 | − | <Link | |
| 327 | − | to={role === "owner" ? `/${slug}/-/settings` : `/${slug}/-/people`} | |
| 328 | − | className="inline-flex items-center gap-1.5 text-muted hover:text-fg" | |
| 329 | − | > | |
| 330 | − | <Settings size={13} /> | |
| 331 | − | Settings | |
| 332 | − | </Link> | |
| 333 | − | <Link to={`/${slug}/-/agents`} className="inline-flex items-center gap-1.5 text-muted hover:text-fg"> | |
| 334 | − | <Radio size={13} /> | |
| 335 | − | Fleet | |
| 336 | − | </Link> | |
| 337 | − | </div> | |
| 338 | − | </div> | |
| 339 | − | ); | |
| 340 | − | } | |
| 341 | − | ||
| 342 | − | function UsageCard({ shell, weekCost }: { shell: ShellData; weekCost: number }) { | |
| 343 | − | const slug = shell.workspace?.slug; | |
| 344 | − | const used = shell.monthUsageMicros ?? 0; | |
| 345 | − | const limit = shell.limit; | |
| 346 | − | const ceiling = limit && !limit.comped ? limit.ceilingMicros : null; | |
| 347 | − | const share = ceiling ? Math.min(1, limit!.exposureMicros / ceiling) : null; | |
| 348 | − | const month = new Date().toLocaleDateString("en-US", { month: "long" }); | |
| 349 | − | return ( | |
| 350 | − | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 351 | − | <div className="flex items-center justify-between"> | |
| 352 | − | <h2 className="flex items-center gap-1.5 text-sm font-semibold"> | |
| 353 | − | <Coins size={14} className="text-faint" /> | |
| 354 | − | Usage in {month} | |
| 355 | − | </h2> | |
| 356 | − | {slug && ( | |
| 357 | − | <Link to={`/${slug}/-/usage`} className="text-xs text-muted hover:text-fg"> | |
| 358 | − | Details | |
| 359 | − | </Link> | |
| 360 | − | )} | |
| 361 | − | </div> | |
| 362 | − | <p className="mt-3 text-2xl font-semibold tracking-tight tabular-nums">{dollars(used)}</p> | |
| 363 | − | <p className="text-xs text-muted"> | |
| 364 | − | {shell.free ? "At cost. g1t charges nothing while it is being built out." : "Charged so far this month."} | |
| 365 | − | {weekCost > 0 && ` ${formatCost(weekCost)} of it in the last 7 days.`} | |
| 366 | − | </p> | |
| 367 | − | {ceiling != null && ( | |
| 368 | − | <div className="mt-3"> | |
| 369 | − | <Meter value={share} tone={limit!.state === "ok" ? "bg-accent" : limit!.state === "warning" ? "bg-warn" : "bg-danger"} /> | |
| 370 | − | <p className="mt-1.5 text-[0.6875rem] text-faint"> | |
| 371 | − | {dollars(limit!.exposureMicros)} of the {dollars(ceiling)} limit | |
| 372 | − | </p> | |
| 373 | − | </div> | |
| 374 | − | )} | |
| 375 | − | </div> | |
| 376 | − | ); | |
| 377 | − | } | |
| 378 | − | ||
| 637 | + | // --- The page ----------------------------------------------------------------------- | |
| 379 | 638 | ||
| 380 | − | // --- The page ------------------------------------------------------------------- | |
| 639 | + | const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting on agents", landed: "Landed today" }; | |
| 640 | + | const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting", landed: "Today" }; | |
| 641 | + | const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" }; | |
| 381 | 642 | ||
| 382 | 643 | export default function MissionControl({ loaderData }: { loaderData: Loaded }) { | |
| 383 | 644 | const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined; | |
| 384 | 645 | const loaded: Loaded = loaderData; | |
| 646 | + | const [params] = useSearchParams(); | |
| 385 | 647 | // Agents are at work, so the page changes without anyone touching it. | |
| 386 | − | const changing = | |
| 387 | − | (loaded?.liveTotal ?? 0) > 0 || | |
| 388 | − | (loaded?.active ?? []).some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready"); | |
| 389 | − | useLiveRefresh(changing); | |
| 390 | − | const [greeting, setGreeting] = useState(loaded?.greeting ?? ""); | |
| 391 | − | const [pullsTab, setPullsTab] = useState(() => (loaded && loaded.pullsTabs.review.length > 0 ? "review" : "authored")); | |
| 648 | + | useLiveRefresh(loaded.changing); | |
| 649 | + | const [greeting, setGreeting] = useState(loaded.greeting); | |
| 650 | + | const [date, setDate] = useState(loaded.date); | |
| 392 | 651 | useEffect(() => { | |
| 393 | − | // The greeting follows the viewer's own clock, and the server learns | |
| 394 | − | // their zone for next time. | |
| 652 | + | // The greeting and date follow the viewer's own clock, and the server | |
| 653 | + | // learns their zone for next time. | |
| 395 | 654 | setGreeting(greetingFor(new Date().getHours())); | |
| 396 | 655 | try { | |
| 397 | 656 | const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; | |
| 657 | + | setDate(dateLine(Date.now(), zone ?? null)); | |
| 398 | 658 | if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`; | |
| 399 | 659 | } catch { | |
| 400 | 660 | // Nothing to remember. | |
| 401 | 661 | } | |
| 402 | 662 | }, []); | |
| 403 | 663 | ||
| 404 | − | const { viewer, repos, active, needs: serverNeeds, live, liveTotal, pullsTabs, issuesTabs, groups, pulse, projects, canRunAgents, trial } = loaded; | |
| 664 | + | const { viewer, repos, waiting, landed, liveTotal, week, groups, titles, stats, canRunAgents, trial } = loaded; | |
| 665 | + | const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null; | |
| 405 | 666 | ||
| 406 | − | const workspace = shell?.workspace?.slug ?? viewer.workspaces?.[0]?.slug ?? null; | |
| 407 | 667 | // The usage limit, as the sidebar knows it, heads the list when it bites. | |
| 408 | − | const limitNeed: Need[] = | |
| 409 | − | shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace | |
| 410 | − | ? [ | |
| 411 | − | { | |
| 668 | + | const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null; | |
| 669 | + | const limitRow: NeedRow[] = limit | |
| 670 | + | ? [ | |
| 671 | + | (() => { | |
| 672 | + | const detail = | |
| 673 | + | limit.state === "stopped" | |
| 674 | + | ? "No new agent starts until the limit is raised or a card is added." | |
| 675 | + | : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`; | |
| 676 | + | return { | |
| 412 | 677 | key: "limit", | |
| 413 | − | kind: "limit", | |
| 414 | − | title: shell.limit.state === "stopped" ? "Agents are stopped: the usage limit was reached" : "Usage is nearing its limit", | |
| 415 | − | detail: | |
| 416 | − | shell.limit.state === "stopped" | |
| 417 | − | ? "No new agent starts until the limit is raised or a card is added." | |
| 418 | − | : `${dollars(shell.limit.exposureMicros)} of ${dollars(shell.limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`, | |
| 678 | + | reason: "blocking" as const, | |
| 679 | + | repo: null, | |
| 680 | + | ref: null, | |
| 681 | + | title: limit.state === "stopped" ? "Agents are stopped: the usage limit was reached" : "Usage is nearing its limit", | |
| 682 | + | ask: detail, | |
| 683 | + | by: null, | |
| 684 | + | for: null, | |
| 685 | + | at: Date.now(), | |
| 419 | 686 | to: `/${workspace}/-/billing`, | |
| 420 | − | action: "Billing", | |
| 421 | − | at: Date.now(), | |
| 422 | − | where: null, | |
| 423 | − | }, | |
| 424 | − | ] | |
| 425 | − | : []; | |
| 426 | − | const needs = rankNeeds([...limitNeed, ...serverNeeds]); | |
| 687 | + | open: `/${workspace}/-/billing`, | |
| 688 | + | facts: [ | |
| 689 | + | { label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const }, | |
| 690 | + | ], | |
| 691 | + | why: whyFor("blocking", { kind: "limit", detail }), | |
| 692 | + | quick: null, | |
| 693 | + | link: null, | |
| 694 | + | }; | |
| 695 | + | })(), | |
| 696 | + | ] | |
| 697 | + | : []; | |
| 698 | + | const needs = [...limitRow, ...loaded.needs]; | |
| 699 | + | const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length; | |
| 427 | 700 | ||
| 428 | − | const first = repos[0]; | |
| 429 | − | const handedOff = active.length > 0 || (shell?.monthUsageMicros ?? 0) > 0; | |
| 701 | + | const counts: Record<Tab, number> = { needs: needs.length, waiting: waiting.length, landed: landed.length }; | |
| 702 | + | const tab = parseTab(params.get("tab")) ?? "needs"; | |
| 703 | + | const sort = parseSort(params.get("sort")); | |
| 704 | + | const all = params.get("all") === "1"; | |
| 705 | + | const everyActivity = params.get("activity") === "all"; | |
| 706 | + | const link = (changes: Record<string, string | null>) => { | |
| 707 | + | const next = new URLSearchParams(params); | |
| 708 | + | for (const [key, value] of Object.entries(changes)) { | |
| 709 | + | if (value == null) next.delete(key); | |
| 710 | + | else next.set(key, value); | |
| 711 | + | } | |
| 712 | + | const text = next.toString(); | |
| 713 | + | return text ? `?${text}` : "/"; | |
| 714 | + | }; | |
| 715 | + | ||
| 430 | 716 | const steps: Step[] = [ | |
| 431 | 717 | { | |
| 432 | 718 | done: Boolean(workspace), | |
| 456 | 742 | ? { | |
| 457 | 743 | done: Boolean(shell?.free) || shell?.limit == null || shell.limit.comped || shell.limit.state === "ok", | |
| 458 | 744 | title: "Keep work running", | |
| 459 | − | about: "Usage is charged after it runs. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.", | |
| 745 | + | about: | |
| 746 | + | "Usage is charged after it runs. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.", | |
| 460 | 747 | to: workspace ? `/${workspace}/-/billing` : null, | |
| 461 | 748 | action: "Billing", | |
| 462 | 749 | } | |
| 470 | 757 | action: "Connect", | |
| 471 | 758 | }, | |
| 472 | 759 | { | |
| 473 | − | done: handedOff, | |
| 760 | + | done: loaded.handedOff, | |
| 474 | 761 | title: "Hand off an outcome", | |
| 475 | − | about: "Describe what you want done above, or write an outcome on a project's Plans page and let a planner split it into issues.", | |
| 476 | − | to: first ? `/${first.namespace}/${first.name}/plans` : null, | |
| 762 | + | about: | |
| 763 | + | "Open an issue and assign it to g1t-agent, or write an outcome on a project's Plans page and let a planner split it into issues.", | |
| 764 | + | to: repos[0] ? `/${repos[0].namespace}/${repos[0].name}/plans` : null, | |
| 477 | 765 | action: "Write one", | |
| 478 | 766 | }, | |
| 479 | 767 | ]; | |
| 480 | 768 | const starting = steps.some((step) => !step.done); | |
| 481 | − | const counts = (n: number) => (n > 0 ? <span className="ml-1 tabular-nums text-faint">{n}</span> : null); | |
| 769 | + | const noProjects = repos.length === 0; | |
| 770 | + | ||
| 771 | + | const rows = tab === "needs" ? sortRows(needs, sort) : tab === "waiting" ? sortRows(waiting, sort) : sortRows(landed, sort); | |
| 772 | + | const shown = all ? rows : rows.slice(0, ROWS); | |
| 773 | + | const share = week.total > 0 ? week.byAgents / week.total : null; | |
| 774 | + | const delta = change(week.total, week.previous); | |
| 775 | + | const feed = everyActivity ? groups : groups.slice(0, 8); | |
| 776 | + | ||
| 777 | + | const newIssue = | |
| 778 | + | repos.length > 0 ? ( | |
| 779 | + | <DropdownMenu> | |
| 780 | + | <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"> | |
| 781 | + | <Plus size={14} /> New issue | |
| 782 | + | </DropdownMenuTrigger> | |
| 783 | + | <DropdownMenuContent align="end" className="max-h-80 overflow-y-auto"> | |
| 784 | + | <DropdownMenuLabel>In which project?</DropdownMenuLabel> | |
| 785 | + | {repos.map((repo) => ( | |
| 786 | + | <DropdownMenuItem key={repo.name} asChild> | |
| 787 | + | <Link to={`/${repo.namespace}/${repo.name}/issues/new`}> | |
| 788 | + | <Avatar name={repo.name} size={18} square /> | |
| 789 | + | {repo.name} | |
| 790 | + | </Link> | |
| 791 | + | </DropdownMenuItem> | |
| 792 | + | ))} | |
| 793 | + | </DropdownMenuContent> | |
| 794 | + | </DropdownMenu> | |
| 795 | + | ) : ( | |
| 796 | + | <Link | |
| 797 | + | to={workspace ? `/new?workspace=${workspace}` : "/new"} | |
| 798 | + | 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" | |
| 799 | + | > | |
| 800 | + | <Plus size={14} /> New project | |
| 801 | + | </Link> | |
| 802 | + | ); | |
| 482 | 803 | ||
| 483 | 804 | return ( | |
| 484 | − | <main className="mx-auto grid max-w-7xl gap-x-10 gap-y-10 px-4 py-8 sm:py-10 lg:grid-cols-[minmax(0,1fr)_19rem]"> | |
| 485 | − | <div className="min-w-0 space-y-10"> | |
| 486 | − | <header> | |
| 487 | − | <h1 className="text-2xl font-semibold tracking-tight sm:text-[1.75rem]" suppressHydrationWarning> | |
| 805 | + | <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10"> | |
| 806 | + | <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4"> | |
| 807 | + | <div className="min-w-0"> | |
| 808 | + | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight sm:text-[2rem]" suppressHydrationWarning> | |
| 488 | 809 | {greeting}, {loaded.name} | |
| 489 | 810 | </h1> | |
| 490 | − | <Digest parts={loaded.digest} seenBefore={loaded.seenBefore} /> | |
| 491 | − | </header> | |
| 811 | + | <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning> | |
| 812 | + | <span className="text-fg-soft">{date}</span> · {loaded.summary} | |
| 813 | + | </p> | |
| 814 | + | </div> | |
| 815 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 816 | + | {newIssue} | |
| 817 | + | {needs.length > 0 && ( | |
| 818 | + | <Link | |
| 819 | + | to={`${link({ tab: "needs", all: null })}#work`} | |
| 820 | + | preventScrollReset | |
| 821 | + | className="inline-flex items-center gap-1.5 rounded-md bg-merged px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-[#c9bfff]" | |
| 822 | + | > | |
| 823 | + | Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} /> | |
| 824 | + | </Link> | |
| 825 | + | )} | |
| 826 | + | </div> | |
| 827 | + | </header> | |
| 492 | 828 | ||
| 493 | − | {starting && <GetStarted steps={steps} />} | |
| 829 | + | {starting && <GetStarted steps={steps} />} | |
| 494 | 830 | ||
| 495 | − | <Panel id="needs-you" title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}> | |
| 496 | − | {needs.length === 0 ? ( | |
| 497 | − | <Quiet>Nothing is waiting on you. Reviews, stopped work and failed deploys show up here first.</Quiet> | |
| 498 | − | ) : ( | |
| 499 | − | <NeedsList needs={needs} /> | |
| 500 | − | )} | |
| 501 | − | </Panel> | |
| 831 | + | <section | |
| 832 | + | aria-label="At a glance" | |
| 833 | + | className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-5" | |
| 834 | + | > | |
| 835 | + | <Stat | |
| 836 | + | label="Projects" | |
| 837 | + | value={stats.projects == null ? "—" : String(stats.projects)} | |
| 838 | + | hint={ | |
| 839 | + | stats.projectsThisMonth == null | |
| 840 | + | ? "in this workspace" | |
| 841 | + | : stats.projectsThisMonth > 0 | |
| 842 | + | ? `+${stats.projectsThisMonth} this month` | |
| 843 | + | : "none new this month" | |
| 844 | + | } | |
| 845 | + | /> | |
| 846 | + | <Stat | |
| 847 | + | label="Agents" | |
| 848 | + | value={loaded.runsLoaded || !workspace ? String(liveTotal) : "—"} | |
| 849 | + | dot={liveTotal > 0 ? "bg-accent animate-pulse" : undefined} | |
| 850 | + | hint={ | |
| 851 | + | liveTotal > 0 | |
| 852 | + | ? `live now · ${stats.agentHours < 10 ? stats.agentHours.toFixed(1) : Math.round(stats.agentHours)}h this week` | |
| 853 | + | : stats.agentHours > 0 | |
| 854 | + | ? `none live · ${stats.agentHours < 10 ? stats.agentHours.toFixed(1) : Math.round(stats.agentHours)}h this week` | |
| 855 | + | : "none live now" | |
| 856 | + | } | |
| 857 | + | /> | |
| 858 | + | <Stat | |
| 859 | + | label="Changes this week" | |
| 860 | + | value={loaded.perRepoLoaded ? String(week.total) : "—"} | |
| 861 | + | hint={ | |
| 862 | + | delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merged pull requests" | |
| 863 | + | } | |
| 864 | + | /> | |
| 865 | + | <Stat | |
| 866 | + | label="Landed without you" | |
| 867 | + | dot="bg-merged" | |
| 868 | + | value={share == null ? "—" : `${Math.round(share * 100)}%`} | |
| 869 | + | hint={share == null ? "nothing landed yet" : `${week.byAgents} of ${week.total}`} | |
| 870 | + | title="Merged by g1t, by auto-merge or the merge queue, with no person pressing merge." | |
| 871 | + | /> | |
| 872 | + | <div className="col-span-2 lg:col-span-1"> | |
| 873 | + | <Stat | |
| 874 | + | label="Need you" | |
| 875 | + | dot="bg-warn" | |
| 876 | + | value={String(needs.length)} | |
| 877 | + | hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"} | |
| 878 | + | /> | |
| 879 | + | </div> | |
| 880 | + | </section> | |
| 502 | 881 | ||
| 503 | − | <Panel | |
| 504 | − | id="live" | |
| 505 | − | title="Live now" | |
| 506 | − | icon={<span className={`block size-2 rounded-full ${liveTotal > 0 ? "animate-pulse bg-merged" : "bg-line-strong"}`} />} | |
| 507 | − | count={liveTotal} | |
| 508 | − | all={workspace ? { to: `/${workspace}/-/agents`, label: "Fleet" } : null} | |
| 509 | − | > | |
| 510 | − | {!loaded.runsLoaded && workspace ? ( | |
| 511 | − | <Unavailable what="Agent runs" /> | |
| 512 | − | ) : live.length === 0 ? ( | |
| 513 | − | <Quiet>No agent is at work right now. Assign an issue to g1t-agent and one starts on it in seconds.</Quiet> | |
| 514 | − | ) : ( | |
| 515 | − | <ul className="space-y-3"> | |
| 516 | − | {live.map((run) => ( | |
| 517 | − | <RunCard key={run.id} run={run} member showRepo /> | |
| 882 | + | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]"> | |
| 883 | + | <section id="work" className="min-w-0 scroll-mt-20 self-start overflow-hidden rounded-xl border border-line bg-surface"> | |
| 884 | + | <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3"> | |
| 885 | + | <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto [scrollbar-width:none]" aria-label="Mission control"> | |
| 886 | + | {(["needs", "waiting", "landed"] as const).map((value) => ( | |
| 887 | + | <Link | |
| 888 | + | key={value} | |
| 889 | + | to={link({ tab: value, all: null })} | |
| 890 | + | preventScrollReset | |
| 891 | + | aria-current={tab === value ? "page" : undefined} | |
| 892 | + | className={cn( | |
| 893 | + | "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", | |
| 894 | + | tab === value ? "border-merged font-medium text-fg" : "border-transparent text-muted hover:text-fg", | |
| 895 | + | )} | |
| 896 | + | > | |
| 897 | + | <span className="sm:hidden">{TAB_SHORT[value]}</span> | |
| 898 | + | <span className="hidden sm:inline">{TAB_LABEL[value]}</span> | |
| 899 | + | <span | |
| 900 | + | className={cn( | |
| 901 | + | "rounded-full px-1.5 text-[0.6875rem] tabular-nums", | |
| 902 | + | tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted", | |
| 903 | + | )} | |
| 904 | + | > | |
| 905 | + | {counts[value]} | |
| 906 | + | </span> | |
| 907 | + | </Link> | |
| 518 | 908 | ))} | |
| 519 | − | {liveTotal > live.length && workspace && ( | |
| 520 | − | <li className="text-xs text-muted"> | |
| 521 | − | <Link to={`/${workspace}/-/agents`} className="hover:text-fg"> | |
| 522 | − | {liveTotal - live.length} more at work in the fleet | |
| 523 | − | </Link> | |
| 524 | − | </li> | |
| 525 | − | )} | |
| 526 | − | </ul> | |
| 527 | − | )} | |
| 528 | − | </Panel> | |
| 909 | + | </nav> | |
| 910 | + | <DropdownMenu> | |
| 911 | + | <DropdownMenuTrigger | |
| 912 | + | 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" | |
| 913 | + | aria-label="Sort" | |
| 914 | + | > | |
| 915 | + | <ArrowDownWideNarrow size={14} /> | |
| 916 | + | <span className="hidden sm:inline">{SORT_LABEL[sort]}</span> | |
| 917 | + | </DropdownMenuTrigger> | |
| 918 | + | <DropdownMenuContent align="end"> | |
| 919 | + | {(["impact", "newest"] as const).map((value) => ( | |
| 920 | + | <DropdownMenuItem key={value} asChild> | |
| 921 | + | <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset> | |
| 922 | + | {sort === value ? <Check /> : <span className="size-4" />} | |
| 923 | + | {SORT_LABEL[value]} | |
| 924 | + | </Link> | |
| 925 | + | </DropdownMenuItem> | |
| 926 | + | ))} | |
| 927 | + | </DropdownMenuContent> | |
| 928 | + | </DropdownMenu> | |
| 929 | + | </div> | |
| 930 | + | ||
| 931 | + | {!loaded.perRepoLoaded && tab !== "waiting" ? ( | |
| 932 | + | <div className="p-4"> | |
| 933 | + | <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} /> | |
| 934 | + | </div> | |
| 935 | + | ) : null} | |
| 529 | 936 | ||
| 530 | − | <Panel | |
| 531 | − | title="This week" | |
| 532 | − | icon={<ActivityIcon size={14} />} | |
| 533 | − | all={workspace ? { to: `/${workspace}/-/usage`, label: "Usage" } : null} | |
| 534 | − | > | |
| 535 | − | <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5"> | |
| 536 | − | <PulseTile | |
| 537 | − | label="Changes landed" | |
| 538 | − | value={String(pulse.merged)} | |
| 539 | − | hint="last 7 days" | |
| 540 | − | values={pulse.mergedDays} | |
| 541 | − | format={(v) => `${v} landed`} | |
| 542 | − | /> | |
| 543 | − | <PulseTile | |
| 544 | − | label="Agent hours" | |
| 545 | − | value={pulse.hours < 10 ? pulse.hours.toFixed(1) : String(Math.round(pulse.hours))} | |
| 546 | − | hint="time agents spent working" | |
| 547 | − | values={pulse.hoursDays} | |
| 548 | − | format={(v) => `${v.toFixed(1)} h`} | |
| 549 | − | to={workspace ? `/${workspace}/-/agents` : undefined} | |
| 550 | − | /> | |
| 551 | − | <PulseTile | |
| 552 | − | label="Agent cost" | |
| 553 | − | value={formatCost(pulse.cost) ?? "$0.00"} | |
| 554 | − | hint="model and sandbox" | |
| 555 | − | values={pulse.costDays} | |
| 556 | − | format={(v) => formatCost(v) ?? "$0.00"} | |
| 557 | − | to={workspace ? `/${workspace}/-/usage` : undefined} | |
| 558 | − | /> | |
| 559 | − | <PulseTile | |
| 560 | − | label="Issue to landed" | |
| 561 | − | value={formatSpan(pulse.issueToMerge)} | |
| 562 | − | hint={pulse.mergedWithIssue ? `median of ${pulse.mergedWithIssue}` : "no issue landed yet"} | |
| 563 | − | /> | |
| 564 | − | <PulseTile | |
| 565 | − | label="Checks pass first time" | |
| 566 | − | value={percent(pulse.firstPass)} | |
| 567 | − | hint={pulse.firstPassOf ? `of ${pulse.firstPassOf} pull requests` : "no checks ran yet"} | |
| 568 | − | /> | |
| 569 | − | </div> | |
| 570 | − | </Panel> | |
| 937 | + | {tab === "needs" && | |
| 938 | + | (needs.length === 0 ? ( | |
| 939 | + | noProjects ? ( | |
| 940 | + | <Empty | |
| 941 | + | action={ | |
| 942 | + | <Link | |
| 943 | + | to={workspace ? `/new?workspace=${workspace}` : "/new"} | |
| 944 | + | className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-white" | |
| 945 | + | > | |
| 946 | + | Create or import a project | |
| 947 | + | </Link> | |
| 948 | + | } | |
| 949 | + | > | |
| 950 | + | <p className="font-medium text-fg">No projects yet</p> | |
| 951 | + | <p className="mt-1"> | |
| 952 | + | Create a repository or import one. Assign its issues to g1t-agent, and what needs you shows up here. | |
| 953 | + | </p> | |
| 954 | + | </Empty> | |
| 955 | + | ) : ( | |
| 956 | + | <> | |
| 957 | + | <Empty> | |
| 958 | + | <p className="font-medium text-fg">Nothing needs you</p> | |
| 959 | + | <p className="mt-1"> | |
| 960 | + | Agents are handling everything.{" "} | |
| 961 | + | {landed.length > 0 | |
| 962 | + | ? "Here's what they landed today." | |
| 963 | + | : "Reviews, stopped work and failed deploys show up here first."} | |
| 964 | + | </p> | |
| 965 | + | </Empty> | |
| 966 | + | {landed.length > 0 && ( | |
| 967 | + | <div className="border-t border-line"> | |
| 968 | + | <List> | |
| 969 | + | {landed.slice(0, ROWS).map((row) => ( | |
| 970 | + | <LandedCard key={row.key} row={row} first={false} /> | |
| 971 | + | ))} | |
| 972 | + | </List> | |
| 973 | + | </div> | |
| 974 | + | )} | |
| 975 | + | </> | |
| 976 | + | ) | |
| 977 | + | ) : ( | |
| 978 | + | <List> | |
| 979 | + | {(shown as NeedRow[]).map((row, index) => ( | |
| 980 | + | <NeedCard key={row.key} row={row} first={index === 0} /> | |
| 981 | + | ))} | |
| 982 | + | </List> | |
| 983 | + | ))} | |
| 571 | 984 | ||
| 572 | − | <div className="grid gap-10 xl:grid-cols-2"> | |
| 573 | − | <Tabs value={pullsTab} onValueChange={setPullsTab}> | |
| 574 | − | <Panel | |
| 575 | − | id="your-pulls" | |
| 576 | − | title="Your pull requests" | |
| 577 | − | icon={<GitPullRequest size={14} />} | |
| 578 | − | all={{ to: `/u/${viewer.username}?tab=pulls` }} | |
| 579 | − | > | |
| 580 | − | <TabsList> | |
| 581 | − | <TabsTrigger value="authored">Opened{counts(pullsTabs.authored.length)}</TabsTrigger> | |
| 582 | − | <TabsTrigger value="review">Review requested{counts(pullsTabs.review.length)}</TabsTrigger> | |
| 583 | − | <TabsTrigger value="assigned">Assigned{counts(pullsTabs.assigned.length)}</TabsTrigger> | |
| 584 | − | </TabsList> | |
| 585 | − | <TabsContent value="authored"> | |
| 586 | − | <PullList rows={pullsTabs.authored} empty="None open. Pull requests you or your agents start show here with where each stands." /> | |
| 587 | − | </TabsContent> | |
| 588 | − | <TabsContent value="review"> | |
| 589 | − | {loaded.perRepoLoaded ? ( | |
| 590 | − | <PullList rows={pullsTabs.review} empty="No one is waiting on your review." /> | |
| 591 | − | ) : ( | |
| 592 | − | <Unavailable what="Review requests" /> | |
| 593 | − | )} | |
| 594 | − | </TabsContent> | |
| 595 | − | <TabsContent value="assigned"> | |
| 596 | − | <PullList rows={pullsTabs.assigned} empty="No open pull request is assigned to you." /> | |
| 597 | − | </TabsContent> | |
| 598 | − | </Panel> | |
| 599 | − | </Tabs> | |
| 985 | + | {tab === "waiting" && | |
| 986 | + | (!loaded.runsLoaded && workspace ? ( | |
| 987 | + | <div className="p-4"> | |
| 988 | + | <Unavailable what="Agent runs" /> | |
| 989 | + | </div> | |
| 990 | + | ) : waiting.length === 0 ? ( | |
| 991 | + | <Empty> | |
| 992 | + | <p className="font-medium text-fg">No agent is at work right now</p> | |
| 993 | + | <p className="mt-1">Assign an issue to g1t-agent and one starts on it in seconds. Its run shows here while it works.</p> | |
| 994 | + | </Empty> | |
| 995 | + | ) : ( | |
| 996 | + | <List> | |
| 997 | + | {(shown as WaitingRow[]).map((row, index) => ( | |
| 998 | + | <WaitingCard key={row.key} row={row} first={index === 0} /> | |
| 999 | + | ))} | |
| 1000 | + | </List> | |
| 1001 | + | ))} | |
| 600 | 1002 | ||
| 601 | − | <Tabs defaultValue="assigned"> | |
| 602 | − | <Panel title="Your issues" icon={<CircleDot size={14} />} all={{ to: `/u/${viewer.username}?tab=issues` }}> | |
| 603 | − | <TabsList> | |
| 604 | − | <TabsTrigger value="assigned">Assigned{counts(issuesTabs.assigned.length)}</TabsTrigger> | |
| 605 | − | <TabsTrigger value="authored">Opened{counts(issuesTabs.authored.length)}</TabsTrigger> | |
| 606 | − | </TabsList> | |
| 607 | − | <TabsContent value="assigned"> | |
| 608 | − | <IssueList rows={issuesTabs.assigned} empty="Nothing is assigned to you." /> | |
| 609 | − | </TabsContent> | |
| 610 | − | <TabsContent value="authored"> | |
| 611 | − | <IssueList rows={issuesTabs.authored} empty="You have no open issues." /> | |
| 612 | − | </TabsContent> | |
| 613 | − | </Panel> | |
| 614 | − | </Tabs> | |
| 615 | − | </div> | |
| 1003 | + | {tab === "landed" && | |
| 1004 | + | loaded.perRepoLoaded && | |
| 1005 | + | (landed.length === 0 ? ( | |
| 1006 | + | <Empty> | |
| 1007 | + | <p className="font-medium text-fg">Nothing has landed today yet</p> | |
| 1008 | + | <p className="mt-1"> | |
| 1009 | + | {week.total > 0 | |
| 1010 | + | ? `${plural(week.total, "change")} landed in the last 7 days.` | |
| 1011 | + | : "Merged pull requests show up here the moment they land."} | |
| 1012 | + | </p> | |
| 1013 | + | </Empty> | |
| 1014 | + | ) : ( | |
| 1015 | + | <List> | |
| 1016 | + | {(shown as LandedRow[]).map((row, index) => ( | |
| 1017 | + | <LandedCard key={row.key} row={row} first={index === 0} /> | |
| 1018 | + | ))} | |
| 1019 | + | </List> | |
| 1020 | + | ))} | |
| 616 | 1021 | ||
| 617 | − | <Panel id="activity" title="Activity" icon={<ActivityIcon size={14} />}> | |
| 618 | − | {!loaded.perRepoLoaded ? ( | |
| 619 | − | <Unavailable what="Activity" /> | |
| 620 | − | ) : ( | |
| 621 | − | <ActivityFeed groups={groups} empty="Nothing has moved yet. Merges, deploys, checks, reviews and what agents learn show up here." /> | |
| 1022 | + | {rows.length > ROWS && ( | |
| 1023 | + | <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5"> | |
| 1024 | + | <span> | |
| 1025 | + | Showing {shown.length} of {rows.length} | |
| 1026 | + | </span> | |
| 1027 | + | <Link | |
| 1028 | + | to={link({ all: all ? null : "1" })} | |
| 1029 | + | preventScrollReset | |
| 1030 | + | className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg" | |
| 1031 | + | > | |
| 1032 | + | {all ? "Show fewer" : "See all"} <ChevronRight size={12} /> | |
| 1033 | + | </Link> | |
| 1034 | + | </div> | |
| 622 | 1035 | )} | |
| 623 | − | </Panel> | |
| 1036 | + | </section> | |
| 624 | 1037 | ||
| 625 | − | {workspace && ( | |
| 626 | − | <Panel | |
| 627 | − | id="projects" | |
| 628 | − | title="Projects" | |
| 629 | − | icon={<Box size={14} />} | |
| 630 | − | count={projects.length} | |
| 631 | − | all={{ to: `/${workspace}`, label: "Workspace" }} | |
| 632 | − | extra={ | |
| 633 | − | <Link to={`/new?workspace=${workspace}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 634 | − | <Plus size={12} /> New | |
| 1038 | + | <aside className="min-w-0 space-y-6"> | |
| 1039 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 1040 | + | <div className="flex items-center justify-between"> | |
| 1041 | + | <h2 className="text-base font-semibold tracking-tight">This week</h2> | |
| 1042 | + | {workspace && ( | |
| 1043 | + | <Link to={`/${workspace}/-/agents`} className="text-xs text-muted hover:text-fg"> | |
| 1044 | + | Fleet | |
| 1045 | + | </Link> | |
| 1046 | + | )} | |
| 1047 | + | </div> | |
| 1048 | + | <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div> | |
| 1049 | + | {workspace && stats.weekCost > 0 && ( | |
| 1050 | + | <Link | |
| 1051 | + | to={`/${workspace}/-/usage`} | |
| 1052 | + | className="mt-4 flex items-center justify-between border-t border-line pt-3 text-xs text-muted hover:text-fg" | |
| 1053 | + | > | |
| 1054 | + | <span>Agents and sandboxes cost {usd(stats.weekCost)} this week</span> | |
| 1055 | + | <ChevronRight size={12} /> | |
| 635 | 1056 | </Link> | |
| 636 | − | } | |
| 637 | − | > | |
| 638 | − | {!loaded.projectsLoaded ? ( | |
| 639 | − | <Unavailable what="Projects" /> | |
| 640 | − | ) : projects.length === 0 ? ( | |
| 641 | − | <Quiet>No projects yet. Create a repository or import one, and it gets a project of its own.</Quiet> | |
| 1057 | + | )} | |
| 1058 | + | </section> | |
| 1059 | + | ||
| 1060 | + | <section id="activity" className="scroll-mt-20 rounded-xl border border-line bg-surface p-5"> | |
| 1061 | + | <div className="flex items-center justify-between"> | |
| 1062 | + | <h2 className="text-base font-semibold tracking-tight">Activity</h2> | |
| 1063 | + | {groups.length > 8 && ( | |
| 1064 | + | <Link | |
| 1065 | + | to={`${link({ activity: everyActivity ? null : "all" })}#activity`} | |
| 1066 | + | preventScrollReset | |
| 1067 | + | className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg" | |
| 1068 | + | > | |
| 1069 | + | {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} /> | |
| 1070 | + | </Link> | |
| 1071 | + | )} | |
| 1072 | + | </div> | |
| 1073 | + | <p className="mt-1 flex items-center gap-3 text-[0.6875rem] text-faint"> | |
| 1074 | + | <span className="inline-flex items-center gap-1.5"> | |
| 1075 | + | <Avatar name="g1t-agent" size={12} /> agents | |
| 1076 | + | </span> | |
| 1077 | + | <span className="inline-flex items-center gap-1.5"> | |
| 1078 | + | <Avatar name={viewer.username} size={12} /> people | |
| 1079 | + | </span> | |
| 1080 | + | </p> | |
| 1081 | + | {!loaded.perRepoLoaded ? ( | |
| 1082 | + | <div className="mt-3"> | |
| 1083 | + | <Unavailable what="Activity" /> | |
| 1084 | + | </div> | |
| 1085 | + | ) : feed.length === 0 ? ( | |
| 1086 | + | <p className="mt-3 text-sm leading-6 text-muted"> | |
| 1087 | + | Nothing has moved yet. Merges, deploys, checks, reviews and what agents learn show up here. | |
| 1088 | + | </p> | |
| 642 | 1089 | ) : ( | |
| 643 | − | <ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | |
| 644 | − | {projects.map((project) => ( | |
| 645 | − | <ProjectCard key={project.slug} project={project} workspace={workspace} /> | |
| 1090 | + | <ol className="mt-2 divide-y divide-line/70"> | |
| 1091 | + | {feed.map((group) => ( | |
| 1092 | + | <FeedLine key={group.id} group={group} titles={titles} /> | |
| 646 | 1093 | ))} | |
| 647 | − | </ul> | |
| 1094 | + | </ol> | |
| 648 | 1095 | )} | |
| 649 | − | </Panel> | |
| 650 | − | )} | |
| 651 | − | </div> | |
| 1096 | + | </section> | |
| 652 | 1097 | ||
| 653 | − | <aside className="space-y-4"> | |
| 654 | − | {shell?.workspace && ( | |
| 655 | − | <WorkspaceCard | |
| 656 | − | slug={shell.workspace.slug.toLowerCase()} | |
| 657 | − | name={shell.workspace.name || shell.workspace.slug} | |
| 658 | − | avatar={shell.workspace.avatar} | |
| 659 | − | role={shell.workspace.role} | |
| 660 | − | projects={shell.repos.length} | |
| 661 | − | needs={needs.length} | |
| 662 | − | live={liveTotal} | |
| 663 | − | /> | |
| 664 | − | )} | |
| 665 | − | {shell?.workspace && <UsageCard shell={shell} weekCost={pulse.cost} />} | |
| 666 | − | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 667 | − | <h2 className="text-sm font-semibold">Connect your own agent</h2> | |
| 668 | − | <p className="mt-1.5 text-xs leading-5 text-muted"> | |
| 669 | − | Add g1t to your coding agent. It signs in through your browser; there is no token to copy. | |
| 670 | − | </p> | |
| 671 | − | <AgentSetup className="mt-3" /> | |
| 672 | − | <Link | |
| 673 | − | to="https://docs.g1t.sh/guides/bring-your-own-agent/" | |
| 674 | − | className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg" | |
| 675 | − | > | |
| 676 | − | How it works <ArrowRight size={12} /> | |
| 677 | − | </Link> | |
| 678 | − | </div> | |
| 679 | − | </aside> | |
| 1098 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 1099 | + | <h2 className="text-sm font-semibold">Connect your own agent</h2> | |
| 1100 | + | <p className="mt-1.5 text-xs leading-5 text-muted"> | |
| 1101 | + | Add g1t to your coding agent. It signs in through your browser; there is no token to copy. | |
| 1102 | + | </p> | |
| 1103 | + | <AgentSetup className="mt-3" /> | |
| 1104 | + | <Link | |
| 1105 | + | to="https://docs.g1t.sh/guides/bring-your-own-agent/" | |
| 1106 | + | className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg" | |
| 1107 | + | > | |
| 1108 | + | How it works <ArrowRight size={12} /> | |
| 1109 | + | </Link> | |
| 1110 | + | </section> | |
| 1111 | + | </aside> | |
| 1112 | + | </div> | |
| 680 | 1113 | </main> | |
| 681 | 1114 | ); | |
| 682 | 1115 | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | type Merged, | |
| 6 | + | change, | |
| 7 | + | dayKey, | |
| 8 | + | isTestFile, | |
| 9 | + | landedByAgents, | |
| 10 | + | landedToday, | |
| 11 | + | parseSort, | |
| 12 | + | parseTab, | |
| 13 | + | pullFacts, | |
| 14 | + | reachesBack, | |
| 15 | + | reasonFor, | |
| 16 | + | signedPercent, | |
| 17 | + | sortRows, | |
| 18 | + | stallReason, | |
| 19 | + | summaryLine, | |
| 20 | + | waitingRows, | |
| 21 | + | weekOf, | |
| 22 | + | whyFor, | |
| 23 | + | } from "./mission-control.ts"; | |
| 24 | + | ||
| 25 | + | const HOUR = 3_600_000; | |
| 26 | + | const DAY = 24 * HOUR; | |
| 27 | + | // Monday 2026-10-05, 18:00 UTC. | |
| 28 | + | const NOW = Date.parse("2026-10-05T18:00:00Z"); | |
| 29 | + | const repo = { namespace: "acme", name: "web" }; | |
| 30 | + | ||
| 31 | + | test("each kind of need gets its reason chip", () => { | |
| 32 | + | assert.equal(reasonFor({ kind: "deploy", detail: "" }), "blocking"); | |
| 33 | + | assert.equal(reasonFor({ kind: "limit", detail: "" }), "blocking"); | |
| 34 | + | assert.equal(reasonFor({ kind: "review", detail: "" }), "asked_for_you"); | |
| 35 | + | assert.equal(reasonFor({ kind: "invitation", detail: "" }), "asked_for_you"); | |
| 36 | + | assert.equal(reasonFor({ kind: "ready", detail: "" }), "ready_to_merge"); | |
| 37 | + | assert.equal(reasonFor({ kind: "checks", detail: "" }), "checks_failing"); | |
| 38 | + | assert.equal(reasonFor({ kind: "stuck", detail: "" }), "stalled"); | |
| 39 | + | }); | |
| 40 | + | ||
| 41 | + | test("a stopped pull request's reason comes from the sentence g1t stopped with", () => { | |
| 42 | + | assert.equal(stallReason("The acceptance checks still fail after the agent revised 3 times."), "checks_failing"); | |
| 43 | + | assert.equal(stallReason("CI / push still fails after the agent revised 2 times."), "checks_failing"); | |
| 44 | + | assert.equal(stallReason("The acceptance checks could not be run."), "checks_failing"); | |
| 45 | + | assert.equal( | |
| 46 | + | stallReason("g1t stopped the agent's implement run when it reached its cost cap. Raise the cap under Settings, Guardrails."), | |
| 47 | + | "outside_guardrails", | |
| 48 | + | ); | |
| 49 | + | assert.equal(stallReason("g1t stopped the agent's review run when it reached its time cap."), "outside_guardrails"); | |
| 50 | + | assert.equal(stallReason("g1t could not merge this: the branch moved"), "blocking"); | |
| 51 | + | assert.equal(stallReason("Needs 1 approving review from someone with write access."), "needs_review"); | |
| 52 | + | assert.equal(stallReason("The review still asks for changes after the agent revised 3 times."), "needs_review"); | |
| 53 | + | assert.equal(stallReason("alex asked for changes, and the agent has already revised 3 times."), "needs_review"); | |
| 54 | + | assert.equal(stallReason("syntaqx stopped the agent's implement run."), "stalled"); | |
| 55 | + | assert.equal(stallReason("g1t could not start the next step: no slot"), "stalled"); | |
| 56 | + | }); | |
| 57 | + | ||
| 58 | + | test("why a need needs a person is said for every reason", () => { | |
| 59 | + | for (const kind of ["deploy", "limit", "invitation", "review", "stuck", "checks", "ready", "conflict"] as const) { | |
| 60 | + | const need = { kind, detail: "" }; | |
| 61 | + | assert.ok(whyFor(reasonFor(need), need).length > 20, kind); | |
| 62 | + | } | |
| 63 | + | const stalled = { kind: "stalled" as const, detail: "It reached its cost cap." }; | |
| 64 | + | assert.match(whyFor(reasonFor(stalled), stalled), /Guardrails/); | |
| 65 | + | }); | |
| 66 | + | ||
| 67 | + | test("what the agent knows lists only what is known", () => { | |
| 68 | + | const facts = pullFacts({ | |
| 69 | + | checkStatus: "failed", | |
| 70 | + | files: [ | |
| 71 | + | { path: "src/retry.ts", additions: 40, deletions: 2 }, | |
| 72 | + | { path: "src/retry.test.ts", additions: 60, deletions: 0 }, | |
| 73 | + | ], | |
| 74 | + | lifecycle: { revisions: 2 }, | |
| 75 | + | runs: [ | |
| 76 | + | { kind: "implement", costUsd: 0.5 }, | |
| 77 | + | { kind: "revise", costUsd: 0.25 }, | |
| 78 | + | { kind: "checks", costUsd: null }, | |
| 79 | + | ], | |
| 80 | + | }); | |
| 81 | + | const by = Object.fromEntries(facts.map((f) => [f.label, f])); | |
| 82 | + | assert.equal(by.Checks.value, "Failing"); | |
| 83 | + | assert.equal(by.Checks.tone, "bad"); | |
| 84 | + | assert.equal(by["Files changed"].value, "2"); | |
| 85 | + | assert.equal(by.Lines.value, "+100 −2"); | |
| 86 | + | assert.equal(by.Tests.value, "1 file"); | |
| 87 | + | assert.equal(by["Sent back"].value, "2 times"); | |
| 88 | + | assert.equal(by["Agent runs"].value, "2 · $0.75"); | |
| 89 | + | ||
| 90 | + | const bare = pullFacts({ checkStatus: null, files: [] }); | |
| 91 | + | assert.deepEqual(bare, [{ label: "Checks", value: "Not run", tone: null }]); | |
| 92 | + | }); | |
| 93 | + | ||
| 94 | + | test("test files are recognised by the names test runners use", () => { | |
| 95 | + | assert.ok(isTestFile("src/a.test.ts")); | |
| 96 | + | assert.ok(isTestFile("src/__tests__/a.ts")); | |
| 97 | + | assert.ok(isTestFile("tests/api.rs")); | |
| 98 | + | assert.ok(isTestFile("pkg/store_test.go")); | |
| 99 | + | assert.ok(!isTestFile("src/testing-utils.ts")); | |
| 100 | + | assert.ok(!isTestFile("src/contest.ts")); | |
| 101 | + | }); | |
| 102 | + | ||
| 103 | + | const merged = (daysAgo: number, mergedBy: string | null, number = 1): Merged => ({ | |
| 104 | + | repo, | |
| 105 | + | number, | |
| 106 | + | title: `Change ${number}`, | |
| 107 | + | agent: "g1t-agent", | |
| 108 | + | mergedBy, | |
| 109 | + | mergedAt: new Date(NOW - daysAgo * DAY).toISOString(), | |
| 110 | + | files: [], | |
| 111 | + | }); | |
| 112 | + | ||
| 113 | + | test("a change landed without a person when g1t merged it", () => { | |
| 114 | + | assert.ok(landedByAgents({ mergedBy: "g1t" })); | |
| 115 | + | assert.ok(landedByAgents({ mergedBy: "g1t-agent" })); | |
| 116 | + | assert.ok(landedByAgents({ mergedBy: null })); | |
| 117 | + | assert.ok(!landedByAgents({ mergedBy: "syntaqx" })); | |
| 118 | + | }); | |
| 119 | + | ||
| 120 | + | test("the week is seven days, each split by who landed it, with the week before", () => { | |
| 121 | + | const week = weekOf( | |
| 122 | + | [merged(0, "g1t"), merged(0, "syntaqx"), merged(1, "g1t"), merged(6, "g1t"), merged(8, "g1t"), merged(10, "alex"), merged(20, "g1t")], | |
| 123 | + | NOW, | |
| 124 | + | "UTC", | |
| 125 | + | ); | |
| 126 | + | assert.equal(week.days.length, 7); | |
| 127 | + | assert.equal(week.days[6].key, "2026-10-05"); | |
| 128 | + | assert.equal(week.days[6].label, "Mon"); | |
| 129 | + | assert.deepEqual( | |
| 130 | + | week.days.map((d) => [d.agents, d.people]), | |
| 131 | + | [[1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 1]], | |
| 132 | + | ); | |
| 133 | + | assert.equal(week.total, 4); | |
| 134 | + | assert.equal(week.byAgents, 3); | |
| 135 | + | assert.equal(week.previous, 2); | |
| 136 | + | // Not knowing the week before is not the same as nothing in it. | |
| 137 | + | assert.equal(weekOf([merged(0, "g1t")], NOW, "UTC", false).previous, null); | |
| 138 | + | }); | |
| 139 | + | ||
| 140 | + | test("days follow the viewer's time zone", () => { | |
| 141 | + | // 02:00 UTC on the 5th is still the 4th in Los Angeles. | |
| 142 | + | const late = Date.parse("2026-10-05T02:00:00Z"); | |
| 143 | + | assert.equal(dayKey(late, "America/Los_Angeles"), "2026-10-04"); | |
| 144 | + | assert.equal(dayKey(late, "UTC"), "2026-10-05"); | |
| 145 | + | assert.equal(dayKey(late, "Not/AZone"), "2026-10-05"); | |
| 146 | + | const week = weekOf([{ mergedAt: new Date(late).toISOString(), mergedBy: "g1t" }], NOW, "America/Los_Angeles"); | |
| 147 | + | assert.equal(week.days.find((d) => d.key === "2026-10-04")?.agents, 1); | |
| 148 | + | }); | |
| 149 | + | ||
| 150 | + | test("the change from last week, and how it reads", () => { | |
| 151 | + | assert.equal(change(12, 10), 0.2); | |
| 152 | + | assert.equal(change(5, 0), null); | |
| 153 | + | assert.equal(change(5, null), null); | |
| 154 | + | assert.equal(signedPercent(0.2), "+20%"); | |
| 155 | + | assert.equal(signedPercent(-0.054), "−5%"); | |
| 156 | + | assert.equal(signedPercent(0.001), "0%"); | |
| 157 | + | }); | |
| 158 | + | ||
| 159 | + | test("a repository's merged list reaches back when it is not full or its oldest is old enough", () => { | |
| 160 | + | const since = NOW - 14 * DAY; | |
| 161 | + | const at = (days: number) => ({ mergedAt: new Date(NOW - days * DAY).toISOString(), updatedAt: new Date(NOW).toISOString() }); | |
| 162 | + | assert.ok(reachesBack([at(1), at(2)], since, 100)); | |
| 163 | + | assert.ok(reachesBack([at(1), at(20)], since, 2)); | |
| 164 | + | assert.ok(!reachesBack([at(1), at(3)], since, 2)); | |
| 165 | + | }); | |
| 166 | + | ||
| 167 | + | test("landed today is the viewer's calendar day, newest first", () => { | |
| 168 | + | const rows = landedToday([merged(0.1, "g1t", 3), merged(0.5, "syntaqx", 4), merged(1, "g1t", 2)], NOW, "UTC"); | |
| 169 | + | assert.deepEqual( | |
| 170 | + | rows.map((r) => [r.ref, r.byAgents]), | |
| 171 | + | [ | |
| 172 | + | ["#3", true], | |
| 173 | + | ["#4", false], | |
| 174 | + | ], | |
| 175 | + | ); | |
| 176 | + | assert.equal(rows[1].by?.name, "syntaqx"); | |
| 177 | + | }); | |
| 178 | + | ||
| 179 | + | test("waiting on agents holds each pull request once, running ones first, and nothing that needs you", () => { | |
| 180 | + | const pull = (number: number, minutesAgo: number) => ({ | |
| 181 | + | number, | |
| 182 | + | title: `Pull ${number}`, | |
| 183 | + | agent: "g1t-agent", | |
| 184 | + | updatedAt: new Date(NOW - minutesAgo * 60_000).toISOString(), | |
| 185 | + | checkStatus: null, | |
| 186 | + | files: [], | |
| 187 | + | }); | |
| 188 | + | const rows = waitingRows({ | |
| 189 | + | active: [ | |
| 190 | + | { pull: pull(1, 30), lifecycle: { stage: "checking", detail: "The acceptance checks are running.", revisions: 0 }, repo }, | |
| 191 | + | { pull: pull(2, 5), lifecycle: { stage: "needs_you", detail: "Stopped.", revisions: 0 }, repo }, | |
| 192 | + | { pull: pull(3, 50), lifecycle: { stage: "queued", detail: "In the merge queue.", revisions: 0 }, repo }, | |
| 193 | + | { pull: pull(9, 1), lifecycle: { stage: "reviewing", detail: "Reviewing.", revisions: 0 }, repo }, | |
| 194 | + | ], | |
| 195 | + | live: [ | |
| 196 | + | { | |
| 197 | + | id: "r1", | |
| 198 | + | repo, | |
| 199 | + | number: 3, | |
| 200 | + | title: "Pull 3", | |
| 201 | + | kind: "update", | |
| 202 | + | agent: "g1t-agent", | |
| 203 | + | step: "Merging main in", | |
| 204 | + | costUsd: 0.1, | |
| 205 | + | startedAt: new Date(NOW - 60_000).toISOString(), | |
| 206 | + | createdAt: new Date(NOW - 60_000).toISOString(), | |
| 207 | + | updatedAt: new Date(NOW - 10_000).toISOString(), | |
| 208 | + | }, | |
| 209 | + | { | |
| 210 | + | id: "r2", | |
| 211 | + | repo, | |
| 212 | + | number: null, | |
| 213 | + | title: null, | |
| 214 | + | kind: "plan", | |
| 215 | + | agent: "g1t-agent", | |
| 216 | + | step: null, | |
| 217 | + | costUsd: null, | |
| 218 | + | startedAt: null, | |
| 219 | + | createdAt: new Date(NOW - 120_000).toISOString(), | |
| 220 | + | updatedAt: new Date(NOW - 120_000).toISOString(), | |
| 221 | + | }, | |
| 222 | + | ], | |
| 223 | + | drafts: [ | |
| 224 | + | { ...pull(1, 30), repo }, | |
| 225 | + | { ...pull(4, 20), repo }, | |
| 226 | + | { ...pull(5, 20), agent: "syntaqx", repo }, | |
| 227 | + | ], | |
| 228 | + | needKeys: new Set(["acme/web#9"]), | |
| 229 | + | }); | |
| 230 | + | assert.deepEqual( | |
| 231 | + | rows.map((r) => [r.ref, r.chip, r.live]), | |
| 232 | + | [ | |
| 233 | + | ["#3", "In queue", true], | |
| 234 | + | [null, "Planning", true], | |
| 235 | + | ["#4", "Working", false], | |
| 236 | + | ["#1", "Checking", false], | |
| 237 | + | ], | |
| 238 | + | ); | |
| 239 | + | assert.equal(rows[0].detail, "Merging main in"); | |
| 240 | + | assert.equal(rows[0].run, "/acme/web/agents/runs/r1"); | |
| 241 | + | assert.equal(rows[1].title, "Planning in web"); | |
| 242 | + | }); | |
| 243 | + | ||
| 244 | + | test("tabs and sorting come from the address", () => { | |
| 245 | + | assert.equal(parseTab("waiting"), "waiting"); | |
| 246 | + | assert.equal(parseTab("nope"), null); | |
| 247 | + | assert.equal(parseSort("newest"), "newest"); | |
| 248 | + | assert.equal(parseSort(null), "impact"); | |
| 249 | + | const rows = [{ at: 1 }, { at: 3 }, { at: 2 }]; | |
| 250 | + | assert.deepEqual(sortRows(rows, "impact"), rows); | |
| 251 | + | assert.deepEqual( | |
| 252 | + | sortRows(rows, "newest").map((r) => r.at), | |
| 253 | + | [3, 2, 1], | |
| 254 | + | ); | |
| 255 | + | }); | |
| 256 | + | ||
| 257 | + | test("the summary says the week honestly", () => { | |
| 258 | + | assert.equal(summaryLine({ total: 47, byAgents: 39, live: 2, needs: 8 }), "Agents landed 39 of 47 changes this week without you."); | |
| 259 | + | assert.equal(summaryLine({ total: 3, byAgents: 3, live: 0, needs: 0 }), "Agents landed all 3 changes this week without you."); | |
| 260 | + | assert.equal(summaryLine({ total: 2, byAgents: 0, live: 0, needs: 0 }), "2 changes landed this week, each merged by a person."); | |
| 261 | + | assert.equal(summaryLine({ total: 0, byAgents: 0, live: 1, needs: 0 }), "1 agent is at work. Nothing has landed this week yet."); | |
| 262 | + | assert.match(summaryLine({ total: 0, byAgents: 0, live: 0, needs: 0 }), /Assign an issue/); | |
| 263 | + | }); |
| 1 | + | /** | |
| 2 | + | * The shaping behind mission control's dashboard: why each thing needs the | |
| 3 | + | * viewer, what an agent already knows about it, the three tabs, the week | |
| 4 | + | * split into what agents landed alone and what a person merged, and the | |
| 5 | + | * one sentence under the greeting. Pure, so it is tested on its own; it | |
| 6 | + | * imports only types. | |
| 7 | + | */ | |
| 8 | + | import type { AgentRun, CheckStatus, ChangedFile, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | import type { Need } from "./mission"; | |
| 11 | + | ||
| 12 | + | const DAY = 24 * 60 * 60 * 1000; | |
| 13 | + | ||
| 14 | + | /** Accounts that are g1t's own agents and machinery: `isAgent` in ./mission, kept here so this module imports only types. */ | |
| 15 | + | export function isAgent(name: string | null | undefined): boolean { | |
| 16 | + | return name === "g1t-agent" || name === "g1t" || (name ?? "").endsWith("-agent"); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // --- Reasons ---------------------------------------------------------------- | |
| 20 | + | ||
| 21 | + | /** Why something is waiting on a person, as the chip beside it says. */ | |
| 22 | + | export type Reason = "blocking" | "asked_for_you" | "checks_failing" | "outside_guardrails" | "needs_review" | "stalled" | "ready_to_merge"; | |
| 23 | + | ||
| 24 | + | export const REASON_LABEL: Record<Reason, string> = { | |
| 25 | + | blocking: "Blocking", | |
| 26 | + | asked_for_you: "Asked for you", | |
| 27 | + | checks_failing: "Checks failing", | |
| 28 | + | outside_guardrails: "Outside guardrails", | |
| 29 | + | needs_review: "Needs review", | |
| 30 | + | stalled: "Stalled", | |
| 31 | + | ready_to_merge: "Ready to merge", | |
| 32 | + | }; | |
| 33 | + | ||
| 34 | + | /** | |
| 35 | + | * The reason a need is shown, from its kind and, for a pull request g1t | |
| 36 | + | * stopped seeing through, the sentence it stopped with. | |
| 37 | + | */ | |
| 38 | + | export function reasonFor(need: Pick<Need, "kind" | "detail">): Reason { | |
| 39 | + | switch (need.kind) { | |
| 40 | + | case "limit": | |
| 41 | + | case "deploy": | |
| 42 | + | case "conflict": | |
| 43 | + | return "blocking"; | |
| 44 | + | case "invitation": | |
| 45 | + | case "review": | |
| 46 | + | return "asked_for_you"; | |
| 47 | + | case "checks": | |
| 48 | + | return "checks_failing"; | |
| 49 | + | case "ready": | |
| 50 | + | return "ready_to_merge"; | |
| 51 | + | case "stuck": | |
| 52 | + | return "stalled"; | |
| 53 | + | case "stalled": | |
| 54 | + | return stallReason(need.detail); | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /** What a pull request's `needs_you` sentence says it is waiting for. */ | |
| 59 | + | export function stallReason(detail: string): Reason { | |
| 60 | + | if (/cost cap|time cap|unusual CPU/i.test(detail)) return "outside_guardrails"; | |
| 61 | + | if (/conflict|could not (?:be )?merge/i.test(detail)) return "blocking"; | |
| 62 | + | if (/checks? (?:still )?fail|still fails|could not be run/i.test(detail)) return "checks_failing"; | |
| 63 | + | if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review"; | |
| 64 | + | return "stalled"; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** Why only a person can move it: the callout beside what the agent knows. */ | |
| 68 | + | export function whyFor(reason: Reason, need: Pick<Need, "kind" | "detail">): string { | |
| 69 | + | if (need.kind === "limit") return "Agents start nothing new past the workspace's usage limit. Only an owner can raise it or add a card."; | |
| 70 | + | if (need.kind === "deploy") | |
| 71 | + | return "Production still serves the build before this one. Every push to the default branch builds again, so this fails until what broke is fixed."; | |
| 72 | + | if (need.kind === "invitation") return "An invitation is to you. No one else can accept it."; | |
| 73 | + | if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict."; | |
| 74 | + | if (need.kind === "stuck") | |
| 75 | + | return "A running agent has stopped reporting. It may be working on something long, or stuck; a look at its session tells which."; | |
| 76 | + | switch (reason) { | |
| 77 | + | case "outside_guardrails": | |
| 78 | + | return "The run reached a limit set in Guardrails. g1t does not lift a cap on its own; a person raises it, then asks for the next step."; | |
| 79 | + | case "blocking": | |
| 80 | + | return "g1t could not land it and stopped rather than guess. A person decides how it lands, or whether it should."; | |
| 81 | + | case "checks_failing": | |
| 82 | + | return "The agent revised and the checks still fail, so g1t stopped sending it back instead of looping. Guide it, fix it yourself, or close it."; | |
| 83 | + | case "needs_review": | |
| 84 | + | return "This repository wants a person's verdict before it lands, and the agent has done what it can without one."; | |
| 85 | + | case "ready_to_merge": | |
| 86 | + | return "Checks passed and it was approved. This repository lands a change only when a person merges it."; | |
| 87 | + | case "stalled": | |
| 88 | + | return "The agent stopped and g1t does not start it again on its own. Ask for a review, a revision or a catch-up, or close it."; | |
| 89 | + | case "asked_for_you": | |
| 90 | + | return "It was put to you by name."; | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** Reasons where nothing moves until a person acts on it. */ | |
| 95 | + | export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]); | |
| 96 | + | ||
| 97 | + | // --- What the agent knows --------------------------------------------------- | |
| 98 | + | ||
| 99 | + | export type FactTone = "good" | "warn" | "bad" | null; | |
| 100 | + | export type Fact = { label: string; value: string; tone: FactTone }; | |
| 101 | + | ||
| 102 | + | const CHECKS: Record<CheckStatus, { text: string; tone: FactTone }> = { | |
| 103 | + | passed: { text: "Passed", tone: "good" }, | |
| 104 | + | failed: { text: "Failing", tone: "bad" }, | |
| 105 | + | errored: { text: "Could not run", tone: "bad" }, | |
| 106 | + | running: { text: "Running", tone: null }, | |
| 107 | + | queued: { text: "Queued", tone: null }, | |
| 108 | + | }; | |
| 109 | + | ||
| 110 | + | /** Test files, by the names test runners look for. */ | |
| 111 | + | export function isTestFile(path: string): boolean { | |
| 112 | + | return /(^|\/)(__tests__|tests?|spec)\//i.test(path) || /[._-](test|spec)\.[a-z0-9]+$/i.test(path) || /_test\.(go|rs|py)$/i.test(path); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 116 | + | ||
| 117 | + | /** "$0.42", or "<$0.01" for a sliver. */ | |
| 118 | + | export function usd(value: number): string { | |
| 119 | + | if (value > 0 && value < 0.01) return "<$0.01"; | |
| 120 | + | return `$${value.toFixed(2)}`; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /** | |
| 124 | + | * What is known about a pull request without opening it: its checks, how | |
| 125 | + | * much it changes, the tests it touches, how often the agent was sent back, | |
| 126 | + | * and what its runs cost. Only what is known is listed. | |
| 127 | + | */ | |
| 128 | + | export function pullFacts(input: { | |
| 129 | + | checkStatus: CheckStatus | null; | |
| 130 | + | files: ChangedFile[]; | |
| 131 | + | lifecycle?: Pick<Lifecycle, "revisions"> | null; | |
| 132 | + | runs?: Pick<AgentRun, "costUsd" | "kind">[]; | |
| 133 | + | }): Fact[] { | |
| 134 | + | const facts: Fact[] = []; | |
| 135 | + | const checks = input.checkStatus ? CHECKS[input.checkStatus] : null; | |
| 136 | + | facts.push({ label: "Checks", value: checks?.text ?? "Not run", tone: checks?.tone ?? null }); | |
| 137 | + | if (input.files.length > 0) { | |
| 138 | + | const added = input.files.reduce((sum, f) => sum + f.additions, 0); | |
| 139 | + | const removed = input.files.reduce((sum, f) => sum + f.deletions, 0); | |
| 140 | + | facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null }); | |
| 141 | + | facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null }); | |
| 142 | + | const tests = input.files.filter((f) => isTestFile(f.path)).length; | |
| 143 | + | facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" }); | |
| 144 | + | } | |
| 145 | + | if (input.lifecycle && input.lifecycle.revisions > 0) { | |
| 146 | + | facts.push({ | |
| 147 | + | label: "Sent back", | |
| 148 | + | value: plural(input.lifecycle.revisions, "time"), | |
| 149 | + | tone: input.lifecycle.revisions > 1 ? "warn" : null, | |
| 150 | + | }); | |
| 151 | + | } | |
| 152 | + | const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck"); | |
| 153 | + | if (agentRuns.length > 0) { | |
| 154 | + | const costs = agentRuns.filter((run) => run.costUsd != null); | |
| 155 | + | const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0); | |
| 156 | + | facts.push({ | |
| 157 | + | label: "Agent runs", | |
| 158 | + | value: costs.length > 0 ? `${agentRuns.length} · ${usd(cost)}` : String(agentRuns.length), | |
| 159 | + | tone: null, | |
| 160 | + | }); | |
| 161 | + | } | |
| 162 | + | return facts; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // --- Rows ------------------------------------------------------------------- | |
| 166 | + | ||
| 167 | + | /** Someone or something on a row: an agent, a person, or g1t itself. */ | |
| 168 | + | export type Who = { name: string; agent: boolean }; | |
| 169 | + | ||
| 170 | + | export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null); | |
| 171 | + | ||
| 172 | + | /** A form on mission control that acts without leaving it. */ | |
| 173 | + | export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string }; | |
| 174 | + | ||
| 175 | + | /** One thing that needs the viewer, with what is known about it. */ | |
| 176 | + | export type NeedRow = { | |
| 177 | + | key: string; | |
| 178 | + | reason: Reason; | |
| 179 | + | repo: RepoPath | null; | |
| 180 | + | /** "#41", when it is an issue or pull request. */ | |
| 181 | + | ref: string | null; | |
| 182 | + | title: string; | |
| 183 | + | /** The ask, in one sentence. */ | |
| 184 | + | ask: string; | |
| 185 | + | /** Who is waiting: the agent on it, or the person who asked. */ | |
| 186 | + | by: Who | null; | |
| 187 | + | /** The person who started it, when an agent did the work. */ | |
| 188 | + | for: string | null; | |
| 189 | + | at: number; | |
| 190 | + | to: string; | |
| 191 | + | open: string; | |
| 192 | + | facts: Fact[]; | |
| 193 | + | why: string; | |
| 194 | + | quick: QuickAction | null; | |
| 195 | + | /** Somewhere else to act, when that is the next step. */ | |
| 196 | + | link: { label: string; to: string } | null; | |
| 197 | + | }; | |
| 198 | + | ||
| 199 | + | /** The agents' stages a pull request can wait in, as their chips say them. */ | |
| 200 | + | export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = { | |
| 201 | + | working: "Working", | |
| 202 | + | checking: "Checking", | |
| 203 | + | reviewing: "Reviewing", | |
| 204 | + | revising: "Revising", | |
| 205 | + | catching_up: "Catching up", | |
| 206 | + | answering: "Answering", | |
| 207 | + | queued: "In queue", | |
| 208 | + | }; | |
| 209 | + | ||
| 210 | + | /** A run's kind, as its chip says it. */ | |
| 211 | + | export const RUN_LABEL: Record<RunKind, string> = { | |
| 212 | + | implement: "Working", | |
| 213 | + | revise: "Revising", | |
| 214 | + | review: "Reviewing", | |
| 215 | + | answer: "Answering", | |
| 216 | + | update: "Catching up", | |
| 217 | + | plan: "Planning", | |
| 218 | + | checks: "Checking", | |
| 219 | + | queue: "In queue", | |
| 220 | + | mergecheck: "Checking", | |
| 221 | + | }; | |
| 222 | + | ||
| 223 | + | /** Something agents are doing, which the viewer can leave to them. */ | |
| 224 | + | export type WaitingRow = { | |
| 225 | + | key: string; | |
| 226 | + | repo: RepoPath; | |
| 227 | + | ref: string | null; | |
| 228 | + | title: string; | |
| 229 | + | chip: string; | |
| 230 | + | detail: string; | |
| 231 | + | by: Who | null; | |
| 232 | + | at: number; | |
| 233 | + | to: string; | |
| 234 | + | /** Whether a run is going on it right now. */ | |
| 235 | + | live: boolean; | |
| 236 | + | /** The run going on it, to watch. */ | |
| 237 | + | run: string | null; | |
| 238 | + | facts: Fact[]; | |
| 239 | + | }; | |
| 240 | + | ||
| 241 | + | const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase(); | |
| 242 | + | ||
| 243 | + | /** | |
| 244 | + | * What is in agents' hands: pull requests in an agent's stage, runs going | |
| 245 | + | * now, and drafts agents are still making. Each once, and nothing that is | |
| 246 | + | * already waiting on the viewer. | |
| 247 | + | */ | |
| 248 | + | export function waitingRows(input: { | |
| 249 | + | active: { | |
| 250 | + | pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] }; | |
| 251 | + | lifecycle: Lifecycle | null; | |
| 252 | + | repo: RepoPath; | |
| 253 | + | }[]; | |
| 254 | + | live: Pick< | |
| 255 | + | AgentRun, | |
| 256 | + | "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt" | |
| 257 | + | >[]; | |
| 258 | + | drafts: { | |
| 259 | + | number: number; | |
| 260 | + | title: string; | |
| 261 | + | agent: string; | |
| 262 | + | updatedAt: string; | |
| 263 | + | checkStatus: CheckStatus | null; | |
| 264 | + | files: ChangedFile[]; | |
| 265 | + | repo: RepoPath; | |
| 266 | + | }[]; | |
| 267 | + | needKeys: ReadonlySet<string>; | |
| 268 | + | }): WaitingRow[] { | |
| 269 | + | const rows = new Map<string, WaitingRow>(); | |
| 270 | + | for (const { pull, lifecycle, repo } of input.active) { | |
| 271 | + | if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue; | |
| 272 | + | const key = pathKey(repo, pull.number); | |
| 273 | + | if (input.needKeys.has(key)) continue; | |
| 274 | + | rows.set(key, { | |
| 275 | + | key, | |
| 276 | + | repo, | |
| 277 | + | ref: `#${pull.number}`, | |
| 278 | + | title: pull.title, | |
| 279 | + | chip: STAGE_CHIP[lifecycle.stage], | |
| 280 | + | detail: lifecycle.detail, | |
| 281 | + | by: who(pull.agent), | |
| 282 | + | at: Date.parse(pull.updatedAt), | |
| 283 | + | to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`, | |
| 284 | + | live: false, | |
| 285 | + | run: null, | |
| 286 | + | facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }), | |
| 287 | + | }); | |
| 288 | + | } | |
| 289 | + | for (const run of input.live) { | |
| 290 | + | const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`; | |
| 291 | + | if (input.needKeys.has(key)) continue; | |
| 292 | + | const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`; | |
| 293 | + | const existing = rows.get(key); | |
| 294 | + | const started = Date.parse(run.startedAt ?? run.createdAt); | |
| 295 | + | const runFacts: Fact[] = [ | |
| 296 | + | { label: "Run", value: RUN_LABEL[run.kind], tone: null }, | |
| 297 | + | ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []), | |
| 298 | + | ]; | |
| 299 | + | if (existing) { | |
| 300 | + | existing.live = true; | |
| 301 | + | existing.run = runTo; | |
| 302 | + | if (run.step) existing.detail = run.step; | |
| 303 | + | existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")]; | |
| 304 | + | existing.at = Math.max(existing.at, Date.parse(run.updatedAt)); | |
| 305 | + | continue; | |
| 306 | + | } | |
| 307 | + | rows.set(key, { | |
| 308 | + | key, | |
| 309 | + | repo: run.repo, | |
| 310 | + | ref: run.number != null ? `#${run.number}` : null, | |
| 311 | + | title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`, | |
| 312 | + | chip: RUN_LABEL[run.kind], | |
| 313 | + | detail: run.step ?? "Starting.", | |
| 314 | + | by: who(run.agent), | |
| 315 | + | at: Number.isFinite(started) ? started : Date.parse(run.updatedAt), | |
| 316 | + | to: runTo, | |
| 317 | + | live: true, | |
| 318 | + | run: runTo, | |
| 319 | + | facts: runFacts, | |
| 320 | + | }); | |
| 321 | + | } | |
| 322 | + | for (const draft of input.drafts) { | |
| 323 | + | const key = pathKey(draft.repo, draft.number); | |
| 324 | + | if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue; | |
| 325 | + | rows.set(key, { | |
| 326 | + | key, | |
| 327 | + | repo: draft.repo, | |
| 328 | + | ref: `#${draft.number}`, | |
| 329 | + | title: draft.title, | |
| 330 | + | chip: "Working", | |
| 331 | + | detail: `${draft.agent} is making the change.`, | |
| 332 | + | by: who(draft.agent), | |
| 333 | + | at: Date.parse(draft.updatedAt), | |
| 334 | + | to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`, | |
| 335 | + | live: false, | |
| 336 | + | run: null, | |
| 337 | + | facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }), | |
| 338 | + | }); | |
| 339 | + | } | |
| 340 | + | // Running now first, then what moved most recently. | |
| 341 | + | return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at); | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | /** The key a need is known by when matching it to agents' work. */ | |
| 345 | + | export const needPathKey = pathKey; | |
| 346 | + | ||
| 347 | + | // --- Landed ----------------------------------------------------------------- | |
| 348 | + | ||
| 349 | + | /** A merged pull request, as the week counts it. */ | |
| 350 | + | export type Merged = { | |
| 351 | + | repo: RepoPath; | |
| 352 | + | number: number; | |
| 353 | + | title: string; | |
| 354 | + | agent: string; | |
| 355 | + | mergedBy: string | null; | |
| 356 | + | mergedAt: string; | |
| 357 | + | files: ChangedFile[]; | |
| 358 | + | }; | |
| 359 | + | ||
| 360 | + | /** | |
| 361 | + | * Whether a change landed without a person: g1t merged it, by auto-merge or | |
| 362 | + | * from the merge queue, rather than someone pressing merge. | |
| 363 | + | */ | |
| 364 | + | export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean { | |
| 365 | + | return change.mergedBy == null || isAgent(change.mergedBy); | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | /** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */ | |
| 369 | + | export function dayKey(at: number, timeZone: string | null): string { | |
| 370 | + | try { | |
| 371 | + | return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at); | |
| 372 | + | } catch { | |
| 373 | + | return new Date(at).toISOString().slice(0, 10); | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | export type WeekDay = { key: string; label: string; agents: number; people: number }; | |
| 378 | + | ||
| 379 | + | export type Week = { | |
| 380 | + | days: WeekDay[]; | |
| 381 | + | /** Changes landed in the last seven days, and how many without a person. */ | |
| 382 | + | total: number; | |
| 383 | + | byAgents: number; | |
| 384 | + | /** The seven days before, or null when the lists read do not reach back that far. */ | |
| 385 | + | previous: number | null; | |
| 386 | + | }; | |
| 387 | + | ||
| 388 | + | /** | |
| 389 | + | * The last seven days in the viewer's zone, oldest first, each split into | |
| 390 | + | * what agents landed alone and what a person merged, and the week before | |
| 391 | + | * as one number. `complete` says whether what was read reaches back two | |
| 392 | + | * weeks; when it does not, the week before is not guessed. | |
| 393 | + | */ | |
| 394 | + | export function weekOf(changes: Pick<Merged, "mergedAt" | "mergedBy">[], now: number, timeZone: string | null, complete = true): Week { | |
| 395 | + | const days: WeekDay[] = []; | |
| 396 | + | const index = new Map<string, number>(); | |
| 397 | + | for (let back = 6; back >= 0; back -= 1) { | |
| 398 | + | const at = now - back * DAY; | |
| 399 | + | const key = dayKey(at, timeZone); | |
| 400 | + | if (index.has(key)) continue; | |
| 401 | + | index.set(key, days.length); | |
| 402 | + | let label: string; | |
| 403 | + | try { | |
| 404 | + | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at); | |
| 405 | + | } catch { | |
| 406 | + | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at); | |
| 407 | + | } | |
| 408 | + | days.push({ key, label, agents: 0, people: 0 }); | |
| 409 | + | } | |
| 410 | + | const oldest = days[0]?.key ?? ""; | |
| 411 | + | const twoWeeks = dayKey(now - 13 * DAY, timeZone); | |
| 412 | + | let previous = 0; | |
| 413 | + | for (const change of changes) { | |
| 414 | + | const at = Date.parse(change.mergedAt); | |
| 415 | + | if (!Number.isFinite(at) || at > now) continue; | |
| 416 | + | const key = dayKey(at, timeZone); | |
| 417 | + | const slot = index.get(key); | |
| 418 | + | if (slot != null) { | |
| 419 | + | if (landedByAgents(change)) days[slot].agents += 1; | |
| 420 | + | else days[slot].people += 1; | |
| 421 | + | } else if (key < oldest && key >= twoWeeks) { | |
| 422 | + | previous += 1; | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | const byAgents = days.reduce((sum, day) => sum + day.agents, 0); | |
| 426 | + | const total = byAgents + days.reduce((sum, day) => sum + day.people, 0); | |
| 427 | + | return { days, total, byAgents, previous: complete ? previous : null }; | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | /** The change from one number to another, as a share; null from nothing. */ | |
| 431 | + | export function change(current: number, previous: number | null): number | null { | |
| 432 | + | if (previous == null || previous === 0) return null; | |
| 433 | + | return (current - previous) / previous; | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | /** "+18%", "−5%", "0%". */ | |
| 437 | + | export function signedPercent(share: number): string { | |
| 438 | + | const n = Math.round(share * 100); | |
| 439 | + | return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%"; | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | /** | |
| 443 | + | * Whether the merged pull requests read for one repository reach back to | |
| 444 | + | * `since`: the list is not full, or its oldest goes back that far. | |
| 445 | + | */ | |
| 446 | + | export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean { | |
| 447 | + | if (list.length < page) return true; | |
| 448 | + | const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite)); | |
| 449 | + | return oldest <= since; | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /** One change that landed, as the third tab lists it. */ | |
| 453 | + | export type LandedRow = { | |
| 454 | + | key: string; | |
| 455 | + | repo: RepoPath; | |
| 456 | + | ref: string; | |
| 457 | + | title: string; | |
| 458 | + | by: Who | null; | |
| 459 | + | agent: string; | |
| 460 | + | byAgents: boolean; | |
| 461 | + | at: number; | |
| 462 | + | to: string; | |
| 463 | + | facts: Fact[]; | |
| 464 | + | }; | |
| 465 | + | ||
| 466 | + | /** What landed on the viewer's calendar day, newest first. */ | |
| 467 | + | export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] { | |
| 468 | + | const today = dayKey(now, timeZone); | |
| 469 | + | return changes | |
| 470 | + | .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today) | |
| 471 | + | .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt)) | |
| 472 | + | .map((change) => ({ | |
| 473 | + | key: pathKey(change.repo, change.number), | |
| 474 | + | repo: change.repo, | |
| 475 | + | ref: `#${change.number}`, | |
| 476 | + | title: change.title, | |
| 477 | + | by: who(change.mergedBy ?? "g1t"), | |
| 478 | + | agent: change.agent, | |
| 479 | + | byAgents: landedByAgents(change), | |
| 480 | + | at: Date.parse(change.mergedAt), | |
| 481 | + | to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`, | |
| 482 | + | facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Checks"), | |
| 483 | + | })); | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | // --- Tabs and sorting ------------------------------------------------------- | |
| 487 | + | ||
| 488 | + | export type Tab = "needs" | "waiting" | "landed"; | |
| 489 | + | export type Sort = "impact" | "newest"; | |
| 490 | + | ||
| 491 | + | export const TABS: Tab[] = ["needs", "waiting", "landed"]; | |
| 492 | + | ||
| 493 | + | export function parseTab(value: string | null): Tab | null { | |
| 494 | + | return value === "needs" || value === "waiting" || value === "landed" ? value : null; | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | export function parseSort(value: string | null): Sort { | |
| 498 | + | return value === "newest" ? "newest" : "impact"; | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | /** Rows in the order chosen: as ranked (most urgent first), or newest first. */ | |
| 502 | + | export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] { | |
| 503 | + | return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows; | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | // --- The summary ------------------------------------------------------------ | |
| 507 | + | ||
| 508 | + | /** The sentence under the greeting: the week, honestly, in one line. */ | |
| 509 | + | export function summaryLine(input: { total: number; byAgents: number; live: number; needs: number }): string { | |
| 510 | + | const { total, byAgents, live, needs } = input; | |
| 511 | + | if (total > 0) { | |
| 512 | + | const landed = | |
| 513 | + | byAgents === total | |
| 514 | + | ? `Agents landed all ${plural(total, "change")} this week without you.` | |
| 515 | + | : byAgents === 0 | |
| 516 | + | ? `${plural(total, "change")} landed this week, each merged by a person.` | |
| 517 | + | : `Agents landed ${byAgents} of ${plural(total, "change")} this week without you.`; | |
| 518 | + | return landed; | |
| 519 | + | } | |
| 520 | + | if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`; | |
| 521 | + | if (needs > 0) return "Nothing has landed this week. What is waiting on you is below."; | |
| 522 | + | return "Nothing has landed this week yet. Assign an issue to g1t-agent and it starts in seconds."; | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | /** "Monday, Oct 5", in the viewer's zone. */ | |
| 526 | + | export function dateLine(now: number, timeZone: string | null): string { | |
| 527 | + | const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" }; | |
| 528 | + | try { | |
| 529 | + | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now); | |
| 530 | + | } catch { | |
| 531 | + | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now); | |
| 532 | + | } | |
| 533 | + | } |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { Suspense, lazy } from "react"; | |
| 3 | − | import { data } from "react-router"; | |
| 3 | + | import { type ShouldRevalidateFunctionArgs, data } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { | |
| 6 | − | type AgentRun, | |
| 7 | − | type AuthoredItem, | |
| 8 | − | type CheckStatus, | |
| 9 | − | type Deployment, | |
| 10 | − | type G1tEvent, | |
| 11 | − | type Issue, | |
| 12 | 6 | type Lifecycle, | |
| 13 | − | type LiveApp, | |
| 14 | 7 | type Pull, | |
| 15 | − | type PullStatus, | |
| 16 | 8 | type Repo, | |
| 17 | 9 | type RepoPath, | |
| 18 | − | type Stage, | |
| 19 | 10 | REPO_ROLE_LABELS, | |
| 20 | 11 | isActiveRun, | |
| 21 | 12 | } from "@g1t/contracts"; | |
| 26 | 17 | import { | |
| 27 | 18 | type ActivityItem, | |
| 28 | 19 | type Need, | |
| 29 | − | SEEN_COOKIE, | |
| 30 | 20 | TIME, | |
| 31 | 21 | agentHours, | |
| 32 | 22 | dailyBuckets, | |
| 33 | − | digestParts, | |
| 34 | 23 | eventItem, | |
| 35 | − | firstPassRate, | |
| 36 | 24 | greetingFor, | |
| 37 | 25 | groupActivity, | |
| 38 | 26 | hourIn, | |
| 39 | − | issueToMerge, | |
| 40 | − | median, | |
| 41 | − | nextSeen, | |
| 27 | + | isAgent, | |
| 42 | 28 | rankNeeds, | |
| 43 | 29 | readCookie, | |
| 44 | 30 | stuckMinutes, | |
| 45 | 31 | } from "../lib/mission"; | |
| 32 | + | import { | |
| 33 | + | type Fact, | |
| 34 | + | type Merged, | |
| 35 | + | type NeedRow, | |
| 36 | + | type QuickAction, | |
| 37 | + | RUN_LABEL, | |
| 38 | + | dateLine, | |
| 39 | + | dayKey, | |
| 40 | + | landedToday, | |
| 41 | + | needPathKey, | |
| 42 | + | pullFacts, | |
| 43 | + | reachesBack, | |
| 44 | + | reasonFor, | |
| 45 | + | summaryLine, | |
| 46 | + | usd, | |
| 47 | + | waitingRows, | |
| 48 | + | weekOf, | |
| 49 | + | who, | |
| 50 | + | whyFor, | |
| 51 | + | } from "../lib/mission-control"; | |
| 46 | 52 | import { Landing } from "../components/landing"; | |
| 47 | 53 | import { | |
| 48 | 54 | agents, | |
| 61 | 67 | /** The most projects whose pull requests and events are read for the page. */ | |
| 62 | 68 | const MAX_PROJECTS = 10; | |
| 63 | 69 | const EVENTS_PER_PROJECT = 80; | |
| 70 | + | /** How many pull requests work lists at once (`LIST_PAGE` in services/work). */ | |
| 71 | + | const PULL_PAGE = 100; | |
| 64 | 72 | ||
| 65 | 73 | export function meta(args: Route.MetaArgs) { | |
| 66 | 74 | return page(args, { | |
| 79 | 87 | return out; | |
| 80 | 88 | } | |
| 81 | 89 | ||
| 82 | − | /** A row of the viewer's pull requests, whichever list it comes from. */ | |
| 83 | − | type PullRow = { | |
| 84 | − | key: string; | |
| 85 | − | repo: RepoPath; | |
| 86 | − | number: number; | |
| 87 | − | title: string; | |
| 88 | − | status: PullStatus; | |
| 89 | − | agent: string | null; | |
| 90 | − | stage: Stage | null; | |
| 91 | − | detail: string | null; | |
| 92 | − | checkStatus: CheckStatus | null; | |
| 93 | − | updatedAt: string; | |
| 94 | − | }; | |
| 95 | − | ||
| 96 | − | /** A row of the viewer's issues. */ | |
| 97 | − | type IssueRow = { | |
| 98 | − | key: string; | |
| 99 | − | repo: RepoPath; | |
| 100 | − | number: number; | |
| 101 | − | title: string; | |
| 102 | − | agent: string | null; | |
| 103 | − | queued: boolean; | |
| 104 | − | updatedAt: string; | |
| 105 | − | }; | |
| 106 | − | ||
| 107 | − | type ProjectHealth = { | |
| 108 | − | slug: string; | |
| 109 | − | name: string; | |
| 110 | − | private: boolean; | |
| 111 | − | repo: RepoPath | null; | |
| 112 | − | deploys: boolean; | |
| 113 | − | production: LiveApp | null; | |
| 114 | − | latest: Deployment | null; | |
| 115 | − | openPulls: number | null; | |
| 116 | − | agents: number; | |
| 117 | − | passRate: number | null; | |
| 118 | − | checks: number; | |
| 119 | − | }; | |
| 90 | + | /** | |
| 91 | + | * Switching tabs, the sort or the activity list changes only the address: | |
| 92 | + | * everything is already loaded. A refresh, a form, or anything else loads | |
| 93 | + | * again as usual. | |
| 94 | + | */ | |
| 95 | + | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { | |
| 96 | + | if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false; | |
| 97 | + | return defaultShouldRevalidate; | |
| 98 | + | } | |
| 120 | 99 | ||
| 121 | − | const pullRow = (pull: Pull, repo: RepoPath, lifecycle: Lifecycle | null): PullRow => ({ | |
| 122 | − | key: pull.id, | |
| 123 | − | repo, | |
| 124 | − | number: pull.number, | |
| 125 | − | title: pull.title, | |
| 126 | − | status: pull.status, | |
| 127 | − | agent: pull.agent, | |
| 128 | − | stage: lifecycle?.stage ?? null, | |
| 129 | − | detail: lifecycle?.detail ?? null, | |
| 130 | − | checkStatus: pull.checkStatus, | |
| 131 | − | updatedAt: pull.updatedAt, | |
| 132 | − | }); | |
| 100 | + | /** Where a need came from, so its row can say what is known about it. */ | |
| 101 | + | type Extra = Partial<Pick<NeedRow, "repo" | "ref" | "by" | "for" | "facts" | "quick" | "link" | "open">>; | |
| 133 | 102 | ||
| 134 | − | const authoredPull = (item: AuthoredItem): PullRow => ({ | |
| 135 | − | key: `${item.repo.namespace}/${item.repo.name}#${item.number}`, | |
| 136 | − | repo: item.repo, | |
| 137 | − | number: item.number, | |
| 138 | − | title: item.title, | |
| 139 | − | status: item.status ?? (item.draft ? "draft" : "open"), | |
| 140 | − | agent: null, | |
| 141 | − | stage: null, | |
| 142 | − | detail: null, | |
| 143 | − | checkStatus: null, | |
| 144 | − | updatedAt: item.updatedAt, | |
| 145 | − | }); | |
| 146 | − | ||
| 147 | 103 | export async function loader({ context, request }: Route.LoaderArgs) { | |
| 148 | 104 | const viewer = getViewer(context); | |
| 149 | 105 | if (!viewer) { | |
| 169 | 125 | const okOr = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null); | |
| 170 | 126 | ||
| 171 | 127 | const cookies = request.headers.get("cookie"); | |
| 172 | − | const seen = nextSeen(readCookie(cookies, SEEN_COOKIE), now); | |
| 173 | 128 | const tz = readCookie(cookies, TZ_COOKIE); | |
| 174 | 129 | const memberships = viewer.workspaces ?? []; | |
| 175 | 130 | // The workspace you chose, as the sidebar shows it (lib/workspace-choice.ts). | |
| 176 | 131 | const slug = chosenWorkspace(memberships, readCookie(cookies, WORKSPACE_COOKIE))?.slug ?? null; | |
| 177 | − | const mine = new Set(memberships.map((m) => m.slug.toLowerCase())); | |
| 178 | 132 | const username = viewer.username; | |
| 179 | 133 | ||
| 180 | 134 | const reposP = soft("repos", reposApi.list(viewer, { memberOnly: true })); | |
| 181 | − | // The workspace's projects' open pull requests and recent events, read | |
| 182 | − | // once each, all at once, as soon as the projects are known. | |
| 135 | + | // The chosen workspace's projects: their open and recently merged pull | |
| 136 | + | // requests and recent events, read once each, all at once, as soon as | |
| 137 | + | // the projects are known. | |
| 183 | 138 | const perRepoP = reposP.then((repos) => | |
| 184 | 139 | Promise.all( | |
| 185 | 140 | (repos ?? []) | |
| 186 | − | .filter((repo) => mine.has(repo.namespace.toLowerCase())) | |
| 141 | + | .filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase()) | |
| 187 | 142 | .slice(0, MAX_PROJECTS) | |
| 188 | 143 | .map(async (repo) => { | |
| 189 | 144 | const path = { namespace: repo.namespace, name: repo.name }; | |
| 190 | − | const [pulls, log] = await Promise.all([ | |
| 145 | + | const [pulls, closed, log] = await Promise.all([ | |
| 191 | 146 | work.listPulls(path, viewer, "open").catch(() => null), | |
| 147 | + | work.listPulls(path, viewer, "closed").catch(() => null), | |
| 192 | 148 | eventLog.list({ repoId: repo.id, limit: EVENTS_PER_PROJECT }).catch(() => null), | |
| 193 | 149 | ]); | |
| 194 | − | return { repo, pulls: pulls?.ok ? pulls.value.slice(0, 60) : null, events: log }; | |
| 150 | + | return { | |
| 151 | + | repo, | |
| 152 | + | pulls: pulls?.ok ? pulls.value.slice(0, 60) : null, | |
| 153 | + | closed: closed?.ok ? closed.value : null, | |
| 154 | + | events: log, | |
| 155 | + | }; | |
| 195 | 156 | }), | |
| 196 | 157 | ), | |
| 197 | 158 | ); | |
| 198 | 159 | ||
| 199 | − | const [repos, perRepo, active, assigned, models, profile, runs, overview, usage, authoredPulls, authoredIssues, projectList, memories, invitations] = | |
| 200 | − | await Promise.all([ | |
| 201 | − | reposP, | |
| 202 | − | soft("projects", perRepoP), | |
| 203 | − | soft("pulls", work.listActivePulls(viewer)), | |
| 204 | − | soft("assigned", work.listAssignedIssues(viewer)), | |
| 205 | − | slug ? soft("models", env.RUNNER.modelAccess(slug)) : null, | |
| 206 | − | soft("profile", identity.profile(username)), | |
| 207 | − | slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null, | |
| 208 | − | slug ? soft("deploys", deployments.overview(slug, viewer)) : null, | |
| 209 | − | slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null, | |
| 210 | − | soft("authoredPulls", work.byAuthor(username, viewer, { kind: "pull", state: "open", sort: "updated", limit: 10 })), | |
| 211 | − | soft("authoredIssues", work.byAuthor(username, viewer, { kind: "issue", state: "open", sort: "updated", limit: 10 })), | |
| 212 | − | slug ? soft("projectList", projectsApi.list(slug, viewer)) : null, | |
| 213 | − | slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null, | |
| 214 | − | // Repositories someone has invited the viewer to. | |
| 215 | − | soft("invitations", identity.myRepoInvitations(viewer)), | |
| 216 | − | ]); | |
| 160 | + | const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations] = await Promise.all([ | |
| 161 | + | reposP, | |
| 162 | + | soft("projects", perRepoP), | |
| 163 | + | soft("pulls", work.listActivePulls(viewer)), | |
| 164 | + | slug ? soft("models", env.RUNNER.modelAccess(slug)) : null, | |
| 165 | + | soft("profile", identity.profile(username)), | |
| 166 | + | slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null, | |
| 167 | + | slug ? soft("deploys", deployments.overview(slug, viewer)) : null, | |
| 168 | + | slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null, | |
| 169 | + | slug ? soft("projectList", projectsApi.list(slug, viewer)) : null, | |
| 170 | + | slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null, | |
| 171 | + | // Repositories someone has invited the viewer to. | |
| 172 | + | soft("invitations", identity.myRepoInvitations(viewer)), | |
| 173 | + | ]); | |
| 217 | 174 | ||
| 218 | 175 | const repoList = repos ?? []; | |
| 219 | − | // Each issue and pull request is shown under its repository. Most are in | |
| 220 | − | // the viewer's own, already listed; the rest are looked up once each. | |
| 176 | + | // Each pull request is shown under its repository. Most are in the | |
| 177 | + | // viewer's own, already listed; the rest are looked up once each. | |
| 221 | 178 | const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo])); | |
| 222 | − | const missing = [ | |
| 223 | − | ...new Set([...(assigned ?? []).map((issue) => issue.repoId), ...(active ?? []).map(({ pull }) => pull.repoId)]), | |
| 224 | − | ].filter((id) => !known.has(id)); | |
| 179 | + | const missing = [...new Set((active ?? []).map(({ pull }) => pull.repoId))].filter((id) => !known.has(id)); | |
| 225 | 180 | const looked = await Promise.all(missing.map((id) => reposApi.getById(id, viewer).catch(() => null))); | |
| 226 | 181 | for (const found of looked) if (found?.ok) known.set(found.value.id, found.value); | |
| 227 | 182 | const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name }); | |
| 183 | + | const inWorkspace = (repo: RepoPath) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase(); | |
| 228 | 184 | ||
| 229 | 185 | const activeList = (active ?? []).flatMap((item) => { | |
| 230 | 186 | const repo = known.get(item.pull.repoId); | |
| 234 | 190 | const liveRuns = runList.filter((run) => isActiveRun(run.status)); | |
| 235 | 191 | const overviewList = okOr(overview ?? null); | |
| 236 | 192 | const projectsOk = okOr(projectList ?? null); | |
| 193 | + | const runsOn = (repo: RepoPath, number: number) => | |
| 194 | + | runList.filter( | |
| 195 | + | (run) => run.number === number && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === `${repo.namespace}/${repo.name}`.toLowerCase(), | |
| 196 | + | ); | |
| 237 | 197 | ||
| 238 | − | // --- What the viewer's pull requests and issues are ---------------------- | |
| 239 | 198 | const lower = username.toLowerCase(); | |
| 240 | 199 | const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo }))); | |
| 241 | 200 | const reviewRequested = openPulls.filter( | |
| 244 | 203 | pull.author.username.toLowerCase() !== lower && | |
| 245 | 204 | pull.reviewers.some((name) => name.toLowerCase() === lower), | |
| 246 | 205 | ); | |
| 247 | − | const assignedPulls = openPulls.filter(({ pull }) => pull.assignees.some((name) => name.toLowerCase() === lower)); | |
| 248 | − | const authoredRows = | |
| 249 | − | activeList.length > 0 | |
| 250 | − | ? activeList.map(({ pull, lifecycle, repo }) => pullRow(pull, pathOf(repo), lifecycle)) | |
| 251 | − | : (okOr(authoredPulls)?.items ?? []).map(authoredPull); | |
| 252 | − | const pullsTabs = { | |
| 253 | − | authored: authoredRows.slice(0, 8), | |
| 254 | − | review: reviewRequested.slice(0, 8).map(({ pull, repo }) => pullRow(pull, pathOf(repo), null)), | |
| 255 | − | assigned: assignedPulls.slice(0, 8).map(({ pull, repo }) => pullRow(pull, pathOf(repo), null)), | |
| 206 | + | ||
| 207 | + | // --- Needs you ------------------------------------------------------------ | |
| 208 | + | const needs: Need[] = []; | |
| 209 | + | const extras = new Map<string, Extra>(); | |
| 210 | + | /** What every pull request's row shares: where it is, who is on it, what is known. */ | |
| 211 | + | const pullExtra = (pull: Pull, repo: Repo, lifecycle: Lifecycle | null): Extra => { | |
| 212 | + | const base = `/${repo.namespace}/${repo.name}`; | |
| 213 | + | const agentWork = isAgent(pull.agent); | |
| 214 | + | return { | |
| 215 | + | repo: pathOf(repo), | |
| 216 | + | ref: `#${pull.number}`, | |
| 217 | + | by: agentWork ? who(pull.agent) : who(pull.author.username), | |
| 218 | + | for: agentWork ? pull.author.username : null, | |
| 219 | + | facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle, runs: runsOn(repo, pull.number) }), | |
| 220 | + | open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}?tab=changes`, | |
| 221 | + | }; | |
| 256 | 222 | }; | |
| 257 | − | const issueRow = (issue: Issue, repo: RepoPath): IssueRow => ({ | |
| 258 | − | key: issue.id, | |
| 259 | − | repo, | |
| 260 | − | number: issue.number, | |
| 261 | − | title: issue.title, | |
| 262 | − | agent: issue.agent, | |
| 263 | − | queued: issue.queued, | |
| 264 | − | updatedAt: issue.updatedAt, | |
| 223 | + | const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({ | |
| 224 | + | ...quick, | |
| 225 | + | to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`, | |
| 265 | 226 | }); | |
| 266 | − | const issuesTabs = { | |
| 267 | − | assigned: (assigned ?? []).flatMap((issue) => { | |
| 268 | − | const repo = known.get(issue.repoId); | |
| 269 | − | return repo ? [issueRow(issue, pathOf(repo))] : []; | |
| 270 | − | }).slice(0, 8), | |
| 271 | − | authored: (okOr(authoredIssues)?.items ?? []).slice(0, 8).map((item) => ({ | |
| 272 | − | key: `${item.repo.namespace}/${item.repo.name}#${item.number}`, | |
| 273 | − | repo: item.repo, | |
| 274 | − | number: item.number, | |
| 275 | − | title: item.title, | |
| 276 | − | agent: null, | |
| 277 | − | queued: false, | |
| 278 | − | updatedAt: item.updatedAt, | |
| 279 | − | })), | |
| 280 | − | }; | |
| 227 | + | const approve = (repo: Repo, pull: Pull) => | |
| 228 | + | pullAction(repo, pull, { | |
| 229 | + | label: isAgent(pull.agent) ? "Approve the agent's change" : "Approve the change", | |
| 230 | + | fields: { action: "comment", verdict: "approve", body: "" }, | |
| 231 | + | done: "Approved", | |
| 232 | + | }); | |
| 281 | 233 | ||
| 282 | − | // --- Needs you ------------------------------------------------------------ | |
| 283 | − | const needs: Need[] = []; | |
| 284 | 234 | for (const invitation of invitations ?? []) { | |
| 285 | 235 | if (invitation.status !== "pending") continue; | |
| 236 | + | const key = `invitation:${invitation.id}`; | |
| 286 | 237 | needs.push({ | |
| 287 | − | key: `invitation:${invitation.id}`, | |
| 238 | + | key, | |
| 288 | 239 | kind: "invitation", | |
| 289 | 240 | title: `${invitation.invited_by ?? "Someone"} invited you to ${invitation.repo}`, | |
| 290 | 241 | detail: `With the ${REPO_ROLE_LABELS[invitation.role]} role. The invitation expires ${new Date(invitation.expires_at).toISOString().slice(0, 10)}.`, | |
| 293 | 244 | at: Date.parse(invitation.created_at), | |
| 294 | 245 | where: invitation.repo, | |
| 295 | 246 | }); | |
| 247 | + | const [namespace, name] = invitation.repo.split("/"); | |
| 248 | + | extras.set(key, { | |
| 249 | + | repo: namespace && name ? { namespace, name } : null, | |
| 250 | + | by: who(invitation.invited_by), | |
| 251 | + | facts: [ | |
| 252 | + | { label: "Role", value: REPO_ROLE_LABELS[invitation.role], tone: null }, | |
| 253 | + | { label: "Expires", value: new Date(invitation.expires_at).toISOString().slice(0, 10), tone: null }, | |
| 254 | + | ], | |
| 255 | + | }); | |
| 296 | 256 | } | |
| 297 | 257 | for (const entry of overviewList ?? []) { | |
| 298 | 258 | const latest = entry.latest; | |
| 299 | 259 | if (latest?.kind === "production" && latest.status === "failed" && slug) { | |
| 260 | + | const key = `deploy:${entry.slug}`; | |
| 261 | + | const to = `/${slug}/${entry.slug}/deployments/${latest.id}`; | |
| 300 | 262 | needs.push({ | |
| 301 | − | key: `deploy:${entry.slug}`, | |
| 263 | + | key, | |
| 302 | 264 | kind: "deploy", | |
| 303 | 265 | title: `Production build of ${entry.slug} failed`, | |
| 304 | 266 | detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.", | |
| 305 | − | to: `/${slug}/${entry.slug}/deployments/${latest.id}`, | |
| 267 | + | to, | |
| 306 | 268 | action: "See the build", | |
| 307 | 269 | at: Date.parse(latest.finishedAt ?? latest.createdAt), | |
| 308 | 270 | where: `${slug}/${entry.slug}`, | |
| 309 | 271 | }); | |
| 272 | + | const facts: Fact[] = [ | |
| 273 | + | { label: "Commit", value: latest.commit.slice(0, 7), tone: null }, | |
| 274 | + | { | |
| 275 | + | label: "Production", | |
| 276 | + | value: entry.production ? "Serving the build before" : "Not live yet", | |
| 277 | + | tone: entry.production ? "good" : "warn", | |
| 278 | + | }, | |
| 279 | + | ]; | |
| 280 | + | extras.set(key, { | |
| 281 | + | repo: { namespace: slug, name: entry.slug }, | |
| 282 | + | by: who(latest.createdBy), | |
| 283 | + | facts, | |
| 284 | + | link: { label: "Deployment settings", to: `/${slug}/${entry.slug}/settings/deployments` }, | |
| 285 | + | }); | |
| 310 | 286 | } | |
| 311 | 287 | } | |
| 312 | 288 | for (const { pull, lifecycle, repo } of activeList) { | |
| 313 | 289 | const where = `${repo.namespace}/${repo.name}#${pull.number}`; | |
| 314 | 290 | const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`; | |
| 291 | + | const key = `pull:${pull.id}`; | |
| 292 | + | const extra = pullExtra(pull, repo, lifecycle); | |
| 315 | 293 | if (lifecycle?.stage === "needs_you") { | |
| 316 | 294 | const conflict = /conflict/i.test(lifecycle.detail); | |
| 317 | − | needs.push({ | |
| 318 | − | key: `pull:${pull.id}`, | |
| 295 | + | const need: Need = { | |
| 296 | + | key, | |
| 319 | 297 | kind: conflict ? "conflict" : "stalled", | |
| 320 | 298 | title: pull.title, | |
| 321 | 299 | detail: lifecycle.detail, | |
| 323 | 301 | action: conflict ? "Resolve" : "Decide", | |
| 324 | 302 | at: Date.parse(pull.updatedAt), | |
| 325 | 303 | where, | |
| 304 | + | }; | |
| 305 | + | needs.push(need); | |
| 306 | + | const reason = reasonFor(need); | |
| 307 | + | extras.set(key, { | |
| 308 | + | ...extra, | |
| 309 | + | quick: | |
| 310 | + | reason === "needs_review" | |
| 311 | + | ? approve(repo, pull) | |
| 312 | + | : /could not be run/i.test(lifecycle.detail) | |
| 313 | + | ? pullAction(repo, pull, { label: "Run the checks again", fields: { action: "recheck" }, done: "Checks started" }) | |
| 314 | + | : null, | |
| 315 | + | link: reason === "outside_guardrails" ? { label: "Raise the cap", to: `/${repo.namespace}/${repo.name}/settings/guardrails` } : null, | |
| 326 | 316 | }); | |
| 327 | 317 | } else if (lifecycle?.stage === "ready") { | |
| 328 | − | needs.push({ key: `pull:${pull.id}`, kind: "ready", title: pull.title, detail: "Checks passed and it was approved. It lands when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where }); | |
| 318 | + | needs.push({ key, kind: "ready", title: pull.title, detail: "Checks passed and it was approved. It lands when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where }); | |
| 319 | + | extras.set(key, { | |
| 320 | + | ...extra, | |
| 321 | + | quick: pullAction(repo, pull, { | |
| 322 | + | label: isAgent(pull.agent) ? "Merge the agent's change" : "Merge it", | |
| 323 | + | fields: { action: "merge" }, | |
| 324 | + | done: "Merging", | |
| 325 | + | }), | |
| 326 | + | }); | |
| 329 | 327 | } else if (!lifecycle && pull.status === "open" && pull.checkStatus === "failed") { | |
| 330 | − | needs.push({ key: `pull:${pull.id}`, kind: "checks", title: pull.title, detail: "Its acceptance checks failed on the latest push.", to, action: "See checks", at: Date.parse(pull.updatedAt), where }); | |
| 328 | + | needs.push({ key, kind: "checks", title: pull.title, detail: "Its acceptance checks failed on the latest push.", to, action: "See checks", at: Date.parse(pull.updatedAt), where }); | |
| 329 | + | extras.set(key, { | |
| 330 | + | ...extra, | |
| 331 | + | quick: pullAction(repo, pull, { label: "Run the checks again", fields: { action: "recheck" }, done: "Checks started" }), | |
| 332 | + | }); | |
| 331 | 333 | } | |
| 332 | 334 | } | |
| 333 | 335 | for (const { pull, repo } of reviewRequested) { | |
| 336 | + | const key = `review:${pull.id}`; | |
| 334 | 337 | needs.push({ | |
| 335 | − | key: `review:${pull.id}`, | |
| 338 | + | key, | |
| 336 | 339 | kind: "review", | |
| 337 | 340 | title: pull.title, | |
| 338 | 341 | detail: `${pull.author.username} asked for your review.`, | |
| 341 | 344 | at: Date.parse(pull.updatedAt), | |
| 342 | 345 | where: `${repo.namespace}/${repo.name}#${pull.number}`, | |
| 343 | 346 | }); | |
| 347 | + | extras.set(key, { ...pullExtra(pull, repo, null), quick: approve(repo, pull) }); | |
| 344 | 348 | } | |
| 345 | − | let quietest: number | null = null; | |
| 346 | 349 | for (const run of liveRuns) { | |
| 347 | 350 | const minutes = stuckMinutes(run, now); | |
| 348 | 351 | if (minutes == null) continue; | |
| 349 | − | quietest = Math.max(quietest ?? 0, minutes); | |
| 352 | + | const key = `run:${run.id}`; | |
| 350 | 353 | needs.push({ | |
| 351 | − | key: `run:${run.id}`, | |
| 354 | + | key, | |
| 352 | 355 | kind: "stuck", | |
| 353 | 356 | title: run.title ?? `${run.agent}'s run`, | |
| 354 | 357 | detail: `${run.agent} has reported nothing for ${minutes} min${run.step ? `. Last: ${run.step}` : ""}.`, | |
| 357 | 360 | at: Date.parse(run.updatedAt), | |
| 358 | 361 | where: `${run.repo.namespace}/${run.repo.name}${run.number != null ? `#${run.number}` : ""}`, | |
| 359 | 362 | }); | |
| 363 | + | extras.set(key, { | |
| 364 | + | repo: run.repo, | |
| 365 | + | ref: run.number != null ? `#${run.number}` : null, | |
| 366 | + | by: who(run.agent), | |
| 367 | + | facts: [ | |
| 368 | + | { label: "Run", value: RUN_LABEL[run.kind], tone: null }, | |
| 369 | + | { label: "Quiet for", value: `${minutes} min`, tone: "warn" }, | |
| 370 | + | { label: "Steps so far", value: String(run.stepCount), tone: null }, | |
| 371 | + | ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []), | |
| 372 | + | ], | |
| 373 | + | open: run.number != null ? `/${run.repo.namespace}/${run.repo.name}/pull/${run.number}` : undefined, | |
| 374 | + | }); | |
| 360 | 375 | } | |
| 361 | 376 | ||
| 377 | + | const needRows: NeedRow[] = rankNeeds(needs).map((need) => { | |
| 378 | + | const extra = extras.get(need.key) ?? {}; | |
| 379 | + | const reason = reasonFor(need); | |
| 380 | + | return { | |
| 381 | + | key: need.key, | |
| 382 | + | reason, | |
| 383 | + | repo: extra.repo ?? null, | |
| 384 | + | ref: extra.ref ?? null, | |
| 385 | + | title: need.title, | |
| 386 | + | ask: need.detail, | |
| 387 | + | by: extra.by ?? null, | |
| 388 | + | for: extra.for ?? null, | |
| 389 | + | at: need.at, | |
| 390 | + | to: need.to, | |
| 391 | + | open: extra.open ?? need.to, | |
| 392 | + | facts: extra.facts ?? [], | |
| 393 | + | why: whyFor(reason, need), | |
| 394 | + | quick: extra.quick ?? null, | |
| 395 | + | link: extra.link ?? null, | |
| 396 | + | }; | |
| 397 | + | }); | |
| 398 | + | ||
| 399 | + | // --- Waiting on agents ---------------------------------------------------- | |
| 400 | + | const needKeys = new Set( | |
| 401 | + | needRows.flatMap((row) => (row.repo && row.ref ? [needPathKey(row.repo, Number(row.ref.slice(1)))] : [])), | |
| 402 | + | ); | |
| 403 | + | const waiting = waitingRows({ | |
| 404 | + | active: activeList.filter(({ repo }) => inWorkspace(repo)).map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo: pathOf(repo) })), | |
| 405 | + | live: liveRuns, | |
| 406 | + | drafts: openPulls.filter(({ pull }) => pull.status === "draft").map(({ pull, repo }) => ({ ...pull, repo: pathOf(repo) })), | |
| 407 | + | needKeys, | |
| 408 | + | }); | |
| 409 | + | ||
| 410 | + | // --- Landed --------------------------------------------------------------- | |
| 411 | + | const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) => | |
| 412 | + | (closed ?? []).flatMap((pull) => | |
| 413 | + | pull.status === "merged" && pull.mergedAt | |
| 414 | + | ? [{ repo: pathOf(repo), number: pull.number, title: pull.title, agent: pull.agent, mergedBy: pull.mergedBy, mergedAt: pull.mergedAt, files: pull.files }] | |
| 415 | + | : [], | |
| 416 | + | ), | |
| 417 | + | ); | |
| 418 | + | const twoWeeksAgo = now - 14 * TIME.DAY; | |
| 419 | + | const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE)); | |
| 420 | + | const week = weekOf(merged, now, tz, complete); | |
| 421 | + | const landed = landedToday(merged, now, tz); | |
| 422 | + | ||
| 362 | 423 | // --- Activity ------------------------------------------------------------- | |
| 363 | 424 | const items: ActivityItem[] = []; | |
| 364 | − | const allEvents: { repo: string; event: G1tEvent }[] = []; | |
| 365 | − | for (const { repo, events } of perRepo ?? []) { | |
| 425 | + | const titles: Record<string, string> = {}; | |
| 426 | + | const titleKey = (repo: RepoPath, number: number) => `${repo.namespace}/${repo.name}#${number}`.toLowerCase(); | |
| 427 | + | for (const { repo, pulls, closed, events } of perRepo ?? []) { | |
| 428 | + | for (const pull of [...(pulls ?? []), ...(closed ?? [])]) titles[titleKey(repo, pull.number)] = pull.title; | |
| 366 | 429 | for (const event of events ?? []) { | |
| 367 | − | allEvents.push({ repo: repo.id, event }); | |
| 430 | + | if (event.type === "issue.opened") titles[titleKey(repo, event.data.number)] ??= event.data.title; | |
| 368 | 431 | const item = eventItem(event, pathOf(repo)); | |
| 369 | 432 | if (item) items.push(item); | |
| 370 | 433 | } | |
| 397 | 460 | to: `/${slug}/-/memory`, | |
| 398 | 461 | }); | |
| 399 | 462 | } | |
| 400 | − | const groups = groupActivity(items).slice(0, 60); | |
| 401 | − | ||
| 402 | − | // --- Since you were last here -------------------------------------------- | |
| 403 | − | const since = seen.since ?? now - TIME.DAY; | |
| 404 | − | const after = (verb: ActivityItem["verb"]) => items.filter((item) => item.verb === verb && item.at > since).length; | |
| 405 | − | const digest = digestParts({ | |
| 406 | − | landed: after("landed"), | |
| 407 | − | reviews: reviewRequested.length, | |
| 408 | − | opened: after("opened_issue"), | |
| 409 | − | deploys: after("deployed"), | |
| 410 | − | failedDeploys: after("deploy_failed"), | |
| 411 | − | stuck: quietest, | |
| 412 | − | }); | |
| 463 | + | const groups = groupActivity(items).slice(0, 40); | |
| 464 | + | // Only the titles the feed names travel to the page. | |
| 465 | + | const shownTitles: Record<string, string> = {}; | |
| 466 | + | for (const group of groups) { | |
| 467 | + | for (const part of group.parts) { | |
| 468 | + | for (const number of part.numbers) { | |
| 469 | + | const key = titleKey(group.repo, number); | |
| 470 | + | if (titles[key]) shownTitles[key] = titles[key]; | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | } | |
| 413 | 474 | ||
| 414 | − | // --- Pulse ---------------------------------------------------------------- | |
| 415 | − | const week = allEvents.filter(({ event }) => Date.parse(event.time) >= weekAgo); | |
| 416 | − | const mergedWeek = week.filter(({ event }) => event.type === "pull.merged"); | |
| 417 | − | const spans = issueToMerge( | |
| 418 | − | allEvents.flatMap(({ repo, event }) => (event.type === "issue.opened" ? [{ repo, number: event.data.number, at: Date.parse(event.time) }] : [])), | |
| 419 | − | mergedWeek.flatMap(({ repo, event }) => (event.type === "pull.merged" ? [{ repo, issue: event.data.issue ?? null, at: Date.parse(event.time) }] : [])), | |
| 420 | − | ); | |
| 421 | − | const checkEvents = allEvents.flatMap(({ repo, event }) => | |
| 422 | − | event.type === "checks.completed" ? [{ repo, number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [], | |
| 423 | − | ); | |
| 424 | − | const firstPass = firstPassRate(checkEvents.filter((c) => c.at >= weekAgo)); | |
| 475 | + | // --- The strip ------------------------------------------------------------ | |
| 425 | 476 | const usageOk = okOr(usage ?? null); | |
| 426 | 477 | const costPoints = usageOk | |
| 427 | 478 | ? usageOk.byDay.map((slice) => ({ at: Date.parse(`${slice.key.split("/")[0]}T12:00:00Z`), value: slice.micros / 1_000_000 })) | |
| 428 | 479 | : runList.filter((run) => run.costUsd != null).map((run) => ({ at: Date.parse(run.createdAt), value: run.costUsd ?? 0 })); | |
| 429 | − | const costDays = dailyBuckets(costPoints, 7, now); | |
| 430 | − | const pulse = { | |
| 431 | − | merged: mergedWeek.length, | |
| 432 | − | mergedDays: dailyBuckets(mergedWeek.map(({ event }) => ({ at: Date.parse(event.time) })), 7, now), | |
| 433 | − | hours: agentHours(runList, weekAgo, now), | |
| 434 | − | hoursDays: dailyBuckets( | |
| 435 | − | runList | |
| 436 | − | .filter((run) => run.startedAt) | |
| 437 | − | .map((run) => ({ at: Date.parse(run.startedAt!), value: agentHours([run], weekAgo, now) })), | |
| 438 | − | 7, | |
| 439 | − | now, | |
| 440 | − | ), | |
| 441 | − | cost: costDays.reduce((sum, v) => sum + v, 0), | |
| 442 | − | costDays, | |
| 443 | − | issueToMerge: median(spans), | |
| 444 | − | mergedWithIssue: spans.length, | |
| 445 | − | firstPass: firstPass.rate, | |
| 446 | − | firstPassOf: firstPass.of, | |
| 447 | − | }; | |
| 480 | + | const weekCost = dailyBuckets(costPoints, 7, now).reduce((sum, v) => sum + v, 0); | |
| 481 | + | const month = dayKey(now, tz).slice(0, 7); | |
| 482 | + | const projectCount = projectsOk?.length ?? (perRepo != null ? perRepo.length : null); | |
| 483 | + | const projectsThisMonth = projectsOk ? projectsOk.filter((project) => dayKey(Date.parse(project.createdAt), tz).slice(0, 7) === month).length : null; | |
| 448 | 484 | ||
| 449 | − | // --- Projects ------------------------------------------------------------- | |
| 450 | − | const byRepoName = new Map((perRepo ?? []).map((entry) => [`${entry.repo.namespace}/${entry.repo.name}`.toLowerCase(), entry])); | |
| 451 | − | const projectsHealth: ProjectHealth[] = (projectsOk ?? []).slice(0, 12).map((project) => { | |
| 452 | − | const repo = project.source.kind === "hosted" ? project.source.repo : null; | |
| 453 | − | const key = repo ? `${repo.namespace}/${repo.name}`.toLowerCase() : ""; | |
| 454 | − | const entry = byRepoName.get(key); | |
| 455 | − | const deploy = overviewList?.find((d) => d.slug === project.slug) ?? null; | |
| 456 | − | const checks = (entry?.events ?? []).flatMap((event) => (event.type === "checks.completed" ? [event.data.status === "passed"] : [])); | |
| 457 | − | return { | |
| 458 | − | slug: project.slug, | |
| 459 | − | name: project.name, | |
| 460 | − | private: project.private, | |
| 461 | − | repo, | |
| 462 | − | deploys: deploy?.enabled ?? false, | |
| 463 | − | production: deploy?.production ?? null, | |
| 464 | − | latest: deploy?.latest ?? null, | |
| 465 | − | openPulls: entry?.pulls ? entry.pulls.length : null, | |
| 466 | − | agents: liveRuns.filter((run) => repo && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === key).length, | |
| 467 | − | passRate: checks.length ? checks.filter(Boolean).length / checks.length : null, | |
| 468 | − | checks: checks.length, | |
| 469 | − | }; | |
| 470 | − | }); | |
| 471 | − | ||
| 472 | 485 | const models_ = models ?? null; | |
| 473 | 486 | times.total = Date.now() - started; | |
| 474 | 487 | const serverTiming = Object.entries(times) | |
| 475 | 488 | .map(([name, ms]) => `${name};dur=${ms}`) | |
| 476 | 489 | .join(", "); | |
| 477 | − | const secure = new URL(request.url).protocol === "https:" ? "; Secure" : ""; | |
| 478 | 490 | return data( | |
| 479 | 491 | { | |
| 480 | 492 | signedIn: true as const, | |
| 481 | 493 | viewer, | |
| 482 | 494 | name: profile?.name?.trim() || username, | |
| 483 | 495 | greeting: greetingFor(hourIn(now, tz)), | |
| 484 | − | seenBefore: seen.since, | |
| 485 | − | repos: repoList, | |
| 496 | + | date: dateLine(now, tz), | |
| 497 | + | summary: summaryLine({ total: week.total, byAgents: week.byAgents, live: liveRuns.length, needs: needRows.length }), | |
| 498 | + | workspace: slug, | |
| 499 | + | repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf), | |
| 486 | 500 | canRunAgents: models_ == null || models_.hosted || models_.own != null, | |
| 487 | 501 | trial: models_?.own == null ? (models_?.trial ?? null) : null, | |
| 488 | − | active: activeList.map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo })), | |
| 489 | − | needs: rankNeeds(needs), | |
| 490 | − | live: liveRuns.slice(0, 12) as AgentRun[], | |
| 502 | + | handedOff: activeList.length > 0 || runList.length > 0 || merged.length > 0, | |
| 503 | + | needs: needRows, | |
| 504 | + | waiting, | |
| 505 | + | landed, | |
| 491 | 506 | liveTotal: liveRuns.length, | |
| 492 | 507 | runsLoaded: runs != null && runs.ok, | |
| 493 | − | pullsTabs, | |
| 494 | − | issuesTabs, | |
| 495 | 508 | perRepoLoaded: perRepo != null, | |
| 509 | + | stats: { | |
| 510 | + | projects: projectCount, | |
| 511 | + | projectsThisMonth, | |
| 512 | + | agentHours: agentHours(runList, weekAgo, now), | |
| 513 | + | weekCost, | |
| 514 | + | }, | |
| 515 | + | week, | |
| 496 | 516 | groups, | |
| 497 | − | digest, | |
| 498 | − | pulse, | |
| 499 | − | projects: projectsHealth, | |
| 500 | − | projectsLoaded: projectsOk != null, | |
| 517 | + | titles: shownTitles, | |
| 518 | + | // A run that is going makes the page worth refreshing on its own. | |
| 519 | + | changing: | |
| 520 | + | liveRuns.length > 0 || | |
| 521 | + | activeList.some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready"), | |
| 501 | 522 | }, | |
| 502 | 523 | { | |
| 503 | − | headers: { | |
| 504 | − | "Server-Timing": serverTiming, | |
| 505 | − | "Set-Cookie": `${SEEN_COOKIE}=${seen.value}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${secure}`, | |
| 506 | − | }, | |
| 524 | + | headers: { "Server-Timing": serverTiming }, | |
| 507 | 525 | }, | |
| 508 | 526 | ); | |
| 509 | 527 | } |
| 32 | 32 | // renamed workspace's old name answers with a 301, which git follows and | |
| 33 | 33 | // must see, so the redirect is never followed here. | |
| 34 | 34 | if (GIT_PATH.test(pathname)) { | |
| 35 | − | return env.REPOS.fetch(new Request(request, { redirect: "manual" })); | |
| 35 | + | return proxyGit(env, request); | |
| 36 | 36 | } | |
| 37 | 37 | const avatar = AVATAR_PATH.exec(pathname); | |
| 38 | 38 | if (avatar) { | |
| 49 | 49 | } satisfies ExportedHandler<Env>; | |
| 50 | 50 | ||
| 51 | 51 | /** | |
| 52 | + | * A git request, answered by the repos service. Its `Server-Timing` header | |
| 53 | + | * gains `repos`: how long the answer took to start from here, so the time | |
| 54 | + | * between this Worker and the repos service shows beside the steps the | |
| 55 | + | * repos service reports. | |
| 56 | + | */ | |
| 57 | + | async function proxyGit(env: Env, request: Request): Promise<Response> { | |
| 58 | + | const started = Date.now(); | |
| 59 | + | const answer = await env.REPOS.fetch(new Request(request, { redirect: "manual" })); | |
| 60 | + | const response = new Response(answer.body, answer); | |
| 61 | + | response.headers.append("server-timing", `repos;dur=${Date.now() - started}`); | |
| 62 | + | return response; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /** | |
| 52 | 66 | * An uploaded avatar. Its address is its hash, so it never changes and is | |
| 53 | 67 | * kept for good. It is served as nothing but an image: the stored type, | |
| 54 | 68 | * no sniffing, and a policy that lets nothing in it run. |
| 45 | 45 | format!("{VERSION}{}", STANDARD.encode(out)) | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | + | /// Bytes sealed the same way, kept as bytes: the version, the nonce, | |
| 49 | + | /// then the ciphertext. For values stored as bytes, such as a cache's. | |
| 50 | + | pub fn seal_bytes(&self, plaintext: &[u8], bound_to: &str) -> Vec<u8> { | |
| 51 | + | let mut nonce = [0u8; 12]; | |
| 52 | + | getrandom::getrandom(&mut nonce).expect("no source of randomness"); | |
| 53 | + | let sealed = self | |
| 54 | + | .cipher | |
| 55 | + | .encrypt( | |
| 56 | + | Nonce::from_slice(&nonce), | |
| 57 | + | Payload { | |
| 58 | + | msg: plaintext, | |
| 59 | + | aad: bound_to.as_bytes(), | |
| 60 | + | }, | |
| 61 | + | ) | |
| 62 | + | .expect("encrypting cannot fail"); | |
| 63 | + | let mut out = VERSION.as_bytes().to_vec(); | |
| 64 | + | out.extend(nonce); | |
| 65 | + | out.extend(sealed); | |
| 66 | + | out | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// What [`Sealer::seal_bytes`] sealed; `None` under another key, for | |
| 70 | + | /// another row, or for anything else. | |
| 71 | + | pub fn open_bytes(&self, sealed: &[u8], bound_to: &str) -> Option<Vec<u8>> { | |
| 72 | + | let bytes = sealed.strip_prefix(VERSION.as_bytes())?; | |
| 73 | + | if bytes.len() < 12 { | |
| 74 | + | return None; | |
| 75 | + | } | |
| 76 | + | let (nonce, ciphertext) = bytes.split_at(12); | |
| 77 | + | self.cipher | |
| 78 | + | .decrypt( | |
| 79 | + | Nonce::from_slice(nonce), | |
| 80 | + | Payload { | |
| 81 | + | msg: ciphertext, | |
| 82 | + | aad: bound_to.as_bytes(), | |
| 83 | + | }, | |
| 84 | + | ) | |
| 85 | + | .ok() | |
| 86 | + | } | |
| 87 | + | ||
| 48 | 88 | /// `None` when it was sealed under another key or for another row. | |
| 49 | 89 | pub fn open(&self, sealed: &str, bound_to: &str) -> Option<String> { | |
| 50 | 90 | let bytes = STANDARD.decode(sealed.strip_prefix(VERSION)?).ok()?; | |
| 67 | 107 | } | |
| 68 | 108 | ||
| 69 | 109 | pub fn sha256_hex(value: &str) -> String { | |
| 70 | − | hex::encode(Sha256::digest(value.as_bytes())) | |
| 110 | + | sha256_hex_bytes(value.as_bytes()) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | pub fn sha256_hex_bytes(value: &[u8]) -> String { | |
| 114 | + | hex::encode(Sha256::digest(value)) | |
| 71 | 115 | } | |
| 72 | 116 | ||
| 73 | 117 | pub fn random_hex(bytes: usize) -> String { | |
| 117 | 161 | } | |
| 118 | 162 | ||
| 119 | 163 | #[test] | |
| 164 | + | fn sealed_bytes_open_only_for_their_own_key() { | |
| 165 | + | let sealer = Sealer::new(KEY).unwrap(); | |
| 166 | + | let plain = b"0032HEAD\0symref=HEAD:refs/heads/main\n"; | |
| 167 | + | let sealed = sealer.seal_bytes(plain, "refs:rep_1:abc"); | |
| 168 | + | assert!(!sealed.windows(4).any(|window| window == b"HEAD")); | |
| 169 | + | assert_eq!(sealer.open_bytes(&sealed, "refs:rep_1:abc").as_deref(), Some(&plain[..])); | |
| 170 | + | assert_eq!(sealer.open_bytes(&sealed, "refs:rep_2:abc"), None); | |
| 171 | + | let other = Sealer::new(&"ff".repeat(32)).unwrap(); | |
| 172 | + | assert_eq!(other.open_bytes(&sealed, "refs:rep_1:abc"), None); | |
| 173 | + | assert_eq!(sealer.open_bytes(b"v1:short", "refs:rep_1:abc"), None); | |
| 174 | + | assert_eq!(sealer.open_bytes(plain, "refs:rep_1:abc"), None); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | #[test] | |
| 120 | 178 | fn a_key_of_the_wrong_length_is_refused() { | |
| 121 | 179 | assert!(Sealer::new("abcd").is_none()); | |
| 122 | 180 | } |
| 95 | 95 | | `apps/sudo` | TS Worker plus assets | Access JWT | Not run | | |
| 96 | 96 | | `apps/docs` | Static | — | Not run (docs.g1t.sh serves them) | | |
| 97 | 97 | | `services/identity` | Rust | Email Sending, KV `AVATARS` | Runs unchanged; `EMAIL` goes to the mail shim | | |
| 98 | − | | `services/repos` | Rust | **Artifacts**, Cache API | Runs unchanged; `ARTIFACTS` goes to the git store | | |
| 98 | + | | `services/repos` | Rust | **Artifacts**, Cache API, optional KV `GIT_CACHE` with `REPOS_KEY` | Runs unchanged; `ARTIFACTS` goes to the git store. Without `GIT_CACHE` and `REPOS_KEY`, credentials and ref listings are kept per isolate only | | |
| 99 | 99 | | `services/work` | Rust | Queue consumer | Runs unchanged | | |
| 100 | 100 | | `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to | | |
| 101 | 101 | | `services/projects` | TS | Queue consumer | Runs unchanged | |
| 12 | 12 | g1t-contracts.workspace = true | |
| 13 | 13 | g1t-kit.workspace = true | |
| 14 | 14 | g1t-scan.workspace = true | |
| 15 | + | g1t-secrets.workspace = true | |
| 15 | 16 | serde.workspace = true | |
| 16 | 17 | serde_json.workspace = true | |
| 17 | 18 | worker.workspace = true |
| 1 | + | -- Where a repository's refs stand, for the answers that list them, which | |
| 2 | + | -- git asks for first on every clone and fetch and which are kept for a | |
| 3 | + | -- moment (src/refs_cache.rs). | |
| 4 | + | -- | |
| 5 | + | -- refs_version: goes up after everything g1t does that changes the refs: | |
| 6 | + | -- a push through git over HTTPS, a merge, a pull request brought up to | |
| 7 | + | -- date, a branch deleted or renamed, the default branch changed, a mirror | |
| 8 | + | -- catching up, an import. An answer is kept under the version it was | |
| 9 | + | -- made at, so a change leaves it behind. | |
| 10 | + | -- refs_open_until: milliseconds since the epoch. Set when a credential | |
| 11 | + | -- that can push is handed out of g1t's hands (`git_access`); until then | |
| 12 | + | -- nothing is kept, because a push with it would not move the version. | |
| 13 | + | ALTER TABLE repos ADD COLUMN refs_version INTEGER NOT NULL DEFAULT 0; | |
| 14 | + | ALTER TABLE repos ADD COLUMN refs_open_until INTEGER; |
| 525 | 525 | // Only if the branch is still where it was: a push that landed | |
| 526 | 526 | // meanwhile is kept, and this is refused. | |
| 527 | 527 | let pushed = land::push_pack(&source_access, &branch, Some(&head.hash), &commit_id, pack).await?; | |
| 528 | + | self.refs_moved(&source.id).await; | |
| 528 | 529 | let git_ref = format!("refs/heads/{branch}"); | |
| 529 | 530 | let path = RepoPath { | |
| 530 | 531 | namespace: source.namespace.clone(), |
| 58 | 58 | }) | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | /// How long each step of a git request took, sent back to git as a | |
| 62 | + | /// `Server-Timing` header so that a slow clone shows where its time went. | |
| 63 | + | /// Step names and whole milliseconds only. The Workers clock moves only | |
| 64 | + | /// while a request waits on something, so each step is the time spent | |
| 65 | + | /// waiting on the database, another service or the git store. A note | |
| 66 | + | /// says how a step went without a duration: `refs;desc=hit-colo`. | |
| 67 | + | pub struct Timing { | |
| 68 | + | started: u64, | |
| 69 | + | last: u64, | |
| 70 | + | steps: Vec<(&'static str, u64)>, | |
| 71 | + | notes: Vec<(&'static str, &'static str)>, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | impl Timing { | |
| 75 | + | pub fn start() -> Self { | |
| 76 | + | let now = g1t_kit::now_ms(); | |
| 77 | + | Self { | |
| 78 | + | started: now, | |
| 79 | + | last: now, | |
| 80 | + | steps: Vec::new(), | |
| 81 | + | notes: Vec::new(), | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Says how `name` went: where an answer or a credential came from. | |
| 86 | + | pub fn note(&mut self, name: &'static str, description: &'static str) { | |
| 87 | + | self.notes.push((name, description)); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Ends a step, named `step`, that began when the last one ended. | |
| 91 | + | pub fn mark(&mut self, step: &'static str) { | |
| 92 | + | let now = g1t_kit::now_ms(); | |
| 93 | + | self.steps.push((step, now.saturating_sub(self.last))); | |
| 94 | + | self.last = now; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// `response`, with how long each step took. | |
| 98 | + | pub fn apply(&self, response: Response) -> Result<Response> { | |
| 99 | + | let total = g1t_kit::now_ms().saturating_sub(self.started); | |
| 100 | + | let headers = response.headers().clone(); | |
| 101 | + | headers.set("server-timing", &server_timing(&self.steps, &self.notes, total))?; | |
| 102 | + | Ok(response.with_headers(headers)) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// A `Server-Timing` value: each step with its duration, the notes, then | |
| 107 | + | /// the total. | |
| 108 | + | fn server_timing(steps: &[(&str, u64)], notes: &[(&str, &str)], total: u64) -> String { | |
| 109 | + | steps | |
| 110 | + | .iter() | |
| 111 | + | .map(|(step, ms)| format!("{step};dur={ms}")) | |
| 112 | + | .chain(notes.iter().map(|(name, description)| format!("{name};desc={description}"))) | |
| 113 | + | .chain(std::iter::once(format!("total;dur={total}"))) | |
| 114 | + | .collect::<Vec<_>>() | |
| 115 | + | .join(", ") | |
| 116 | + | } | |
| 117 | + | ||
| 61 | 118 | /// The user named by an HTTP Basic `Authorization` header, as git sends it. | |
| 62 | 119 | pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> { | |
| 63 | 120 | let Some(header) = request.headers().get("authorization")? else { | |
| 471 | 528 | /// Sends the request on to the git store and returns its response as is, | |
| 472 | 529 | /// unless it is a push that would change the `protected` branch, or one | |
| 473 | 530 | /// that `scan` (push protection) answers itself. A fetch's ref listing has | |
| 474 | − | /// its `HEAD` pointed at `default_branch` (see [`with_head`]). | |
| 531 | + | /// its `HEAD` pointed at `default_branch` (see [`with_head`]). A POST's | |
| 532 | + | /// body is `read` when the caller has read it already. | |
| 475 | 533 | pub async fn forward( | |
| 476 | 534 | mut request: Request, | |
| 535 | + | read: Option<Vec<u8>>, | |
| 477 | 536 | git: &GitRequest, | |
| 478 | 537 | access: &GitAccess, | |
| 479 | 538 | protected: Option<&str>, | |
| 499 | 558 | let mut lists_head = request.method() == Method::Get && names_head(git, None); | |
| 500 | 559 | if request.method() == Method::Post { | |
| 501 | 560 | // Pushes are capped at 100 MB by the platform, so buffering is safe. | |
| 502 | − | let body = request.bytes().await?; | |
| 561 | + | let body = match read { | |
| 562 | + | Some(body) => body, | |
| 563 | + | None => request.bytes().await?, | |
| 564 | + | }; | |
| 503 | 565 | if git.endpoint == "git-receive-pack" { | |
| 504 | 566 | if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) { | |
| 505 | 567 | let headers = Headers::new(); | |
| 539 | 601 | ||
| 540 | 602 | #[cfg(test)] | |
| 541 | 603 | mod tests { | |
| 542 | − | use super::{Pushed, RepoPath, Url, ZERO_ID, framed, pack_bytes, pushed_branches, refusal, transferred, with_head, with_namespace}; | |
| 604 | + | use super::{Pushed, RepoPath, Url, ZERO_ID, framed, pack_bytes, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace}; | |
| 605 | + | ||
| 606 | + | #[test] | |
| 607 | + | fn server_timing_names_each_step_and_the_total() { | |
| 608 | + | assert_eq!( | |
| 609 | + | server_timing(&[("repo", 12), ("token", 0), ("store", 140)], &[], 153), | |
| 610 | + | "repo;dur=12, token;dur=0, store;dur=140, total;dur=153" | |
| 611 | + | ); | |
| 612 | + | assert_eq!(server_timing(&[], &[], 3), "total;dur=3"); | |
| 613 | + | assert_eq!( | |
| 614 | + | server_timing(&[("repo", 1), ("cache", 2)], &[("refs", "hit-colo")], 4), | |
| 615 | + | "repo;dur=1, cache;dur=2, refs;desc=hit-colo, total;dur=4" | |
| 616 | + | ); | |
| 617 | + | } | |
| 543 | 618 | ||
| 544 | 619 | #[test] | |
| 545 | 620 | fn a_renamed_repository_redirects_to_its_new_name() { |
| 16 | 16 | mod listing; | |
| 17 | 17 | mod mirror; | |
| 18 | 18 | mod refs; | |
| 19 | + | mod refs_cache; | |
| 19 | 20 | mod registry; | |
| 20 | 21 | mod run_access; | |
| 21 | 22 | mod secret_scan; | |
| 23 | + | mod shared; | |
| 22 | 24 | mod store; | |
| 23 | 25 | mod transfer; | |
| 24 | 26 | ||
| 32 | 34 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer, is_valid_repo_name, new_id}; | |
| 33 | 35 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 34 | 36 | use std::collections::{HashMap, HashSet, VecDeque}; | |
| 37 | + | use std::rc::Rc; | |
| 35 | 38 | ||
| 36 | 39 | use serde::Serialize; | |
| 37 | 40 | use worker::{ | |
| 150 | 153 | identity: Option<Fetcher>, | |
| 151 | 154 | /// What a free workspace's private repositories may hold. | |
| 152 | 155 | free_private_bytes: i64, | |
| 156 | + | /// What isolates share: answers that list refs (refs_cache.rs). | |
| 157 | + | shared: Option<Rc<shared::Shared>>, | |
| 153 | 158 | } | |
| 154 | 159 | ||
| 155 | 160 | impl<S: GitStore> Repos<S> { | |
| 161 | + | /// Records that the refs of the repository with this id changed, once | |
| 162 | + | /// they have, so that the answers kept that list them go stale (see | |
| 163 | + | /// refs_cache.rs). Everything that changes a repository's refs calls | |
| 164 | + | /// this after it (`every_ref_writer_records_the_change` checks). A | |
| 165 | + | /// failure is logged: the change itself happened, and what was kept | |
| 166 | + | /// expires within `refs_cache::TTL_SECONDS` regardless. | |
| 167 | + | pub(crate) async fn refs_moved(&self, repo_id: &str) { | |
| 168 | + | if let Err(error) = self.registry.refs_moved(repo_id).await { | |
| 169 | + | worker::console_error!("refs of {repo_id} changed but not recorded: {error}"); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 156 | 173 | pub(crate) async fn publish<T: Serialize>(&self, event: NewEvent<T>) -> Result<()> { | |
| 157 | 174 | g1t_kit::call( | |
| 158 | 175 | &self.events, | |
| 475 | 492 | .await?; | |
| 476 | 493 | let stored = | |
| 477 | 494 | land::push_pack(&access, &repo.default_branch, None, &remote.head, pack).await?; | |
| 495 | + | self.refs_moved(&repo.id).await; | |
| 478 | 496 | if let Err(reason) = stored { | |
| 479 | 497 | self.registry.remove(&repo.id).await?; | |
| 480 | 498 | return Ok(Outcome::fail( | |
| 492 | 510 | .access(Scope::Write) | |
| 493 | 511 | .await?; | |
| 494 | 512 | let target = mirror::Endpoint::bearer(&access.remote, &access.token); | |
| 495 | − | match mirror::copy(&source, &target, mirror::Prune::Yes).await? { | |
| 513 | + | let copied = mirror::copy(&source, &target, mirror::Prune::Yes).await?; | |
| 514 | + | self.refs_moved(&repo.id).await; | |
| 515 | + | match copied { | |
| 496 | 516 | Ok(copied) => { | |
| 497 | 517 | let branch = format!("refs/heads/{}", repo.default_branch); | |
| 498 | 518 | pushed = copied | |
| 789 | 809 | return Ok(Outcome::Ok(false)); | |
| 790 | 810 | }; | |
| 791 | 811 | let access = git.access(Scope::Write).await?; | |
| 792 | − | if let Err(reason) = land::delete_ref(&access, &a.branch, &old).await? { | |
| 812 | + | let deleted = land::delete_ref(&access, &a.branch, &old).await?; | |
| 813 | + | self.refs_moved(&repo.id).await; | |
| 814 | + | if let Err(reason) = deleted { | |
| 793 | 815 | return Ok(Outcome::fail( | |
| 794 | 816 | FailureCode::Conflict, | |
| 795 | 817 | format!("{} could not be deleted: {reason}", a.branch), | |
| 851 | 873 | } | |
| 852 | 874 | ||
| 853 | 875 | async fn git_access(&self, a: GitAccessArgs) -> Result<Outcome<GitAccess>> { | |
| 876 | + | let found = self.registry.by_path(&a.path).await?; | |
| 877 | + | Ok(match self.authorize_git(&a.path, &a.viewer, a.service, found).await? { | |
| 878 | + | Outcome::Ok(repo) => { | |
| 879 | + | let write = a.service == GitService::ReceivePack; | |
| 880 | + | if write { | |
| 881 | + | // A push with this credential would not pass through | |
| 882 | + | // here, so nothing that lists the refs is kept until it | |
| 883 | + | // has expired (see refs_cache.rs). | |
| 884 | + | let until = now_ms() + store::CREDENTIAL_LIFE_MS + 60_000; | |
| 885 | + | if let Err(error) = self.registry.refs_open(&repo.id, until).await { | |
| 886 | + | // Before the column exists nothing is kept anyway. | |
| 887 | + | if registry::refs_state(&repo.id).is_some() { | |
| 888 | + | return Err(error); | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | } | |
| 892 | + | let scope = if write { Scope::Write } else { Scope::Read }; | |
| 893 | + | Outcome::Ok(self.store.access(&store_key(&repo), scope).await?) | |
| 894 | + | } | |
| 895 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 896 | + | }) | |
| 897 | + | } | |
| 898 | + | ||
| 899 | + | /// The repository at `path` (`found`, as just read), if the viewer may | |
| 900 | + | /// use `service` on it: fetch from it, or push to it. A push to a path | |
| 901 | + | /// with nothing there makes the repository, in a workspace the pusher | |
| 902 | + | /// belongs to. | |
| 903 | + | async fn authorize_git( | |
| 904 | + | &self, | |
| 905 | + | path: &RepoPath, | |
| 906 | + | viewer: &Viewer, | |
| 907 | + | service: GitService, | |
| 908 | + | found: Option<Repo>, | |
| 909 | + | ) -> Result<Outcome<Repo>> { | |
| 910 | + | let a = GitAccessArgs { | |
| 911 | + | path: path.clone(), | |
| 912 | + | viewer: viewer.clone(), | |
| 913 | + | service, | |
| 914 | + | }; | |
| 854 | 915 | let write = a.service == GitService::ReceivePack; | |
| 855 | 916 | // Anonymous callers are asked to authenticate whether or not the repo | |
| 856 | 917 | // exists, so private repos cannot be told apart from missing ones. | |
| 872 | 933 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 873 | 934 | } | |
| 874 | 935 | ||
| 875 | − | let repo = match self.registry.by_path(&a.path).await? { | |
| 936 | + | let repo = match found { | |
| 876 | 937 | Some(repo) => { | |
| 877 | 938 | let allowed = if write { | |
| 878 | 939 | can_write(&repo, &a.viewer) | |
| 927 | 988 | } | |
| 928 | 989 | } | |
| 929 | 990 | }; | |
| 930 | − | let git = self.store.open(&store_key(&repo)).await?; | |
| 931 | − | let scope = if write { Scope::Write } else { Scope::Read }; | |
| 932 | − | Ok(Outcome::Ok(git.access(scope).await?)) | |
| 991 | + | Ok(Outcome::Ok(repo)) | |
| 933 | 992 | } | |
| 934 | 993 | ||
| 935 | 994 | async fn land(&self, a: LandArgs) -> Result<Outcome<Landed>> { | |
| 1022 | 1081 | let pushed = | |
| 1023 | 1082 | land::fast_forward(&source_access, &target_access, branch, old.as_deref(), &new) | |
| 1024 | 1083 | .await?; | |
| 1084 | + | self.refs_moved(&target.id).await; | |
| 1025 | 1085 | if let Err(reason) = pushed { | |
| 1026 | 1086 | // Most often another pull request landed between the check and the push. | |
| 1027 | 1087 | return Ok(Outcome::fail( | |
| 1133 | 1193 | .await | |
| 1134 | 1194 | } | |
| 1135 | 1195 | ||
| 1136 | − | /// Git over HTTPS. | |
| 1137 | − | async fn git_http(&self, request: Request, env: &Env) -> Result<Response> { | |
| 1196 | + | /// Git over HTTPS. Only what decides the answer happens before it: | |
| 1197 | + | /// the repository, who is asking and whether they may, the free | |
| 1198 | + | /// workspace limits, push protection, and the store's own answer. The | |
| 1199 | + | /// audit entry and what a push changed are recorded once git has its | |
| 1200 | + | /// answer. Each answer says how long its steps took (`Server-Timing`). | |
| 1201 | + | async fn git_http(&self, request: Request, env: &Env, ctx: &Context) -> Result<Response> { | |
| 1202 | + | let mut timing = git_http::Timing::start(); | |
| 1138 | 1203 | let Some(git) = git_http::parse(&request.url()?) else { | |
| 1139 | 1204 | return Response::error("Not found", 404); | |
| 1140 | 1205 | }; | |
| 1141 | − | // A workspace that was renamed: git follows a redirect when it | |
| 1142 | − | // first asks for refs, and uses the new address from then on. | |
| 1143 | − | if self.registry.by_path(&git.path).await?.is_none() { | |
| 1144 | − | if let Some(location) = git_http::renamed(&request.url()?, &env.service("IDENTITY")?).await? { | |
| 1145 | − | return git_http::moved(&location, request.method() == Method::Get); | |
| 1206 | + | let response = self.answer_git(request, &git, env, ctx, &mut timing).await?; | |
| 1207 | + | timing.apply(response) | |
| 1208 | + | } | |
| 1209 | + | ||
| 1210 | + | async fn answer_git( | |
| 1211 | + | &self, | |
| 1212 | + | request: Request, | |
| 1213 | + | git: &git_http::GitRequest, | |
| 1214 | + | env: &Env, | |
| 1215 | + | ctx: &Context, | |
| 1216 | + | timing: &mut git_http::Timing, | |
| 1217 | + | ) -> Result<Response> { | |
| 1218 | + | let write = git.service == GitService::ReceivePack; | |
| 1219 | + | let get = request.method() == Method::Get; | |
| 1220 | + | let identity = env.service("IDENTITY")?; | |
| 1221 | + | // The repository and the caller's credentials, at once. A fetch may | |
| 1222 | + | // go by the row as read a moment ago, for the same clone's next | |
| 1223 | + | // request; a push always reads it. Anonymous callers cost nothing. | |
| 1224 | + | let lookup = async { | |
| 1225 | + | if write { | |
| 1226 | + | self.registry.by_path(&git.path).await | |
| 1227 | + | } else { | |
| 1228 | + | self.registry.by_path_recent(&git.path).await | |
| 1229 | + | } | |
| 1230 | + | }; | |
| 1231 | + | let (found, viewer) = | |
| 1232 | + | futures_util::future::join(lookup, git_http::viewer(&request, &identity)).await; | |
| 1233 | + | let found = found?; | |
| 1234 | + | timing.mark("repo"); | |
| 1235 | + | if found.is_none() { | |
| 1236 | + | // A workspace that was renamed: git follows a redirect when it | |
| 1237 | + | // first asks for refs, and uses the new address from then on. | |
| 1238 | + | // A repository transferred to another workspace: the same, to | |
| 1239 | + | // its new path. Fetches and pushes both follow either. | |
| 1240 | + | let url = request.url()?; | |
| 1241 | + | let (renamed, moved) = futures_util::future::join( | |
| 1242 | + | git_http::renamed(&url, &identity), | |
| 1243 | + | self.registry.resolve_moved(&git.path), | |
| 1244 | + | ) | |
| 1245 | + | .await; | |
| 1246 | + | timing.mark("moved"); | |
| 1247 | + | if let Some(location) = renamed? { | |
| 1248 | + | return git_http::moved(&location, get); | |
| 1146 | 1249 | } | |
| 1147 | − | // A repository transferred to another workspace: the same, | |
| 1148 | − | // to its new path. Fetches and pushes both follow it. | |
| 1149 | − | if let Some(now) = self.registry.resolve_moved(&git.path).await? | |
| 1150 | − | && let Some(location) = git_http::transferred(&request.url()?, &now) | |
| 1250 | + | if let Some(now) = moved? | |
| 1251 | + | && let Some(location) = git_http::transferred(&url, &now) | |
| 1151 | 1252 | { | |
| 1152 | − | return git_http::moved(&location, request.method() == Method::Get); | |
| 1253 | + | return git_http::moved(&location, get); | |
| 1153 | 1254 | } | |
| 1154 | 1255 | } | |
| 1155 | − | let viewer = git_http::viewer(&request, &env.service("IDENTITY")?).await?; | |
| 1256 | + | let viewer = viewer?; | |
| 1156 | 1257 | // A run credential is checked against its grants, then acts as the | |
| 1157 | 1258 | // person it works for. See run_access.rs. | |
| 1158 | − | let (request, viewer, audit) = match self.admit_git(request, &git, viewer).await? { | |
| 1259 | + | let (request, viewer, audit) = match self.admit_git(request, git, viewer, found.as_ref()).await? { | |
| 1159 | 1260 | run_access::Admitted::Go { request, viewer, entry } => (request, viewer, entry), | |
| 1160 | 1261 | run_access::Admitted::Refused(response) => return Ok(response), | |
| 1161 | 1262 | }; | |
| 1162 | − | let access = self | |
| 1163 | − | .git_access(GitAccessArgs { | |
| 1164 | − | path: git.path.clone(), | |
| 1165 | − | viewer: viewer.clone(), | |
| 1166 | − | service: git.service, | |
| 1167 | − | }) | |
| 1168 | − | .await?; | |
| 1169 | − | let access = match access { | |
| 1170 | − | Outcome::Ok(access) => access, | |
| 1263 | + | let mut after = AfterGit { | |
| 1264 | + | audit, | |
| 1265 | + | status: 0, | |
| 1266 | + | message: None, | |
| 1267 | + | push: None, | |
| 1268 | + | }; | |
| 1269 | + | let repo = match self.authorize_git(&git.path, &viewer, git.service, found).await? { | |
| 1270 | + | Outcome::Ok(repo) => repo, | |
| 1171 | 1271 | refused => { | |
| 1172 | 1272 | let response = git_http::refuse(refused)?; | |
| 1173 | − | self.finish_git(audit, response.status_code(), None).await; | |
| 1273 | + | after.ended(response.status_code(), None); | |
| 1274 | + | after.spawn(env, ctx); | |
| 1174 | 1275 | return Ok(response); | |
| 1175 | 1276 | } | |
| 1176 | 1277 | }; | |
| 1278 | + | timing.mark("access"); | |
| 1177 | 1279 | // A protected default branch takes changes only from a merged pull | |
| 1178 | 1280 | // request, which lands without going through here. | |
| 1179 | − | let here = self.registry.by_path(&git.path).await?; | |
| 1180 | − | let protected = match &here { | |
| 1181 | − | Some(repo) if repo.protected && repo.fork_of.is_none() => Some(repo.default_branch.clone()), | |
| 1182 | − | _ => None, | |
| 1183 | − | }; | |
| 1281 | + | let protected = (repo.protected && repo.fork_of.is_none()).then(|| repo.default_branch.clone()); | |
| 1184 | 1282 | // Clones check out the default branch g1t keeps, which can have | |
| 1185 | 1283 | // changed since the store made the repository. | |
| 1186 | − | let default_branch = here | |
| 1187 | − | .as_ref() | |
| 1188 | − | .filter(|repo| repo.fork_of.is_none()) | |
| 1189 | − | .map(|repo| repo.default_branch.clone()); | |
| 1190 | − | // Each clone, fetch and push is a git operation, which the git store | |
| 1191 | − | // charges g1t for: counted for billing, and a free workspace far | |
| 1192 | − | // past its share is slowed down rather than charged. See git_ops.rs. | |
| 1193 | − | if request.method() == Method::Post && git.endpoint != "info/refs" { | |
| 1194 | − | let namespace = git.path.namespace.to_lowercase(); | |
| 1195 | − | match git_ops::count(&self.registry.db, &namespace, &rfc3339(now_ms())).await { | |
| 1196 | − | Ok((month, hour)) => { | |
| 1197 | − | let limits = git_ops::Limits::from_env(env); | |
| 1198 | − | if git_ops::slow_down(month, hour, limits.free_cap, limits.hourly) | |
| 1199 | − | && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await | |
| 1200 | − | { | |
| 1201 | − | self.finish_git(audit, 429, Some("Too many git operations this hour.".to_owned())).await; | |
| 1202 | − | return git_ops::too_many(&namespace, limits.free_cap, limits.hourly); | |
| 1284 | + | let default_branch = repo.fork_of.is_none().then(|| repo.default_branch.clone()); | |
| 1285 | + | let key = store_key(&repo); | |
| 1286 | + | let scope = if write { Scope::Write } else { Scope::Read }; | |
| 1287 | + | let mut request = request; | |
| 1288 | + | let protocol = refs_cache::protocol(request.headers().get("git-protocol")?.as_deref()); | |
| 1289 | + | // A fetch's POST is read here, to tell an `ls-refs` from a fetch of | |
| 1290 | + | // objects; the store would have it read in full anyway. | |
| 1291 | + | let body = if !write && !get { Some(request.bytes().await?) } else { None }; | |
| 1292 | + | // An answer that lists refs may have been kept: see refs_cache.rs. | |
| 1293 | + | let kept_key = refs_cache::kind(git, get, protocol, body.as_deref()) | |
| 1294 | + | .zip(refs_cache::usable(registry::refs_state(&repo.id), now_ms())) | |
| 1295 | + | .map(|(kind, version)| { | |
| 1296 | + | refs_cache::Key::new(&repo.id, version, default_branch.as_deref(), protocol, &kind) | |
| 1297 | + | }); | |
| 1298 | + | // A kept answer and the free workspace limits, with a kept | |
| 1299 | + | // credential looked up alongside. A kept answer goes back without | |
| 1300 | + | // waiting for the credential, which it does not need. | |
| 1301 | + | let ((answer, limited), kept_access) = { | |
| 1302 | + | let shared = self.shared.as_deref(); | |
| 1303 | + | let answer_and_limits = std::pin::pin!(futures_util::future::join( | |
| 1304 | + | async { | |
| 1305 | + | match &kept_key { | |
| 1306 | + | Some(kept_key) => refs_cache::get(shared, kept_key).await, | |
| 1307 | + | None => None, | |
| 1203 | 1308 | } | |
| 1309 | + | }, | |
| 1310 | + | self.git_limits(&request, git, &repo, env), | |
| 1311 | + | )); | |
| 1312 | + | let kept_access = std::pin::pin!(self.store.kept_access(&key, scope)); | |
| 1313 | + | match futures_util::future::select(answer_and_limits, kept_access).await { | |
| 1314 | + | futures_util::future::Either::Left((first, kept_access)) => { | |
| 1315 | + | let answered = first.0.is_some() || matches!(first.1, Ok(Some(_)) | Err(_)); | |
| 1316 | + | (first, if answered { None } else { kept_access.await }) | |
| 1204 | 1317 | } | |
| 1205 | − | Err(error) => worker::console_error!("git operation for {namespace} not counted: {error}"), | |
| 1318 | + | futures_util::future::Either::Right((kept_access, first)) => (first.await, kept_access), | |
| 1206 | 1319 | } | |
| 1320 | + | }; | |
| 1321 | + | timing.mark("kept"); | |
| 1322 | + | if let Some((response, status, message)) = limited? { | |
| 1323 | + | after.ended(status, Some(message.to_owned())); | |
| 1324 | + | after.spawn(env, ctx); | |
| 1325 | + | return Ok(response); | |
| 1207 | 1326 | } | |
| 1208 | − | // A free workspace is never charged for private storage: once its | |
| 1209 | − | // private repositories hold the free amount, pushes to them stop. | |
| 1210 | − | // Checked when a push begins, so git shows the reason. | |
| 1211 | − | if git.service == GitService::ReceivePack && git.endpoint == "info/refs" { | |
| 1212 | − | let namespace = git.path.namespace.to_lowercase(); | |
| 1213 | − | let private = self.registry.by_path(&git.path).await?.is_some_and(|repo| repo.is_private); | |
| 1214 | − | if private { | |
| 1215 | − | let free = git_ops::free_private_bytes(env); | |
| 1216 | − | let held = self.registry.private_bytes(&namespace).await.unwrap_or(0); | |
| 1217 | − | if git_ops::storage_full(held, free) | |
| 1218 | − | && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await | |
| 1219 | − | { | |
| 1220 | − | self.finish_git(audit, 403, Some("Free private storage is full.".to_owned())).await; | |
| 1221 | − | return git_ops::storage_full_response(&namespace, held, free); | |
| 1222 | − | } | |
| 1327 | + | if let (Some((entry, found)), Some(kept_key)) = (answer, &kept_key) { | |
| 1328 | + | timing.note("refs", found.as_str()); | |
| 1329 | + | if found == refs_cache::Found::Shared { | |
| 1330 | + | let (kept_key, entry) = (kept_key.clone(), entry.clone()); | |
| 1331 | + | ctx.wait_until(async move { refs_cache::keep_in_colo(&kept_key, &entry).await }); | |
| 1223 | 1332 | } | |
| 1333 | + | after.ended(200, None); | |
| 1334 | + | after.spawn(env, ctx); | |
| 1335 | + | return entry.response(); | |
| 1336 | + | } | |
| 1337 | + | if kept_key.is_some() { | |
| 1338 | + | timing.note("refs", "miss"); | |
| 1224 | 1339 | } | |
| 1340 | + | // The store's credential: one made a moment ago, here or in another | |
| 1341 | + | // isolate (see store.rs), or a new one. | |
| 1342 | + | let access = match kept_access { | |
| 1343 | + | Some((access, from)) => { | |
| 1344 | + | timing.note("cred", from.as_str()); | |
| 1345 | + | access | |
| 1346 | + | } | |
| 1347 | + | None => { | |
| 1348 | + | let access = self.store.mint_access(&key, scope).await?; | |
| 1349 | + | timing.mark("mint"); | |
| 1350 | + | timing.note("cred", "mint"); | |
| 1351 | + | access | |
| 1352 | + | } | |
| 1353 | + | }; | |
| 1354 | + | // Should the store turn a kept credential down, a fetch's first | |
| 1355 | + | // request is tried again with a new one; the requests after it then | |
| 1356 | + | // have that one too. | |
| 1357 | + | let again = if get { Some(request.clone()?) } else { None }; | |
| 1225 | 1358 | // Push protection: a push that adds a secret is refused. See secret_scan.rs. | |
| 1226 | − | let scan = async |body: &[u8]| self.protect(&git.path, viewer.as_ref(), body).await; | |
| 1359 | + | let scan = async |body: &[u8]| self.protect(&repo, viewer.as_ref(), body).await; | |
| 1360 | + | let mut outcome = git_http::forward( | |
| 1361 | + | request, | |
| 1362 | + | body, | |
| 1363 | + | git, | |
| 1364 | + | &access, | |
| 1365 | + | protected.as_deref(), | |
| 1366 | + | default_branch.as_deref(), | |
| 1367 | + | scan, | |
| 1368 | + | ) | |
| 1369 | + | .await?; | |
| 1370 | + | let turned_down = matches!( | |
| 1371 | + | &outcome, | |
| 1372 | + | git_http::Push::Forwarded(forwarded) if matches!(forwarded.response.status_code(), 401 | 403) | |
| 1373 | + | ); | |
| 1374 | + | if turned_down { | |
| 1375 | + | self.store.forget_access(&key).await; | |
| 1376 | + | if let Some(again) = again { | |
| 1377 | + | let access = self.store.mint_access(&key, scope).await?; | |
| 1378 | + | let nothing = async |_: &[u8]| Ok(None); | |
| 1379 | + | outcome = git_http::forward(again, None, git, &access, protected.as_deref(), default_branch.as_deref(), nothing) | |
| 1380 | + | .await?; | |
| 1381 | + | } | |
| 1382 | + | } | |
| 1227 | 1383 | let forwarded = | |
| 1228 | − | match git_http::forward(request, &git, &access, protected.as_deref(), default_branch.as_deref(), scan).await? { | |
| 1384 | + | match outcome { | |
| 1229 | 1385 | git_http::Push::Forwarded(forwarded) => forwarded, | |
| 1230 | 1386 | git_http::Push::Refused(response) => { | |
| 1231 | − | self.finish_git(audit, 403, Some("The push would change a protected branch.".to_owned())).await; | |
| 1387 | + | after.ended(403, Some("The push would change a protected branch.".to_owned())); | |
| 1388 | + | after.spawn(env, ctx); | |
| 1232 | 1389 | return Ok(response); | |
| 1233 | 1390 | } | |
| 1234 | 1391 | git_http::Push::Blocked(response) => { | |
| 1235 | − | self.finish_git(audit, 403, Some("The push adds a secret.".to_owned())).await; | |
| 1392 | + | after.ended(403, Some("The push adds a secret.".to_owned())); | |
| 1393 | + | after.spawn(env, ctx); | |
| 1236 | 1394 | return Ok(response); | |
| 1237 | 1395 | } | |
| 1238 | 1396 | }; | |
| 1239 | − | self.finish_git(audit, forwarded.response.status_code(), None).await; | |
| 1397 | + | timing.mark("store"); | |
| 1398 | + | let mut response = forwarded.response; | |
| 1399 | + | let status = response.status_code(); | |
| 1400 | + | if write && !get { | |
| 1401 | + | // A push: the store has moved its refs once it has answered in | |
| 1402 | + | // full, so the answer is read before the change is recorded, and | |
| 1403 | + | // only then goes back. Whoever fetches after it sees the push. | |
| 1404 | + | let headers = response.headers().clone(); | |
| 1405 | + | headers.delete("content-length")?; | |
| 1406 | + | let report = response.bytes().await?; | |
| 1407 | + | self.refs_moved(&repo.id).await; | |
| 1408 | + | timing.mark("refs"); | |
| 1409 | + | response = Response::from_bytes(report)?.with_headers(headers).with_status(status); | |
| 1410 | + | } else if let (Some(kept_key), 200) = (&kept_key, status) { | |
| 1411 | + | // A miss: this answer is kept for the next to ask. | |
| 1412 | + | let headers = response.headers().clone(); | |
| 1413 | + | headers.delete("content-length")?; | |
| 1414 | + | let body = response.bytes().await?; | |
| 1415 | + | if let Some(content_type) = headers.get("content-type")? { | |
| 1416 | + | let entry = refs_cache::Entry { content_type, body: body.clone() }; | |
| 1417 | + | if entry.keepable() { | |
| 1418 | + | let shared = self.shared.clone(); | |
| 1419 | + | let kept_key = kept_key.clone(); | |
| 1420 | + | ctx.wait_until(async move { refs_cache::keep(shared.as_deref(), &kept_key, &entry).await }); | |
| 1421 | + | } | |
| 1422 | + | } | |
| 1423 | + | response = Response::from_bytes(body)?.with_headers(headers).with_status(status); | |
| 1424 | + | } | |
| 1425 | + | after.ended(status, None); | |
| 1426 | + | if status == 200 && (forwarded.pack_bytes > 0 || !forwarded.pushed.is_empty()) { | |
| 1427 | + | after.push = Some(PushDone { | |
| 1428 | + | repo, | |
| 1429 | + | pushed: forwarded.pushed, | |
| 1430 | + | pack_bytes: forwarded.pack_bytes, | |
| 1431 | + | actor: viewer.map(|user: User| user.id), | |
| 1432 | + | }); | |
| 1433 | + | } | |
| 1434 | + | after.spawn(env, ctx); | |
| 1435 | + | Ok(response) | |
| 1436 | + | } | |
| 1437 | + | ||
| 1438 | + | /// The answer for a request a free workspace's limits stop, with its | |
| 1439 | + | /// status and reason for the audit log; `None` to go on. | |
| 1440 | + | /// | |
| 1441 | + | /// Each clone, fetch and push is a git operation, which the git store | |
| 1442 | + | /// charges g1t for: counted for billing, and a free workspace far past | |
| 1443 | + | /// its share is slowed down rather than charged (see git_ops.rs). And a | |
| 1444 | + | /// free workspace is never charged for private storage: once its | |
| 1445 | + | /// private repositories hold the free amount, pushes to them stop, | |
| 1446 | + | /// checked when a push begins so that git shows the reason. | |
| 1447 | + | async fn git_limits( | |
| 1448 | + | &self, | |
| 1449 | + | request: &Request, | |
| 1450 | + | git: &git_http::GitRequest, | |
| 1451 | + | repo: &Repo, | |
| 1452 | + | env: &Env, | |
| 1453 | + | ) -> Result<Option<(Response, u16, &'static str)>> { | |
| 1454 | + | let namespace = git.path.namespace.to_lowercase(); | |
| 1455 | + | if request.method() == Method::Post && git.endpoint != "info/refs" { | |
| 1456 | + | match git_ops::count(&self.registry.db, &namespace, &rfc3339(now_ms())).await { | |
| 1457 | + | Ok((month, hour)) => { | |
| 1458 | + | let limits = git_ops::Limits::from_env(env); | |
| 1459 | + | if git_ops::slow_down(month, hour, limits.free_cap, limits.hourly) | |
| 1460 | + | && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await | |
| 1461 | + | { | |
| 1462 | + | return Ok(Some(( | |
| 1463 | + | git_ops::too_many(&namespace, limits.free_cap, limits.hourly)?, | |
| 1464 | + | 429, | |
| 1465 | + | "Too many git operations this hour.", | |
| 1466 | + | ))); | |
| 1467 | + | } | |
| 1468 | + | } | |
| 1469 | + | Err(error) => worker::console_error!("git operation for {namespace} not counted: {error}"), | |
| 1470 | + | } | |
| 1471 | + | } | |
| 1472 | + | if git.service == GitService::ReceivePack && git.endpoint == "info/refs" && repo.is_private { | |
| 1473 | + | let free = git_ops::free_private_bytes(env); | |
| 1474 | + | let held = self.registry.private_bytes(&namespace).await.unwrap_or(0); | |
| 1475 | + | if git_ops::storage_full(held, free) | |
| 1476 | + | && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await | |
| 1477 | + | { | |
| 1478 | + | return Ok(Some(( | |
| 1479 | + | git_ops::storage_full_response(&namespace, held, free)?, | |
| 1480 | + | 403, | |
| 1481 | + | "Free private storage is full.", | |
| 1482 | + | ))); | |
| 1483 | + | } | |
| 1484 | + | } | |
| 1485 | + | Ok(None) | |
| 1486 | + | } | |
| 1240 | 1487 | ||
| 1488 | + | /// What a push changed, recorded once git has its answer. | |
| 1489 | + | async fn record_push(&self, push: PushDone) -> Result<()> { | |
| 1490 | + | let PushDone { | |
| 1491 | + | repo, | |
| 1492 | + | pushed, | |
| 1493 | + | pack_bytes, | |
| 1494 | + | actor, | |
| 1495 | + | } = push; | |
| 1241 | 1496 | // What the push stored, for billing's storage meter. A failure only | |
| 1242 | 1497 | // leaves the count short. | |
| 1243 | − | if forwarded.response.status_code() == 200 | |
| 1244 | − | && forwarded.pack_bytes > 0 | |
| 1245 | − | && let Some(repo) = self.registry.by_path(&git.path).await? | |
| 1246 | − | && let Err(error) = self.registry.add_stored_bytes(&repo, forwarded.pack_bytes).await | |
| 1498 | + | if pack_bytes > 0 | |
| 1499 | + | && let Err(error) = self.registry.add_stored_bytes(&repo, pack_bytes).await | |
| 1247 | 1500 | { | |
| 1248 | − | worker::console_error!("stored bytes for {} not counted: {error}", git.path.name); | |
| 1501 | + | worker::console_error!("stored bytes for {} not counted: {error}", repo.name); | |
| 1249 | 1502 | } | |
| 1250 | − | ||
| 1503 | + | if pushed.is_empty() { | |
| 1504 | + | return Ok(()); | |
| 1505 | + | } | |
| 1251 | 1506 | // Artifacts' own push notifications are per repository, which does | |
| 1252 | 1507 | // not fit a repo per pull request, so the front end reports pushes | |
| 1253 | 1508 | // itself: one event for each branch that moved. | |
| 1254 | − | let accepted = forwarded.response.status_code() == 200 && !forwarded.pushed.is_empty(); | |
| 1255 | − | if accepted && let Some(repo) = self.registry.by_path(&git.path).await? { | |
| 1256 | − | let stored = self.store.open(&store_key(&repo)).await?; | |
| 1257 | − | let actor = viewer.map(|user: User| user.id); | |
| 1258 | − | for pushed in &forwarded.pushed { | |
| 1259 | − | // The store can refuse one ref and accept another, so each | |
| 1260 | − | // branch is checked against where it actually is. A tag the | |
| 1261 | − | // store cannot read back is taken as pushed. | |
| 1262 | − | let moved = match pushed.branch() { | |
| 1263 | − | Some(branch) => stored | |
| 1264 | − | .log(branch, 1) | |
| 1265 | − | .await? | |
| 1266 | − | .first() | |
| 1267 | − | .is_some_and(|commit| commit.hash == pushed.after), | |
| 1268 | − | None => stored.log(&pushed.git_ref, 1).await.map_or(true, |head| { | |
| 1269 | − | head.first().is_none_or(|commit| commit.hash == pushed.after) | |
| 1270 | − | }), | |
| 1271 | − | }; | |
| 1272 | − | if moved { | |
| 1273 | − | self.publish_push( | |
| 1274 | − | &repo, | |
| 1275 | − | &pushed.git_ref, | |
| 1276 | − | pushed.before.as_deref(), | |
| 1277 | − | &pushed.after, | |
| 1278 | − | actor.clone(), | |
| 1279 | − | ) | |
| 1280 | − | .await?; | |
| 1509 | + | let stored = self.store.open(&store_key(&repo)).await?; | |
| 1510 | + | for pushed in &pushed { | |
| 1511 | + | // The store can refuse one ref and accept another, so each | |
| 1512 | + | // branch is checked against where it actually is. A tag the | |
| 1513 | + | // store cannot read back is taken as pushed. | |
| 1514 | + | let moved = match pushed.branch() { | |
| 1515 | + | Some(branch) => stored | |
| 1516 | + | .log(branch, 1) | |
| 1517 | + | .await? | |
| 1518 | + | .first() | |
| 1519 | + | .is_some_and(|commit| commit.hash == pushed.after), | |
| 1520 | + | None => stored.log(&pushed.git_ref, 1).await.map_or(true, |head| { | |
| 1521 | + | head.first().is_none_or(|commit| commit.hash == pushed.after) | |
| 1522 | + | }), | |
| 1523 | + | }; | |
| 1524 | + | if moved { | |
| 1525 | + | self.publish_push( | |
| 1526 | + | &repo, | |
| 1527 | + | &pushed.git_ref, | |
| 1528 | + | pushed.before.as_deref(), | |
| 1529 | + | &pushed.after, | |
| 1530 | + | actor.clone(), | |
| 1531 | + | ) | |
| 1532 | + | .await?; | |
| 1533 | + | } | |
| 1534 | + | } | |
| 1535 | + | Ok(()) | |
| 1536 | + | } | |
| 1537 | + | } | |
| 1538 | + | ||
| 1539 | + | /// A push the store accepted, to be recorded once git has its answer. | |
| 1540 | + | struct PushDone { | |
| 1541 | + | repo: Repo, | |
| 1542 | + | pushed: Vec<git_http::Pushed>, | |
| 1543 | + | pack_bytes: u64, | |
| 1544 | + | actor: Option<String>, | |
| 1545 | + | } | |
| 1546 | + | ||
| 1547 | + | /// What a git request leaves for after its answer: its audit entry, with | |
| 1548 | + | /// how the request ended, and what a push changed. | |
| 1549 | + | struct AfterGit { | |
| 1550 | + | audit: Option<Box<g1t_contracts::audit::NewAuditEntry>>, | |
| 1551 | + | status: u16, | |
| 1552 | + | message: Option<String>, | |
| 1553 | + | push: Option<PushDone>, | |
| 1554 | + | } | |
| 1555 | + | ||
| 1556 | + | impl AfterGit { | |
| 1557 | + | fn ended(&mut self, status: u16, message: Option<String>) { | |
| 1558 | + | self.status = status; | |
| 1559 | + | self.message = message; | |
| 1560 | + | } | |
| 1561 | + | ||
| 1562 | + | /// Does the work once the response is on its way. A failure is logged: | |
| 1563 | + | /// git has already been told how its request went. | |
| 1564 | + | fn spawn(self, env: &Env, ctx: &Context) { | |
| 1565 | + | if self.audit.is_none() && self.push.is_none() { | |
| 1566 | + | return; | |
| 1567 | + | } | |
| 1568 | + | let env = env.clone(); | |
| 1569 | + | ctx.wait_until(async move { | |
| 1570 | + | let repos = match service(&env) { | |
| 1571 | + | Ok(repos) => repos, | |
| 1572 | + | Err(error) => { | |
| 1573 | + | worker::console_error!("git request not recorded: {error}"); | |
| 1574 | + | return; | |
| 1281 | 1575 | } | |
| 1576 | + | }; | |
| 1577 | + | repos.finish_git(self.audit, self.status, self.message).await; | |
| 1578 | + | if let Some(push) = self.push | |
| 1579 | + | && let Err(error) = repos.record_push(push).await | |
| 1580 | + | { | |
| 1581 | + | worker::console_error!("push not recorded: {error}"); | |
| 1282 | 1582 | } | |
| 1283 | − | } | |
| 1284 | − | Ok(forwarded.response) | |
| 1583 | + | }); | |
| 1285 | 1584 | } | |
| 1286 | 1585 | } | |
| 1287 | 1586 | ||
| 1288 | 1587 | fn service(env: &Env) -> Result<Repos<ArtifactsStore>> { | |
| 1588 | + | let shared = shared::Shared::from_env(env).map(Rc::new); | |
| 1289 | 1589 | Ok(Repos { | |
| 1290 | 1590 | registry: Registry { db: env.d1("DB")? }, | |
| 1291 | − | store: ArtifactsStore::new(env)?, | |
| 1591 | + | store: ArtifactsStore::new(env, shared.clone())?, | |
| 1592 | + | shared, | |
| 1292 | 1593 | events: env.service("EVENTS")?, | |
| 1293 | 1594 | security: env.service("SECURITY").ok(), | |
| 1294 | 1595 | billing: env.service("BILLING").ok(), | |
| 1298 | 1599 | } | |
| 1299 | 1600 | ||
| 1300 | 1601 | #[event(fetch)] | |
| 1301 | − | async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> { | |
| 1602 | + | async fn fetch(mut request: Request, env: Env, ctx: Context) -> Result<Response> { | |
| 1302 | 1603 | let repos = service(&env)?; | |
| 1303 | 1604 | let Some(method) = rpc_method(&request) else { | |
| 1304 | − | return repos.git_http(request, &env).await; | |
| 1605 | + | return repos.git_http(request, &env, &ctx).await; | |
| 1305 | 1606 | }; | |
| 1306 | 1607 | let body: serde_json::Value = request.json().await?; | |
| 1307 | 1608 |
| 1135 | 1135 | )); | |
| 1136 | 1136 | } | |
| 1137 | 1137 | self.registry.set_default_branch(&repo.id, &branch).await?; | |
| 1138 | + | // HEAD in what git is told follows it. | |
| 1139 | + | self.refs_moved(&repo.id).await; | |
| 1138 | 1140 | let from = repo.default_branch.clone(); | |
| 1139 | 1141 | let changed = Repo { | |
| 1140 | 1142 | default_branch: branch.clone(), | |
| 1195 | 1197 | return Ok(not_found()); | |
| 1196 | 1198 | }; | |
| 1197 | 1199 | let access = git.access(Scope::Write).await?; | |
| 1198 | − | if let Err(reason) = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await? { | |
| 1200 | + | let made = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await?; | |
| 1201 | + | self.refs_moved(&repo.id).await; | |
| 1202 | + | if let Err(reason) = made { | |
| 1199 | 1203 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{to} could not be made: {reason}"))); | |
| 1200 | 1204 | } | |
| 1201 | 1205 | // The default moves before the old name goes, so it never names a | |
| 1203 | 1207 | if is_default { | |
| 1204 | 1208 | self.registry.set_default_branch(&repo.id, &to).await?; | |
| 1205 | 1209 | } | |
| 1206 | − | if let Err(reason) = land::delete_ref(&access, &from, &head).await? { | |
| 1210 | + | let removed = land::delete_ref(&access, &from, &head).await; | |
| 1211 | + | self.refs_moved(&repo.id).await; | |
| 1212 | + | if let Err(reason) = removed? { | |
| 1207 | 1213 | worker::console_error!("{from} not removed after renaming it to {to}: {reason}"); | |
| 1208 | 1214 | } | |
| 1209 | 1215 | self.registry.add_branch_redirect(&repo.id, &from, &to).await?; | |
| 1279 | 1285 | return Ok(()); | |
| 1280 | 1286 | }; | |
| 1281 | 1287 | let access = git.access(Scope::Write).await?; | |
| 1282 | − | if !branches.iter().any(|b| b.name == to) | |
| 1283 | − | && let Err(reason) = land::push_pack(&access, to, None, &head, EMPTY_PACK.to_vec()).await? | |
| 1284 | − | { | |
| 1285 | − | worker::console_error!("working copy {} did not get {to}: {reason}", fork.id); | |
| 1286 | − | return Ok(()); | |
| 1288 | + | if !branches.iter().any(|b| b.name == to) { | |
| 1289 | + | let made = land::push_pack(&access, to, None, &head, EMPTY_PACK.to_vec()).await; | |
| 1290 | + | self.refs_moved(&fork.id).await; | |
| 1291 | + | if let Err(reason) = made? { | |
| 1292 | + | worker::console_error!("working copy {} did not get {to}: {reason}", fork.id); | |
| 1293 | + | return Ok(()); | |
| 1294 | + | } | |
| 1287 | 1295 | } | |
| 1288 | 1296 | self.registry.set_default_branch(&fork.id, to).await?; | |
| 1289 | − | if renamed && let Err(reason) = land::delete_ref(&access, from, &head).await? { | |
| 1290 | − | worker::console_error!("working copy {} kept {from}: {reason}", fork.id); | |
| 1297 | + | if renamed { | |
| 1298 | + | let removed = land::delete_ref(&access, from, &head).await; | |
| 1299 | + | self.refs_moved(&fork.id).await; | |
| 1300 | + | if let Err(reason) = removed? { | |
| 1301 | + | worker::console_error!("working copy {} kept {from}: {reason}", fork.id); | |
| 1302 | + | } | |
| 1291 | 1303 | } | |
| 1292 | 1304 | Ok(()) | |
| 1293 | 1305 | } |
| 380 | 380 | let ours = Endpoint::bearer(&access.remote, &access.token); | |
| 381 | 381 | let theirs = Endpoint::github(&url, &a.token); | |
| 382 | 382 | let copied = match a.direction { | |
| 383 | − | MirrorDirection::Pull => copy(&theirs, &ours, Prune::Yes).await?, | |
| 383 | + | MirrorDirection::Pull => { | |
| 384 | + | let copied = copy(&theirs, &ours, Prune::Yes).await?; | |
| 385 | + | self.refs_moved(&repo.id).await; | |
| 386 | + | copied | |
| 387 | + | } | |
| 384 | 388 | MirrorDirection::Push => copy(&ours, &theirs, Prune::No).await?, | |
| 385 | 389 | }; | |
| 386 | 390 | let copied = match copied { |
| 1 | + | //! The answers that list a repository's refs, kept for a moment: the ref | |
| 2 | + | //! advertisement git asks for first on every clone and fetch | |
| 3 | + | //! (`info/refs?service=git-upload-pack`), and protocol v2's `ls-refs`. | |
| 4 | + | //! Asking the git store for one takes hundreds of milliseconds; a kept one | |
| 5 | + | //! is a few. | |
| 6 | + | //! | |
| 7 | + | //! An answer is kept under the repository's id and the version of its refs | |
| 8 | + | //! (`refs_version`, see registry.rs), which goes up after everything g1t | |
| 9 | + | //! does that changes them, so a change leaves the old answer behind rather | |
| 10 | + | //! than having to find and remove it. The key also holds the default branch | |
| 11 | + | //! (the answer's `HEAD` is rewritten to it, see `git_http::with_head`), the | |
| 12 | + | //! protocol version, and for `ls-refs` the whole request. Answers are kept | |
| 13 | + | //! in this colo's cache and, sealed, in the cache isolates share | |
| 14 | + | //! (shared.rs), each for [`TTL_SECONDS`] at most, which bounds how stale | |
| 15 | + | //! one can be should a change ever fail to move the version. | |
| 16 | + | //! | |
| 17 | + | //! Only ever served after the request was authorized, like any answer | |
| 18 | + | //! from the store: a private repository's answers are kept like any | |
| 19 | + | //! other's, and read only by whoever may read it. | |
| 20 | + | ||
| 21 | + | use crate::git_http::GitRequest; | |
| 22 | + | use crate::registry::RefsState; | |
| 23 | + | use crate::shared::Shared; | |
| 24 | + | use g1t_contracts::repos::GitService; | |
| 25 | + | use worker::{Headers, Response, Result}; | |
| 26 | + | ||
| 27 | + | /// How long an answer is kept, at most. | |
| 28 | + | pub const TTL_SECONDS: u64 = 60; | |
| 29 | + | /// Larger answers (repositories with tens of thousands of refs) are not kept. | |
| 30 | + | const MAX_KEPT_BYTES: usize = 1024 * 1024; | |
| 31 | + | /// An `ls-refs` request larger than this (a great many ref prefixes) is | |
| 32 | + | /// passed through. | |
| 33 | + | const MAX_REQUEST_BYTES: usize = 64 * 1024; | |
| 34 | + | /// Where answers live in this colo's cache. Not reachable from outside. | |
| 35 | + | const COLO_CACHE: &str = "https://refs.g1t.internal/"; | |
| 36 | + | ||
| 37 | + | /// What a request asks for that can be kept. | |
| 38 | + | #[derive(Debug, PartialEq, Eq)] | |
| 39 | + | pub enum Kind { | |
| 40 | + | /// `GET info/refs?service=git-upload-pack`: the ref advertisement, or | |
| 41 | + | /// for protocol v2 the capabilities. | |
| 42 | + | Advertisement, | |
| 43 | + | /// A protocol v2 `ls-refs` command, by the SHA-256 of the whole request, | |
| 44 | + | /// so only the same question gets the same answer. | |
| 45 | + | LsRefs { request: String }, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// What `git` asks that can be kept, if anything: only fetches, and of | |
| 49 | + | /// those only the answers that list refs. `body` is a POST's. | |
| 50 | + | pub fn kind(git: &GitRequest, get: bool, protocol: u8, body: Option<&[u8]>) -> Option<Kind> { | |
| 51 | + | if git.service != GitService::UploadPack { | |
| 52 | + | return None; | |
| 53 | + | } | |
| 54 | + | match (get, git.endpoint, body) { | |
| 55 | + | (true, "info/refs", _) => Some(Kind::Advertisement), | |
| 56 | + | (false, "git-upload-pack", Some(body)) if protocol == 2 && is_ls_refs(body) => Some(Kind::LsRefs { | |
| 57 | + | request: g1t_secrets::sha256_hex_bytes(body), | |
| 58 | + | }), | |
| 59 | + | _ => None, | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Whether `body` is a whole protocol v2 request whose command is `ls-refs`. | |
| 64 | + | fn is_ls_refs(body: &[u8]) -> bool { | |
| 65 | + | if body.len() > MAX_REQUEST_BYTES { | |
| 66 | + | return false; | |
| 67 | + | } | |
| 68 | + | let (lines, end) = crate::land::read_pkt_lines(body); | |
| 69 | + | end == body.len() | |
| 70 | + | && lines | |
| 71 | + | .first() | |
| 72 | + | .is_some_and(|line| line.strip_suffix(b"\n").unwrap_or(line) == b"command=ls-refs") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// The protocol version a `Git-Protocol` header asks for: `version=2` | |
| 76 | + | /// among its colon-separated parameters. 0 without one. | |
| 77 | + | pub fn protocol(header: Option<&str>) -> u8 { | |
| 78 | + | header | |
| 79 | + | .into_iter() | |
| 80 | + | .flat_map(|value| value.split(':')) | |
| 81 | + | .filter_map(|parameter| parameter.trim().strip_prefix("version=")) | |
| 82 | + | .filter_map(|version| version.parse().ok()) | |
| 83 | + | .next_back() | |
| 84 | + | .unwrap_or(0) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// The version to keep answers under, if they may be kept now: not before | |
| 88 | + | /// the version is known, nor while a credential that could push is out of | |
| 89 | + | /// g1t's hands. | |
| 90 | + | pub fn usable(state: Option<RefsState>, now: u64) -> Option<u64> { | |
| 91 | + | state.filter(|state| now >= state.open_until).map(|state| state.version) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// Where one answer is kept. | |
| 95 | + | #[derive(Debug, PartialEq, Eq, Clone)] | |
| 96 | + | pub struct Key { | |
| 97 | + | repo_id: String, | |
| 98 | + | hash: String, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | impl Key { | |
| 102 | + | /// `head` is the default branch `HEAD` is rewritten to, if it is. | |
| 103 | + | pub fn new(repo_id: &str, version: u64, head: Option<&str>, protocol: u8, kind: &Kind) -> Key { | |
| 104 | + | let what = match kind { | |
| 105 | + | Kind::Advertisement => "advertisement".to_owned(), | |
| 106 | + | Kind::LsRefs { request } => format!("ls-refs {request}"), | |
| 107 | + | }; | |
| 108 | + | // Branch names can hold characters a URL cannot, and git forbids | |
| 109 | + | // newlines in them. | |
| 110 | + | let head = head.map_or_else(|| "-".to_owned(), |branch| format!("refs/heads/{branch}")); | |
| 111 | + | Key { | |
| 112 | + | repo_id: repo_id.to_owned(), | |
| 113 | + | hash: g1t_secrets::sha256_hex(&format!("{version}\nv{protocol}\n{what}\n{head}")), | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | fn colo_url(&self) -> String { | |
| 118 | + | format!("{COLO_CACHE}{}/{}", self.repo_id, self.hash) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | fn shared_key(&self) -> String { | |
| 122 | + | format!("refs:{}:{}", self.repo_id, self.hash) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// A kept answer. | |
| 127 | + | #[derive(Debug, PartialEq, Eq, Clone)] | |
| 128 | + | pub struct Entry { | |
| 129 | + | pub content_type: String, | |
| 130 | + | pub body: Vec<u8>, | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | impl Entry { | |
| 134 | + | /// Whether it is small enough to keep. | |
| 135 | + | pub fn keepable(&self) -> bool { | |
| 136 | + | // Every answer that lists refs ends with a flush packet; one that | |
| 137 | + | // does not (an error the store sent as a 200) is not kept. | |
| 138 | + | self.body.len() <= MAX_KEPT_BYTES && self.body.ends_with(b"0000") && !self.content_type.contains('\n') | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn encode(&self) -> Vec<u8> { | |
| 142 | + | let mut out = self.content_type.as_bytes().to_vec(); | |
| 143 | + | out.push(b'\n'); | |
| 144 | + | out.extend_from_slice(&self.body); | |
| 145 | + | out | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | fn decode(bytes: &[u8]) -> Option<Entry> { | |
| 149 | + | let at = bytes.iter().position(|byte| *byte == b'\n')?; | |
| 150 | + | Some(Entry { | |
| 151 | + | content_type: String::from_utf8(bytes[..at].to_vec()).ok()?, | |
| 152 | + | body: bytes[at + 1..].to_vec(), | |
| 153 | + | }) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// The answer, as the git store gives it. | |
| 157 | + | pub fn response(&self) -> Result<Response> { | |
| 158 | + | let headers = Headers::new(); | |
| 159 | + | headers.set("content-type", &self.content_type)?; | |
| 160 | + | headers.set("cache-control", "no-cache")?; | |
| 161 | + | Ok(Response::from_bytes(self.body.clone())?.with_headers(headers)) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// Where a kept answer was found, for `Server-Timing`. | |
| 166 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 167 | + | pub enum Found { | |
| 168 | + | Colo, | |
| 169 | + | Shared, | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | impl Found { | |
| 173 | + | pub fn as_str(self) -> &'static str { | |
| 174 | + | match self { | |
| 175 | + | Found::Colo => "hit-colo", | |
| 176 | + | Found::Shared => "hit-shared", | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// The answer kept under `key`: this colo's first, then the shared one. | |
| 182 | + | pub async fn get(shared: Option<&Shared>, key: &Key) -> Option<(Entry, Found)> { | |
| 183 | + | if let Ok(Some(mut response)) = worker::Cache::default().get(key.colo_url(), false).await { | |
| 184 | + | let content_type = response.headers().get("content-type").ok().flatten(); | |
| 185 | + | if let (Some(content_type), Ok(body)) = (content_type, response.bytes().await) { | |
| 186 | + | return Some((Entry { content_type, body }, Found::Colo)); | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | let bytes = shared?.get(&key.shared_key()).await?; | |
| 190 | + | Some((Entry::decode(&bytes)?, Found::Shared)) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Keeps `entry` in this colo's cache. A failure only costs a later miss. | |
| 194 | + | pub async fn keep_in_colo(key: &Key, entry: &Entry) { | |
| 195 | + | let headers = Headers::new(); | |
| 196 | + | let _ = headers.set("content-type", &entry.content_type); | |
| 197 | + | let _ = headers.set("cache-control", &format!("public, max-age={TTL_SECONDS}")); | |
| 198 | + | let Ok(response) = Response::from_bytes(entry.body.clone()) else { | |
| 199 | + | return; | |
| 200 | + | }; | |
| 201 | + | let _ = worker::Cache::default() | |
| 202 | + | .put(key.colo_url(), response.with_headers(headers)) | |
| 203 | + | .await; | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Keeps `entry` in this colo's cache and the shared one. | |
| 207 | + | pub async fn keep(shared: Option<&Shared>, key: &Key, entry: &Entry) { | |
| 208 | + | if !entry.keepable() { | |
| 209 | + | return; | |
| 210 | + | } | |
| 211 | + | let shared_put = async { | |
| 212 | + | if let Some(shared) = shared { | |
| 213 | + | shared.put(&key.shared_key(), &entry.encode(), TTL_SECONDS).await; | |
| 214 | + | } | |
| 215 | + | }; | |
| 216 | + | futures_util::future::join(keep_in_colo(key, entry), shared_put).await; | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[cfg(test)] | |
| 220 | + | mod tests { | |
| 221 | + | use super::*; | |
| 222 | + | use g1t_contracts::repos::RepoPath; | |
| 223 | + | ||
| 224 | + | fn git(endpoint: &'static str, service: GitService) -> GitRequest { | |
| 225 | + | GitRequest { | |
| 226 | + | path: RepoPath { | |
| 227 | + | namespace: "acme".into(), | |
| 228 | + | name: "rocket".into(), | |
| 229 | + | }, | |
| 230 | + | endpoint, | |
| 231 | + | service, | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | fn pkt(payload: &str) -> Vec<u8> { | |
| 236 | + | format!("{:04x}{payload}", payload.len() + 4).into_bytes() | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | fn ls_refs(prefix: &str) -> Vec<u8> { | |
| 240 | + | [ | |
| 241 | + | pkt("command=ls-refs\n"), | |
| 242 | + | pkt("agent=git/2.45.0\n"), | |
| 243 | + | pkt("object-format=sha1\n"), | |
| 244 | + | b"0001".to_vec(), | |
| 245 | + | pkt("peel\n"), | |
| 246 | + | pkt("symrefs\n"), | |
| 247 | + | pkt(&format!("ref-prefix {prefix}\n")), | |
| 248 | + | b"0000".to_vec(), | |
| 249 | + | ] | |
| 250 | + | .concat() | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | #[test] | |
| 254 | + | fn only_the_answers_that_list_refs_are_kept() { | |
| 255 | + | let refs = git("info/refs", GitService::UploadPack); | |
| 256 | + | assert_eq!(kind(&refs, true, 0, None), Some(Kind::Advertisement)); | |
| 257 | + | assert_eq!(kind(&refs, true, 2, None), Some(Kind::Advertisement)); | |
| 258 | + | // A push's advertisement is never kept: a push needs the refs as | |
| 259 | + | // they are. | |
| 260 | + | assert_eq!(kind(&git("info/refs", GitService::ReceivePack), true, 0, None), None); | |
| 261 | + | let pack = git("git-upload-pack", GitService::UploadPack); | |
| 262 | + | let listing = ls_refs("refs/heads/"); | |
| 263 | + | assert!(matches!(kind(&pack, false, 2, Some(&listing)), Some(Kind::LsRefs { .. }))); | |
| 264 | + | // Only under protocol v2, and never a fetch of objects. | |
| 265 | + | assert_eq!(kind(&pack, false, 0, Some(&listing)), None); | |
| 266 | + | let fetch = [pkt("command=fetch\n"), b"0001".to_vec(), pkt("want 1111111111111111111111111111111111111111\n"), pkt("done\n"), b"0000".to_vec()].concat(); | |
| 267 | + | assert_eq!(kind(&pack, false, 2, Some(&fetch)), None); | |
| 268 | + | let v0 = [pkt("want 1111111111111111111111111111111111111111 side-band-64k\n"), b"0000".to_vec(), pkt("done\n")].concat(); | |
| 269 | + | assert_eq!(kind(&pack, false, 0, Some(&v0)), None); | |
| 270 | + | // Not a whole request, or one too large: passed through. | |
| 271 | + | assert_eq!(kind(&pack, false, 2, Some(&listing[..listing.len() - 2])), None); | |
| 272 | + | let huge = [pkt("command=ls-refs\n"), b"0001".to_vec(), (0..3000).flat_map(|n| pkt(&format!("ref-prefix refs/heads/branch-{n}\n"))).collect(), b"0000".to_vec()].concat(); | |
| 273 | + | assert_eq!(kind(&pack, false, 2, Some(&huge)), None); | |
| 274 | + | assert_eq!(kind(&pack, false, 2, None), None); | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | #[test] | |
| 278 | + | fn the_protocol_version_is_read_from_the_header() { | |
| 279 | + | assert_eq!(protocol(None), 0); | |
| 280 | + | assert_eq!(protocol(Some("version=2")), 2); | |
| 281 | + | assert_eq!(protocol(Some("version=1")), 1); | |
| 282 | + | assert_eq!(protocol(Some("other=x:version=2")), 2); | |
| 283 | + | assert_eq!(protocol(Some("version=banana")), 0); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[test] | |
| 287 | + | fn nothing_is_kept_without_a_version_or_while_a_push_credential_is_out() { | |
| 288 | + | assert_eq!(usable(None, 1_000), None); | |
| 289 | + | assert_eq!(usable(Some(RefsState { version: 4, open_until: 0 }), 1_000), Some(4)); | |
| 290 | + | assert_eq!(usable(Some(RefsState { version: 4, open_until: 2_000 }), 1_000), None); | |
| 291 | + | assert_eq!(usable(Some(RefsState { version: 4, open_until: 2_000 }), 2_000), Some(4)); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn every_part_of_the_question_is_in_the_key() { | |
| 296 | + | let base = Key::new("rep_1", 3, Some("main"), 0, &Kind::Advertisement); | |
| 297 | + | assert_eq!(base, Key::new("rep_1", 3, Some("main"), 0, &Kind::Advertisement)); | |
| 298 | + | // A change to the refs moves the version and leaves the answer behind. | |
| 299 | + | assert_ne!(base, Key::new("rep_1", 4, Some("main"), 0, &Kind::Advertisement)); | |
| 300 | + | // So does a new default branch, which HEAD is rewritten to. | |
| 301 | + | assert_ne!(base, Key::new("rep_1", 3, Some("trunk"), 0, &Kind::Advertisement)); | |
| 302 | + | assert_ne!(base, Key::new("rep_1", 3, None, 0, &Kind::Advertisement)); | |
| 303 | + | // v0 and v2 answer differently. | |
| 304 | + | assert_ne!(base, Key::new("rep_1", 3, Some("main"), 2, &Kind::Advertisement)); | |
| 305 | + | assert_ne!(base, Key::new("rep_2", 3, Some("main"), 0, &Kind::Advertisement)); | |
| 306 | + | let heads = Kind::LsRefs { request: g1t_secrets::sha256_hex_bytes(&ls_refs("refs/heads/")) }; | |
| 307 | + | let tags = Kind::LsRefs { request: g1t_secrets::sha256_hex_bytes(&ls_refs("refs/tags/")) }; | |
| 308 | + | assert_ne!(Key::new("rep_1", 3, Some("main"), 2, &heads), Key::new("rep_1", 3, Some("main"), 2, &tags)); | |
| 309 | + | assert_ne!(Key::new("rep_1", 3, Some("main"), 2, &heads), Key::new("rep_1", 3, Some("main"), 2, &Kind::Advertisement)); | |
| 310 | + | // Odd branch names still make a usable address. | |
| 311 | + | let odd = Key::new("rep_1", 3, Some("fix/#12 %20"), 0, &Kind::Advertisement); | |
| 312 | + | assert!(odd.colo_url().starts_with("https://refs.g1t.internal/rep_1/")); | |
| 313 | + | assert!(!odd.colo_url().contains('#') && !odd.colo_url().contains('%')); | |
| 314 | + | assert!(odd.shared_key().starts_with("refs:rep_1:")); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// The functions in `source` (outside its tests) that call any of | |
| 318 | + | /// `writes`, each with whether it also records the change. | |
| 319 | + | fn writers(source: &str, writes: &[&str]) -> Vec<(String, bool)> { | |
| 320 | + | let code = source.split("#[cfg(test)]").next().unwrap_or_default(); | |
| 321 | + | let lines: Vec<&str> = code.lines().collect(); | |
| 322 | + | let starts: Vec<usize> = lines | |
| 323 | + | .iter() | |
| 324 | + | .enumerate() | |
| 325 | + | .filter(|(_, line)| { | |
| 326 | + | let indent = line.len() - line.trim_start().len(); | |
| 327 | + | let rest = line.trim_start(); | |
| 328 | + | indent <= 4 | |
| 329 | + | && ["fn ", "async fn ", "pub fn ", "pub async fn ", "pub(crate) fn ", "pub(crate) async fn "] | |
| 330 | + | .iter() | |
| 331 | + | .any(|prefix| rest.starts_with(prefix)) | |
| 332 | + | }) | |
| 333 | + | .map(|(at, _)| at) | |
| 334 | + | .collect(); | |
| 335 | + | let mut found = Vec::new(); | |
| 336 | + | for (n, start) in starts.iter().enumerate() { | |
| 337 | + | let end = starts.get(n + 1).copied().unwrap_or(lines.len()); | |
| 338 | + | let body = lines[*start..end].join("\n"); | |
| 339 | + | if writes.iter().any(|write| body.contains(write)) { | |
| 340 | + | found.push((lines[*start].trim().to_owned(), body.contains("refs_moved("))); | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | found | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | /// Everything that changes a repository's refs moves its version, or a | |
| 347 | + | /// kept answer would list them as they were. A new way of writing refs | |
| 348 | + | /// belongs in this list, and its caller must call `refs_moved`. | |
| 349 | + | #[test] | |
| 350 | + | fn every_ref_writer_records_the_change() { | |
| 351 | + | let writes = [ | |
| 352 | + | "land::push_pack(", | |
| 353 | + | "land::delete_ref(", | |
| 354 | + | "land::fast_forward(", | |
| 355 | + | "mirror::copy(", | |
| 356 | + | "copy(&theirs, &ours", | |
| 357 | + | ]; | |
| 358 | + | let sources = [ | |
| 359 | + | ("lib.rs", include_str!("lib.rs")), | |
| 360 | + | ("catch_up.rs", include_str!("catch_up.rs")), | |
| 361 | + | ("lifecycle.rs", include_str!("lifecycle.rs")), | |
| 362 | + | ("mirror.rs", include_str!("mirror.rs")), | |
| 363 | + | ("import.rs", include_str!("import.rs")), | |
| 364 | + | ("transfer.rs", include_str!("transfer.rs")), | |
| 365 | + | ("secret_scan.rs", include_str!("secret_scan.rs")), | |
| 366 | + | ("run_access.rs", include_str!("run_access.rs")), | |
| 367 | + | ("git_http.rs", include_str!("git_http.rs")), | |
| 368 | + | ]; | |
| 369 | + | let mut all = Vec::new(); | |
| 370 | + | for (file, source) in sources { | |
| 371 | + | for (function, records) in writers(source, &writes) { | |
| 372 | + | assert!(records, "{file}: `{function}` changes refs without calling refs_moved"); | |
| 373 | + | all.push(function); | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | // The writers known today, so that the check is seen to find them. | |
| 377 | + | for expected in ["create", "delete_branch", "land", "update_pull_branch", "mirror", "rename_branch", "forks_follow"] { | |
| 378 | + | assert!( | |
| 379 | + | all.iter().any(|function| function.contains(&format!("fn {expected}("))), | |
| 380 | + | "{expected} not found among {all:?}" | |
| 381 | + | ); | |
| 382 | + | } | |
| 383 | + | // A push through git over HTTPS, and the default branch, which HEAD follows. | |
| 384 | + | let forwards = writers(include_str!("lib.rs"), &["git_http::forward("]); | |
| 385 | + | assert!(forwards.iter().any(|(function, _)| function.contains("fn answer_git("))); | |
| 386 | + | assert!(forwards.iter().all(|(_, records)| *records)); | |
| 387 | + | let defaults = writers(include_str!("lifecycle.rs"), &["registry.set_default_branch("]); | |
| 388 | + | assert_eq!(defaults.len(), 3); | |
| 389 | + | assert!(defaults.iter().all(|(_, records)| *records)); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | #[test] | |
| 393 | + | fn an_entry_survives_being_kept() { | |
| 394 | + | let entry = Entry { | |
| 395 | + | content_type: "application/x-git-upload-pack-advertisement".into(), | |
| 396 | + | body: b"001e# service=git-upload-pack\n0000".to_vec(), | |
| 397 | + | }; | |
| 398 | + | assert_eq!(Entry::decode(&entry.encode()), Some(entry.clone())); | |
| 399 | + | assert!(entry.keepable()); | |
| 400 | + | let large = Entry { body: vec![b'0'; MAX_KEPT_BYTES + 1], ..entry }; | |
| 401 | + | assert!(!large.keepable()); | |
| 402 | + | assert_eq!(Entry::decode(b"no newline"), None); | |
| 403 | + | } | |
| 404 | + | } |
| 35 | 35 | archived_at: Option<String>, | |
| 36 | 36 | #[serde(default)] | |
| 37 | 37 | deleted_at: Option<String>, | |
| 38 | + | /// Bumped by everything that changes the repository's refs; see | |
| 39 | + | /// [`RefsState`]. Absent on rows read before the column existed. | |
| 40 | + | #[serde(default)] | |
| 41 | + | refs_version: Option<f64>, | |
| 42 | + | #[serde(default)] | |
| 43 | + | refs_open_until: Option<f64>, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Where a repository's refs stand, as its row last said: `version` goes up | |
| 47 | + | /// with every change g1t makes to them, so an answer that lists them (see | |
| 48 | + | /// refs_cache.rs) is kept under the version it was made at, and a change | |
| 49 | + | /// leaves it behind. Until `open_until` (milliseconds) a credential that | |
| 50 | + | /// can change them is out of g1t's hands, and nothing is kept. | |
| 51 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 52 | + | pub struct RefsState { | |
| 53 | + | pub version: u64, | |
| 54 | + | pub open_until: u64, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The newest [`RefsState`] this isolate has read or written, by | |
| 58 | + | /// repository id. A version only goes up, so an older read finishing late | |
| 59 | + | /// never takes a newer one back. | |
| 60 | + | #[derive(Default)] | |
| 61 | + | pub struct RefsStates { | |
| 62 | + | states: HashMap<String, RefsState>, | |
| 38 | 63 | } | |
| 39 | 64 | ||
| 65 | + | impl RefsStates { | |
| 66 | + | pub fn note(&mut self, id: &str, state: RefsState) { | |
| 67 | + | let kept = self.states.entry(id.to_owned()).or_default(); | |
| 68 | + | kept.version = kept.version.max(state.version); | |
| 69 | + | kept.open_until = kept.open_until.max(state.open_until); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | pub fn get(&self, id: &str) -> Option<RefsState> { | |
| 73 | + | self.states.get(id).copied() | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 40 | 77 | thread_local! { | |
| 78 | + | static REFS: RefCell<RefsStates> = RefCell::new(RefsStates::default()); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Where the refs of the repository with this id stand, as this isolate | |
| 82 | + | /// last read them; `None` before the column existed or before its row was | |
| 83 | + | /// read here. | |
| 84 | + | pub fn refs_state(id: &str) -> Option<RefsState> { | |
| 85 | + | REFS.with(|refs| refs.borrow().get(id)) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | fn note_refs(id: &str, version: Option<f64>, open_until: Option<f64>) { | |
| 89 | + | if let Some(version) = version { | |
| 90 | + | let state = RefsState { | |
| 91 | + | version: version as u64, | |
| 92 | + | open_until: open_until.unwrap_or(0.0) as u64, | |
| 93 | + | }; | |
| 94 | + | REFS.with(|refs| refs.borrow_mut().note(id, state)); | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | thread_local! { | |
| 41 | 99 | /// Store keys that differ from the one a repository's path gives: those | |
| 42 | 100 | /// of repositories whose workspace was renamed after they were made. | |
| 43 | 101 | /// Filled whenever a row is read or written, so every `Repo` this | |
| 46 | 104 | static MOVED: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new()); | |
| 47 | 105 | } | |
| 48 | 106 | ||
| 107 | + | /// How long a fetch may go by a repository's row as it was read a moment | |
| 108 | + | /// ago: a clone is two or three requests in quick succession, and each | |
| 109 | + | /// would otherwise read the same row. Short enough that making a repository | |
| 110 | + | /// private, archiving or deleting it applies within seconds. | |
| 111 | + | pub const RECENT_MS: u64 = 5_000; | |
| 112 | + | ||
| 113 | + | /// Repositories read in the last [`RECENT_MS`], by path. Only rows that | |
| 114 | + | /// were found are kept, so a repository just made is never missed. | |
| 115 | + | #[derive(Default)] | |
| 116 | + | pub struct Recent { | |
| 117 | + | rows: HashMap<(String, String), (Repo, u64)>, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | impl Recent { | |
| 121 | + | fn key(path: &RepoPath) -> (String, String) { | |
| 122 | + | (path.namespace.to_lowercase(), path.name.to_lowercase()) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | pub fn get(&self, path: &RepoPath, now: u64) -> Option<Repo> { | |
| 126 | + | self.rows | |
| 127 | + | .get(&Self::key(path)) | |
| 128 | + | .filter(|(_, read)| now.saturating_sub(*read) < RECENT_MS) | |
| 129 | + | .map(|(repo, _)| repo.clone()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | pub fn keep(&mut self, path: &RepoPath, repo: &Repo, now: u64) { | |
| 133 | + | self.rows.retain(|_, (_, read)| now.saturating_sub(*read) < RECENT_MS); | |
| 134 | + | self.rows.insert(Self::key(path), (repo.clone(), now)); | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | thread_local! { | |
| 139 | + | static RECENT: RefCell<Recent> = RefCell::new(Recent::default()); | |
| 140 | + | } | |
| 141 | + | ||
| 49 | 142 | /// The key a repository's path gives: what every repository was stored | |
| 50 | 143 | /// under before workspaces could be renamed. | |
| 51 | 144 | pub fn path_key(repo: &Repo) -> String { | |
| 83 | 176 | if let Some(store) = &row.store { | |
| 84 | 177 | remember_store(&repo, store); | |
| 85 | 178 | } | |
| 179 | + | note_refs(&repo.id, row.refs_version, row.refs_open_until); | |
| 86 | 180 | repo | |
| 87 | 181 | } | |
| 88 | 182 | } | |
| 156 | 250 | .map(Repo::from)) | |
| 157 | 251 | } | |
| 158 | 252 | ||
| 253 | + | /// The repository at `path`, as read in the last few seconds if it was | |
| 254 | + | /// (see [`RECENT_MS`]). For fetches only: a push always reads the row. | |
| 255 | + | pub async fn by_path_recent(&self, path: &RepoPath) -> Result<Option<Repo>> { | |
| 256 | + | let now = g1t_kit::now_ms(); | |
| 257 | + | if let Some(repo) = RECENT.with(|recent| recent.borrow().get(path, now)) { | |
| 258 | + | return Ok(Some(repo)); | |
| 259 | + | } | |
| 260 | + | let found = self.by_path(path).await?; | |
| 261 | + | if let Some(repo) = &found { | |
| 262 | + | RECENT.with(|recent| recent.borrow_mut().keep(path, repo, now)); | |
| 263 | + | } | |
| 264 | + | Ok(found) | |
| 265 | + | } | |
| 266 | + | ||
| 159 | 267 | /// Its details; who can see it changes with `set_private`. | |
| 160 | 268 | pub async fn update( | |
| 161 | 269 | &self, | |
| 516 | 624 | .map_or(0, |row| row.left)) | |
| 517 | 625 | } | |
| 518 | 626 | ||
| 627 | + | /// Records that the refs of the repository with this id changed, after | |
| 628 | + | /// they did: what anything that lists them keeps goes stale. | |
| 629 | + | pub async fn refs_moved(&self, id: &str) -> Result<()> { | |
| 630 | + | self.bump_refs( | |
| 631 | + | "UPDATE repos SET refs_version = refs_version + 1 WHERE id = ? | |
| 632 | + | RETURNING refs_version, refs_open_until", | |
| 633 | + | &[id.into()], | |
| 634 | + | id, | |
| 635 | + | ) | |
| 636 | + | .await | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | /// Records that a credential able to change the refs of the repository | |
| 640 | + | /// with this id was handed out of g1t's hands, until `until` | |
| 641 | + | /// (milliseconds): until then, nothing that lists them is kept. | |
| 642 | + | pub async fn refs_open(&self, id: &str, until: u64) -> Result<()> { | |
| 643 | + | self.bump_refs( | |
| 644 | + | "UPDATE repos SET refs_version = refs_version + 1, | |
| 645 | + | refs_open_until = max(coalesce(refs_open_until, 0), ?) | |
| 646 | + | WHERE id = ? RETURNING refs_version, refs_open_until", | |
| 647 | + | &[(until as f64).into(), id.into()], | |
| 648 | + | id, | |
| 649 | + | ) | |
| 650 | + | .await | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | async fn bump_refs(&self, sql: &str, params: &[JsValue], id: &str) -> Result<()> { | |
| 654 | + | #[derive(Deserialize)] | |
| 655 | + | struct Bumped { | |
| 656 | + | refs_version: Option<f64>, | |
| 657 | + | refs_open_until: Option<f64>, | |
| 658 | + | } | |
| 659 | + | let bumped = self | |
| 660 | + | .db | |
| 661 | + | .prepare(sql) | |
| 662 | + | .bind(params)? | |
| 663 | + | .first::<Bumped>(None) | |
| 664 | + | .await?; | |
| 665 | + | if let Some(bumped) = bumped { | |
| 666 | + | note_refs(id, bumped.refs_version, bumped.refs_open_until); | |
| 667 | + | } | |
| 668 | + | Ok(()) | |
| 669 | + | } | |
| 670 | + | ||
| 519 | 671 | pub async fn insert(&self, repo: &Repo) -> Result<()> { | |
| 520 | 672 | self.db | |
| 521 | 673 | .prepare( | |
| 548 | 700 | use g1t_contracts::access::{BasePermission, RepoGrant}; | |
| 549 | 701 | use g1t_contracts::{Membership, Role, User}; | |
| 550 | 702 | ||
| 703 | + | #[test] | |
| 704 | + | fn the_refs_state_kept_only_moves_forward() { | |
| 705 | + | let mut states = RefsStates::default(); | |
| 706 | + | assert_eq!(states.get("rep_1"), None); | |
| 707 | + | states.note("rep_1", RefsState { version: 3, open_until: 0 }); | |
| 708 | + | // A read that started before a bump and finished after it. | |
| 709 | + | states.note("rep_1", RefsState { version: 2, open_until: 0 }); | |
| 710 | + | assert_eq!(states.get("rep_1").unwrap().version, 3); | |
| 711 | + | states.note("rep_1", RefsState { version: 4, open_until: 9_000 }); | |
| 712 | + | states.note("rep_1", RefsState { version: 5, open_until: 0 }); | |
| 713 | + | assert_eq!(states.get("rep_1"), Some(RefsState { version: 5, open_until: 9_000 })); | |
| 714 | + | assert_eq!(states.get("rep_2"), None); | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | #[test] | |
| 718 | + | fn a_row_from_before_the_column_has_no_refs_state() { | |
| 719 | + | let row = |version: Option<f64>| RepoRow { | |
| 720 | + | id: format!("rep_row_{}", version.is_some()), | |
| 721 | + | namespace: "acme".into(), | |
| 722 | + | name: "rocket".into(), | |
| 723 | + | description: None, | |
| 724 | + | is_private: 0, | |
| 725 | + | owner_id: "usr_owner".into(), | |
| 726 | + | default_branch: "main".into(), | |
| 727 | + | fork_of: None, | |
| 728 | + | protected: 0, | |
| 729 | + | created_at: String::new(), | |
| 730 | + | store: None, | |
| 731 | + | topics: None, | |
| 732 | + | website: None, | |
| 733 | + | archived_at: None, | |
| 734 | + | deleted_at: None, | |
| 735 | + | refs_version: version, | |
| 736 | + | refs_open_until: None, | |
| 737 | + | }; | |
| 738 | + | let old = Repo::from(row(None)); | |
| 739 | + | assert_eq!(refs_state(&old.id), None); | |
| 740 | + | let new = Repo::from(row(Some(7.0))); | |
| 741 | + | assert_eq!(refs_state(&new.id), Some(RefsState { version: 7, open_until: 0 })); | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | #[test] | |
| 745 | + | fn a_repository_read_a_moment_ago_is_reused_for_a_few_seconds() { | |
| 746 | + | let mut recent = Recent::default(); | |
| 747 | + | let path = RepoPath { | |
| 748 | + | namespace: "Acme".into(), | |
| 749 | + | name: "Rocket".into(), | |
| 750 | + | }; | |
| 751 | + | recent.keep(&path, &repo(false), 1_000); | |
| 752 | + | // Paths are matched as the table matches them, ignoring case. | |
| 753 | + | let lower = RepoPath { | |
| 754 | + | namespace: "acme".into(), | |
| 755 | + | name: "rocket".into(), | |
| 756 | + | }; | |
| 757 | + | assert_eq!(recent.get(&lower, 1_000 + RECENT_MS - 1).unwrap().id, "rep_1"); | |
| 758 | + | assert!(recent.get(&lower, 1_000 + RECENT_MS).is_none()); | |
| 759 | + | let other = RepoPath { | |
| 760 | + | namespace: "acme".into(), | |
| 761 | + | name: "booster".into(), | |
| 762 | + | }; | |
| 763 | + | assert!(recent.get(&other, 1_000).is_none()); | |
| 764 | + | // Keeping another later drops the stale row. | |
| 765 | + | recent.keep(&other, &repo(true), 1_000 + RECENT_MS); | |
| 766 | + | assert_eq!(recent.rows.len(), 1); | |
| 767 | + | } | |
| 768 | + | ||
| 551 | 769 | fn repo(private: bool) -> Repo { | |
| 552 | 770 | Repo { | |
| 553 | 771 | id: "rep_1".into(), |
| 9 | 9 | ||
| 10 | 10 | use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; | |
| 11 | 11 | use g1t_contracts::credentials::{Decision, as_person, decide_git, decide_refs, limits_branches}; | |
| 12 | − | use g1t_contracts::repos::{GitService, RepoPath}; | |
| 12 | + | use g1t_contracts::repos::{GitService, Repo, RepoPath}; | |
| 13 | 13 | use g1t_contracts::{PrincipalKind, Viewer}; | |
| 14 | 14 | use worker::js_sys::Uint8Array; | |
| 15 | 15 | use worker::{Headers, Method, Request, RequestInit, Response, Result, console_error}; | |
| 84 | 84 | /// Where a git request's entry belongs: the repository a fork came | |
| 85 | 85 | /// from, so that a pull request's pushes are in its workspace's log. | |
| 86 | 86 | pub(crate) async fn audit_target(&self, path: &RepoPath) -> Result<AuditTarget> { | |
| 87 | + | let found = self.registry.by_path(path).await?; | |
| 88 | + | self.audit_target_of(path, found.as_ref()).await | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// The same, for the repository at `path` as already read (`found`). | |
| 92 | + | async fn audit_target_of(&self, path: &RepoPath, found: Option<&Repo>) -> Result<AuditTarget> { | |
| 87 | 93 | let mut repo = path.clone(); | |
| 88 | − | if let Some(found) = self.registry.by_path(path).await? | |
| 94 | + | if let Some(found) = found | |
| 89 | 95 | && let Some(source) = found.fork_of.as_deref() | |
| 90 | 96 | && let Some(source) = self.registry.by_id(source).await? | |
| 91 | 97 | { | |
| 122 | 128 | mut request: Request, | |
| 123 | 129 | git: &GitRequest, | |
| 124 | 130 | viewer: Viewer, | |
| 131 | + | found: Option<&Repo>, | |
| 125 | 132 | ) -> Result<Admitted> { | |
| 126 | 133 | let post = request.method() == Method::Post; | |
| 127 | 134 | let write = git.service == GitService::ReceivePack; | |
| 164 | 171 | PrincipalKind::User => "person", | |
| 165 | 172 | }; | |
| 166 | 173 | Some(Box::new(entry( | |
| 167 | − | self.audit_target(&git.path).await?, | |
| 174 | + | self.audit_target_of(&git.path, found).await?, | |
| 168 | 175 | &Decision::allow(rule), | |
| 169 | 176 | ))) | |
| 170 | 177 | } else { | |
| 188 | 195 | // A fork is the pull request's own: any branch of it. | |
| 189 | 196 | refs.clear(); | |
| 190 | 197 | } | |
| 191 | − | let mut target = self.audit_target(&git.path).await?; | |
| 198 | + | let mut target = self.audit_target_of(&git.path, found).await?; | |
| 192 | 199 | if !refs.is_empty() { | |
| 193 | 200 | target.git_ref = Some(refs.join(" ")); | |
| 194 | 201 | } |
| 12 | 12 | use futures_util::future::try_join_all; | |
| 13 | 13 | use g1t_contracts::User; | |
| 14 | 14 | use g1t_contracts::accounts::{CommitIdentityArgs, PushEmailGuard, mask_email}; | |
| 15 | − | use g1t_contracts::repos::{EntryKind, RepoPath}; | |
| 15 | + | use g1t_contracts::repos::{EntryKind, Repo, RepoPath}; | |
| 16 | 16 | use g1t_contracts::security::{ | |
| 17 | 17 | FindLockfilesArgs, HistoryPage, LockfileText, Lockfiles, NewSecret, PushBlockedArgs, PushVerdict, | |
| 18 | 18 | ScanHistoryArgs, | |
| 308 | 308 | /// Push protection: the response refusing a push that adds secrets | |
| 309 | 309 | /// nobody has allowed, or that would publish the pusher's private | |
| 310 | 310 | /// address, or `None` to let it through. | |
| 311 | − | pub(crate) async fn protect(&self, path: &RepoPath, pusher: Option<&User>, body: &[u8]) -> Result<Option<Response>> { | |
| 312 | − | if let Some(guard) = self.push_email_guard(pusher).await | |
| 311 | + | /// `repo` is the repository pushed to, as the request read it. | |
| 312 | + | pub(crate) async fn protect(&self, repo: &Repo, pusher: Option<&User>, body: &[u8]) -> Result<Option<Response>> { | |
| 313 | + | // Asking identity about the pusher's address and scanning the push | |
| 314 | + | // do not depend on each other, so they happen at once. | |
| 315 | + | let scan = async { | |
| 316 | + | let git = self.store.open(&store_key(repo)).await?; | |
| 317 | + | scan_push(&git, body).await | |
| 318 | + | }; | |
| 319 | + | let (guard, found) = futures_util::future::join(self.push_email_guard(pusher), scan).await; | |
| 320 | + | if let Some(guard) = guard | |
| 313 | 321 | && let Some((commit, email)) = exposed_address(body, &guard) | |
| 314 | 322 | { | |
| 315 | 323 | return Ok(Some(crate::git_http::declined( | |
| 318 | 326 | &exposed_message(&commit, &email, &guard.noreply), | |
| 319 | 327 | )?)); | |
| 320 | 328 | } | |
| 321 | − | let Some(repo) = self.registry.by_path(path).await? else { | |
| 322 | − | return Ok(None); | |
| 323 | − | }; | |
| 324 | − | let git = self.store.open(&store_key(&repo)).await?; | |
| 325 | − | let found = scan_push(&git, body).await?; | |
| 329 | + | let found = found?; | |
| 326 | 330 | if found.is_empty() { | |
| 327 | 331 | return Ok(None); | |
| 328 | 332 | } |
| 6 | 6 | use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry}; | |
| 7 | 7 | use g1t_contracts::time::rfc3339; | |
| 8 | 8 | use g1t_kit::js; | |
| 9 | − | use serde::Deserialize; | |
| 9 | + | use serde::{Deserialize, Serialize}; | |
| 10 | + | use std::cell::RefCell; | |
| 11 | + | use std::collections::HashMap; | |
| 12 | + | use std::rc::Rc; | |
| 10 | 13 | use worker::js_sys::{Reflect, Uint8Array}; | |
| 11 | 14 | use worker::wasm_bindgen::{JsCast, JsValue}; | |
| 12 | 15 | use worker::{Env, Result}; | |
| 13 | 16 | ||
| 14 | 17 | /// How long a credential handed to git stays valid. | |
| 15 | 18 | const TOKEN_TTL_SECONDS: u32 = 300; | |
| 19 | + | /// The same, in milliseconds. | |
| 20 | + | pub const CREDENTIAL_LIFE_MS: u64 = TOKEN_TTL_SECONDS as u64 * 1000; | |
| 21 | + | /// How long a credential is reused for, so that every one used has at | |
| 22 | + | /// least two minutes left. Credentials never leave this service: g1t has | |
| 23 | + | /// already decided who may do what before one is used. | |
| 24 | + | const TOKEN_REUSE_MS: u64 = 180_000; | |
| 16 | 25 | ||
| 17 | − | #[derive(Clone, Copy)] | |
| 26 | + | #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] | |
| 18 | 27 | pub enum Scope { | |
| 19 | 28 | Read, | |
| 20 | 29 | Write, | |
| 21 | 30 | } | |
| 22 | 31 | ||
| 32 | + | impl Scope { | |
| 33 | + | fn as_str(self) -> &'static str { | |
| 34 | + | match self { | |
| 35 | + | Scope::Read => "read", | |
| 36 | + | Scope::Write => "write", | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Where a credential handed out came from, for `Server-Timing`. | |
| 42 | + | #[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 43 | + | pub enum Kept { | |
| 44 | + | /// This isolate made it, or had it from another, a moment ago. | |
| 45 | + | Isolate, | |
| 46 | + | /// Another isolate made it and shared it. | |
| 47 | + | Shared, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | impl Kept { | |
| 51 | + | pub fn as_str(self) -> &'static str { | |
| 52 | + | match self { | |
| 53 | + | Kept::Isolate => "isolate", | |
| 54 | + | Kept::Shared => "shared", | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 23 | 59 | /// A place repositories live. `key` is the store's own name for a repo. | |
| 24 | 60 | #[allow(async_fn_in_trait)] | |
| 25 | 61 | pub trait GitStore { | |
| 33 | 69 | default_branch: &str, | |
| 34 | 70 | ) -> Result<()>; | |
| 35 | 71 | async fn open(&self, key: &str) -> Result<Self::Repo>; | |
| 72 | + | /// A credential for `key` made a moment ago, if the store keeps one. | |
| 73 | + | async fn kept_access(&self, _key: &str, _scope: Scope) -> Option<(GitAccess, Kept)> { | |
| 74 | + | None | |
| 75 | + | } | |
| 76 | + | /// A new credential for `key`, which the store may keep for next time. | |
| 77 | + | async fn mint_access(&self, key: &str, scope: Scope) -> Result<GitAccess> { | |
| 78 | + | self.open(key).await?.access(scope).await | |
| 79 | + | } | |
| 80 | + | /// A remote URL and credential for git itself, for the repository at | |
| 81 | + | /// `key`. A store may hand out one it made a moment ago. | |
| 82 | + | async fn access(&self, key: &str, scope: Scope) -> Result<GitAccess> { | |
| 83 | + | match self.kept_access(key, scope).await { | |
| 84 | + | Some((access, _)) => Ok(access), | |
| 85 | + | None => self.mint_access(key, scope).await, | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | /// Stops handing out the credentials it keeps for `key`: the store | |
| 89 | + | /// turned one down, or the repository is gone. | |
| 90 | + | async fn forget_access(&self, _key: &str) {} | |
| 36 | 91 | /// Removes a repository and everything in it, for good. Succeeds if it | |
| 37 | 92 | /// is already gone. | |
| 38 | 93 | async fn delete(&self, key: &str) -> Result<()>; | |
| 59 | 114 | ||
| 60 | 115 | pub struct ArtifactsStore { | |
| 61 | 116 | binding: JsValue, | |
| 117 | + | /// Where isolates share the credentials they make; see shared.rs. | |
| 118 | + | shared: Option<Rc<crate::shared::Shared>>, | |
| 62 | 119 | } | |
| 63 | 120 | ||
| 64 | 121 | impl ArtifactsStore { | |
| 65 | − | pub fn new(env: &Env) -> Result<Self> { | |
| 122 | + | pub fn new(env: &Env, shared: Option<Rc<crate::shared::Shared>>) -> Result<Self> { | |
| 66 | 123 | Ok(Self { | |
| 67 | 124 | binding: js::binding(env, "ARTIFACTS")?, | |
| 125 | + | shared, | |
| 68 | 126 | }) | |
| 69 | 127 | } | |
| 70 | 128 | } | |
| 71 | 129 | ||
| 130 | + | /// A credential as isolates share it, sealed (see shared.rs): with when it | |
| 131 | + | /// was made, so that one shared is reused no longer than one kept here. | |
| 132 | + | #[derive(Serialize, Deserialize)] | |
| 133 | + | struct SharedCredential { | |
| 134 | + | remote: String, | |
| 135 | + | token: String, | |
| 136 | + | made: u64, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// The shared cache's key for a credential: the store's key for the | |
| 140 | + | /// repository, and the scope. | |
| 141 | + | fn shared_key(key: &str, scope: Scope) -> String { | |
| 142 | + | format!("cred:{key}:{}", scope.as_str()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// A shared credential, if it was made less than [`TOKEN_REUSE_MS`] before | |
| 146 | + | /// `now`; with when it was made. | |
| 147 | + | fn shared_credential(bytes: &[u8], now: u64) -> Option<(GitAccess, u64)> { | |
| 148 | + | let kept: SharedCredential = serde_json::from_slice(bytes).ok()?; | |
| 149 | + | (now.saturating_sub(kept.made) < TOKEN_REUSE_MS).then_some(( | |
| 150 | + | GitAccess { | |
| 151 | + | remote: kept.remote, | |
| 152 | + | token: kept.token, | |
| 153 | + | }, | |
| 154 | + | kept.made, | |
| 155 | + | )) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Credentials made in the last few minutes, by repository and scope. | |
| 159 | + | /// Making one is a round trip to the store on every git request; reusing | |
| 160 | + | /// it saves that, and the store's lookup of the repository with it. | |
| 161 | + | #[derive(Default)] | |
| 162 | + | pub struct Credentials { | |
| 163 | + | kept: HashMap<(String, Scope), (GitAccess, u64)>, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | impl Credentials { | |
| 167 | + | /// One made for `key` and `scope` less than [`TOKEN_REUSE_MS`] before `now`. | |
| 168 | + | pub fn get(&self, key: &str, scope: Scope, now: u64) -> Option<GitAccess> { | |
| 169 | + | self.kept | |
| 170 | + | .get(&(key.to_owned(), scope)) | |
| 171 | + | .filter(|(_, made)| now.saturating_sub(*made) < TOKEN_REUSE_MS) | |
| 172 | + | .map(|(access, _)| access.clone()) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | pub fn keep(&mut self, key: &str, scope: Scope, access: GitAccess, now: u64) { | |
| 176 | + | // Expired ones go first, so the map stays as small as the isolate's | |
| 177 | + | // recent repositories. | |
| 178 | + | self.kept | |
| 179 | + | .retain(|_, (_, made)| now.saturating_sub(*made) < TOKEN_REUSE_MS); | |
| 180 | + | self.kept.insert((key.to_owned(), scope), (access, now)); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | pub fn forget(&mut self, key: &str) { | |
| 184 | + | self.kept.retain(|(kept, _), _| kept != key); | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | thread_local! { | |
| 189 | + | static CREDENTIALS: RefCell<Credentials> = RefCell::new(Credentials::default()); | |
| 190 | + | } | |
| 191 | + | ||
| 72 | 192 | impl GitStore for ArtifactsStore { | |
| 73 | 193 | type Repo = ArtifactsRepo; | |
| 74 | 194 | ||
| 195 | + | /// One kept in this isolate, else one another isolate shared. A shared | |
| 196 | + | /// one is kept here only for the rest of its own reuse window. | |
| 197 | + | async fn kept_access(&self, key: &str, scope: Scope) -> Option<(GitAccess, Kept)> { | |
| 198 | + | let now = g1t_kit::now_ms(); | |
| 199 | + | if let Some(access) = CREDENTIALS.with(|kept| kept.borrow().get(key, scope, now)) { | |
| 200 | + | return Some((access, Kept::Isolate)); | |
| 201 | + | } | |
| 202 | + | let bytes = self.shared.as_ref()?.get(&shared_key(key, scope)).await?; | |
| 203 | + | let (access, made) = shared_credential(&bytes, now)?; | |
| 204 | + | CREDENTIALS.with(|kept| kept.borrow_mut().keep(key, scope, access.clone(), made)); | |
| 205 | + | Some((access, Kept::Shared)) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// Made by the store, then kept here and shared with other isolates. | |
| 209 | + | async fn mint_access(&self, key: &str, scope: Scope) -> Result<GitAccess> { | |
| 210 | + | let now = g1t_kit::now_ms(); | |
| 211 | + | let access = self.open(key).await?.access(scope).await?; | |
| 212 | + | CREDENTIALS.with(|kept| kept.borrow_mut().keep(key, scope, access.clone(), now)); | |
| 213 | + | if let Some(shared) = &self.shared { | |
| 214 | + | let value = SharedCredential { | |
| 215 | + | remote: access.remote.clone(), | |
| 216 | + | token: access.token.clone(), | |
| 217 | + | made: now, | |
| 218 | + | }; | |
| 219 | + | if let Ok(bytes) = serde_json::to_vec(&value) { | |
| 220 | + | shared | |
| 221 | + | .put(&shared_key(key, scope), &bytes, TOKEN_REUSE_MS / 1000) | |
| 222 | + | .await; | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | Ok(access) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | async fn forget_access(&self, key: &str) { | |
| 229 | + | CREDENTIALS.with(|kept| kept.borrow_mut().forget(key)); | |
| 230 | + | if let Some(shared) = &self.shared { | |
| 231 | + | futures_util::future::join( | |
| 232 | + | shared.delete(&shared_key(key, Scope::Read)), | |
| 233 | + | shared.delete(&shared_key(key, Scope::Write)), | |
| 234 | + | ) | |
| 235 | + | .await; | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 75 | 239 | async fn create( | |
| 76 | 240 | &self, | |
| 77 | 241 | key: &str, | |
| 90 | 254 | } | |
| 91 | 255 | ||
| 92 | 256 | async fn delete(&self, key: &str) -> Result<()> { | |
| 257 | + | self.forget_access(key).await; | |
| 93 | 258 | match js::call(&self.binding, "delete", &[key.into()]).await { | |
| 94 | 259 | // Gone already: an earlier purge got this far. | |
| 95 | 260 | Err(thrown) if !thrown.is("NOT_FOUND") => Err(thrown.into()), | |
| 199 | 364 | ||
| 200 | 365 | impl GitRepo for ArtifactsRepo { | |
| 201 | 366 | async fn access(&self, scope: Scope) -> Result<GitAccess> { | |
| 202 | − | let scope = match scope { | |
| 203 | − | Scope::Read => "read", | |
| 204 | − | Scope::Write => "write", | |
| 205 | − | }; | |
| 206 | − | let info: RawInfo = js::from_js(&js::call(&self.handle, "info", &[]).await?)?; | |
| 207 | − | let token: RawToken = js::from_js( | |
| 208 | − | &js::call( | |
| 367 | + | let scope = scope.as_str(); | |
| 368 | + | // Two round trips to the store, at once. | |
| 369 | + | let (info, token) = futures_util::future::join( | |
| 370 | + | js::call(&self.handle, "info", &[]), | |
| 371 | + | js::call( | |
| 209 | 372 | &self.handle, | |
| 210 | 373 | "createToken", | |
| 211 | 374 | &[scope.into(), TOKEN_TTL_SECONDS.into()], | |
| 212 | − | ) | |
| 213 | − | .await?, | |
| 214 | − | )?; | |
| 375 | + | ), | |
| 376 | + | ) | |
| 377 | + | .await; | |
| 378 | + | let info: RawInfo = js::from_js(&info?)?; | |
| 379 | + | let token: RawToken = js::from_js(&token?)?; | |
| 215 | 380 | Ok(GitAccess { | |
| 216 | 381 | remote: info.remote, | |
| 217 | 382 | token: token.plaintext, | |
| 293 | 458 | } | |
| 294 | 459 | } | |
| 295 | 460 | } | |
| 461 | + | ||
| 462 | + | #[cfg(test)] | |
| 463 | + | mod tests { | |
| 464 | + | use super::{Credentials, GitAccess, Scope, SharedCredential, TOKEN_REUSE_MS, shared_credential, shared_key}; | |
| 465 | + | ||
| 466 | + | fn access(token: &str) -> GitAccess { | |
| 467 | + | GitAccess { | |
| 468 | + | remote: "https://store.example/acme--rocket.git".to_owned(), | |
| 469 | + | token: token.to_owned(), | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | #[test] | |
| 474 | + | fn a_credential_is_reused_only_while_it_has_time_left() { | |
| 475 | + | let mut kept = Credentials::default(); | |
| 476 | + | kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000); | |
| 477 | + | assert_eq!(kept.get("acme--rocket", Scope::Read, 1_000).unwrap().token, "r1"); | |
| 478 | + | assert_eq!( | |
| 479 | + | kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS - 1).unwrap().token, | |
| 480 | + | "r1" | |
| 481 | + | ); | |
| 482 | + | assert!(kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS).is_none()); | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | #[test] | |
| 486 | + | fn a_credential_is_kept_for_its_own_repository_and_scope() { | |
| 487 | + | let mut kept = Credentials::default(); | |
| 488 | + | kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000); | |
| 489 | + | // A read credential never stands in for a write one. | |
| 490 | + | assert!(kept.get("acme--rocket", Scope::Write, 1_000).is_none()); | |
| 491 | + | assert!(kept.get("acme--booster", Scope::Read, 1_000).is_none()); | |
| 492 | + | kept.keep("acme--rocket", Scope::Write, access("w1"), 1_000); | |
| 493 | + | assert_eq!(kept.get("acme--rocket", Scope::Write, 1_000).unwrap().token, "w1"); | |
| 494 | + | assert_eq!(kept.get("acme--rocket", Scope::Read, 1_000).unwrap().token, "r1"); | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | #[test] | |
| 498 | + | fn a_shared_credential_is_reused_only_in_its_own_window() { | |
| 499 | + | let value = serde_json::to_vec(&SharedCredential { | |
| 500 | + | remote: "https://store.example/acme--rocket.git".to_owned(), | |
| 501 | + | token: "r1".to_owned(), | |
| 502 | + | made: 10_000, | |
| 503 | + | }) | |
| 504 | + | .unwrap(); | |
| 505 | + | let (access, made) = shared_credential(&value, 10_000 + TOKEN_REUSE_MS - 1).unwrap(); | |
| 506 | + | assert_eq!(access.token, "r1"); | |
| 507 | + | // Kept here only for what is left of its window, not a new one. | |
| 508 | + | assert_eq!(made, 10_000); | |
| 509 | + | assert!(shared_credential(&value, 10_000 + TOKEN_REUSE_MS).is_none()); | |
| 510 | + | // Anything else is a miss. | |
| 511 | + | assert!(shared_credential(b"not json", 10_000).is_none()); | |
| 512 | + | // Each repository and scope has its own key. | |
| 513 | + | assert_eq!(shared_key("acme--rocket", Scope::Read), "cred:acme--rocket:read"); | |
| 514 | + | assert_ne!(shared_key("acme--rocket", Scope::Read), shared_key("acme--rocket", Scope::Write)); | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | #[test] | |
| 518 | + | fn a_shared_credential_kept_here_expires_with_the_original() { | |
| 519 | + | let mut kept = Credentials::default(); | |
| 520 | + | // Made at 1_000 elsewhere, found here at 100_000. | |
| 521 | + | kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000); | |
| 522 | + | assert!(kept.get("acme--rocket", Scope::Read, 100_000).is_some()); | |
| 523 | + | assert!(kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS).is_none()); | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | #[test] | |
| 527 | + | fn a_turned_down_credential_is_forgotten_and_old_ones_are_dropped() { | |
| 528 | + | let mut kept = Credentials::default(); | |
| 529 | + | kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000); | |
| 530 | + | kept.keep("acme--rocket", Scope::Write, access("w1"), 1_000); | |
| 531 | + | kept.keep("acme--booster", Scope::Read, access("b1"), 1_000); | |
| 532 | + | kept.forget("acme--rocket"); | |
| 533 | + | assert!(kept.get("acme--rocket", Scope::Read, 1_000).is_none()); | |
| 534 | + | assert!(kept.get("acme--rocket", Scope::Write, 1_000).is_none()); | |
| 535 | + | assert!(kept.get("acme--booster", Scope::Read, 1_000).is_some()); | |
| 536 | + | // Keeping another later drops the expired one from the map. | |
| 537 | + | kept.keep("acme--other", Scope::Read, access("o1"), 1_000 + TOKEN_REUSE_MS); | |
| 538 | + | assert_eq!(kept.kept.len(), 1); | |
| 539 | + | } | |
| 540 | + | } |
| 18 | 18 | } | |
| 19 | 19 | ], | |
| 20 | 20 | "artifacts": [{ "binding": "ARTIFACTS", "namespace": "g1t" }], | |
| 21 | + | // Shared between isolates (src/shared.rs): the git store's credentials | |
| 22 | + | // (src/store.rs) and the ref listings git asks for first | |
| 23 | + | // (src/refs_cache.rs), every value sealed with the REPOS_KEY secret (64 | |
| 24 | + | // hex characters). Without the binding or the secret each isolate keeps | |
| 25 | + | // only its own. Made by `npx wrangler kv namespace create g1t-repos-git-cache`: | |
| 26 | + | "kv_namespaces": [{ "binding": "GIT_CACHE", "id": "be765052d0124c2a935b3db4dff99f1f" }], | |
| 21 | 27 | "services": [ | |
| 22 | 28 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 23 | 29 | { "binding": "EVENTS", "service": "g1t-events" }, |