Merge branch 'worktree-agent-aa68cf6ecc03dffaf'
# Conflicts: # apps/web/app/routes/repo/queue.tsx
27 files+2247−2890/27 viewed
| 239 | 239 | A combined state that passes is recorded too: an `integration_passed` | |
| 240 | 240 | decision and the `integration.passed` event, with `pull` and `combination`. | |
| 241 | 241 | ||
| 242 | + | On the site, the pull request shows the failure above its checks until it | |
| 243 | + | passes in the queue or merges: the pull requests tested together, with the | |
| 244 | + | one that answers for it marked, the check that failed (linked to its run), | |
| 245 | + | what it fails with, and the fix once one is recorded. Its issue shows the | |
| 246 | + | same under its pull requests, and the repository's | |
| 247 | + | [Work page](/guides/sessions/#on-the-site) lists it among what happened. | |
| 248 | + | ||
| 242 | 249 | ### Getting back in the queue | |
| 243 | 250 | ||
| 244 | 251 | The merge stays asked for. Push a fix to the pull request, starting from |
| 21 | 21 | What git and g1t record (commits, pull requests, checks, merges) is the | |
| 22 | 22 | truth; sessions are the account of how it got there. | |
| 23 | 23 | ||
| 24 | + | ## On the site | |
| 25 | + | ||
| 26 | + | Everything here shows on g1t.sh as it happens, without reloading. | |
| 27 | + | ||
| 28 | + | - **The Work page**, `g1t.sh/<workspace>/<repo>/work`, a tab beside | |
| 29 | + | **Pull requests**, **Merge queue** and **Sessions**: | |
| 30 | + | - **Live now**: the active claims and the active and idle sessions, | |
| 31 | + | grouped by the issue they are on, with sessions on no issue yet | |
| 32 | + | after them. An issue with more than one claim is marked. | |
| 33 | + | - **Merge queue**: what is waiting to land and what is being tested, | |
| 34 | + | as on the [Merge queue](/guides/merge-queue/#the-merge-queue-page) page. | |
| 35 | + | - **What happened**: the latest 30 coordination events, from claims to | |
| 36 | + | what the merge queue found when it combined changes. | |
| 37 | + | - **Just landed**: the latest merges, each with a link to the session | |
| 38 | + | behind it. | |
| 39 | + | ||
| 40 | + | With nothing going on, it says how to start: install the | |
| 41 | + | [Claude Code plugin](/guides/claude-code-plugin/), or run | |
| 42 | + | `g1t work claim <number>` from any harness. | |
| 43 | + | - **An issue's Work panel**, at the top of its sidebar: its claims (who, | |
| 44 | + | from which harness, since when and when each lapses; ones released, | |
| 45 | + | expired or handed off lately are shown dimmed), the sessions on it with | |
| 46 | + | their state and when each was last heard from, what it is blocked by | |
| 47 | + | and where each of those stands, and the issues it blocks. Its author | |
| 48 | + | and anyone with the Triage role change what it is blocked by with | |
| 49 | + | **Edit**. Under its pull requests, the issue shows the latest | |
| 50 | + | combination that failed in the merge queue, and its | |
| 51 | + | [decision records](/guides/why-blame/#decision-records), each marked | |
| 52 | + | **Claimed**, **Tested** or **Approved**. | |
| 53 | + | - **A session's page**, opened from any of these: who ran it, in which | |
| 54 | + | harness, the issue, pull request, branch and commit it is on, and what | |
| 55 | + | it recorded. | |
| 56 | + | ||
| 24 | 57 | ## Picking up an issue | |
| 25 | 58 | ||
| 26 | 59 | Before working on an issue, load its whole context in one call: `issue` |
| 79 | 79 | - the commit that last changed it; | |
| 80 | 80 | - the pull request it arrived in, and who or what wrote it and merged it; | |
| 81 | 81 | - the issue that asked for it, with its description; | |
| 82 | + | - how it was decided: the [decision records](#decision-records) on the | |
| 83 | + | issue and every pull request for it, oldest first, each marked | |
| 84 | + | **Claimed**, **Tested** or **Approved**. A combination that failed in | |
| 85 | + | the merge queue is shown first, with the pull requests tested together, | |
| 86 | + | the check that failed and the fix that answered it; then the | |
| 87 | + | proposals, the fix, the approval and the merge; | |
| 82 | 88 | - when a coding agent wrote it, the agent's own account of the change and the | |
| 83 | 89 | commands it ran, taken from its session. The steps shown are from the | |
| 84 | 90 | work that produced that commit: the agent's messages, and the tool calls | |
| ⋯ | |||
| 155 | 161 | attributed to whoever recorded it, carries | |
| 156 | 162 | the issue the pull request is for, and is published as `decision.recorded`. | |
| 157 | 163 | ||
| 164 | + | A pull request's **Session** tab lists its decisions above what was | |
| 165 | + | recorded, with the harness sessions working on it; an issue lists its own | |
| 166 | + | and its pull requests' under them. | |
| 167 | + | ||
| 158 | 168 | Read them with the `pull_request` tool's `list_decisions` action, with | |
| 159 | 169 | `GET /repos/{owner}/{name}/pulls/{number}/decisions`, or for an issue and | |
| 160 | 170 | all its pull requests with | |
| 1 | 1 | import { | |
| 2 | 2 | ArrowUpRight, | |
| 3 | 3 | CircleDot, | |
| 4 | + | Scale, | |
| 4 | 5 | GitCommitHorizontal, | |
| 5 | 6 | GitPullRequest, | |
| 6 | 7 | MessagesSquare, | |
| ⋯ | |||
| 16 | 17 | import type { loader as whyLoader } from "../routes/repo/why"; | |
| 17 | 18 | import { repoAt } from "../lib/markdown-plugins"; | |
| 18 | 19 | import { CommitAvatars, CommitNames } from "./commit-person"; | |
| 20 | + | import { DecisionLine, IntegrationFailure } from "./coordination"; | |
| 21 | + | import { resolutionOf } from "../lib/coordination"; | |
| 19 | 22 | import { Markdown } from "./markdown"; | |
| 20 | 23 | import { TimeAgo } from "./ui"; | |
| 21 | 24 | import { Button } from "./ui/button"; | |
| ⋯ | |||
| 296 | 299 | </section> | |
| 297 | 300 | )} | |
| 298 | 301 | ||
| 302 | + | {current.decisions.length > 0 && <WhyDecisions decisions={current.decisions} base={base} />} | |
| 303 | + | ||
| 299 | 304 | {current.steps.length > 0 && current.pull && ( | |
| 300 | 305 | <section className="p-4"> | |
| 301 | 306 | <p className="flex items-center gap-1.5 text-xs text-faint"> | |
| ⋯ | |||
| 345 | 350 | </div> | |
| 346 | 351 | ); | |
| 347 | 352 | } | |
| 353 | + | ||
| 354 | + | /** | |
| 355 | + | * The decisions behind the line, oldest first: each proposal, the | |
| 356 | + | * combination that failed in the merge queue and the fix that answered it, | |
| 357 | + | * the approval and the merge, each with how sure it is. | |
| 358 | + | */ | |
| 359 | + | function WhyDecisions({ decisions, base }: { decisions: WhyData["decisions"]; base: string }) { | |
| 360 | + | const failures = decisions.filter((decision) => decision.kind === "integration_failure"); | |
| 361 | + | const failure = failures[failures.length - 1] ?? null; | |
| 362 | + | const answered = failure ? resolutionOf(decisions, failure) : null; | |
| 363 | + | return ( | |
| 364 | + | <section className="p-4"> | |
| 365 | + | <p className="flex items-center gap-1.5 text-xs text-faint"> | |
| 366 | + | <Scale size={13} /> | |
| 367 | + | How it was decided | |
| 368 | + | </p> | |
| 369 | + | {failure && ( | |
| 370 | + | <IntegrationFailure className="mt-3" failure={failure} base={base} fixed={answered?.fix ?? answered?.passed} /> | |
| 371 | + | )} | |
| 372 | + | <ol className="mt-3 space-y-3"> | |
| 373 | + | {decisions | |
| 374 | + | .filter((decision) => decision !== failure) | |
| 375 | + | .map((decision) => ( | |
| 376 | + | <li key={decision.id}> | |
| 377 | + | <DecisionLine decision={decision} base={base} /> | |
| 378 | + | </li> | |
| 379 | + | ))} | |
| 380 | + | </ol> | |
| 381 | + | </section> | |
| 382 | + | ); | |
| 383 | + | } | |
| 1 | + | /** | |
| 2 | + | * Coordinating work, drawn: claims, harness sessions, dependencies, | |
| 3 | + | * integration failures and decision records, as the issue page, the pull | |
| 4 | + | * request page, the Work page and the why panel show them. What each says | |
| 5 | + | * is worked out in lib/coordination.ts. | |
| 6 | + | */ | |
| 7 | + | import { | |
| 8 | + | Activity, | |
| 9 | + | ArrowRightLeft, | |
| 10 | + | CircleCheck, | |
| 11 | + | CircleDot, | |
| 12 | + | CircleX, | |
| 13 | + | GitBranch, | |
| 14 | + | GitCommitHorizontal, | |
| 15 | + | GitMerge, | |
| 16 | + | Hand, | |
| 17 | + | Layers, | |
| 18 | + | Link2, | |
| 19 | + | MessageSquare, | |
| 20 | + | Pencil, | |
| 21 | + | Play, | |
| 22 | + | Square, | |
| 23 | + | Timer, | |
| 24 | + | TriangleAlert, | |
| 25 | + | } from "lucide-react"; | |
| 26 | + | import { Fragment, type ReactNode } from "react"; | |
| 27 | + | import { Form, Link } from "react-router"; | |
| 28 | + | ||
| 29 | + | import type { | |
| 30 | + | Blocker, | |
| 31 | + | Claim, | |
| 32 | + | Decision, | |
| 33 | + | DecisionConfidence, | |
| 34 | + | Harness, | |
| 35 | + | SessionState, | |
| 36 | + | State, | |
| 37 | + | WorkContext, | |
| 38 | + | WorkSession, | |
| 39 | + | } from "@g1t/contracts"; | |
| 40 | + | ||
| 41 | + | import { | |
| 42 | + | CLAIM_ENDED, | |
| 43 | + | CONFIDENCE, | |
| 44 | + | DECISION_KINDS, | |
| 45 | + | type FeedLine, | |
| 46 | + | SESSION_STATES, | |
| 47 | + | checkHref, | |
| 48 | + | evidenceOf, | |
| 49 | + | expiresIn, | |
| 50 | + | harnessName, | |
| 51 | + | numberedParts, | |
| 52 | + | } from "../lib/coordination"; | |
| 53 | + | import { Input, SubmitButton, TimeAgo } from "./ui"; | |
| 54 | + | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 55 | + | import { Avatar } from "./ui/avatar"; | |
| 56 | + | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 57 | + | import { Button } from "./ui/button"; | |
| 58 | + | import { Card } from "./ui/card"; | |
| 59 | + | import { Hint } from "./ui/hint"; | |
| 60 | + | import { Label } from "./ui/label"; | |
| 61 | + | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 62 | + | import { PersonLink } from "./work"; | |
| 63 | + | import { IssueIcon } from "./work-icons"; | |
| 64 | + | ||
| 65 | + | /** Text with each `#N` linked to that issue or pull request (the issue's address leads to a pull request too). */ | |
| 66 | + | export function NumberedText({ text, base }: { text: string; base: string }) { | |
| 67 | + | return ( | |
| 68 | + | <> | |
| 69 | + | {numberedParts(text).map((part, index) => | |
| 70 | + | typeof part === "number" ? ( | |
| 71 | + | <Link key={index} to={`${base}/issues/${part}`} className="font-medium text-fg hover:text-accent"> | |
| 72 | + | #{part} | |
| 73 | + | </Link> | |
| 74 | + | ) : ( | |
| 75 | + | <Fragment key={index}>{part}</Fragment> | |
| 76 | + | ), | |
| 77 | + | )} | |
| 78 | + | </> | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /** The harness a session or claim came from. */ | |
| 83 | + | export function HarnessBadge({ harness }: { harness: Harness | null | undefined }) { | |
| 84 | + | const name = harnessName(harness); | |
| 85 | + | if (!name) return null; | |
| 86 | + | return <Badge>{name}</Badge>; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** Who did it: an agent's avatar when it came through a harness (a person's token, the harness declared), else theirs. */ | |
| 90 | + | export function Actor({ username, harness, size = 20 }: { username: string; harness?: Harness | null; size?: number }) { | |
| 91 | + | return <Avatar name={username} size={size} agent={harness != null} />; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | const SESSION_TONE: Record<SessionState, BadgeTone> = { active: "success", idle: "warn", ended: "neutral" }; | |
| 95 | + | ||
| 96 | + | export function SessionStateBadge({ state }: { state: SessionState }) { | |
| 97 | + | const { label, hint } = SESSION_STATES[state]; | |
| 98 | + | return ( | |
| 99 | + | <Hint label={hint}> | |
| 100 | + | <Badge tone={SESSION_TONE[state]}>{label}</Badge> | |
| 101 | + | </Hint> | |
| 102 | + | ); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /** How sure a decision is: claimed, tested and approved read apart at a glance. */ | |
| 106 | + | export function ConfidenceBadge({ confidence }: { confidence: DecisionConfidence }) { | |
| 107 | + | const { label, hint, tone } = CONFIDENCE[confidence]; | |
| 108 | + | const Icon = confidence === "approved" ? CircleCheck : confidence === "tested" ? Layers : MessageSquare; | |
| 109 | + | return ( | |
| 110 | + | <Hint label={hint}> | |
| 111 | + | <Badge tone={tone}> | |
| 112 | + | <Icon size={11} /> | |
| 113 | + | {label} | |
| 114 | + | </Badge> | |
| 115 | + | </Hint> | |
| 116 | + | ); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /** One claim: who, from which harness, since when, and when it lapses; one no longer active is muted. */ | |
| 120 | + | export function ClaimLine({ claim, now, showIssue, base }: { claim: Claim; now: number; showIssue?: boolean; base: string }) { | |
| 121 | + | const active = claim.state === "active"; | |
| 122 | + | const left = expiresIn(claim, now); | |
| 123 | + | return ( | |
| 124 | + | <div className={active ? "flex items-start gap-2.5" : "flex items-start gap-2.5 opacity-60"}> | |
| 125 | + | <Actor username={claim.actor.username} harness={claim.harness} /> | |
| 126 | + | <div className="min-w-0 grow"> | |
| 127 | + | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"> | |
| 128 | + | <PersonLink name={claim.actor.username} className="font-medium hover:underline" /> | |
| 129 | + | {showIssue ? ( | |
| 130 | + | <span className="text-muted"> | |
| 131 | + | claimed{" "} | |
| 132 | + | <Link to={`${base}/issues/${claim.issue}`} className="font-medium text-fg hover:text-accent"> | |
| 133 | + | #{claim.issue} | |
| 134 | + | </Link> | |
| 135 | + | </span> | |
| 136 | + | ) : null} | |
| 137 | + | <HarnessBadge harness={claim.harness} /> | |
| 138 | + | {!active && <Badge>{CLAIM_ENDED[claim.state as Exclude<Claim["state"], "active">]}</Badge>} | |
| 139 | + | </p> | |
| 140 | + | <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted"> | |
| 141 | + | <span> | |
| 142 | + | since <TimeAgo at={claim.createdAt} /> | |
| 143 | + | </span> | |
| 144 | + | {left && ( | |
| 145 | + | <span className="inline-flex items-center gap-1" suppressHydrationWarning> | |
| 146 | + | · <Timer size={11} /> | |
| 147 | + | {left} | |
| 148 | + | </span> | |
| 149 | + | )} | |
| 150 | + | {!active && claim.state === "handed_off" && <span>· to {claim.handoffTo ?? "anyone"}</span>} | |
| 151 | + | </p> | |
| 152 | + | {claim.note && <p className="mt-1 text-xs text-fg-soft">{claim.note}</p>} | |
| 153 | + | </div> | |
| 154 | + | </div> | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** One harness session: who, which harness, where it stands, when it was last heard from, linked to its page. */ | |
| 159 | + | export function SessionLine({ | |
| 160 | + | session, | |
| 161 | + | base, | |
| 162 | + | showWhere = true, | |
| 163 | + | showBranch = true, | |
| 164 | + | }: { | |
| 165 | + | session: WorkSession; | |
| 166 | + | base: string; | |
| 167 | + | /** Its pull request and branch; not where they are already said. */ | |
| 168 | + | showWhere?: boolean; | |
| 169 | + | /** Its branch; not in a narrow sidebar. */ | |
| 170 | + | showBranch?: boolean; | |
| 171 | + | }) { | |
| 172 | + | return ( | |
| 173 | + | <div className="flex items-start gap-2.5"> | |
| 174 | + | <Actor username={session.actor.username} harness={session.harness} /> | |
| 175 | + | <div className="min-w-0 grow"> | |
| 176 | + | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"> | |
| 177 | + | <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent"> | |
| 178 | + | {session.title ?? `${session.actor.username}'s session`} | |
| 179 | + | </Link> | |
| 180 | + | <HarnessBadge harness={session.harness} /> | |
| 181 | + | <SessionStateBadge state={session.state} /> | |
| 182 | + | </p> | |
| 183 | + | <p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted"> | |
| 184 | + | <PersonLink name={session.actor.username} className="hover:text-fg" /> | |
| 185 | + | <span> | |
| 186 | + | · {session.state === "ended" ? "ended" : "last seen"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} /> | |
| 187 | + | </span> | |
| 188 | + | {showWhere && session.pull != null && ( | |
| 189 | + | <span> | |
| 190 | + | · on{" "} | |
| 191 | + | <Link to={`${base}/pull/${session.pull}`} className="text-fg hover:text-accent"> | |
| 192 | + | #{session.pull} | |
| 193 | + | </Link> | |
| 194 | + | </span> | |
| 195 | + | )} | |
| 196 | + | {showWhere && showBranch && session.branch && ( | |
| 197 | + | <span className="inline-flex min-w-0 items-center gap-1 font-mono"> | |
| 198 | + | · <GitBranch size={11} /> | |
| 199 | + | <span className="truncate">{session.branch}</span> | |
| 200 | + | </span> | |
| 201 | + | )} | |
| 202 | + | </p> | |
| 203 | + | </div> | |
| 204 | + | </div> | |
| 205 | + | ); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | const KIND_ICON: Record<Decision["kind"], ReactNode> = { | |
| 209 | + | proposal: <MessageSquare size={14} />, | |
| 210 | + | claim: <MessageSquare size={14} />, | |
| 211 | + | integration_failure: <CircleX size={14} className="text-danger" />, | |
| 212 | + | integration_passed: <Layers size={14} className="text-success" />, | |
| 213 | + | fix: <Link2 size={14} className="text-accent" />, | |
| 214 | + | approval: <CircleCheck size={14} className="text-success" />, | |
| 215 | + | merge: <GitMerge size={14} className="text-merged" />, | |
| 216 | + | note: <MessageSquare size={14} />, | |
| 217 | + | }; | |
| 218 | + | ||
| 219 | + | /** The pull requests tested together, in queue order: `#41 + #44`. */ | |
| 220 | + | export function Combination({ numbers, base, culprit }: { numbers: number[]; base: string; culprit?: number | null }) { | |
| 221 | + | return ( | |
| 222 | + | <span className="inline-flex flex-wrap items-center gap-1"> | |
| 223 | + | {numbers.map((number, index) => ( | |
| 224 | + | <Fragment key={number}> | |
| 225 | + | {index > 0 && <span className="text-faint">+</span>} | |
| 226 | + | <Link to={`${base}/pull/${number}`}> | |
| 227 | + | <Badge shape="tag" tone={number === culprit ? "danger" : "neutral"} className="font-mono hover:border-line-strong"> | |
| 228 | + | #{number} | |
| 229 | + | </Badge> | |
| 230 | + | </Link> | |
| 231 | + | </Fragment> | |
| 232 | + | ))} | |
| 233 | + | </span> | |
| 234 | + | ); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /** The check a decision rests on, linked where it can be seen. */ | |
| 238 | + | function CheckLink({ check }: { check: { name: string; conclusion: string; url: string | null } }) { | |
| 239 | + | const href = checkHref(check.url); | |
| 240 | + | const text = ( | |
| 241 | + | <> | |
| 242 | + | <span className="font-mono">{check.name}</span> {check.conclusion} | |
| 243 | + | </> | |
| 244 | + | ); | |
| 245 | + | if (!href) return <span>{text}</span>; | |
| 246 | + | return href.startsWith("/") ? ( | |
| 247 | + | <Link to={href} className="text-fg underline-offset-2 hover:underline"> | |
| 248 | + | {text} | |
| 249 | + | </Link> | |
| 250 | + | ) : ( | |
| 251 | + | <a href={href} className="text-fg underline-offset-2 hover:underline" rel="noreferrer" target="_blank"> | |
| 252 | + | {text} | |
| 253 | + | </a> | |
| 254 | + | ); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /** One decision record: its kind, how sure it is, the sentence, who and when, and what it rests on. */ | |
| 258 | + | export function DecisionLine({ decision, base }: { decision: Decision; base: string }) { | |
| 259 | + | const evidence = evidenceOf(decision); | |
| 260 | + | return ( | |
| 261 | + | <div className="flex items-start gap-2.5"> | |
| 262 | + | <span className="mt-0.5 shrink-0 text-faint">{KIND_ICON[decision.kind]}</span> | |
| 263 | + | <div className="min-w-0 grow"> | |
| 264 | + | <p className="flex flex-wrap items-center gap-1.5 text-xs"> | |
| 265 | + | <span className="font-medium text-fg">{DECISION_KINDS[decision.kind]}</span> | |
| 266 | + | <ConfidenceBadge confidence={decision.confidence} /> | |
| 267 | + | {decision.pull != null && ( | |
| 268 | + | <Link to={`${base}/pull/${decision.pull}`} className="text-muted hover:text-fg"> | |
| 269 | + | #{decision.pull} | |
| 270 | + | </Link> | |
| 271 | + | )} | |
| 272 | + | </p> | |
| 273 | + | <p className="mt-1 text-sm wrap-break-word"> | |
| 274 | + | <NumberedText text={decision.summary} base={base} /> | |
| 275 | + | </p> | |
| 276 | + | <p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted"> | |
| 277 | + | <span>{decision.actor ?? "g1t"}</span> | |
| 278 | + | <span> | |
| 279 | + | · <TimeAgo at={decision.createdAt} /> | |
| 280 | + | </span> | |
| 281 | + | {evidence.combination.length > 1 && ( | |
| 282 | + | <span className="inline-flex items-center gap-1.5"> | |
| 283 | + | · tested as <Combination numbers={evidence.combination} base={base} culprit={decision.kind === "integration_failure" ? decision.pull : null} /> | |
| 284 | + | </span> | |
| 285 | + | )} | |
| 286 | + | {evidence.check && decision.kind === "integration_failure" && ( | |
| 287 | + | <span> | |
| 288 | + | · <CheckLink check={evidence.check} /> | |
| 289 | + | </span> | |
| 290 | + | )} | |
| 291 | + | {evidence.commit && ( | |
| 292 | + | <span className="inline-flex items-center gap-1 font-mono"> | |
| 293 | + | · <GitCommitHorizontal size={11} /> | |
| 294 | + | {evidence.commit.slice(0, 7)} | |
| 295 | + | </span> | |
| 296 | + | )} | |
| 297 | + | </p> | |
| 298 | + | </div> | |
| 299 | + | </div> | |
| 300 | + | ); | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | /** Decision records, as a list in a card. */ | |
| 304 | + | export function DecisionList({ decisions, base }: { decisions: Decision[]; base: string }) { | |
| 305 | + | return ( | |
| 306 | + | <Card asChild divided> | |
| 307 | + | <ol> | |
| 308 | + | {decisions.map((decision) => ( | |
| 309 | + | <li key={decision.id} className="px-4 py-3"> | |
| 310 | + | <DecisionLine decision={decision} base={base} /> | |
| 311 | + | </li> | |
| 312 | + | ))} | |
| 313 | + | </ol> | |
| 314 | + | </Card> | |
| 315 | + | ); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | /** | |
| 319 | + | * A combination that failed in the merge queue: which pull requests were | |
| 320 | + | * tested together, the check that failed, which ones it fails with, and | |
| 321 | + | * whether it has been answered since. | |
| 322 | + | */ | |
| 323 | + | export function IntegrationFailure({ | |
| 324 | + | failure, | |
| 325 | + | base, | |
| 326 | + | fixed, | |
| 327 | + | branch = "main", | |
| 328 | + | className, | |
| 329 | + | }: { | |
| 330 | + | failure: Decision; | |
| 331 | + | base: string; | |
| 332 | + | /** The branch the combination was built on. */ | |
| 333 | + | branch?: string; | |
| 334 | + | /** A fix or a passing combination since, when there is one. */ | |
| 335 | + | fixed?: Decision | null; | |
| 336 | + | className?: string; | |
| 337 | + | }) { | |
| 338 | + | const evidence = evidenceOf(failure); | |
| 339 | + | return ( | |
| 340 | + | <Alert tone={fixed ? "info" : "danger"} className={className}> | |
| 341 | + | <AlertTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-fg"> | |
| 342 | + | {failure.pull != null ? ( | |
| 343 | + | <span> | |
| 344 | + | <TriangleAlert size={15} className={`mr-2 inline align-[-3px] ${fixed ? "text-info" : "text-danger"}`} /> | |
| 345 | + | <Link to={`${base}/pull/${failure.pull}`} className="hover:underline"> | |
| 346 | + | #{failure.pull} | |
| 347 | + | </Link>{" "} | |
| 348 | + | failed in the merge queue {evidence.combination.length > 1 ? "together with others" : "on its own"} | |
| 349 | + | </span> | |
| 350 | + | ) : ( | |
| 351 | + | <span>A combination failed in the merge queue</span> | |
| 352 | + | )} | |
| 353 | + | <ConfidenceBadge confidence={failure.confidence} /> | |
| 354 | + | <span className="text-xs font-normal text-muted"> | |
| 355 | + | <TimeAgo at={failure.createdAt} /> | |
| 356 | + | </span> | |
| 357 | + | </AlertTitle> | |
| 358 | + | <AlertDescription className="space-y-1.5"> | |
| 359 | + | {evidence.combination.length > 0 && ( | |
| 360 | + | <p className="flex flex-wrap items-center gap-1.5 text-xs"> | |
| 361 | + | Tested as <span className="font-mono text-fg">{branch}</span> <span className="text-faint">+</span> | |
| 362 | + | <Combination numbers={evidence.combination} base={base} culprit={failure.pull} /> | |
| 363 | + | </p> | |
| 364 | + | )} | |
| 365 | + | {evidence.check && ( | |
| 366 | + | <p className="text-xs"> | |
| 367 | + | Failing check: <CheckLink check={evidence.check} /> | |
| 368 | + | </p> | |
| 369 | + | )} | |
| 370 | + | <p className="text-xs"> | |
| 371 | + | {evidence.suspects.length > 0 ? ( | |
| 372 | + | <> | |
| 373 | + | Fails with{" "} | |
| 374 | + | <NumberedText text={evidence.suspects.map((n) => `#${n}`).join(", ")} base={base} /> | |
| 375 | + | {evidence.bisected ? ", narrowed down by building smaller combinations." : "."} | |
| 376 | + | </> | |
| 377 | + | ) : ( | |
| 378 | + | "It fails by itself: the change is the cause." | |
| 379 | + | )} | |
| 380 | + | </p> | |
| 381 | + | {fixed && ( | |
| 382 | + | <p className="text-xs text-fg"> | |
| 383 | + | <CircleCheck size={13} className="mr-1.5 inline align-[-2px] text-success" /> | |
| 384 | + | {fixed.kind === "integration_passed" ? "Passed together since" : "Fixed since"}:{" "} | |
| 385 | + | <span className="text-muted"> | |
| 386 | + | <NumberedText text={fixed.summary} base={base} /> | |
| 387 | + | </span> | |
| 388 | + | </p> | |
| 389 | + | )} | |
| 390 | + | </AlertDescription> | |
| 391 | + | </Alert> | |
| 392 | + | ); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | const FEED_ICON: Record<FeedLine["type"], ReactNode> = { | |
| 396 | + | "work.claimed": <Hand size={14} />, | |
| 397 | + | "work.released": <Hand size={14} />, | |
| 398 | + | "work.claim_expired": <Timer size={14} />, | |
| 399 | + | "work.handoff_requested": <ArrowRightLeft size={14} />, | |
| 400 | + | "session.started": <Play size={14} />, | |
| 401 | + | "session.ended": <Square size={14} />, | |
| 402 | + | "dependency.changed": <Link2 size={14} />, | |
| 403 | + | "overlap.detected": <TriangleAlert size={14} />, | |
| 404 | + | "integration.failed": <CircleX size={14} />, | |
| 405 | + | "integration.passed": <Layers size={14} />, | |
| 406 | + | "decision.recorded": <CircleDot size={14} />, | |
| 407 | + | }; | |
| 408 | + | ||
| 409 | + | const FEED_TONE: Record<FeedLine["tone"], string> = { | |
| 410 | + | neutral: "text-faint", | |
| 411 | + | accent: "text-accent", | |
| 412 | + | success: "text-success", | |
| 413 | + | warn: "text-warn", | |
| 414 | + | danger: "text-danger", | |
| 415 | + | info: "text-info", | |
| 416 | + | }; | |
| 417 | + | ||
| 418 | + | /** Recent coordination events, newest first, one line each. */ | |
| 419 | + | export function CoordinationFeed({ lines, base }: { lines: FeedLine[]; base: string }) { | |
| 420 | + | return ( | |
| 421 | + | <Card asChild divided> | |
| 422 | + | <ol> | |
| 423 | + | {lines.map((line) => ( | |
| 424 | + | <li key={line.id} className="flex items-start gap-3 px-4 py-2.5 text-sm"> | |
| 425 | + | <span className={`mt-0.5 shrink-0 ${FEED_TONE[line.tone]}`}>{FEED_ICON[line.type]}</span> | |
| 426 | + | <p className="min-w-0 grow wrap-break-word text-muted"> | |
| 427 | + | <PersonLink name={line.who ?? "g1t"} className="font-medium text-fg hover:underline" /> | |
| 428 | + | {line.harness && <span className="text-faint"> via {harnessName(line.harness)}</span>}{" "} | |
| 429 | + | <NumberedText text={line.text} base={base} /> | |
| 430 | + | </p> | |
| 431 | + | <span className="shrink-0 text-xs text-faint"> | |
| 432 | + | <TimeAgo at={line.time} /> | |
| 433 | + | </span> | |
| 434 | + | </li> | |
| 435 | + | ))} | |
| 436 | + | </ol> | |
| 437 | + | </Card> | |
| 438 | + | ); | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | /** An issue's place among others: one it waits on, or one waiting on it. */ | |
| 442 | + | function DependencyLine({ base, number, title, state, detail }: { base: string; number: number; title: string; state: State; detail?: ReactNode }) { | |
| 443 | + | return ( | |
| 444 | + | <li className="flex items-start gap-2"> | |
| 445 | + | <span className="mt-0.5"> | |
| 446 | + | <IssueIcon issue={{ state, reason: null }} size={14} /> | |
| 447 | + | </span> | |
| 448 | + | <span className="min-w-0 grow"> | |
| 449 | + | <Link to={`${base}/issues/${number}`} className="block truncate text-sm hover:text-accent"> | |
| 450 | + | {title} <span className="text-faint">#{number}</span> | |
| 451 | + | </Link> | |
| 452 | + | {detail && <span className="block text-xs text-muted">{detail}</span>} | |
| 453 | + | </span> | |
| 454 | + | </li> | |
| 455 | + | ); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | /** Where a blocker stands, in a few words: landed, being worked on, or waiting. */ | |
| 459 | + | export function blockerDetail(blocker: Blocker): string { | |
| 460 | + | if (blocker.pulls.some((pull) => pull.status === "merged")) return "Merged"; | |
| 461 | + | if (blocker.state === "closed") return "Closed"; | |
| 462 | + | const open = blocker.pulls.filter((pull) => pull.status === "open" || pull.status === "draft"); | |
| 463 | + | const who = blocker.claims.map((claim) => claim.actor.username); | |
| 464 | + | return [ | |
| 465 | + | who.length > 0 ? `Claimed by ${who.join(", ")}` : "Not claimed", | |
| 466 | + | open.length > 0 ? `${open.length} pull ${open.length === 1 ? "request" : "requests"} open` : null, | |
| 467 | + | ] | |
| 468 | + | .filter(Boolean) | |
| 469 | + | .join(" · "); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /** A part of the Work panel, under a rule. */ | |
| 473 | + | function PanelPart({ title, action, children }: { title: ReactNode; action?: ReactNode; children: ReactNode }) { | |
| 474 | + | return ( | |
| 475 | + | <div className="border-t border-line pt-3"> | |
| 476 | + | <div className="flex min-h-7 items-center justify-between gap-2"> | |
| 477 | + | <p className="text-xs font-medium text-muted">{title}</p> | |
| 478 | + | {action} | |
| 479 | + | </div> | |
| 480 | + | <div className="mt-1.5">{children}</div> | |
| 481 | + | </div> | |
| 482 | + | ); | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | /** | |
| 486 | + | * The Work panel of an issue's sidebar: who has claimed it, the sessions on | |
| 487 | + | * it, what it waits on and what waits on it, and for those who may, the | |
| 488 | + | * blockers to change. | |
| 489 | + | */ | |
| 490 | + | export function IssueWorkPanel({ | |
| 491 | + | work, | |
| 492 | + | ended, | |
| 493 | + | base, | |
| 494 | + | now, | |
| 495 | + | canEdit, | |
| 496 | + | error, | |
| 497 | + | }: { | |
| 498 | + | work: WorkContext; | |
| 499 | + | /** Claims on it that ended lately, shown muted. */ | |
| 500 | + | ended: Claim[]; | |
| 501 | + | base: string; | |
| 502 | + | now: number; | |
| 503 | + | canEdit: boolean; | |
| 504 | + | error?: string | null; | |
| 505 | + | }) { | |
| 506 | + | const open = work.issue.state === "open"; | |
| 507 | + | const sessions = work.sessions.slice(0, 5); | |
| 508 | + | const waiting = work.blockedBy.filter((blocker) => blocker.state === "open").length; | |
| 509 | + | return ( | |
| 510 | + | <Card asChild className="space-y-4 p-4"> | |
| 511 | + | <section aria-label="Work"> | |
| 512 | + | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 513 | + | <Activity size={14} className="text-accent" /> | |
| 514 | + | Work | |
| 515 | + | </h3> | |
| 516 | + | ||
| 517 | + | <div className="space-y-3"> | |
| 518 | + | {work.claims.length === 0 && ended.length === 0 ? ( | |
| 519 | + | <p className="text-xs text-muted"> | |
| 520 | + | {open ? ( | |
| 521 | + | <> | |
| 522 | + | Nobody has claimed it. Claim it from your harness, or with <code className="font-mono text-fg-soft">g1t work claim {work.issue.number}</code>. | |
| 523 | + | </> | |
| 524 | + | ) : ( | |
| 525 | + | "Nobody is working on it." | |
| 526 | + | )} | |
| 527 | + | </p> | |
| 528 | + | ) : ( | |
| 529 | + | [...work.claims, ...ended.slice(0, 3)].map((claim) => <ClaimLine key={claim.id} claim={claim} now={now} base={base} />) | |
| 530 | + | )} | |
| 531 | + | {work.claims.length > 1 && ( | |
| 532 | + | <Alert tone="warn" className="flex items-start gap-1.5 px-2.5 py-1.5 text-xs"> | |
| 533 | + | <TriangleAlert size={13} className="mt-0.5 shrink-0 text-warn" /> | |
| 534 | + | {work.claims.length} claims stand on it: they may be doing the same work. | |
| 535 | + | </Alert> | |
| 536 | + | )} | |
| 537 | + | </div> | |
| 538 | + | ||
| 539 | + | {sessions.length > 0 && ( | |
| 540 | + | <PanelPart title="Sessions"> | |
| 541 | + | <ul className="space-y-3"> | |
| 542 | + | {sessions.map((session) => ( | |
| 543 | + | <li key={session.id}> | |
| 544 | + | <SessionLine session={session} base={base} showBranch={false} /> | |
| 545 | + | </li> | |
| 546 | + | ))} | |
| 547 | + | </ul> | |
| 548 | + | {work.sessions.length > sessions.length && ( | |
| 549 | + | <Link to={`${base}/work`} className="mt-2 inline-block text-xs text-muted hover:text-fg"> | |
| 550 | + | {work.sessions.length - sessions.length} more on the Work page | |
| 551 | + | </Link> | |
| 552 | + | )} | |
| 553 | + | </PanelPart> | |
| 554 | + | )} | |
| 555 | + | ||
| 556 | + | <PanelPart | |
| 557 | + | title={ | |
| 558 | + | <> | |
| 559 | + | Blocked by | |
| 560 | + | {waiting > 0 && <span className="text-warn"> · {waiting} open</span>} | |
| 561 | + | </> | |
| 562 | + | } | |
| 563 | + | action={canEdit ? <BlockedByEditor current={work.issue.blockedBy} /> : undefined} | |
| 564 | + | > | |
| 565 | + | {work.blockedBy.length === 0 ? ( | |
| 566 | + | <p className="text-xs text-faint">Nothing. It can start now.</p> | |
| 567 | + | ) : ( | |
| 568 | + | <ul className="space-y-2"> | |
| 569 | + | {work.blockedBy.map((blocker) => ( | |
| 570 | + | <DependencyLine key={blocker.number} base={base} number={blocker.number} title={blocker.title} state={blocker.state} detail={blockerDetail(blocker)} /> | |
| 571 | + | ))} | |
| 572 | + | </ul> | |
| 573 | + | )} | |
| 574 | + | {error && <p className="mt-2 text-xs text-danger">{error}</p>} | |
| 575 | + | </PanelPart> | |
| 576 | + | ||
| 577 | + | {work.blocks.length > 0 && ( | |
| 578 | + | <PanelPart title="Blocks"> | |
| 579 | + | <ul className="space-y-2"> | |
| 580 | + | {work.blocks.map((issue) => ( | |
| 581 | + | <DependencyLine key={issue.number} base={base} number={issue.number} title={issue.title} state={issue.state} /> | |
| 582 | + | ))} | |
| 583 | + | </ul> | |
| 584 | + | </PanelPart> | |
| 585 | + | )} | |
| 586 | + | </section> | |
| 587 | + | </Card> | |
| 588 | + | ); | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | /** Changing what an issue waits on: the whole list, as numbers. */ | |
| 592 | + | function BlockedByEditor({ current }: { current: number[] }) { | |
| 593 | + | return ( | |
| 594 | + | <Popover> | |
| 595 | + | <PopoverTrigger asChild> | |
| 596 | + | <Button variant="ghost" size="xs"> | |
| 597 | + | <Pencil size={12} /> | |
| 598 | + | Edit | |
| 599 | + | </Button> | |
| 600 | + | </PopoverTrigger> | |
| 601 | + | <PopoverContent align="end"> | |
| 602 | + | <Form method="post" className="space-y-2.5"> | |
| 603 | + | <input type="hidden" name="action" value="blocked-by" /> | |
| 604 | + | <Label htmlFor="blocked-by">Blocked by</Label> | |
| 605 | + | <Input id="blocked-by" name="blocked_by" defaultValue={current.map((n) => `#${n}`).join(", ")} placeholder="#12, #14" autoComplete="off" /> | |
| 606 | + | <p className="text-xs text-muted">Issues of this repository that have to land first. Leave it empty for none.</p> | |
| 607 | + | <SubmitButton size="sm" match={{ action: "blocked-by" }} pending="Saving…"> | |
| 608 | + | Save | |
| 609 | + | </SubmitButton> | |
| 610 | + | </Form> | |
| 611 | + | </PopoverContent> | |
| 612 | + | </Popover> | |
| 613 | + | ); | |
| 614 | + | } |
| 1 | + | /** | |
| 2 | + | * The merge queue as a train: the default branch, then each entry in | |
| 3 | + | * order with the combination it is tested as and how its checks stand. | |
| 4 | + | * The Merge queue page shows it whole; the Work page shows what is moving. | |
| 5 | + | */ | |
| 6 | + | import { Check, CircleDashed, GitCommitHorizontal, GitMerge, Loader2, Minus, X } from "lucide-react"; | |
| 7 | + | import { type ReactNode, Suspense } from "react"; | |
| 8 | + | import { Await, Link } from "react-router"; | |
| 9 | + | ||
| 10 | + | import type { QueueEntry, QueueState } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | import { TimeAgo } from "./ui"; | |
| 13 | + | import { Avatar } from "./ui/avatar"; | |
| 14 | + | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 15 | + | import { Card } from "./ui/card"; | |
| 16 | + | ||
| 17 | + | /** A merge_group workflow run on a combined state. */ | |
| 18 | + | export type GroupRun = { id: string; name: string }; | |
| 19 | + | ||
| 20 | + | const STATE: Record<QueueState, { label: string; tone: BadgeTone; icon: ReactNode }> = { | |
| 21 | + | waiting: { label: "Waiting", tone: "neutral", icon: <CircleDashed size={13} /> }, | |
| 22 | + | testing: { label: "Testing", tone: "accent", icon: <Loader2 size={13} className="animate-spin" /> }, | |
| 23 | + | passed: { label: "Passed", tone: "success", icon: <Check size={13} /> }, | |
| 24 | + | failed: { label: "Failed", tone: "danger", icon: <X size={13} /> }, | |
| 25 | + | landed: { label: "Landed", tone: "merged", icon: <GitMerge size={13} /> }, | |
| 26 | + | removed: { label: "Removed", tone: "neutral", icon: <Minus size={13} /> }, | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | export function QueueStateBadge({ state }: { state: QueueState }) { | |
| 30 | + | const { label, tone, icon } = STATE[state]; | |
| 31 | + | return ( | |
| 32 | + | <Badge tone={tone} size="md" className="gap-1.5"> | |
| 33 | + | {icon} | |
| 34 | + | {label} | |
| 35 | + | </Badge> | |
| 36 | + | ); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /** `main + #12 + #14`: the state an entry is being tested in. */ | |
| 40 | + | export function TestedAs({ entry, branch }: { entry: QueueEntry; branch: string }) { | |
| 41 | + | return ( | |
| 42 | + | <span className="flex flex-wrap items-center gap-1 font-mono text-xs text-muted"> | |
| 43 | + | <Badge shape="tag">{branch}</Badge> | |
| 44 | + | {[...entry.ahead, entry.number].map((number) => ( | |
| 45 | + | <span key={number} className="flex items-center gap-1"> | |
| 46 | + | <span className="text-faint">+</span> | |
| 47 | + | <Badge shape="tag" tone={number === entry.number ? "accent" : "neutral"}> | |
| 48 | + | #{number} | |
| 49 | + | </Badge> | |
| 50 | + | </span> | |
| 51 | + | ))} | |
| 52 | + | </span> | |
| 53 | + | ); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** | |
| 57 | + | * How an entry's checks went, linked to the merge_group run on its combined | |
| 58 | + | * state; with several runs, each is named and linked after the count. | |
| 59 | + | */ | |
| 60 | + | function GroupChecks({ entry, base, runs }: { entry: QueueEntry; base: string; runs: GroupRun[] }) { | |
| 61 | + | const ran = entry.results.length; | |
| 62 | + | const passed = entry.results.filter((result) => result.passed).length; | |
| 63 | + | const [first, ...more] = runs; | |
| 64 | + | const tone = passed === ran ? "text-success" : "text-danger"; | |
| 65 | + | const count = ran > 0 ? `${passed}/${ran} checks passed` : first ? "merge_group run" : null; | |
| 66 | + | if (!count) return null; | |
| 67 | + | return ( | |
| 68 | + | <span className="flex flex-wrap items-center gap-x-2"> | |
| 69 | + | {first ? ( | |
| 70 | + | <Link to={`${base}/actions/runs/${first.id}`} className={`underline-offset-2 hover:underline ${ran > 0 ? tone : "text-muted"}`}> | |
| 71 | + | {count} | |
| 72 | + | {more.length > 0 && ` · ${first.name}`} | |
| 73 | + | </Link> | |
| 74 | + | ) : ( | |
| 75 | + | <span className={tone}>{count}</span> | |
| 76 | + | )} | |
| 77 | + | {more.map((run) => ( | |
| 78 | + | <Link key={run.id} to={`${base}/actions/runs/${run.id}`} className="text-muted underline-offset-2 hover:text-fg hover:underline"> | |
| 79 | + | {run.name} | |
| 80 | + | </Link> | |
| 81 | + | ))} | |
| 82 | + | </span> | |
| 83 | + | ); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | const NO_RUNS: Promise<Record<string, GroupRun[]>> = Promise.resolve({}); | |
| 87 | + | ||
| 88 | + | /** Where an entry sits on the train's rail. */ | |
| 89 | + | const STOP: Record<QueueState, string> = { | |
| 90 | + | waiting: "border-line-strong bg-bg", | |
| 91 | + | testing: "animate-pulse border-accent bg-accent/30", | |
| 92 | + | passed: "border-success bg-success", | |
| 93 | + | landed: "border-success bg-success", | |
| 94 | + | failed: "border-danger bg-danger", | |
| 95 | + | removed: "border-line-strong bg-bg", | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | export function QueueEntryCard({ | |
| 99 | + | entry, | |
| 100 | + | base, | |
| 101 | + | branch, | |
| 102 | + | position, | |
| 103 | + | groupRuns = NO_RUNS, | |
| 104 | + | }: { | |
| 105 | + | entry: QueueEntry; | |
| 106 | + | base: string; | |
| 107 | + | branch: string; | |
| 108 | + | position?: number; | |
| 109 | + | /** The merge_group runs on each combined state, by commit. */ | |
| 110 | + | groupRuns?: Promise<Record<string, GroupRun[]>>; | |
| 111 | + | }) { | |
| 112 | + | const runsOf = (found: Record<string, GroupRun[]>) => (entry.combinedCommit ? (found[entry.combinedCommit] ?? []) : []); | |
| 113 | + | return ( | |
| 114 | + | <li className="relative pl-10"> | |
| 115 | + | <span className={`absolute top-4 left-3.25 size-3 rounded-full border-2 ${STOP[entry.state]}`} /> | |
| 116 | + | <Card className="p-4 transition-colors hover:border-line-strong"> | |
| 117 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 118 | + | {position != null && <span className="font-mono text-xs text-faint">{position}</span>} | |
| 119 | + | <Link to={`${base}/pull/${entry.number}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent"> | |
| 120 | + | {entry.title} <span className="font-normal text-faint">#{entry.number}</span> | |
| 121 | + | </Link> | |
| 122 | + | <QueueStateBadge state={entry.state} /> | |
| 123 | + | </div> | |
| 124 | + | <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted"> | |
| 125 | + | {(entry.state === "testing" || entry.state === "passed" || entry.state === "failed") && <TestedAs entry={entry} branch={branch} />} | |
| 126 | + | <Suspense fallback={<GroupChecks entry={entry} base={base} runs={[]} />}> | |
| 127 | + | <Await resolve={groupRuns} errorElement={<GroupChecks entry={entry} base={base} runs={[]} />}> | |
| 128 | + | {(found) => <GroupChecks entry={entry} base={base} runs={runsOf(found)} />} | |
| 129 | + | </Await> | |
| 130 | + | </Suspense> | |
| 131 | + | <span className="flex items-center gap-1.5"> | |
| 132 | + | <Avatar name={entry.agent} image={null} size={14} /> | |
| 133 | + | {entry.agent} | |
| 134 | + | </span> | |
| 135 | + | <span> | |
| 136 | + | queued by {entry.enqueuedBy} <TimeAgo at={entry.createdAt} /> | |
| 137 | + | </span> | |
| 138 | + | {entry.combinedCommit && ( | |
| 139 | + | <span className="flex items-center gap-1 font-mono"> | |
| 140 | + | <GitCommitHorizontal size={12} /> | |
| 141 | + | {entry.combinedCommit.slice(0, 7)} | |
| 142 | + | </span> | |
| 143 | + | )} | |
| 144 | + | </div> | |
| 145 | + | {entry.error && <p className="mt-2.5 text-xs text-danger">{entry.error}</p>} | |
| 146 | + | {entry.state === "failed" && | |
| 147 | + | entry.results | |
| 148 | + | .filter((result) => !result.passed) | |
| 149 | + | .slice(0, 2) | |
| 150 | + | .map((result) => ( | |
| 151 | + | <details key={result.command} className="mt-2"> | |
| 152 | + | <summary className="cursor-pointer font-mono text-xs text-danger">{result.command}</summary> | |
| 153 | + | <pre className="mt-1.5 max-h-48 overflow-auto rounded-lg bg-bg p-3 text-xs text-muted"> | |
| 154 | + | <code>{result.output}</code> | |
| 155 | + | </pre> | |
| 156 | + | </details> | |
| 157 | + | ))} | |
| 158 | + | </Card> | |
| 159 | + | </li> | |
| 160 | + | ); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /** The train: the default branch at the commit the front is built on, then each entry in order. */ | |
| 164 | + | export function QueueTrain({ | |
| 165 | + | active, | |
| 166 | + | base, | |
| 167 | + | branch, | |
| 168 | + | groupRuns, | |
| 169 | + | empty = "Nothing is waiting. Merged pull requests appear here.", | |
| 170 | + | }: { | |
| 171 | + | active: QueueEntry[]; | |
| 172 | + | base: string; | |
| 173 | + | branch: string; | |
| 174 | + | groupRuns?: Promise<Record<string, GroupRun[]>>; | |
| 175 | + | empty?: ReactNode; | |
| 176 | + | }) { | |
| 177 | + | return ( | |
| 178 | + | <ol className="relative space-y-3 before:absolute before:top-0 before:bottom-4 before:left-4.5 before:w-px before:bg-line"> | |
| 179 | + | <li className="relative flex items-center gap-2 pl-10 text-xs text-muted"> | |
| 180 | + | <span className="absolute left-2.75 flex size-4 items-center justify-center rounded-full bg-raised ring-1 ring-line-strong"> | |
| 181 | + | <GitCommitHorizontal size={10} /> | |
| 182 | + | </span> | |
| 183 | + | <span className="font-mono text-fg">{branch}</span> | |
| 184 | + | {active[0]?.baseCommit && <span className="font-mono">at {active[0].baseCommit.slice(0, 7)}</span>} | |
| 185 | + | </li> | |
| 186 | + | {active.map((entry, index) => ( | |
| 187 | + | <QueueEntryCard key={entry.id} entry={entry} base={base} branch={branch} position={index + 1} groupRuns={groupRuns} /> | |
| 188 | + | ))} | |
| 189 | + | {active.length === 0 && <li className="pl-10 text-sm text-muted">{empty}</li>} | |
| 190 | + | </ol> | |
| 191 | + | ); | |
| 192 | + | } |
| 1 | + | /** | |
| 2 | + | * What a session recorded, step by step: prompts folded, messages in full, | |
| 3 | + | * tool calls in a line each and their results folded. The pull request's | |
| 4 | + | * Sessions page and a harness session's page both list them. | |
| 5 | + | */ | |
| 6 | + | import { MessageSquare, StickyNote, Terminal, Wrench } from "lucide-react"; | |
| 7 | + | ||
| 8 | + | import type { SessionEntry } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | import { TimeAgo } from "./ui"; | |
| 11 | + | import { Card } from "./ui/card"; | |
| 12 | + | import { Hint } from "./ui/hint"; | |
| 13 | + | ||
| 14 | + | function Entry({ entry }: { entry: SessionEntry }) { | |
| 15 | + | const at = ( | |
| 16 | + | <span className="shrink-0 text-xs text-faint"> | |
| 17 | + | <TimeAgo at={entry.at} /> | |
| 18 | + | </span> | |
| 19 | + | ); | |
| 20 | + | if (entry.kind === "prompt") { | |
| 21 | + | return ( | |
| 22 | + | <li className="px-4 py-3"> | |
| 23 | + | <details> | |
| 24 | + | <summary className="flex cursor-pointer items-center gap-2 text-sm font-medium"> | |
| 25 | + | <Terminal size={14} className="text-accent" /> | |
| 26 | + | <span className="grow">Prompt</span> | |
| 27 | + | {at} | |
| 28 | + | </summary> | |
| 29 | + | <pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-bg p-3 text-xs whitespace-pre-wrap text-muted">{entry.text}</pre> | |
| 30 | + | </details> | |
| 31 | + | </li> | |
| 32 | + | ); | |
| 33 | + | } | |
| 34 | + | if (entry.kind === "message") { | |
| 35 | + | return ( | |
| 36 | + | <li className="flex gap-2.5 px-4 py-3 text-sm"> | |
| 37 | + | <MessageSquare size={14} className="mt-0.5 shrink-0 text-faint" /> | |
| 38 | + | <p className="min-w-0 grow whitespace-pre-wrap wrap-break-word">{entry.text}</p> | |
| 39 | + | {at} | |
| 40 | + | </li> | |
| 41 | + | ); | |
| 42 | + | } | |
| 43 | + | if (entry.kind === "tool_call") { | |
| 44 | + | return ( | |
| 45 | + | <li className="flex gap-2.5 px-4 py-2 text-xs"> | |
| 46 | + | <Wrench size={13} className="mt-0.5 shrink-0 text-faint" /> | |
| 47 | + | <span className="shrink-0 font-medium text-muted">{entry.tool}</span> | |
| 48 | + | <Hint label={entry.text}> | |
| 49 | + | <code className="min-w-0 grow truncate font-mono text-fg/85">{entry.text}</code> | |
| 50 | + | </Hint> | |
| 51 | + | </li> | |
| 52 | + | ); | |
| 53 | + | } | |
| 54 | + | if (entry.kind === "tool_result") { | |
| 55 | + | return ( | |
| 56 | + | <li className="px-4 py-1.5 pl-10"> | |
| 57 | + | <details> | |
| 58 | + | <summary className="cursor-pointer text-xs text-faint">Result</summary> | |
| 59 | + | <pre className="mt-1.5 max-h-72 overflow-auto rounded-lg bg-bg p-3 text-xs text-muted">{entry.text}</pre> | |
| 60 | + | </details> | |
| 61 | + | </li> | |
| 62 | + | ); | |
| 63 | + | } | |
| 64 | + | return ( | |
| 65 | + | <li className="flex gap-2.5 px-4 py-2.5 text-xs text-muted"> | |
| 66 | + | <StickyNote size={13} className="mt-0.5 shrink-0 text-faint" /> | |
| 67 | + | <span className="min-w-0 grow">{entry.text}</span> | |
| 68 | + | {at} | |
| 69 | + | </li> | |
| 70 | + | ); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** A session's entries in a card, oldest first. */ | |
| 74 | + | export function SessionEntries({ entries }: { entries: SessionEntry[] }) { | |
| 75 | + | return ( | |
| 76 | + | <Card asChild divided> | |
| 77 | + | <ol> | |
| 78 | + | {entries.map((entry) => ( | |
| 79 | + | <Entry key={entry.seq} entry={entry} /> | |
| 80 | + | ))} | |
| 81 | + | </ol> | |
| 82 | + | </Card> | |
| 83 | + | ); | |
| 84 | + | } |
| 814 | 814 | </SidebarLink> | |
| 815 | 815 | )} | |
| 816 | 816 | {shows.has("pulls") && ( | |
| 817 | − | <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`, `${base}/sessions`]} icon={<GitPullRequest size={15} />} count={repo.pulls}> | |
| 817 | + | <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/work`, `${base}/queue`, `${base}/sessions`]} icon={<GitPullRequest size={15} />} count={repo.pulls}> | |
| 818 | 818 | Pull requests | |
| 819 | 819 | </SidebarLink> | |
| 820 | 820 | )} | |
| ⋯ | |||
| 1418 | 1418 | observability: "Observability", | |
| 1419 | 1419 | insights: "Insights", | |
| 1420 | 1420 | sessions: "Sessions", | |
| 1421 | + | work: "Work", | |
| 1421 | 1422 | chat: "Chat", | |
| 1422 | 1423 | artifacts: "Artifacts", | |
| 1423 | 1424 | home: "Home", | |
| 7 | 7 | // tone says how much it matters. `sm` (the default) is the 11px label beside | |
| 8 | 8 | // a title or in a table; `md` the 12px pill in a list of properties. | |
| 9 | 9 | ||
| 10 | − | export const badgeVariants = cva("inline-flex shrink-0 items-center gap-1 rounded-full border whitespace-nowrap", { | |
| 10 | + | export const badgeVariants = cva("inline-flex shrink-0 items-center gap-1 border whitespace-nowrap", { | |
| 11 | 11 | variants: { | |
| 12 | 12 | tone: { | |
| 13 | 13 | neutral: "border-line text-muted", | |
| ⋯ | |||
| 22 | 22 | sm: "px-2 py-px text-[0.6875rem] font-medium", | |
| 23 | 23 | md: "px-2 py-0.5 text-xs", | |
| 24 | 24 | }, | |
| 25 | + | shape: { | |
| 26 | + | pill: "rounded-full", | |
| 27 | + | tag: "rounded", | |
| 28 | + | }, | |
| 25 | 29 | }, | |
| 26 | − | defaultVariants: { tone: "neutral", size: "sm" }, | |
| 30 | + | defaultVariants: { tone: "neutral", size: "sm", shape: "pill" }, | |
| 27 | 31 | }); | |
| 28 | 32 | ||
| 29 | 33 | export type BadgeTone = NonNullable<VariantProps<typeof badgeVariants>["tone"]>; | |
| 30 | 34 | ||
| 31 | − | export function Badge({ tone, size, className, ...props }: ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { | |
| 32 | − | return <span data-slot="badge" className={cn(badgeVariants({ tone, size }), className)} {...props} />; | |
| 35 | + | export function Badge({ tone, size, shape, className, ...props }: ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { | |
| 36 | + | return <span data-slot="badge" className={cn(badgeVariants({ tone, size, shape }), className)} {...props} />; | |
| 33 | 37 | } | |
| 38 | 38 | "packages", "patterns", "people", "personal-access-tokens", "pins", "policies", "profile", "projects", "pull", | |
| 39 | 39 | "pulls", "queue", "releases", "repositories", "reset", "rules", "runners", "runs", "search", "secrets", "security", | |
| 40 | 40 | "security-log", "settings", "soon", "spend", "tags", "teams", "today", "tokens", "tree", "two-factor", "u", "usage", | |
| 41 | − | "verify", "webhooks", "workspace", "workspaces", | |
| 41 | + | "verify", "webhooks", "work", "workspace", "workspaces", | |
| 42 | 42 | ]); | |
| 43 | 43 | ||
| 44 | 44 | export function isPublicPath(pathname: string): boolean { |
| 110 | 110 | export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean }> = { | |
| 111 | 111 | code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare", "memory"] }, | |
| 112 | 112 | issues: { label: "Issues", path: "issues", also: ["milestones", "labels"] }, | |
| 113 | − | pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue", "sessions"] }, | |
| 113 | + | pulls: { label: "Pull requests", path: "pulls", also: ["pull", "work", "queue", "sessions"] }, | |
| 114 | 114 | actions: { label: "Workflows", path: "actions", also: [] }, | |
| 115 | 115 | deployments: { label: "Deployments", path: "deployments", also: [] }, | |
| 116 | 116 | observability: { label: "Observability", path: "observability", also: [] }, |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { Claim, Decision, G1tEvent, WorkSession } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { | |
| 7 | + | checkHref, | |
| 8 | + | evidenceOf, | |
| 9 | + | expiresIn, | |
| 10 | + | feedLine, | |
| 11 | + | harnessName, | |
| 12 | + | liveByIssue, | |
| 13 | + | numberedParts, | |
| 14 | + | parseBlockedBy, | |
| 15 | + | resolutionOf, | |
| 16 | + | span, | |
| 17 | + | standingFailure, | |
| 18 | + | } from "./coordination.ts"; | |
| 19 | + | ||
| 20 | + | const ana = { id: "usr_ana", username: "ana" }; | |
| 21 | + | const bo = { id: "usr_bo", username: "bo" }; | |
| 22 | + | ||
| 23 | + | function claim(issue: number, over: Partial<Claim> = {}): Claim { | |
| 24 | + | return { | |
| 25 | + | id: `clm_${issue}_${over.actor?.username ?? "ana"}`, | |
| 26 | + | repoId: "rep_1", | |
| 27 | + | issue, | |
| 28 | + | sessionId: null, | |
| 29 | + | actor: ana, | |
| 30 | + | harness: "claude_code", | |
| 31 | + | note: null, | |
| 32 | + | state: "active", | |
| 33 | + | createdAt: "2026-10-12T10:00:00Z", | |
| 34 | + | expiresAt: "2026-10-12T10:30:00Z", | |
| 35 | + | endedAt: null, | |
| 36 | + | endReason: null, | |
| 37 | + | handoffTo: null, | |
| 38 | + | ...over, | |
| 39 | + | }; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | function session(id: string, over: Partial<WorkSession> = {}): WorkSession { | |
| 43 | + | return { | |
| 44 | + | id, | |
| 45 | + | repoId: "rep_1", | |
| 46 | + | issue: null, | |
| 47 | + | pull: null, | |
| 48 | + | branch: null, | |
| 49 | + | actor: bo, | |
| 50 | + | harness: "codex", | |
| 51 | + | harnessSessionId: null, | |
| 52 | + | title: null, | |
| 53 | + | state: "active", | |
| 54 | + | headCommit: null, | |
| 55 | + | startedAt: "2026-10-12T10:00:00Z", | |
| 56 | + | lastSeenAt: "2026-10-12T10:05:00Z", | |
| 57 | + | endedAt: null, | |
| 58 | + | endReason: null, | |
| 59 | + | ...over, | |
| 60 | + | }; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | function decision(kind: Decision["kind"], at: string, over: Partial<Decision> = {}): Decision { | |
| 64 | + | return { | |
| 65 | + | id: `dec_${kind}_${at}`, | |
| 66 | + | issue: 2, | |
| 67 | + | pull: 5, | |
| 68 | + | sessionId: null, | |
| 69 | + | actor: null, | |
| 70 | + | kind, | |
| 71 | + | confidence: "tested", | |
| 72 | + | summary: kind, | |
| 73 | + | evidence: {}, | |
| 74 | + | createdAt: at, | |
| 75 | + | ...over, | |
| 76 | + | }; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | const event = (type: string, data: unknown): G1tEvent => | |
| 80 | + | ({ id: `evt_${type}`, type, source: "work", time: "2026-10-12T10:00:00Z", repoId: "rep_1", actor: "usr_ana", data }) as G1tEvent; | |
| 81 | + | ||
| 82 | + | test("harnesses are named as people know them", () => { | |
| 83 | + | assert.equal(harnessName("claude_code"), "Claude Code"); | |
| 84 | + | assert.equal(harnessName("codex"), "Codex"); | |
| 85 | + | assert.equal(harnessName(null), null); | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test("a claim says when it lapses; one that ended says nothing", () => { | |
| 89 | + | const now = Date.parse("2026-10-12T10:07:00Z"); | |
| 90 | + | assert.equal(expiresIn(claim(1), now), "expires in 23m"); | |
| 91 | + | assert.equal(expiresIn(claim(1, { expiresAt: "2026-10-12T12:00:00Z" }), now), "expires in 1h 53m"); | |
| 92 | + | assert.equal(expiresIn(claim(1, { expiresAt: "2026-10-12T10:00:00Z" }), now), "expiring now"); | |
| 93 | + | assert.equal(expiresIn(claim(1, { state: "released" }), now), null); | |
| 94 | + | assert.equal(span(30_000), "less than a minute"); | |
| 95 | + | assert.equal(span(2 * 3_600_000), "2h"); | |
| 96 | + | }); | |
| 97 | + | ||
| 98 | + | test("live work is grouped by issue, most recent first, with sessions on no issue apart", () => { | |
| 99 | + | const { issues, loose } = liveByIssue( | |
| 100 | + | [claim(1), claim(2, { createdAt: "2026-10-12T10:20:00Z" }), claim(2, { actor: bo })], | |
| 101 | + | [session("ses_a", { issue: 1, lastSeenAt: "2026-10-12T10:30:00Z" }), session("ses_b")], | |
| 102 | + | ); | |
| 103 | + | assert.deepEqual( | |
| 104 | + | issues.map((group) => [group.issue, group.claims.length, group.sessions.length]), | |
| 105 | + | [ | |
| 106 | + | [1, 1, 1], | |
| 107 | + | [2, 2, 0], | |
| 108 | + | ], | |
| 109 | + | ); | |
| 110 | + | assert.deepEqual(loose.map((one) => one.id), ["ses_b"]); | |
| 111 | + | }); | |
| 112 | + | ||
| 113 | + | test("evidence is read as far as it goes", () => { | |
| 114 | + | const failed = decision("integration_failure", "2026-10-12T10:00:00Z", { | |
| 115 | + | evidence: { commit: "abc1234", check: { name: "CI / merge_group", conclusion: "failure", url: "https://g1t.sh/acme/web/actions/runs/run_1" }, combination: [4, 5], suspects: [4], bisected: false }, | |
| 116 | + | }); | |
| 117 | + | assert.deepEqual(evidenceOf(failed), { | |
| 118 | + | commit: "abc1234", | |
| 119 | + | check: { name: "CI / merge_group", conclusion: "failure", url: "https://g1t.sh/acme/web/actions/runs/run_1" }, | |
| 120 | + | combination: [4, 5], | |
| 121 | + | suspects: [4], | |
| 122 | + | bisected: false, | |
| 123 | + | }); | |
| 124 | + | assert.deepEqual(evidenceOf(decision("note", "2026-10-12T10:00:00Z", { evidence: { combination: "nope" } })).combination, []); | |
| 125 | + | assert.equal(checkHref("https://g1t.sh/acme/web/actions/runs/run_1"), "/acme/web/actions/runs/run_1"); | |
| 126 | + | assert.equal(checkHref("https://ci.example.com/build/9"), "https://ci.example.com/build/9"); | |
| 127 | + | assert.equal(checkHref(null), null); | |
| 128 | + | }); | |
| 129 | + | ||
| 130 | + | test("a failure stands until the pull request passes in the queue or merges, and a fix after it answers it", () => { | |
| 131 | + | const failed = decision("integration_failure", "2026-10-12T10:00:00Z"); | |
| 132 | + | const fix = decision("fix", "2026-10-12T10:10:00Z", { confidence: "claimed" }); | |
| 133 | + | const passed = decision("integration_passed", "2026-10-12T10:20:00Z"); | |
| 134 | + | assert.equal(standingFailure([failed], 5), failed); | |
| 135 | + | assert.equal(standingFailure([failed, fix], 5), failed); | |
| 136 | + | assert.equal(standingFailure([passed, failed], 5), null); | |
| 137 | + | assert.equal(standingFailure([failed], 6), null); | |
| 138 | + | assert.deepEqual(resolutionOf([failed, fix, passed], failed), { fix, passed }); | |
| 139 | + | assert.deepEqual(resolutionOf([fix, failed], { ...failed, createdAt: "2026-10-12T10:15:00Z" }), { fix: null, passed: null }); | |
| 140 | + | }); | |
| 141 | + | ||
| 142 | + | test("coordination events read as lines of the feed", () => { | |
| 143 | + | const claimed = feedLine(event("work.claimed", { repoId: "rep_1", number: 2, issue: 2, claim: claim(2, { actor: bo, harness: "codex" }), others: [claim(2)] }))!; | |
| 144 | + | assert.equal(claimed.who, "bo"); | |
| 145 | + | assert.equal(claimed.harness, "codex"); | |
| 146 | + | assert.equal(claimed.text, "claimed #2 (ana is on it too)"); | |
| 147 | + | assert.equal(claimed.tone, "warn"); | |
| 148 | + | ||
| 149 | + | const failed = feedLine( | |
| 150 | + | event("integration.failed", { | |
| 151 | + | repoId: "rep_1", pullId: "pul_5", number: 5, pull: 5, issue: 2, combination: [4, 5], | |
| 152 | + | check: { name: "CI / merge_group", conclusion: "failure", url: null }, bisected: false, suspects: [4], otherPulls: [4], commit: null, | |
| 153 | + | }), | |
| 154 | + | )!; | |
| 155 | + | assert.equal(failed.who, null); | |
| 156 | + | assert.equal(failed.text, "#5 failed in the merge queue combined as #4, #5: CI / merge_group; fails with #4"); | |
| 157 | + | assert.equal(failed.tone, "danger"); | |
| 158 | + | ||
| 159 | + | const unblocked = feedLine( | |
| 160 | + | event("dependency.changed", { repoId: "rep_1", number: 2, issue: 2, blockedBy: [1], added: [], removed: [], cleared: [1], remaining: [], mergedPull: 4 }), | |
| 161 | + | )!; | |
| 162 | + | assert.equal(unblocked.text, "#2 #1 landed (#4 merged); unblocked"); | |
| 163 | + | ||
| 164 | + | const overlap = feedLine(event("overlap.detected", { repoId: "rep_1", number: 5, pull: 5, otherPull: 4, files: ["src/auth.rs"] }))!; | |
| 165 | + | assert.equal(overlap.text, "#5 and #4 change the same file: src/auth.rs"); | |
| 166 | + | ||
| 167 | + | const started = feedLine(event("session.started", { repoId: "rep_1", session: session("ses_a", { issue: 2 }) }))!; | |
| 168 | + | assert.equal(started.text, "started a session on #2"); | |
| 169 | + | assert.equal(feedLine(event("session.appended", { repoId: "rep_1", number: 5, pullId: "p", count: 3 })), null); | |
| 170 | + | }); | |
| 171 | + | ||
| 172 | + | test("numbers in a sentence are found for linking", () => { | |
| 173 | + | assert.deepEqual(numberedParts("#5 fails with #4."), [5, " fails with ", 4, "."]); | |
| 174 | + | assert.deepEqual(numberedParts("no numbers"), ["no numbers"]); | |
| 175 | + | }); | |
| 176 | + | ||
| 177 | + | test("blocked_by is typed as numbers", () => { | |
| 178 | + | assert.deepEqual(parseBlockedBy("#12, 14 #12"), [12, 14]); | |
| 179 | + | assert.deepEqual(parseBlockedBy(""), []); | |
| 180 | + | assert.equal(parseBlockedBy("#12, soon"), null); | |
| 181 | + | assert.equal(parseBlockedBy("0"), null); | |
| 182 | + | }); |
| 1 | + | /** | |
| 2 | + | * Coordinating work, as the site shows it: who is on which issue (claims), | |
| 3 | + | * which harness sessions are running, what the merge queue found when it | |
| 4 | + | * combined changes, and the decision records behind the result. Pure: the | |
| 5 | + | * pages and components/coordination.tsx draw what these say. | |
| 6 | + | */ | |
| 7 | + | import type { | |
| 8 | + | Claim, | |
| 9 | + | ClaimState, | |
| 10 | + | Decision, | |
| 11 | + | DecisionConfidence, | |
| 12 | + | EventType, | |
| 13 | + | G1tEvent, | |
| 14 | + | Harness, | |
| 15 | + | SessionState, | |
| 16 | + | WorkSession, | |
| 17 | + | } from "@g1t/contracts"; | |
| 18 | + | ||
| 19 | + | /** How each harness is named. */ | |
| 20 | + | export const HARNESS_NAMES: Record<Harness, string> = { | |
| 21 | + | claude_code: "Claude Code", | |
| 22 | + | codex: "Codex", | |
| 23 | + | cursor: "Cursor", | |
| 24 | + | copilot: "Copilot", | |
| 25 | + | other: "Another harness", | |
| 26 | + | }; | |
| 27 | + | ||
| 28 | + | export function harnessName(harness: Harness | null | undefined): string | null { | |
| 29 | + | return harness ? (HARNESS_NAMES[harness] ?? harness) : null; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** The coordination events the Work page lists, newest first. */ | |
| 33 | + | export const COORDINATION_TYPES = [ | |
| 34 | + | "work.claimed", | |
| 35 | + | "work.released", | |
| 36 | + | "work.claim_expired", | |
| 37 | + | "work.handoff_requested", | |
| 38 | + | "session.started", | |
| 39 | + | "session.ended", | |
| 40 | + | "dependency.changed", | |
| 41 | + | "overlap.detected", | |
| 42 | + | "integration.failed", | |
| 43 | + | "integration.passed", | |
| 44 | + | "decision.recorded", | |
| 45 | + | ] as const satisfies readonly EventType[]; | |
| 46 | + | ||
| 47 | + | export type CoordinationType = (typeof COORDINATION_TYPES)[number]; | |
| 48 | + | ||
| 49 | + | // ── Time ──────────────────────────────────────────────────────────────── | |
| 50 | + | ||
| 51 | + | const MINUTE = 60_000; | |
| 52 | + | ||
| 53 | + | /** "4m", "1h 20m", "2h": a span, for "expires in …". */ | |
| 54 | + | export function span(ms: number): string { | |
| 55 | + | const minutes = Math.max(0, Math.floor(ms / MINUTE)); | |
| 56 | + | if (minutes < 1) return "less than a minute"; | |
| 57 | + | if (minutes < 60) return `${minutes}m`; | |
| 58 | + | const hours = Math.floor(minutes / 60); | |
| 59 | + | const rest = minutes % 60; | |
| 60 | + | return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /** When an active claim lapses, in words; null for one that is not active. */ | |
| 64 | + | export function expiresIn(claim: Pick<Claim, "state" | "expiresAt">, now: number): string | null { | |
| 65 | + | if (claim.state !== "active") return null; | |
| 66 | + | const left = new Date(claim.expiresAt).getTime() - now; | |
| 67 | + | return left <= 0 ? "expiring now" : `expires in ${span(left)}`; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /** What became of a claim that is no longer active. */ | |
| 71 | + | export const CLAIM_ENDED: Record<Exclude<ClaimState, "active">, string> = { | |
| 72 | + | released: "Released", | |
| 73 | + | expired: "Expired", | |
| 74 | + | handed_off: "Handed off", | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | export const SESSION_STATES: Record<SessionState, { label: string; hint: string }> = { | |
| 78 | + | active: { label: "Active", hint: "Heard from in the last 15 minutes." }, | |
| 79 | + | idle: { label: "Idle", hint: "Not heard from for 15 minutes. It may still come back." }, | |
| 80 | + | ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." }, | |
| 81 | + | }; | |
| 82 | + | ||
| 83 | + | // ── Live now ──────────────────────────────────────────────────────────── | |
| 84 | + | ||
| 85 | + | /** One issue being worked on: its claims and the sessions on it. */ | |
| 86 | + | export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] }; | |
| 87 | + | ||
| 88 | + | /** | |
| 89 | + | * Active claims and sessions grouped by the issue they are on, the issue | |
| 90 | + | * with the most recent activity first; sessions on no issue last, together. | |
| 91 | + | */ | |
| 92 | + | export function liveByIssue(claims: Claim[], sessions: WorkSession[]): { issues: LiveIssue[]; loose: WorkSession[] } { | |
| 93 | + | const byIssue = new Map<number, LiveIssue>(); | |
| 94 | + | const at = (issue: number) => { | |
| 95 | + | let group = byIssue.get(issue); | |
| 96 | + | if (!group) byIssue.set(issue, (group = { issue, claims: [], sessions: [] })); | |
| 97 | + | return group; | |
| 98 | + | }; | |
| 99 | + | for (const claim of claims) at(claim.issue).claims.push(claim); | |
| 100 | + | const loose: WorkSession[] = []; | |
| 101 | + | for (const session of sessions) { | |
| 102 | + | if (session.issue != null) at(session.issue).sessions.push(session); | |
| 103 | + | else loose.push(session); | |
| 104 | + | } | |
| 105 | + | const latest = (group: LiveIssue) => | |
| 106 | + | Math.max( | |
| 107 | + | 0, | |
| 108 | + | ...group.claims.map((claim) => Date.parse(claim.createdAt)), | |
| 109 | + | ...group.sessions.map((session) => Date.parse(session.lastSeenAt)), | |
| 110 | + | ); | |
| 111 | + | return { issues: [...byIssue.values()].sort((a, b) => latest(b) - latest(a) || a.issue - b.issue), loose }; | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // ── Decisions ─────────────────────────────────────────────────────────── | |
| 115 | + | ||
| 116 | + | export const CONFIDENCE: Record<DecisionConfidence, { label: string; hint: string; tone: "neutral" | "info" | "success" }> = { | |
| 117 | + | claimed: { label: "Claimed", hint: "Someone said so: an agent or a person recorded it.", tone: "neutral" }, | |
| 118 | + | tested: { label: "Tested", hint: "A check or the merge queue proved it.", tone: "info" }, | |
| 119 | + | approved: { label: "Approved", hint: "A person approved it.", tone: "success" }, | |
| 120 | + | }; | |
| 121 | + | ||
| 122 | + | export const DECISION_KINDS: Record<Decision["kind"], string> = { | |
| 123 | + | proposal: "Proposal", | |
| 124 | + | claim: "Claim", | |
| 125 | + | integration_failure: "Failed together", | |
| 126 | + | integration_passed: "Passed together", | |
| 127 | + | fix: "Fix", | |
| 128 | + | approval: "Approval", | |
| 129 | + | merge: "Merge", | |
| 130 | + | note: "Note", | |
| 131 | + | }; | |
| 132 | + | ||
| 133 | + | /** The check a decision's evidence names. */ | |
| 134 | + | export type EvidenceCheck = { name: string; conclusion: string; url: string | null }; | |
| 135 | + | ||
| 136 | + | /** What a decision rests on, read from its evidence as far as it says. */ | |
| 137 | + | export type Evidence = { | |
| 138 | + | commit: string | null; | |
| 139 | + | check: EvidenceCheck | null; | |
| 140 | + | /** The pull requests tested together, in queue order. */ | |
| 141 | + | combination: number[]; | |
| 142 | + | /** Those ahead it fails with. */ | |
| 143 | + | suspects: number[]; | |
| 144 | + | bisected: boolean; | |
| 145 | + | }; | |
| 146 | + | ||
| 147 | + | function numbers(value: unknown): number[] { | |
| 148 | + | return Array.isArray(value) ? value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)) : []; | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | export function evidenceOf(decision: Pick<Decision, "evidence">): Evidence { | |
| 152 | + | const raw = (decision.evidence ?? {}) as Record<string, unknown>; | |
| 153 | + | const check = raw.check && typeof raw.check === "object" ? (raw.check as Record<string, unknown>) : null; | |
| 154 | + | return { | |
| 155 | + | commit: typeof raw.commit === "string" ? raw.commit : null, | |
| 156 | + | check: | |
| 157 | + | check && typeof check.name === "string" | |
| 158 | + | ? { | |
| 159 | + | name: check.name, | |
| 160 | + | conclusion: typeof check.conclusion === "string" ? check.conclusion : "failure", | |
| 161 | + | url: typeof check.url === "string" && check.url ? check.url : null, | |
| 162 | + | } | |
| 163 | + | : null, | |
| 164 | + | combination: numbers(raw.combination), | |
| 165 | + | suspects: numbers(raw.suspects), | |
| 166 | + | bisected: raw.bisected === true, | |
| 167 | + | }; | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /** Where a check's address leads on the site: its path when it is g1t's own, else the address as it is. */ | |
| 171 | + | export function checkHref(url: string | null): string | null { | |
| 172 | + | if (!url) return null; | |
| 173 | + | try { | |
| 174 | + | const parsed = new URL(url); | |
| 175 | + | if (parsed.hostname === "g1t.sh" || parsed.hostname.endsWith(".g1t.sh")) return parsed.pathname + parsed.search; | |
| 176 | + | return url; | |
| 177 | + | } catch { | |
| 178 | + | return url.startsWith("/") ? url : null; | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /** | |
| 183 | + | * The integration failure a pull request still answers for: its latest | |
| 184 | + | * `integration_failure`, unless it passed in the queue or merged since. | |
| 185 | + | * `decisions` in any order. | |
| 186 | + | */ | |
| 187 | + | export function standingFailure(decisions: Decision[], pull?: number): Decision | null { | |
| 188 | + | const mine = decisions | |
| 189 | + | .filter((decision) => pull == null || decision.pull === pull) | |
| 190 | + | .filter((decision) => ["integration_failure", "integration_passed", "merge"].includes(decision.kind)) | |
| 191 | + | .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); | |
| 192 | + | return mine[0]?.kind === "integration_failure" ? mine[0] : null; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /** The decisions after a failure that answer it: a fix recorded since, and a combination that then passed. */ | |
| 196 | + | export function resolutionOf(decisions: Decision[], failure: Decision): { fix: Decision | null; passed: Decision | null } { | |
| 197 | + | const after = decisions | |
| 198 | + | .filter((decision) => Date.parse(decision.createdAt) > Date.parse(failure.createdAt)) | |
| 199 | + | .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)); | |
| 200 | + | return { | |
| 201 | + | fix: after.find((decision) => decision.kind === "fix" && (decision.pull === failure.pull || decision.issue === failure.issue)) ?? null, | |
| 202 | + | passed: after.find((decision) => decision.kind === "integration_passed" && decision.pull === failure.pull) ?? null, | |
| 203 | + | }; | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // ── The feed ──────────────────────────────────────────────────────────── | |
| 207 | + | ||
| 208 | + | /** One line of the coordination feed: what happened, in words, and what it links to. */ | |
| 209 | + | export type FeedLine = { | |
| 210 | + | id: string; | |
| 211 | + | time: string; | |
| 212 | + | type: CoordinationType; | |
| 213 | + | /** Who did it, by username; null for g1t itself. */ | |
| 214 | + | who: string | null; | |
| 215 | + | /** The harness it came from, when it says. */ | |
| 216 | + | harness: Harness | null; | |
| 217 | + | /** The sentence, after the name. Numbers in it are written `#N`. */ | |
| 218 | + | text: string; | |
| 219 | + | /** The issue or pull request it is about. */ | |
| 220 | + | number: number | null; | |
| 221 | + | tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info"; | |
| 222 | + | }; | |
| 223 | + | ||
| 224 | + | const list = (all: number[]) => all.map((n) => `#${n}`).join(", "); | |
| 225 | + | const plural = (n: number, one: string, many: string) => (n === 1 ? one : many); | |
| 226 | + | ||
| 227 | + | /** A coordination event as a line of the feed, or null for one it does not show. */ | |
| 228 | + | export function feedLine(event: G1tEvent): FeedLine | null { | |
| 229 | + | const base = { id: event.id, time: event.time, harness: null as Harness | null, number: null as number | null }; | |
| 230 | + | const data = event.data as Record<string, unknown> & { number?: number }; | |
| 231 | + | const number = typeof data.number === "number" ? data.number : null; | |
| 232 | + | switch (event.type) { | |
| 233 | + | case "work.claimed": { | |
| 234 | + | const { claim, others = [], tookOver } = event.data; | |
| 235 | + | const also = others.length > 0 ? ` (${others.map((other) => other.actor.username).join(", ")} ${plural(others.length, "is", "are")} on it too)` : ""; | |
| 236 | + | return { | |
| 237 | + | ...base, | |
| 238 | + | type: event.type, | |
| 239 | + | who: claim.actor.username, | |
| 240 | + | harness: claim.harness, | |
| 241 | + | text: `${tookOver ? "took over" : "claimed"} #${claim.issue}${also}`, | |
| 242 | + | number: claim.issue, | |
| 243 | + | tone: others.length > 0 ? "warn" : "accent", | |
| 244 | + | }; | |
| 245 | + | } | |
| 246 | + | case "work.released": { | |
| 247 | + | const { claim, reason } = event.data; | |
| 248 | + | return { | |
| 249 | + | ...base, | |
| 250 | + | type: event.type, | |
| 251 | + | who: claim.actor.username, | |
| 252 | + | harness: claim.harness, | |
| 253 | + | text: reason === "issue_closed" ? `no longer holds #${claim.issue}: the issue closed` : `released #${claim.issue}${reason ? `: ${reason}` : ""}`, | |
| 254 | + | number: claim.issue, | |
| 255 | + | tone: "neutral", | |
| 256 | + | }; | |
| 257 | + | } | |
| 258 | + | case "work.claim_expired": { | |
| 259 | + | const { claim } = event.data; | |
| 260 | + | return { ...base, type: event.type, who: claim.actor.username, harness: claim.harness, text: `let the claim on #${claim.issue} expire`, number: claim.issue, tone: "neutral" }; | |
| 261 | + | } | |
| 262 | + | case "work.handoff_requested": { | |
| 263 | + | const { claim, to, note } = event.data; | |
| 264 | + | return { | |
| 265 | + | ...base, | |
| 266 | + | type: event.type, | |
| 267 | + | who: claim.actor.username, | |
| 268 | + | harness: claim.harness, | |
| 269 | + | text: `handed #${claim.issue} off to ${to ?? "anyone"}${note ? `: ${note}` : ""}`, | |
| 270 | + | number: claim.issue, | |
| 271 | + | tone: "info", | |
| 272 | + | }; | |
| 273 | + | } | |
| 274 | + | case "session.started": | |
| 275 | + | case "session.ended": { | |
| 276 | + | const { session } = event.data; | |
| 277 | + | const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : ""; | |
| 278 | + | const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : ""; | |
| 279 | + | return { | |
| 280 | + | ...base, | |
| 281 | + | type: event.type, | |
| 282 | + | who: session.actor.username, | |
| 283 | + | harness: session.harness, | |
| 284 | + | text: `${event.type === "session.started" ? "started a session" : "ended a session"}${on}${why}`, | |
| 285 | + | number: session.issue ?? session.pull, | |
| 286 | + | tone: event.type === "session.started" ? "accent" : "neutral", | |
| 287 | + | }; | |
| 288 | + | } | |
| 289 | + | case "dependency.changed": { | |
| 290 | + | const { issue, added, removed, cleared, remaining, mergedPull } = event.data; | |
| 291 | + | const parts = [ | |
| 292 | + | added.length > 0 && `now waits on ${list(added)}`, | |
| 293 | + | removed.length > 0 && `no longer waits on ${list(removed)}`, | |
| 294 | + | cleared.length > 0 && `${list(cleared)} ${mergedPull ? `landed (#${mergedPull} merged)` : "closed"}`, | |
| 295 | + | ].filter(Boolean); | |
| 296 | + | // What it still waits on, unless that is only what was just added. | |
| 297 | + | const said = remaining.every((n) => added.includes(n)) && parts.length > 0; | |
| 298 | + | const left = remaining.length === 0 ? "unblocked" : said ? null : `still waits on ${list(remaining)}`; | |
| 299 | + | return { | |
| 300 | + | ...base, | |
| 301 | + | type: event.type, | |
| 302 | + | who: null, | |
| 303 | + | text: `#${issue} ${[...parts, left].filter(Boolean).join("; ")}`, | |
| 304 | + | number: issue, | |
| 305 | + | tone: remaining.length === 0 ? "success" : "info", | |
| 306 | + | }; | |
| 307 | + | } | |
| 308 | + | case "overlap.detected": { | |
| 309 | + | const { pull, otherPull, files } = event.data; | |
| 310 | + | return { | |
| 311 | + | ...base, | |
| 312 | + | type: event.type, | |
| 313 | + | who: null, | |
| 314 | + | text: `#${pull} and #${otherPull} change the same ${plural(files.length, "file", "files")}: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}`, | |
| 315 | + | number: pull, | |
| 316 | + | tone: "warn", | |
| 317 | + | }; | |
| 318 | + | } | |
| 319 | + | case "integration.failed": { | |
| 320 | + | const { pull, combination, check, suspects } = event.data; | |
| 321 | + | const together = combination.length > 1 ? ` combined as ${list(combination)}` : " on its own"; | |
| 322 | + | const with_ = suspects.length > 0 ? `; fails with ${list(suspects)}` : ""; | |
| 323 | + | return { ...base, type: event.type, who: null, text: `#${pull} failed in the merge queue${together}: ${check.name}${with_}`, number: pull, tone: "danger" }; | |
| 324 | + | } | |
| 325 | + | case "integration.passed": { | |
| 326 | + | const { pull, combination } = event.data; | |
| 327 | + | return { | |
| 328 | + | ...base, | |
| 329 | + | type: event.type, | |
| 330 | + | who: null, | |
| 331 | + | text: combination.length > 1 ? `#${pull} passed in the merge queue combined as ${list(combination)}` : `#${pull} passed in the merge queue`, | |
| 332 | + | number: pull, | |
| 333 | + | tone: "success", | |
| 334 | + | }; | |
| 335 | + | } | |
| 336 | + | case "decision.recorded": { | |
| 337 | + | const { decision } = event.data; | |
| 338 | + | const kind = DECISION_KINDS[decision.kind as Decision["kind"]]?.toLowerCase() ?? decision.kind; | |
| 339 | + | return { | |
| 340 | + | ...base, | |
| 341 | + | type: event.type, | |
| 342 | + | who: decision.actor, | |
| 343 | + | text: `recorded ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, ${decision.confidence}: ${decision.summary}`, | |
| 344 | + | number: decision.pull ?? decision.issue ?? number, | |
| 345 | + | tone: decision.kind === "integration_failure" ? "danger" : decision.confidence === "approved" ? "success" : "neutral", | |
| 346 | + | }; | |
| 347 | + | } | |
| 348 | + | default: | |
| 349 | + | return null; | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /** Splits text at `#N`, for linking each number. */ | |
| 354 | + | export function numberedParts(text: string): (string | number)[] { | |
| 355 | + | const parts: (string | number)[] = []; | |
| 356 | + | let last = 0; | |
| 357 | + | for (const match of text.matchAll(/#(\d+)\b/g)) { | |
| 358 | + | if (match.index > last) parts.push(text.slice(last, match.index)); | |
| 359 | + | parts.push(Number(match[1])); | |
| 360 | + | last = match.index + match[0].length; | |
| 361 | + | } | |
| 362 | + | if (last < text.length) parts.push(text.slice(last)); | |
| 363 | + | return parts; | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | /** `blocked_by` as typed: numbers, with or without `#`, by commas or spaces; null when any is not one. */ | |
| 367 | + | export function parseBlockedBy(text: string): number[] | null { | |
| 368 | + | const words = text.split(/[\s,]+/).map((word) => word.trim().replace(/^#/, "")).filter(Boolean); | |
| 369 | + | if (words.some((word) => !/^\d+$/.test(word) || Number(word) <= 0)) return null; | |
| 370 | + | return [...new Set(words.map(Number))]; | |
| 371 | + | } |
| 96 | 96 | }, | |
| 97 | 97 | ]; | |
| 98 | 98 | ||
| 99 | − | /** Pull requests and the merge queue share a page's tabs too. */ | |
| 99 | + | /** Pull requests, the work on them, the merge queue and sessions share a page's tabs too. */ | |
| 100 | 100 | export const PULLS: Section = { | |
| 101 | 101 | key: "Code", | |
| 102 | 102 | tabs: [ | |
| 103 | 103 | { label: "Pull requests", path: "pulls", also: ["pull"] }, | |
| 104 | + | { label: "Work", path: "work" }, | |
| 104 | 105 | { label: "Merge queue", path: "queue" }, | |
| 105 | 106 | { label: "Sessions", path: "sessions" }, | |
| 106 | 107 | ], |
| 93 | 93 | assert.equal(issue(push("main")), false); | |
| 94 | 94 | }); | |
| 95 | 95 | ||
| 96 | + | test("the Work page hears claims, sessions starting and ending, what the queue finds, and merges", () => { | |
| 97 | + | const page = repoPages.work(); | |
| 98 | + | for (const kind of ["work.claimed", "work.claim_expired", "session.started", "session.ended", "dependency.changed", "overlap.detected", "integration.failed", "decision.recorded", "queue.changed", "pull.merged"]) { | |
| 99 | + | assert.equal(page(change(kind, { number: 3 })), true, kind); | |
| 100 | + | } | |
| 101 | + | assert.equal(page(change("session.appended", { number: 3 })), false); | |
| 102 | + | assert.equal(page(change("comment.created", { number: 3 })), false); | |
| 103 | + | assert.equal(page(push("main")), false); | |
| 104 | + | // An issue hears coordination about itself and its pull requests by number. | |
| 105 | + | assert.equal(repoPages.issue(3, [9])(change("integration.failed", { number: 9 })), true); | |
| 106 | + | }); | |
| 107 | + | ||
| 96 | 108 | test("runs, one run, and security", () => { | |
| 97 | 109 | assert.equal(repoPages.runs()(change("run.updated", { run: "run_1" })), true); | |
| 98 | 110 | assert.equal(repoPages.runs()(change("status.created", { sha: "abc" })), true, "a new run reports pending on its commit"); |
| 116 | 116 | issue(number: number, linked: number[] = []): RepoMatch { | |
| 117 | 117 | return (change) => change.number != null && (change.number === number || linked.includes(change.number)); | |
| 118 | 118 | }, | |
| 119 | + | /** | |
| 120 | + | * The Work page: claims, sessions starting and ending, dependencies, | |
| 121 | + | * overlaps, what the merge queue finds and decisions, the queue moving, | |
| 122 | + | * and merges. | |
| 123 | + | */ | |
| 124 | + | work(): RepoMatch { | |
| 125 | + | return (change) => | |
| 126 | + | ["work", "dependency", "overlap", "integration", "decision", "queue"].includes(family(change)) || | |
| 127 | + | change.kind === "session.started" || | |
| 128 | + | change.kind === "session.ended" || | |
| 129 | + | change.kind === "pull.merged"; | |
| 130 | + | }, | |
| 119 | 131 | /** The runs list: runs starting, moving and finishing, and what starts them. */ | |
| 120 | 132 | runs(): RepoMatch { | |
| 121 | 133 | return (change) => ["workflow", "run", "status", "check_suite"].includes(family(change)); |
| 268 | 268 | route("pulls/new", "routes/repo/pull-new.tsx"), | |
| 269 | 269 | route("pull/:number", "routes/repo/pull.tsx"), | |
| 270 | 270 | route("queue", "routes/repo/queue.tsx"), | |
| 271 | + | // Who is working on what, live: claims, harness sessions, the queue, coordination events. | |
| 272 | + | route("work", "routes/repo/work.tsx"), | |
| 273 | + | route("work/sessions/:id", "routes/repo/work-session.tsx"), | |
| 271 | 274 | route("usage", "routes/repo/usage.tsx"), | |
| 272 | 275 | route("sessions", "routes/repo/sessions.tsx"), | |
| 273 | 276 | route("sessions/:number", "routes/repo/session.tsx"), |
| 39 | 39 | import { DetailsDisclosure, detailsSummary } from "../../components/details-disclosure"; | |
| 40 | 40 | import { repoPages } from "../../lib/repo-live"; | |
| 41 | 41 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 42 | + | import { DecisionList, IntegrationFailure, IssueWorkPanel } from "../../components/coordination"; | |
| 43 | + | import { parseBlockedBy, resolutionOf } from "../../lib/coordination"; | |
| 42 | 44 | ||
| 43 | 45 | ||
| 44 | 46 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 62 | 64 | const path = { namespace: params.owner, name: params.repo }; | |
| 63 | 65 | const number = Number(params.number); | |
| 64 | 66 | const access = accessTo(context, params); | |
| 65 | − | const [{ can }, found, labels, milestones, members, links, subscription, teams] = await Promise.all([ | |
| 67 | + | const [{ can }, found, labels, milestones, members, links, subscription, teams, coordination, ended] = await Promise.all([ | |
| 66 | 68 | access, | |
| 67 | 69 | work.getIssue(path, number, viewer), | |
| 68 | 70 | work.listLabels(path, viewer), | |
| ⋯ | |||
| 80 | 82 | : null, | |
| 81 | 83 | // The teams a member can mention, for the comment box. | |
| 82 | 84 | roleIn(viewer, params.owner) ? identity.listTeams(viewer, params.owner).catch(() => null) : null, | |
| 85 | + | // Who is on it, its sessions, what it waits on and its decisions | |
| 86 | + | // (services/work/src/coordination): the Work panel. | |
| 87 | + | work.getWork(path, number, viewer).catch(() => null), | |
| 88 | + | // Its claims that ended, shown muted under the active ones. | |
| 89 | + | Promise.all( | |
| 90 | + | (["released", "expired", "handed_off"] as const).map((state) => | |
| 91 | + | work.listClaims(path, viewer, { state, issue: number }).catch(() => null), | |
| 92 | + | ), | |
| 93 | + | ), | |
| 83 | 94 | ]); | |
| 84 | 95 | if (!found.ok) { | |
| 85 | 96 | // Issues and pull requests share numbers; this one may be a pull request. | |
| ⋯ | |||
| 95 | 106 | milestones: milestones.ok ? milestones.value : [], | |
| 96 | 107 | links, | |
| 97 | 108 | subscription, | |
| 109 | + | coordination: coordination?.ok ? coordination.value : null, | |
| 110 | + | endedClaims: ended | |
| 111 | + | .flatMap((claims) => (claims?.ok ? claims.value : [])) | |
| 112 | + | .sort((a, b) => Date.parse(b.endedAt ?? b.createdAt) - Date.parse(a.endedAt ?? a.createdAt)), | |
| 98 | 113 | members: members?.ok ? members.value.map((member) => member.username) : [], | |
| 99 | 114 | handles: teams?.ok ? teams.value.map((team) => `@${team.workspace}/${team.slug}`) : [], | |
| 100 | 115 | // The author can close and reopen their own issue, and whoever g1t's | |
| ⋯ | |||
| 115 | 130 | // What each form needs; closing your own issue is checked by work. | |
| 116 | 131 | // Labels: its author may use the repository's on their own issue, and | |
| 117 | 132 | // work says so; anyone else, and every milestone, needs Triage. | |
| 133 | + | // What it is blocked by: its author or Triage, which work checks too. | |
| 118 | 134 | const needed = form.get("action") === "assign" || form.get("action") === "milestone" ? "triage" : null; | |
| 119 | 135 | const refused = needed ? await refusal(context, params, needed) : null; | |
| 120 | 136 | if (refused) return { error: refused, action: String(form.get("action")) }; | |
| ⋯ | |||
| 148 | 164 | const result = await work.setLabels(user, path, number, form.getAll("label").map(String)); | |
| 149 | 165 | return result.ok ? null : { error: result.error.message, action: "labels" }; | |
| 150 | 166 | } | |
| 167 | + | case "blocked-by": { | |
| 168 | + | const blockedBy = parseBlockedBy(String(form.get("blocked_by") ?? "")); | |
| 169 | + | if (!blockedBy) return { error: "List issue numbers, such as #12, #14.", action: "blocked-by" }; | |
| 170 | + | const result = await work.updateIssue(user, path, number, { blocked_by: blockedBy }); | |
| 171 | + | return result.ok ? null : { error: result.error.message, action: "blocked-by" }; | |
| 172 | + | } | |
| 151 | 173 | case "milestone": { | |
| 152 | 174 | const result = await work.updateIssue(user, path, number, { milestone: Number(form.get("milestone")) || 0 }); | |
| 153 | 175 | return result.ok ? null : { error: result.error.message, action: "milestone" }; | |
| ⋯ | |||
| 245 | 267 | } | |
| 246 | 268 | ||
| 247 | 269 | export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 248 | − | const { issue, pulls, comments, viewer, labels, members, canManage, can } = loaderData; | |
| 270 | + | const { issue, pulls, comments, viewer, labels, members, canManage, can, coordination } = loaderData; | |
| 249 | 271 | // Follow pull requests at work without a manual reload. | |
| 250 | 272 | const running = pulls.some( | |
| 251 | 273 | (pull) => | |
| ⋯ | |||
| 254 | 276 | pull.checkStatus === "running", | |
| 255 | 277 | ); | |
| 256 | 278 | useRefreshWhile(running); | |
| 279 | + | // Claims lapse and sessions go idle with no event to say so: looked at again now and then. | |
| 280 | + | useRefreshWhile((coordination?.claims.length ?? 0) > 0 || (coordination?.sessions.some((session) => session.state !== "ended") ?? false), 30_000); | |
| 281 | + | const now = Date.now(); | |
| 257 | 282 | // Comments, changes and its pull requests, as they happen; one reading at its end follows them. | |
| 258 | 283 | useRepoLive({ match: repoPages.issue(issue.number, pulls.map((pull) => pull.number)), followBottom: true }); | |
| 259 | 284 | ||
| ⋯ | |||
| 266 | 291 | const ordered = [...pulls].sort( | |
| 267 | 292 | (a, b) => rank[a.status] - rank[b.status] || a.number - b.number, | |
| 268 | 293 | ); | |
| 294 | + | // Its decisions as a story, oldest first; the failure that still stands, with what answered it. | |
| 295 | + | const decisions = coordination ? [...coordination.decisions].reverse() : []; | |
| 296 | + | const failure = coordination?.lastIntegrationFailure ?? null; | |
| 297 | + | const resolution = failure ? resolutionOf(coordination!.decisions, failure) : null; | |
| 269 | 298 | ||
| 270 | 299 | return ( | |
| 271 | 300 | // On a phone: the title, then the sidebar as a Details disclosure, then | |
| ⋯ | |||
| 351 | 380 | )} | |
| 352 | 381 | </div> | |
| 353 | 382 | ||
| 383 | + | {failure && ( | |
| 384 | + | <IntegrationFailure className="mt-4" failure={failure} base={base} fixed={resolution?.passed ?? resolution?.fix} /> | |
| 385 | + | )} | |
| 386 | + | ||
| 387 | + | {decisions.length > 0 && ( | |
| 388 | + | <> | |
| 389 | + | <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> | |
| 390 | + | <h3 className="font-semibold tracking-tight">Decisions</h3> | |
| 391 | + | <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p> | |
| 392 | + | </div> | |
| 393 | + | <div className="mt-3"> | |
| 394 | + | <DecisionList decisions={decisions} base={base} /> | |
| 395 | + | </div> | |
| 396 | + | </> | |
| 397 | + | )} | |
| 398 | + | ||
| 354 | 399 | <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3> | |
| 355 | 400 | <div className="mt-3"> | |
| 356 | 401 | <div className="space-y-4"> | |
| ⋯ | |||
| 384 | 429 | </div> | |
| 385 | 430 | <div className="mt-2"> | |
| 386 | 431 | {/* A refused comment edit says so under the comment. */} | |
| 387 | − | {!(actionData && "comment" in actionData) && <ErrorText>{actionData?.error}</ErrorText>} | |
| 432 | + | {!(actionData && ("comment" in actionData || ("action" in actionData && actionData.action === "blocked-by"))) && ( | |
| 433 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 434 | + | )} | |
| 388 | 435 | </div> | |
| 389 | 436 | </div> | |
| 390 | 437 | ||
| 391 | 438 | <DetailsDisclosure | |
| 392 | 439 | className="lg:col-start-2 lg:row-span-2 lg:row-start-1" | |
| 393 | 440 | summary={detailsSummary([ | |
| 441 | + | [coordination?.claims.length ?? 0, "claim", "claims"], | |
| 394 | 442 | [issue.assignees.length, "assignee", "assignees"], | |
| 395 | 443 | [issue.labels.length, "label", "labels"], | |
| 396 | 444 | [issue.milestone ? 1 : 0, "milestone", "milestones"], | |
| 397 | 445 | ])} | |
| 398 | 446 | > | |
| 447 | + | {coordination && ( | |
| 448 | + | <IssueWorkPanel | |
| 449 | + | work={coordination} | |
| 450 | + | ended={loaderData.endedClaims} | |
| 451 | + | base={base} | |
| 452 | + | now={now} | |
| 453 | + | canEdit={canManage} | |
| 454 | + | error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null} | |
| 455 | + | /> | |
| 456 | + | )} | |
| 399 | 457 | {loaderData.links.length > 0 && ( | |
| 400 | 458 | <section> | |
| 401 | 459 | <h3 className="text-sm font-medium">From outside g1t</h3> | |
| ⋯ | |||
| 436 | 494 | </li> | |
| 437 | 495 | ))} | |
| 438 | 496 | </ul> | |
| 439 | − | {issue.blockedBy.length > 0 && ( | |
| 497 | + | {!coordination && issue.blockedBy.length > 0 && ( | |
| 440 | 498 | <p className="mt-3 text-xs text-muted"> | |
| 441 | 499 | Depends on{" "} | |
| 442 | 500 | {issue.blockedBy.map((number, index) => ( | |
| 64 | 64 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 65 | 65 | import { Hint } from "../../components/ui/hint"; | |
| 66 | 66 | import { TabStrip } from "../../components/ui/tab-strip"; | |
| 67 | + | import { DecisionList, IntegrationFailure, SessionLine } from "../../components/coordination"; | |
| 68 | + | import { resolutionOf, standingFailure } from "../../lib/coordination"; | |
| 67 | 69 | import { WorkflowStatuses } from "../../components/actions"; | |
| 68 | 70 | import { AddCiPrompt } from "../../components/add-ci"; | |
| 69 | 71 | import { AttemptsBox } from "../../components/attempts"; | |
| ⋯ | |||
| 138 | 140 | const milestonesFound = work.listMilestones(path, viewer); | |
| 139 | 141 | labelsFound.catch(() => null); | |
| 140 | 142 | milestonesFound.catch(() => null); | |
| 141 | − | const [{ can }, found, repo, settings, members, session, deployed, subscription, teamList] = await Promise.all([ | |
| 143 | + | const [{ can }, found, repo, settings, members, session, deployed, subscription, teamList, decisions, harnessSessions] = await Promise.all([ | |
| 142 | 144 | access, | |
| 143 | 145 | pullFound, | |
| 144 | 146 | repos.get(path, viewer), | |
| ⋯ | |||
| 156 | 158 | : null, | |
| 157 | 159 | // A member asks the workspace's teams to review, and mentions them. | |
| 158 | 160 | member ? identity.listTeams(viewer, params.owner).catch(() => null) : null, | |
| 161 | + | // Its decision records: what the merge queue found when it was combined | |
| 162 | + | // with others, and the reasons kept on it (decisions.rs). | |
| 163 | + | tab !== "changes" ? work.listDecisions(path, number, viewer).catch(() => null) : null, | |
| 164 | + | // The harness sessions working on it, for the Session tab. | |
| 165 | + | tab === "session" ? work.listWorkSessions(path, viewer, { pull: number }).catch(() => null) : null, | |
| 159 | 166 | ]); | |
| 160 | 167 | if (!found.ok) { | |
| 161 | 168 | // Issues and pull requests share numbers; this one may be an issue. | |
| ⋯ | |||
| 226 | 233 | attempts, | |
| 227 | 234 | tab, | |
| 228 | 235 | session: session?.ok ? session.value : [], | |
| 236 | + | decisions: decisions?.ok ? decisions.value : [], | |
| 237 | + | harnessSessions: harnessSessions?.ok ? harnessSessions.value : [], | |
| 229 | 238 | // An empty comparison if it could not be made. | |
| 230 | 239 | comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON), | |
| 231 | 240 | viewer, | |
| ⋯ | |||
| 617 | 626 | rules, | |
| 618 | 627 | tab, | |
| 619 | 628 | session, | |
| 629 | + | decisions, | |
| 630 | + | harnessSessions, | |
| 620 | 631 | comparison, | |
| 621 | 632 | viewer, | |
| 622 | 633 | canMerge, | |
| ⋯ | |||
| 680 | 691 | // ones for the same issue are alternatives, and expected to: those are | |
| 681 | 692 | // compared under their own heading. | |
| 682 | 693 | const { collisions } = splitOverlaps(overlaps, pull.issue); | |
| 694 | + | // A combination with it that failed in the merge queue and has not passed since. | |
| 695 | + | const failure = active ? standingFailure(decisions, pull.number) : null; | |
| 696 | + | const answered = failure ? resolutionOf(decisions, failure).fix : null; | |
| 683 | 697 | const review = { | |
| 684 | 698 | changesUrl: here + "?tab=changes", | |
| 685 | 699 | // Nobody reviews their own pull request, nor one g1t made for them. | |
| ⋯ | |||
| 928 | 942 | }} | |
| 929 | 943 | /> | |
| 930 | 944 | ) : tab === "session" ? ( | |
| 931 | − | session.length === 0 ? ( | |
| 932 | − | <EmptyState title="Nothing recorded yet"> | |
| 933 | − | A coding agent's prompts, reasoning and tool calls appear here once | |
| 934 | − | it records them with record_session. | |
| 935 | − | </EmptyState> | |
| 936 | − | ) : ( | |
| 937 | − | <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line"> | |
| 938 | − | {session.map((entry) => ( | |
| 939 | − | <Entry key={entry.seq} entry={entry} agent={pull.agent} /> | |
| 940 | − | ))} | |
| 941 | − | </ol> | |
| 942 | − | ) | |
| 945 | + | <div className="space-y-8"> | |
| 946 | + | {harnessSessions.length > 0 && ( | |
| 947 | + | <section aria-label="Harness sessions"> | |
| 948 | + | <h3 className="text-sm font-medium">Sessions on it</h3> | |
| 949 | + | <Card asChild divided className="mt-3"> | |
| 950 | + | <ul> | |
| 951 | + | {harnessSessions.map((one) => ( | |
| 952 | + | <li key={one.id} className="px-4 py-3"> | |
| 953 | + | <SessionLine session={one} base={base} showWhere={false} /> | |
| 954 | + | </li> | |
| 955 | + | ))} | |
| 956 | + | </ul> | |
| 957 | + | </Card> | |
| 958 | + | </section> | |
| 959 | + | )} | |
| 960 | + | {decisions.length > 0 && ( | |
| 961 | + | <section aria-label="Decisions"> | |
| 962 | + | <h3 className="text-sm font-medium">Decisions</h3> | |
| 963 | + | <p className="mt-1 text-xs text-muted"> | |
| 964 | + | Why it is the way it is, each with how sure it is: claimed by whoever recorded it, tested by a check or the merge queue, approved by a person. | |
| 965 | + | </p> | |
| 966 | + | <div className="mt-3"> | |
| 967 | + | <DecisionList decisions={decisions} base={base} /> | |
| 968 | + | </div> | |
| 969 | + | </section> | |
| 970 | + | )} | |
| 971 | + | <section aria-label="What happened"> | |
| 972 | + | {(harnessSessions.length > 0 || decisions.length > 0) && <h3 className="mb-3 text-sm font-medium">What happened</h3>} | |
| 973 | + | {session.length === 0 ? ( | |
| 974 | + | <EmptyState title="Nothing recorded yet"> | |
| 975 | + | A coding agent's prompts, reasoning and tool calls appear here once | |
| 976 | + | it records them, through the g1t plugin, a harness session or record_session. | |
| 977 | + | </EmptyState> | |
| 978 | + | ) : ( | |
| 979 | + | <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line"> | |
| 980 | + | {session.map((entry) => ( | |
| 981 | + | <Entry key={entry.seq} entry={entry} agent={pull.agent} /> | |
| 982 | + | ))} | |
| 983 | + | </ol> | |
| 984 | + | )} | |
| 985 | + | </section> | |
| 986 | + | </div> | |
| 943 | 987 | ) : ( | |
| 944 | 988 | <div className="space-y-4"> | |
| 945 | 989 | <TimelineItem | |
| ⋯ | |||
| 1044 | 1088 | </StatusBox> | |
| 1045 | 1089 | )} | |
| 1046 | 1090 | ||
| 1091 | + | {failure && ( | |
| 1092 | + | <div className="flex gap-3"> | |
| 1093 | + | <span className="hidden w-8 shrink-0 sm:block" /> | |
| 1094 | + | <IntegrationFailure className="min-w-0 grow" failure={failure} base={base} branch={defaultBranch} fixed={answered} /> | |
| 1095 | + | </div> | |
| 1096 | + | )} | |
| 1097 | + | ||
| 1047 | 1098 | {pull.status === "open" && ( | |
| 1048 | 1099 | <StatusBox> | |
| 1049 | 1100 | {/* Checks by status at once; job by job when the runs are read. */} | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…