Merge branch 'worktree-agent-a972386958e10ee2b' into ship-blame
39 files+1747−1557 files not counted0/39 viewed
| 3375 | 3375 | "view": { | |
| 3376 | 3376 | "type": "string", | |
| 3377 | 3377 | "enum": ["inbox", "saved", "done"], | |
| 3378 | − | "description": "inbox (the default): not done and not snoozed. saved: what you saved. done: what you marked done.", | |
| 3378 | + | "description": "inbox (the default): not done and not snoozed. saved: what you saved. done: what is done, marked by you or settled by itself; an item that settled itself carries `settled_reason` (such as `CI passed in run 330` or `Merged`) and `settled_by` (a username, or g1t, when someone did).", | |
| 3379 | 3379 | }, | |
| 3380 | 3380 | "reason": { | |
| 3381 | 3381 | "type": "string", |
| 77 | 77 | | `g1t-events-webhooks` | `webhooks` | Everything | | |
| 78 | 78 | | `g1t-events-security` | `security` | `git.push`, pull request changes, `comment.created`, `repo.created`, `repo.visibility_changed` | | |
| 79 | 79 | | `g1t-events-search` | `search` | `git.push`, `repo.*`, `issue.*`, `pull.*`, `user.*`, `workspace.*` | | |
| 80 | − | | `g1t-events-deployments` | `deployments` | Pushes, the pull request lifecycle, workspace deletion and restore | | |
| 80 | + | | `g1t-events-deployments` | `deployments` | Pushes, the pull request lifecycle, workspace deletion and restore, and deployment statuses (what each production deployment shipped) | | |
| 81 | 81 | | `g1t-events-projects` | `projects` | Repositories, packages and deployments | | |
| 82 | 82 | | `g1t-events-repos` | `repos` | `pull.merged`, `pull.closed`, `pull.reopened`, deletions and restores | | |
| 83 | 83 | | `g1t-events-packages` | `packages` | `git.push`, `repo.visibility_changed`, workspace and team changes | |
| 8 | 8 | ||
| 9 | 9 | 1. **Where you're needed**: everything waiting on you, however long ago | |
| 10 | 10 | it started, with the action beside each item. | |
| 11 | − | 2. **What happened while you were away**: what people did; what landed; | |
| 12 | − | deploys, decisions and spend, since you were last here. | |
| 11 | + | 2. **What happened while you were away**: what settled by itself and | |
| 12 | + | what went live; what people did; what landed; deploys and spend, | |
| 13 | + | since you were last here. | |
| 13 | 14 | 3. **What is running now**: workflow runs and builds still going. | |
| 14 | 15 | ||
| 15 | 16 | **Start here** picks the one item to do first. | |
| ⋯ | |||
| 103 | 104 | When Code and Chat both failed to answer, the sentence ends with "what | |
| 104 | 105 | people did couldn't be read" rather than reading as quiet. | |
| 105 | 106 | ||
| 107 | + | ### While you were away | |
| 108 | + | ||
| 109 | + | The first card says what finished by itself in the span, and what is | |
| 110 | + | live. (Over **Last 24 hours** and **Last 7 days** it is called **Settled | |
| 111 | + | for you**.) Its first line counts: | |
| 112 | + | ||
| 113 | + | | Count | What it is | | |
| 114 | + | | --- | --- | | |
| 115 | + | | *4 notifications settled* | Your notifications in this workspace that [settled themselves](/guides/notifications/#threads-that-settle-themselves) in the span: a later run passed, the pull request merged, the deployment went out. One you marked done yourself is not counted | | |
| 116 | + | | *3 changes live*, *1 deploying*, *1 waiting to deploy*, *1 deploy failed* | The merges and pushes under [Landed](#landed), by whether each is [live](#live-after-merge) | | |
| 117 | + | | *2 things still need you* | The rows of [Where you're needed](#where-youre-needed); it takes you there | | |
| 118 | + | ||
| 119 | + | Under it, each notification that settled, newest first, with why it | |
| 120 | + | settled and who did it, such as **CI passed in run 330** or **Merged by | |
| 121 | + | @ana**. **Done** opens the notifications that are done. | |
| 122 | + | ||
| 106 | 123 | ### People | |
| 107 | 124 | ||
| 108 | − | The first card, **People**, lists what people did in the span, one line | |
| 125 | + | The second card, **People**, lists what people did in the span, one line | |
| 109 | 126 | per kind of thing with its count, and each line opens the place that lists | |
| 110 | 127 | them: a project's Activity tab for pushes, its pull requests or issues, | |
| 111 | 128 | Artifacts, Chat. Beside the heading are the faces of the five most active, | |
| ⋯ | |||
| 135 | 152 | | Row | What it is | | |
| 136 | 153 | | --- | --- | | |
| 137 | 154 | | A pull request merged | With who wrote it, and who merged it when that was someone else | | |
| 138 | − | | Commits pushed to a default branch | One row per project with any: how many commits landed on its default branch without a pull request, in how many pushes, and by whom | | |
| 155 | + | | A push to a default branch | One row per push a person made straight to a default branch: its commit's first line when it brought one, else how many commits, and who pushed. For the ten projects pushed to most in the span; past those, one row per project with how many commits and pushes | | |
| 139 | 156 | | A production build that went live | A project's production build that built and was served (previews stay under Deploys) | | |
| 140 | 157 | | A release published | Its tag and name, and who released it | | |
| 141 | 158 | | A package version published | Its name and version, and who published it | | |
| 142 | 159 | ||
| 143 | 160 | The card's line counts them by kind. The first | |
| 144 | − | five show; **Show more** opens the rest. When one of its sources (pull | |
| 161 | + | five show; **Show more** opens the rest. Over **Last 7 days** the rows are | |
| 162 | + | grouped by day (**Today**, **Yesterday**, **Sun, Oct 11**), five of each | |
| 163 | + | day showing. | |
| 164 | + | ||
| 165 | + | #### Live after merge | |
| 166 | + | ||
| 167 | + | Each merge and push says whether it is live yet, for a project that | |
| 168 | + | deploys to production. Landing on `main` is not shipping: a change is | |
| 169 | + | shipped once a production deployment that contains it succeeds. | |
| 170 | + | ||
| 171 | + | | Label | When | | |
| 172 | + | | --- | --- | | |
| 173 | + | | **Live 9 min after merge** | A production deployment that succeeded shipped it, this long after it landed. The label opens that deployment, or the workflow run that deployed it | | |
| 174 | + | | **Deploying** | A production deployment after it is under way | | |
| 175 | + | | **Deploy failed** | The production deployment after it failed, and none has shipped it since | | |
| 176 | + | | **Waiting to deploy** | Nothing has deployed it yet | | |
| 177 | + | ||
| 178 | + | How g1t knows: when a production deployment succeeds, a | |
| 179 | + | [g1t.page build](/guides/deployments/) or one a workflow or the API | |
| 180 | + | reported, g1t reads its commit's history back and writes down each | |
| 181 | + | commit no production deployment shipped before as shipped by this one. | |
| 182 | + | So a commit's first production deployment is the one that took it live, | |
| 183 | + | and a rollback to an older commit ships nothing new. Home reads only what | |
| 184 | + | was written down, so this costs nothing when the page loads. A change | |
| 185 | + | that landed before g1t began writing this down for its project goes live | |
| 186 | + | with the first production deployment that succeeded after it. | |
| 187 | + | ||
| 188 | + | #### Made with an agent | |
| 189 | + | ||
| 190 | + | A merge or push made in a harness session, such as Claude Code working | |
| 191 | + | for you, carries the agent marker beside its title; it opens the | |
| 192 | + | session. A pull request is marked when a session is tied to it (it | |
| 193 | + | pushed the branch, named the pull request, or a commit on it carries a | |
| 194 | + | `G1t-Session:` trailer); a push when one of its commits carries the | |
| 195 | + | trailer. The change is still yours, and stays in the same list. When one of its sources (pull | |
| 145 | 196 | requests, pushes and releases, deployments) didn't answer, the card says | |
| 146 | 197 | so under the list. | |
| 147 | 198 | ||
| ⋯ | |||
| 200 | 251 | - People counts what could be read, and names the part that is missing | |
| 201 | 252 | (Code or Chat). If nothing could be read, the card says so, and the | |
| 202 | 253 | sentence under the heading says what people did couldn't be read. | |
| 203 | − | - Landed, Deploys and Spent each say their part didn't answer. | |
| 254 | + | - Landed, Deploys and Spent each say their part didn't answer. When | |
| 255 | + | deployments or sessions can't be read, Landed shows its rows without | |
| 256 | + | their live labels or agent markers. | |
| 257 | + | - While you were away says when your notifications can't be read. | |
| 204 | 258 | - If your last visit can't be read, the span is the last 24 hours and the | |
| 205 | 259 | page says why. | |
| 206 | 260 | ||
| 117 | 117 | | A repository's security alerts | A scan finds none of that kind open | | |
| 118 | 118 | | g1t stopping on a change it made for you | It picks back up, its head moves, a merge is asked for, or it is merged or closed | | |
| 119 | 119 | ||
| 120 | + | A thread that settled by itself says why under its title in **Done**, in | |
| 121 | + | a few words, and who did it when someone did: **CI passed in run 330**, | |
| 122 | + | **Merged by @ana**, **Production of site deployed at 8e30fb4**, | |
| 123 | + | **Review no longer asked of you**. A thread you marked done yourself says | |
| 124 | + | nothing there. [Home](/guides/home/#while-you-were-away) lists what | |
| 125 | + | settled for you since your last visit. | |
| 126 | + | ||
| 127 | + | A notification that settled is no longer shown again: tabs you open later | |
| 128 | + | do not replay it, and tabs already open drop it from their recent list. | |
| 129 | + | ||
| 120 | 130 | What g1t does itself and leaves nothing for you to do is never a | |
| 121 | 131 | notification: what landed and what deployed is on [Home](/guides/home/). | |
| 122 | 132 |
Binary or large file; its contents are not shown.
| 9 | 9 | ArrowRight, | |
| 10 | 10 | AtSign, | |
| 11 | 11 | Bell, | |
| 12 | + | CheckCheck, | |
| 12 | 13 | GitCommitHorizontal, | |
| 13 | 14 | GitMerge, | |
| 14 | 15 | GitPullRequest, | |
| ⋯ | |||
| 29 | 30 | type Attention, | |
| 30 | 31 | type AttentionKind, | |
| 31 | 32 | type AttentionRow, | |
| 33 | + | type Away, | |
| 32 | 34 | type DeployRow, | |
| 33 | 35 | type Digest, | |
| 34 | 36 | type DigestActor, | |
| 35 | 37 | type DigestColumn, | |
| 36 | 38 | type LandedChange, | |
| 37 | 39 | type RunningRow, | |
| 40 | + | type ShipInfo, | |
| 38 | 41 | type Span, | |
| 39 | 42 | type Spend, | |
| 40 | 43 | type Stake, | |
| 41 | 44 | WINDOWS, | |
| 45 | + | agentLabel, | |
| 46 | + | landedByDay, | |
| 42 | 47 | listOf, | |
| 48 | + | shippedLabel, | |
| 49 | + | timeOfDay, | |
| 43 | 50 | waited, | |
| 44 | 51 | whenShort, | |
| 45 | 52 | } from "../lib/home"; | |
| 46 | 53 | import { cn } from "../lib/cn"; | |
| 47 | 54 | import { money } from "../lib/money"; | |
| 48 | 55 | import { ButtonLink } from "./ui"; | |
| 49 | − | import { Avatar } from "./ui/avatar"; | |
| 50 | − | import { Badge } from "./ui/badge"; | |
| 56 | + | import { AgentMarker, Avatar } from "./ui/avatar"; | |
| 57 | + | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 51 | 58 | import { Skeleton } from "./ui/skeleton"; | |
| 52 | 59 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 53 | 60 | ||
| ⋯ | |||
| 433 | 440 | /** Rows each list shows before the rest fold away. */ | |
| 434 | 441 | const LIST_ROWS = 5; | |
| 435 | 442 | ||
| 436 | − | function When({ at, now, tz }: { at: number; now: number; tz: string | null }) { | |
| 443 | + | function When({ at, now, tz, timeOnly }: { at: number; now: number; tz: string | null; timeOnly?: boolean }) { | |
| 437 | 444 | return ( | |
| 438 | 445 | <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning> | |
| 439 | − | {whenShort(at, now, tz)} | |
| 446 | + | {timeOnly ? timeOfDay(at, tz) : whenShort(at, now, tz)} | |
| 440 | 447 | </span> | |
| 441 | 448 | ); | |
| 442 | 449 | } | |
| ⋯ | |||
| 483 | 490 | return parts.join(" · "); | |
| 484 | 491 | } | |
| 485 | 492 | ||
| 493 | + | const SHIP_TONE: Record<ShipInfo["state"], BadgeTone> = { | |
| 494 | + | live: "success", | |
| 495 | + | deploying: "info", | |
| 496 | + | waiting: "neutral", | |
| 497 | + | failed: "danger", | |
| 498 | + | }; | |
| 499 | + | ||
| 500 | + | /** Whether a merge or a push is live yet, linked to the deployment that shipped it (or the one under way, or the one that failed). */ | |
| 501 | + | function Shipped({ change }: { change: LandedChange }) { | |
| 502 | + | const ship = change.shipped; | |
| 503 | + | if (!ship) return null; | |
| 504 | + | const label = shippedLabel(ship, change); | |
| 505 | + | const badge = ( | |
| 506 | + | <Badge tone={SHIP_TONE[ship.state]} className="relative z-10"> | |
| 507 | + | {ship.state === "live" && <Rocket size={10} aria-hidden="true" />} | |
| 508 | + | {ship.state === "deploying" && <Loader size={10} aria-hidden="true" className="animate-spin motion-reduce:animate-none" />} | |
| 509 | + | {label} | |
| 510 | + | </Badge> | |
| 511 | + | ); | |
| 512 | + | return ship.to ? ( | |
| 513 | + | <Link to={ship.to} className="relative z-10 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"> | |
| 514 | + | {badge} | |
| 515 | + | </Link> | |
| 516 | + | ) : ( | |
| 517 | + | badge | |
| 518 | + | ); | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | /** The agent marker on a change made in a harness session, linked to the session. */ | |
| 522 | + | function Agent({ change }: { change: LandedChange }) { | |
| 523 | + | const mark = change.agent; | |
| 524 | + | if (!mark) return null; | |
| 525 | + | const label = agentLabel(mark); | |
| 526 | + | const marker = <AgentMarker size={16} inline />; | |
| 527 | + | return ( | |
| 528 | + | <Tooltip> | |
| 529 | + | <TooltipTrigger asChild> | |
| 530 | + | {mark.to ? ( | |
| 531 | + | <Link to={mark.to} aria-label={label} className="relative z-10 inline-flex shrink-0 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"> | |
| 532 | + | {marker} | |
| 533 | + | </Link> | |
| 534 | + | ) : ( | |
| 535 | + | <span role="img" aria-label={label} className="inline-flex shrink-0"> | |
| 536 | + | {marker} | |
| 537 | + | </span> | |
| 538 | + | )} | |
| 539 | + | </TooltipTrigger> | |
| 540 | + | <TooltipContent>{label}</TooltipContent> | |
| 541 | + | </Tooltip> | |
| 542 | + | ); | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | function LandedItem({ change, now, tz, timeOnly }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean }) { | |
| 546 | + | return ( | |
| 547 | + | <li className={cn(rowClass, "items-start")}> | |
| 548 | + | <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span> | |
| 549 | + | <div className="min-w-0 grow"> | |
| 550 | + | <div className="flex min-w-0 items-center gap-1.5"> | |
| 551 | + | <Link to={change.to} className="line-clamp-2 text-sm text-fg hover:underline sm:truncate"> | |
| 552 | + | {change.title} | |
| 553 | + | </Link> | |
| 554 | + | <Agent change={change} /> | |
| 555 | + | </div> | |
| 556 | + | <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> | |
| 557 | + | <p className="min-w-0 truncate text-xs text-muted">{change.detail}</p> | |
| 558 | + | <Shipped change={change} /> | |
| 559 | + | </div> | |
| 560 | + | </div> | |
| 561 | + | <When at={change.at} now={now} tz={tz} timeOnly={timeOnly} /> | |
| 562 | + | </li> | |
| 563 | + | ); | |
| 564 | + | } | |
| 565 | + | ||
| 486 | 566 | /** | |
| 487 | 567 | * Everything that landed in the span, newest first: pull requests merged, | |
| 488 | 568 | * commits pushed straight to a default branch, production builds that went | |
| 489 | − | * live, releases and package versions published. | |
| 569 | + | * live, releases and package versions published. Each merge and push says | |
| 570 | + | * whether it is live yet, and one made in a harness session carries the | |
| 571 | + | * agent marker. Over the last 7 days, day by day. | |
| 490 | 572 | */ | |
| 491 | − | export function LandedCard({ landed, missing, code, now, tz }: { landed: LandedChange[] | null; missing: string[]; code: boolean; now: number; tz: string | null }) { | |
| 573 | + | export function LandedCard({ | |
| 574 | + | landed, | |
| 575 | + | missing, | |
| 576 | + | code, | |
| 577 | + | now, | |
| 578 | + | tz, | |
| 579 | + | byDay = false, | |
| 580 | + | }: { | |
| 581 | + | landed: LandedChange[] | null; | |
| 582 | + | missing: string[]; | |
| 583 | + | code: boolean; | |
| 584 | + | now: number; | |
| 585 | + | tz: string | null; | |
| 586 | + | byDay?: boolean; | |
| 587 | + | }) { | |
| 492 | 588 | if (!code) return null; | |
| 589 | + | const render = (change: LandedChange) => <LandedItem key={change.key} change={change} now={now} tz={tz} timeOnly={byDay} />; | |
| 493 | 590 | return ( | |
| 494 | 591 | <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}> | |
| 495 | 592 | {!landed ? ( | |
| 496 | 593 | <Quiet>Code didn't answer, so what landed can't be listed right now.</Quiet> | |
| 497 | 594 | ) : landed.length === 0 ? ( | |
| 498 | 595 | <Quiet>{missing.length > 0 ? "Nothing landed in what could be read." : "Nothing merged, pushed to a default branch, deployed to production or published in this span."}</Quiet> | |
| 596 | + | ) : byDay ? ( | |
| 597 | + | landedByDay(landed, now, tz).map((day) => ( | |
| 598 | + | <section key={day.key} aria-label={day.label} className="border-t border-line first-of-type:border-t-0"> | |
| 599 | + | <h3 className="flex items-baseline gap-2 bg-raised/40 px-5 py-1.5 text-xs font-medium text-fg-soft"> | |
| 600 | + | {day.label} | |
| 601 | + | <span className="font-normal text-faint tabular-nums">{day.rows.length.toLocaleString("en-US")}</span> | |
| 602 | + | </h3> | |
| 603 | + | <Folded items={day.rows} render={render} /> | |
| 604 | + | </section> | |
| 605 | + | )) | |
| 499 | 606 | ) : ( | |
| 500 | − | <Folded | |
| 501 | − | items={landed} | |
| 502 | − | render={(change) => ( | |
| 503 | − | <li key={change.key} className={rowClass}> | |
| 504 | − | {LANDED_ICON[change.kind]} | |
| 505 | − | <div className="min-w-0 grow"> | |
| 506 | − | <Link to={change.to} className="block truncate text-sm text-fg hover:underline"> | |
| 507 | − | {change.title} | |
| 508 | − | </Link> | |
| 509 | − | <p className="truncate text-xs text-muted">{change.detail}</p> | |
| 510 | − | </div> | |
| 511 | − | <When at={change.at} now={now} tz={tz} /> | |
| 512 | − | </li> | |
| 513 | − | )} | |
| 514 | − | /> | |
| 607 | + | <Folded items={landed} render={render} /> | |
| 515 | 608 | )} | |
| 516 | 609 | {landed && <Missing names={missing} what="everything that landed" />} | |
| 517 | 610 | </Card> | |
| 518 | 611 | ); | |
| 519 | 612 | } | |
| 520 | 613 | ||
| 614 | + | // --- While you were away ----------------------------------------------------------- | |
| 615 | + | ||
| 616 | + | /** | |
| 617 | + | * What g1t settled for you since you were last here (a later run passed, | |
| 618 | + | * it merged, it deployed), what shipped, and what still needs you: the | |
| 619 | + | * counts on one line, then each thing that settled and why. | |
| 620 | + | */ | |
| 621 | + | export function AwayCard({ away, span, now }: { away: Away | null; span: Pick<Span, "key" | "note" | "words">; now: number }) { | |
| 622 | + | const title = span.key === "last" && span.note == null ? "While you were away" : "Settled for you"; | |
| 623 | + | if (!away) { | |
| 624 | + | return ( | |
| 625 | + | <Card title={title}> | |
| 626 | + | <Quiet>Your notifications couldn't be read, so what settled can't be listed right now.</Quiet> | |
| 627 | + | </Card> | |
| 628 | + | ); | |
| 629 | + | } | |
| 630 | + | const facts: { key: string; text: string; tone: BadgeTone; to: string | null }[] = []; | |
| 631 | + | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 632 | + | facts.push({ key: "settled", text: `${plural(away.settled.length, "notification")} settled`, tone: away.settled.length > 0 ? "success" : "neutral", to: "/notifications?view=done" }); | |
| 633 | + | if (away.shipped > 0) facts.push({ key: "shipped", text: `${plural(away.shipped, "change")} live`, tone: "success", to: null }); | |
| 634 | + | if (away.deploying > 0) facts.push({ key: "deploying", text: `${away.deploying.toLocaleString("en-US")} deploying`, tone: "info", to: null }); | |
| 635 | + | if (away.waiting > 0) facts.push({ key: "waiting", text: `${away.waiting.toLocaleString("en-US")} waiting to deploy`, tone: "neutral", to: null }); | |
| 636 | + | if (away.failed > 0) facts.push({ key: "failed", text: `${away.failed.toLocaleString("en-US")} deploy failed`, tone: "danger", to: null }); | |
| 637 | + | facts.push({ key: "needs", text: away.needs > 0 ? `${plural(away.needs, "thing")} still ${away.needs === 1 ? "needs" : "need"} you` : "Nothing needs you", tone: away.needs > 0 ? "warn" : "neutral", to: "#needed" }); | |
| 638 | + | return ( | |
| 639 | + | <Card title={title} hint="What finished by itself, and what is live" link={{ label: "Done", to: "/notifications?view=done" }}> | |
| 640 | + | <ul className="flex flex-wrap gap-1.5 px-5 pb-3" aria-label="In short"> | |
| 641 | + | {facts.map((fact) => ( | |
| 642 | + | <li key={fact.key}> | |
| 643 | + | {fact.to ? ( | |
| 644 | + | <Link to={fact.to} className="rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"> | |
| 645 | + | <Badge tone={fact.tone} size="md"> | |
| 646 | + | {fact.text} | |
| 647 | + | </Badge> | |
| 648 | + | </Link> | |
| 649 | + | ) : ( | |
| 650 | + | <Badge tone={fact.tone} size="md"> | |
| 651 | + | {fact.text} | |
| 652 | + | </Badge> | |
| 653 | + | )} | |
| 654 | + | </li> | |
| 655 | + | ))} | |
| 656 | + | </ul> | |
| 657 | + | {away.settled.length > 0 && ( | |
| 658 | + | <div className="border-t border-line"> | |
| 659 | + | <Folded | |
| 660 | + | items={away.settled} | |
| 661 | + | render={(row) => ( | |
| 662 | + | <li key={row.key} className={rowClass}> | |
| 663 | + | <CheckCheck size={15} className="shrink-0 text-success" aria-hidden="true" /> | |
| 664 | + | <div className="min-w-0 grow"> | |
| 665 | + | <Link to={row.to} className="block truncate text-sm text-fg hover:underline"> | |
| 666 | + | {row.title} | |
| 667 | + | </Link> | |
| 668 | + | <p className="truncate text-xs text-muted"> | |
| 669 | + | <span className="text-fg-soft">{row.why}</span> | |
| 670 | + | {row.where && ` · ${row.where}`} | |
| 671 | + | </p> | |
| 672 | + | </div> | |
| 673 | + | <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning> | |
| 674 | + | {waited(row.at, now)} ago | |
| 675 | + | </span> | |
| 676 | + | </li> | |
| 677 | + | )} | |
| 678 | + | /> | |
| 679 | + | </div> | |
| 680 | + | )} | |
| 681 | + | </Card> | |
| 682 | + | ); | |
| 683 | + | } | |
| 684 | + | ||
| 521 | 685 | export function DeploysCard({ deploys, complete, code, now, tz }: { deploys: DeployRow[] | null; complete: boolean; code: boolean; now: number; tz: string | null }) { | |
| 522 | 686 | // A workspace that doesn't deploy has nothing to say here. | |
| 523 | 687 | if (!code || (deploys && deploys.length === 0)) return null; | |
| 16 | 16 | import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 17 | 17 | import { cn } from "../lib/cn"; | |
| 18 | 18 | import { isPending } from "../lib/pending"; | |
| 19 | − | import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox"; | |
| 19 | + | import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, settledLine, tabCount, updatesLabel, whenShort } from "../lib/inbox"; | |
| 20 | 20 | import { useOwnPresence } from "../lib/notify-client"; | |
| 21 | 21 | import { dndOn } from "../lib/presence"; | |
| 22 | 22 | import type { InboxPanelData } from "../routes/inbox-json"; | |
| ⋯ | |||
| 101 | 101 | {item.title} | |
| 102 | 102 | </Link> | |
| 103 | 103 | {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>} | |
| 104 | + | {/* Done by itself: what ended it, and who. */} | |
| 105 | + | {settledLine(item) && ( | |
| 106 | + | <p className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-fg-soft"> | |
| 107 | + | <CheckCheck size={12} aria-hidden="true" className="shrink-0 text-success" /> | |
| 108 | + | <span className="truncate">{settledLine(item)}</span> | |
| 109 | + | </p> | |
| 110 | + | )} | |
| 104 | 111 | <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint [@media(hover:none)]:pr-24"> | |
| 105 | 112 | <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap"> | |
| 106 | 113 | {whenShort(item.updatedAt, now)} | |
| 210 | 210 | * that the squircle tile alone says it). The same mark everywhere, so | |
| 211 | 211 | * nobody has to read a name to know they are looking at an agent. | |
| 212 | 212 | */ | |
| 213 | − | export function AgentMarker({ size, ring = "var(--color-bg)", className }: { size: number; ring?: string; className?: string }) { | |
| 213 | + | export function AgentMarker({ size, ring = "var(--color-bg)", className, inline }: { size: number; ring?: string; className?: string; inline?: boolean }) { | |
| 214 | 214 | const disc = Math.max(9, Math.min(22, Math.round(size * 0.4))); | |
| 215 | 215 | const icon = Math.max(6, Math.round(disc * 0.66)); | |
| 216 | 216 | const inset = Math.round(disc * 0.28); | |
| 217 | + | // `inline`: the marker on its own in a line of text, beside what an agent made, rather than on an avatar's corner. | |
| 218 | + | if (inline) { | |
| 219 | + | return ( | |
| 220 | + | <span aria-hidden="true" className={`inline-flex shrink-0 items-center justify-center rounded-full bg-accent text-bg ${className ?? ""}`} style={{ width: size, height: size }}> | |
| 221 | + | <Bot size={Math.max(8, Math.round(size * 0.66))} strokeWidth={2.6} absoluteStrokeWidth={false} /> | |
| 222 | + | </span> | |
| 223 | + | ); | |
| 224 | + | } | |
| 217 | 225 | return ( | |
| 218 | 226 | <span | |
| 219 | 227 | aria-hidden="true" |
| 8 | 8 | import { | |
| 9 | 9 | type ActivityDigest, | |
| 10 | 10 | type ChatActivity, | |
| 11 | + | type Commit, | |
| 11 | 12 | type Deployment, | |
| 13 | + | type G1tEvent, | |
| 14 | + | type InboxItem, | |
| 12 | 15 | MAX_DIGEST_REPOS, | |
| 13 | 16 | type ProjectDeploys, | |
| 17 | + | type PullSessions, | |
| 14 | 18 | type Repo, | |
| 15 | 19 | type RepoPath, | |
| 20 | + | type ShippedRepo, | |
| 16 | 21 | type User, | |
| 17 | 22 | type WorkflowRun, | |
| 18 | 23 | notifyClient, | |
| 19 | 24 | } from "@g1t/contracts"; | |
| 20 | 25 | ||
| 21 | − | import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type Span, type Spend, deployActorKey, spanMonths, spendIn } from "./home"; | |
| 26 | + | import { | |
| 27 | + | type CodePull, | |
| 28 | + | type CodeWork, | |
| 29 | + | type DirectPush, | |
| 30 | + | G1T_ACTOR, | |
| 31 | + | type LandedChange, | |
| 32 | + | type RepoRef, | |
| 33 | + | type Span, | |
| 34 | + | type Spend, | |
| 35 | + | deployActorKey, | |
| 36 | + | shippedAsks, | |
| 37 | + | spanMonths, | |
| 38 | + | spendIn, | |
| 39 | + | } from "./home"; | |
| 22 | 40 | import { monthSpan } from "./usage"; | |
| 23 | − | import { actions, billing, chat, deployments, events, identity, repos, work } from "./services.server"; | |
| 41 | + | import { actions, billing, chat, deployments, events, identity, inbox, repos, work } from "./services.server"; | |
| 24 | 42 | ||
| 25 | 43 | /** Pull requests read per project and list: `LIST_PAGE` in services/work. */ | |
| 26 | 44 | const PULL_PAGE = 100; | |
| ⋯ | |||
| 97 | 115 | for (const pull of [...entry.open, ...entry.closed]) { | |
| 98 | 116 | pulls.push({ | |
| 99 | 117 | repo: path, | |
| 118 | + | repoId: repo.id, | |
| 119 | + | mergeCommit: pull.mergeCommit ?? null, | |
| 100 | 120 | number: pull.number, | |
| 101 | 121 | title: pull.title, | |
| 102 | 122 | status: pull.status, | |
| ⋯ | |||
| 263 | 283 | ); | |
| 264 | 284 | } | |
| 265 | 285 | ||
| 286 | + | /** The most projects whose pushes to the default branch are listed one by one, pushes read per project, and commits read back. */ | |
| 287 | + | const MAX_PUSH_PROJECTS = 10; | |
| 288 | + | const PUSHES_READ = 100; | |
| 289 | + | const PUSH_HISTORY = 200; | |
| 290 | + | ||
| 291 | + | /** | |
| 292 | + | * Each person's push to a default branch in the span, for the projects | |
| 293 | + | * the digest says were pushed to (those pushed to most, at most | |
| 294 | + | * MAX_PUSH_PROJECTS), with each project's default branch read back from | |
| 295 | + | * its newest push to place each push's commits. `by` is the pusher's | |
| 296 | + | * member key, named by the caller. Null when events did not answer. | |
| 297 | + | */ | |
| 298 | + | export async function loadDirectPushes( | |
| 299 | + | viewer: User, | |
| 300 | + | activity: ActivityDigest | null, | |
| 301 | + | owned: Repo[] | null, | |
| 302 | + | span: Pick<Span, "from" | "now">, | |
| 303 | + | ): Promise<{ pushes: (Omit<DirectPush, "by"> & { actorKey: string })[]; history: Record<string, Commit[]>; complete: boolean } | null> { | |
| 304 | + | if (!activity || !owned) return null; | |
| 305 | + | const byId = new Map(owned.map((repo) => [repo.id, repo])); | |
| 306 | + | const pushedTo = activity.repos | |
| 307 | + | .filter((digest) => digest.pushes.default_branch.count > 0 && byId.has(digest.repo_id)) | |
| 308 | + | .sort((a, b) => b.pushes.default_branch.count - a.pushes.default_branch.count || a.repo_id.localeCompare(b.repo_id)); | |
| 309 | + | const read = pushedTo.slice(0, MAX_PUSH_PROJECTS); | |
| 310 | + | const lists = await Promise.all( | |
| 311 | + | read.map(async (digest) => { | |
| 312 | + | const repo = byId.get(digest.repo_id)!; | |
| 313 | + | const log = await events.list({ repoId: repo.id, types: ["git.push"], limit: PUSHES_READ }).catch(warn(`pushes of ${repo.name}`)); | |
| 314 | + | if (!log) return null; | |
| 315 | + | const pushes = log.filter( | |
| 316 | + | (event): event is G1tEvent<"git.push"> => | |
| 317 | + | event.type === "git.push" && (event as G1tEvent<"git.push">).data.defaultBranch && Date.parse(event.time) >= span.from && Date.parse(event.time) <= span.now, | |
| 318 | + | ); | |
| 319 | + | if (pushes.length === 0) return { repo, pushes, history: [] as Commit[] }; | |
| 320 | + | const path = { namespace: repo.namespace, name: repo.name }; | |
| 321 | + | const history = await repos.log(path, viewer, pushes[0]!.data.after, PUSH_HISTORY).catch(warn(`history of ${repo.name}`)); | |
| 322 | + | return { repo, pushes, history: history?.ok ? history.value : [] }; | |
| 323 | + | }), | |
| 324 | + | ); | |
| 325 | + | const pushes: (Omit<DirectPush, "by"> & { actorKey: string })[] = []; | |
| 326 | + | const history: Record<string, Commit[]> = {}; | |
| 327 | + | for (const entry of lists) { | |
| 328 | + | if (!entry) continue; | |
| 329 | + | history[entry.repo.id] = entry.history; | |
| 330 | + | const ref: RepoRef = { namespace: entry.repo.namespace, name: entry.repo.name, defaultBranch: entry.repo.defaultBranch }; | |
| 331 | + | for (const event of entry.pushes) { | |
| 332 | + | pushes.push({ | |
| 333 | + | repoId: entry.repo.id, | |
| 334 | + | repo: ref, | |
| 335 | + | push: { id: event.id, time: event.time, actor: event.actor ?? null, data: event.data }, | |
| 336 | + | actorKey: event.actor ? deployActorKey(event.actor) ?? "" : "", | |
| 337 | + | }); | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | return { pushes, history, complete: lists.every((entry) => entry != null) && pushedTo.length <= MAX_PUSH_PROJECTS }; | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /** | |
| 344 | + | * Whether each merge and push that landed went live, from deployments' | |
| 345 | + | * record of what each production deployment shipped. Null when | |
| 346 | + | * deployments did not answer. | |
| 347 | + | */ | |
| 348 | + | export async function loadShipped(viewer: User, slug: string, rows: LandedChange[]): Promise<ShippedRepo[] | null> { | |
| 349 | + | const asks = shippedAsks(rows).slice(0, 20); | |
| 350 | + | if (asks.length === 0) return []; | |
| 351 | + | const answer = await deployments.shipped(slug, viewer, asks).catch(warn("shipped")); | |
| 352 | + | return answer?.ok ? answer.value : null; | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /** The harness sessions tied to each merged pull request, per project; null when work did not answer for any. */ | |
| 356 | + | export async function loadPullSessions(viewer: User, pulls: CodePull[]): Promise<{ repo: RepoPath; pulls: PullSessions[] }[] | null> { | |
| 357 | + | const byRepo = new Map<string, { repo: RepoPath; numbers: number[] }>(); | |
| 358 | + | for (const pull of pulls) { | |
| 359 | + | if (pull.status !== "merged") continue; | |
| 360 | + | const key = `${pull.repo.namespace}/${pull.repo.name}`.toLowerCase(); | |
| 361 | + | const entry = byRepo.get(key) ?? { repo: pull.repo, numbers: [] }; | |
| 362 | + | entry.numbers.push(pull.number); | |
| 363 | + | byRepo.set(key, entry); | |
| 364 | + | } | |
| 365 | + | if (byRepo.size === 0) return []; | |
| 366 | + | const lists = await Promise.all( | |
| 367 | + | [...byRepo.values()].slice(0, MAX_PUSH_PROJECTS).map(({ repo, numbers }) => | |
| 368 | + | work | |
| 369 | + | .pullSessions(repo, viewer, numbers.slice(0, 100)) | |
| 370 | + | .then((result) => (result.ok ? { repo, pulls: result.value } : null)) | |
| 371 | + | .catch(warn(`sessions of ${repo.name}`)), | |
| 372 | + | ), | |
| 373 | + | ); | |
| 374 | + | const found = lists.filter((entry) => entry != null); | |
| 375 | + | return found.length === 0 && lists.length > 0 ? null : found; | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /** Notifications read for what settled by itself in the span. */ | |
| 379 | + | const SETTLED_READ = 100; | |
| 380 | + | ||
| 381 | + | /** The viewer's notifications done since the span began, newest activity first; null when the inbox did not answer. */ | |
| 382 | + | export async function loadSettled(viewer: User, span: Pick<Span, "from">): Promise<InboxItem[] | null> { | |
| 383 | + | const page = await inbox.list(viewer, { view: "done", limit: SETTLED_READ }).catch(warn("settled notifications")); | |
| 384 | + | return page ? page.items.filter((item) => item.doneAt != null && Date.parse(item.doneAt) >= span.from) : null; | |
| 385 | + | } | |
| 386 | + | ||
| 266 | 387 | /** How far back Code is read: 7 days before the span, for the comparison. */ | |
| 267 | 388 | export function reachFor(span: Pick<Span, "from">): number { | |
| 268 | 389 | return span.from - 7 * DAY; | |
| 7 | 7 | type AttentionRow, | |
| 8 | 8 | type CodeNeed, | |
| 9 | 9 | type CodePull, | |
| 10 | + | type LandedChange, | |
| 10 | 11 | actorKind, | |
| 12 | + | agentLabel, | |
| 11 | 13 | attention, | |
| 14 | + | awayOf, | |
| 15 | + | landedByDay, | |
| 16 | + | pushRows, | |
| 17 | + | sessionTrailers, | |
| 18 | + | settledIn, | |
| 19 | + | shippedAsks, | |
| 20 | + | shippedLabel, | |
| 21 | + | withSessions, | |
| 22 | + | withShipped, | |
| 12 | 23 | chatRow, | |
| 13 | 24 | codeRow, | |
| 14 | 25 | dayIn, | |
| ⋯ | |||
| 646 | 657 | assert.equal(spend.monthMicros, 13_609_000); | |
| 647 | 658 | assert.equal(spendIn([september, october], { from: Date.parse("2026-09-29T15:00:00Z"), now: NOW }, null).monthMicros, null); | |
| 648 | 659 | }); | |
| 660 | + | ||
| 661 | + | ||
| 662 | + | // --- What landed: shipped, made in a session, day by day ----------------------- | |
| 663 | + | ||
| 664 | + | const merge = (over: Partial<LandedChange> = {}): LandedChange => ({ | |
| 665 | + | key: "landed:acme/web#12", | |
| 666 | + | kind: "merge", | |
| 667 | + | title: "Add retries", | |
| 668 | + | detail: "web#12 · by ana", | |
| 669 | + | to: "/acme/web/pull/12", | |
| 670 | + | at: NOW - 2 * HOUR, | |
| 671 | + | sha: "m12", | |
| 672 | + | repoId: "rep_web", | |
| 673 | + | ...over, | |
| 674 | + | }); | |
| 675 | + | ||
| 676 | + | test("each merge and push says whether it is live, from what deployments recorded", () => { | |
| 677 | + | const push = merge({ key: "push:rep_web:evt_1", kind: "push", sha: "p1", at: NOW - HOUR }); | |
| 678 | + | const release = merge({ key: "release:rep_web:v1", kind: "release", sha: null }); | |
| 679 | + | assert.deepEqual(shippedAsks([merge(), push, release]), [ | |
| 680 | + | { repo_id: "rep_web", commits: [{ sha: "m12", landed_at: iso(NOW - 2 * HOUR) }, { sha: "p1", landed_at: iso(NOW - HOUR) }] }, | |
| 681 | + | ]); | |
| 682 | + | const answers = [ | |
| 683 | + | { | |
| 684 | + | repo_id: "rep_web", | |
| 685 | + | deploys: true, | |
| 686 | + | commits: [ | |
| 687 | + | { sha: "m12", state: "live" as const, deployed_at: iso(NOW - 2 * HOUR + 9 * 60_000), url: "/acme/web/actions/runs/run_1", environment: "production" }, | |
| 688 | + | { sha: "p1", state: "waiting" as const, deployed_at: null, url: null, environment: null }, | |
| 689 | + | ], | |
| 690 | + | }, | |
| 691 | + | ]; | |
| 692 | + | const [shippedMerge, shippedPush, untouched] = withShipped([merge(), push, release], answers); | |
| 693 | + | assert.deepEqual(shippedMerge!.shipped, { state: "live", at: NOW - 2 * HOUR + 9 * 60_000, to: "/acme/web/actions/runs/run_1" }); | |
| 694 | + | assert.equal(shippedLabel(shippedMerge!.shipped!, shippedMerge!), "Live 9 min after merge"); | |
| 695 | + | assert.equal(shippedLabel(shippedPush!.shipped!, shippedPush!), "Waiting to deploy"); | |
| 696 | + | assert.equal(shippedLabel({ state: "failed", at: null, to: "/x" }, push), "Deploy failed"); | |
| 697 | + | assert.equal(shippedLabel({ state: "deploying", at: null, to: null }, push), "Deploying"); | |
| 698 | + | assert.equal(shippedLabel({ state: "live", at: push.at + 3 * HOUR, to: null }, push), "Live 3 h after push"); | |
| 699 | + | assert.equal(untouched!.shipped, undefined); | |
| 700 | + | // A project that does not deploy says nothing; deployments not answering leaves rows as they were. | |
| 701 | + | assert.equal(withShipped([merge()], [{ repo_id: "rep_web", deploys: false, commits: [] }])[0]!.shipped, undefined); | |
| 702 | + | assert.deepEqual(withShipped([merge()], null), [merge()]); | |
| 703 | + | }); | |
| 704 | + | ||
| 705 | + | test("a change made in a harness session carries the agent mark, linked to the session", () => { | |
| 706 | + | const [marked, plain] = withSessions([merge(), merge({ key: "landed:acme/web#13", sha: "m13" })], [ | |
| 707 | + | { repo: { namespace: "acme", name: "web" }, pulls: [{ number: 12, sessions: [{ id: "ses_1", harness: "claude_code" }, { id: "ses_2", harness: "codex" }] }] }, | |
| 708 | + | ]); | |
| 709 | + | assert.deepEqual(marked!.agent, { harness: "claude_code", to: "/acme/web/work/sessions/ses_1" }); | |
| 710 | + | assert.equal(agentLabel(marked!.agent!), "Made in a Claude Code session"); | |
| 711 | + | assert.equal(plain!.agent, undefined); | |
| 712 | + | assert.equal(agentLabel({ harness: null, to: null }), "Made in a session"); | |
| 713 | + | assert.deepEqual(sessionTrailers("Fix the bell\n\nG1t-Session: ses_01ABC\ng1t-session: ses_01abc\nG1t-Session: ses_02x"), ["ses_01abc", "ses_02x"]); | |
| 714 | + | assert.deepEqual(sessionTrailers("Mentions G1t-Session: ses_1 in prose"), []); | |
| 715 | + | }); | |
| 716 | + | ||
| 717 | + | test("each person's push to a default branch is a row: its commit, who pushed, and its session", () => { | |
| 718 | + | const repo = { namespace: "acme", name: "web", defaultBranch: "main" }; | |
| 719 | + | const history = { | |
| 720 | + | rep_web: [ | |
| 721 | + | { hash: "c3", parents: ["c2"], message: "Fix the bell's count\n\nG1t-Session: ses_9" }, | |
| 722 | + | { hash: "c2", parents: ["c1"], message: "Tidy" }, | |
| 723 | + | { hash: "c1", parents: ["c0"], message: "Start" }, | |
| 724 | + | { hash: "c0", parents: [], message: "Root" }, | |
| 725 | + | ], | |
| 726 | + | }; | |
| 727 | + | const push = (id: string, after: string, before: string, at: number, actor = "usr_ana") => ({ | |
| 728 | + | repoId: "rep_web", | |
| 729 | + | repo, | |
| 730 | + | push: { id, time: iso(at), actor, data: { after, before } }, | |
| 731 | + | by: "ana", | |
| 732 | + | }); | |
| 733 | + | const span = { from: NOW - DAY, now: NOW }; | |
| 734 | + | const rows = pushRows([push("evt_2", "c3", "c2", NOW - HOUR), push("evt_1", "c2", "c0", NOW - 2 * HOUR), push("evt_0", "c1", "c0", NOW - 2 * DAY)], history, span); | |
| 735 | + | assert.deepEqual( | |
| 736 | + | rows.map((row) => [row.title, row.detail, row.sha, row.to, row.agent?.to ?? null]), | |
| 737 | + | [ | |
| 738 | + | ["Fix the bell's count", "web · pushed to main · by ana", "c3", "/acme/web/commit/c3", "/acme/web/work/sessions/ses_9"], | |
| 739 | + | ["2 commits pushed to main", "web · Tidy · by ana", "c2", "/acme/web/activity", null], | |
| 740 | + | ], | |
| 741 | + | ); | |
| 742 | + | // g1t's own pushes (a pull request landing) are listed as the merge instead. | |
| 743 | + | assert.deepEqual(pushRows([push("evt_3", "c3", "c2", NOW - HOUR, "usr_g1t_agent")], history, span), []); | |
| 744 | + | }); | |
| 745 | + | ||
| 746 | + | test("what landed over the week reads day by day, newest first", () => { | |
| 747 | + | const tz = "America/Los_Angeles"; | |
| 748 | + | const days = landedByDay([merge({ at: NOW - DAY }), merge({ key: "b", at: NOW - HOUR }), merge({ key: "c", at: NOW - 3 * DAY })], NOW, tz); | |
| 749 | + | assert.deepEqual(days.map((day) => [day.label, day.rows.length]), [["Today", 1], ["Yesterday", 1], ["Tue, Oct 6", 1]]); | |
| 750 | + | }); | |
| 751 | + | ||
| 752 | + | test("while you were away: what settled by itself, what shipped, and what still needs you", () => { | |
| 753 | + | const span = { from: NOW - DAY, now: NOW }; | |
| 754 | + | const settled = settledIn( | |
| 755 | + | [ | |
| 756 | + | item({ id: "n1", doneAt: iso(NOW - HOUR), settledReason: "CI passed in run 330" }), | |
| 757 | + | item({ id: "n2", doneAt: iso(NOW - 2 * HOUR), settledReason: "Merged", settledBy: "ana" }), | |
| 758 | + | // Marked done by hand: yours, not g1t's. | |
| 759 | + | item({ id: "n3", doneAt: iso(NOW - HOUR) }), | |
| 760 | + | // Before the span, or another workspace's. | |
| 761 | + | item({ id: "n4", doneAt: iso(NOW - 2 * DAY), settledReason: "Merged" }), | |
| 762 | + | item({ id: "n5", doneAt: iso(NOW - HOUR), settledReason: "Merged", workspace: "other", repo: "other/x" }), | |
| 763 | + | ], | |
| 764 | + | "acme", | |
| 765 | + | true, | |
| 766 | + | span, | |
| 767 | + | ); | |
| 768 | + | assert.deepEqual( | |
| 769 | + | settled.map((row) => [row.key, row.why, row.where]), | |
| 770 | + | [ | |
| 771 | + | ["settled:n1", "CI passed in run 330", "acme/web#12"], | |
| 772 | + | ["settled:n2", "Merged by @ana", "acme/web#12"], | |
| 773 | + | ], | |
| 774 | + | ); | |
| 775 | + | assert.equal(settledIn([item({ doneAt: iso(NOW - HOUR), settledReason: "Merged" })], "acme", false, span).length, 0, "nothing about repositories without Code"); | |
| 776 | + | const away = awayOf({ | |
| 777 | + | settled, | |
| 778 | + | landed: [merge({ shipped: { state: "live", at: NOW, to: null } }), merge({ key: "x", shipped: { state: "waiting", at: null, to: null } }), merge({ key: "y" })], | |
| 779 | + | needs: 3, | |
| 780 | + | }); | |
| 781 | + | assert.deepEqual({ ...away, settled: away.settled.length }, { settled: 2, shipped: 1, waiting: 1, deploying: 0, failed: 0, needs: 3 }); | |
| 782 | + | }); | |
| 11 | 11 | ActivityDigest, | |
| 12 | 12 | ActorCount, | |
| 13 | 13 | ChatActivity, | |
| 14 | + | Commit, | |
| 14 | 15 | Deployment, | |
| 16 | + | Harness, | |
| 15 | 17 | InboxItem, | |
| 16 | 18 | ProjectDeploys, | |
| 17 | 19 | Pull, | |
| 20 | + | PullSessions, | |
| 18 | 21 | RepoDigest, | |
| 19 | 22 | RepoPath, | |
| 23 | + | ShipState, | |
| 24 | + | ShippedRepo, | |
| 20 | 25 | Statement, | |
| 21 | 26 | WorkflowRun, | |
| 22 | 27 | } from "@g1t/contracts"; | |
| 23 | 28 | ||
| 29 | + | import { HARNESS_NAMES } from "./coordination.ts"; | |
| 30 | + | import { type PushRecord, pushedByPerson, pushedCommits } from "./mission-control.ts"; | |
| 31 | + | import { settledLine } from "./inbox.ts"; | |
| 32 | + | ||
| 24 | 33 | const MINUTE = 60_000; | |
| 25 | 34 | const HOUR = 60 * MINUTE; | |
| 26 | 35 | const DAY = 24 * HOUR; | |
| ⋯ | |||
| 164 | 173 | /** A pull request with where it is: what Home reads from Code. */ | |
| 165 | 174 | export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & { | |
| 166 | 175 | repo: RepoPath; | |
| 176 | + | /** The repository's id, and the commit the merge put on the base branch: what deployments ship. */ | |
| 177 | + | repoId?: string; | |
| 178 | + | mergeCommit?: string | null; | |
| 167 | 179 | author: { username: string; kind?: string }; | |
| 168 | 180 | /** Who merged it, when it merged. */ | |
| 169 | 181 | mergedBy?: string | null; | |
| ⋯ | |||
| 504 | 516 | detail: string; | |
| 505 | 517 | to: string; | |
| 506 | 518 | at: number; | |
| 519 | + | /** A merge's or a push's commit on the default branch, and its repository's id: what a deployment ships. */ | |
| 520 | + | sha?: string | null; | |
| 521 | + | repoId?: string | null; | |
| 522 | + | /** Whether it is live yet (`withShipped`); null when its project does not deploy, or it could not be read. */ | |
| 523 | + | shipped?: ShipInfo | null; | |
| 524 | + | /** Made in a harness session (`withSessions`, or a `G1t-Session:` trailer): the agent marker, and the session. */ | |
| 525 | + | agent?: AgentMark | null; | |
| 507 | 526 | }; | |
| 508 | 527 | ||
| 528 | + | /** Whether a change is live: shipped by a production deployment, on its way, waiting, or the deployment after it failed. */ | |
| 529 | + | export type ShipInfo = { | |
| 530 | + | state: ShipState; | |
| 531 | + | /** Epoch ms: when the deployment that shipped it went live. */ | |
| 532 | + | at: number | null; | |
| 533 | + | /** The deployment (or the run that deploys it). */ | |
| 534 | + | to: string | null; | |
| 535 | + | }; | |
| 536 | + | ||
| 537 | + | /** A change made in a harness session: which harness when known, and the session's page. */ | |
| 538 | + | export type AgentMark = { harness: Harness | null; to: string | null }; | |
| 539 | + | ||
| 509 | 540 | /** Every pull request that merged in the span, newest first. */ | |
| 510 | 541 | export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] { | |
| 511 | 542 | return pulls | |
| ⋯ | |||
| 520 | 551 | detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`, | |
| 521 | 552 | to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`, | |
| 522 | 553 | at: Date.parse(pull.mergedAt!), | |
| 554 | + | sha: pull.mergeCommit ?? null, | |
| 555 | + | repoId: pull.repoId ?? null, | |
| 523 | 556 | }; | |
| 524 | 557 | }) | |
| 525 | 558 | .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| ⋯ | |||
| 601 | 634 | return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 602 | 635 | } | |
| 603 | 636 | ||
| 637 | + | // --- Pushes straight to a default branch, each --------------------------------- | |
| 638 | + | ||
| 639 | + | /** A push to a default branch, with the history it brought read back. */ | |
| 640 | + | export type DirectPush = { | |
| 641 | + | repoId: string; | |
| 642 | + | repo: RepoRef; | |
| 643 | + | push: PushRecord & { id: string }; | |
| 644 | + | /** Who pushed, by name; `someone` when it could not be read. */ | |
| 645 | + | by: string; | |
| 646 | + | }; | |
| 647 | + | ||
| 648 | + | /** The sessions a commit message names in `G1t-Session:` trailers. */ | |
| 649 | + | export function sessionTrailers(message: string): string[] { | |
| 650 | + | const ids = [...message.matchAll(/^G1t-Session:\s*(ses_[0-9a-z]+)\s*$/gim)].map((match) => match[1]!.toLowerCase()); | |
| 651 | + | return [...new Set(ids)]; | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | /** A commit message's first line. */ | |
| 655 | + | const subject = (message: string) => message.split("\n", 1)[0]!.trim(); | |
| 656 | + | ||
| 657 | + | /** | |
| 658 | + | * Each person's push to a default branch in the span, newest first, as a | |
| 659 | + | * landed change: its commit when it brought one ("Fix the bell's count"), | |
| 660 | + | * else how many; the session a commit names in a `G1t-Session:` trailer | |
| 661 | + | * as its agent mark. `history` is each repository's default branch read | |
| 662 | + | * back from its newest push, newest first; a push whose commits are not | |
| 663 | + | * in it (rewritten since) is left out, and so is g1t's own (a pull | |
| 664 | + | * request landing: Landed lists the merge). | |
| 665 | + | */ | |
| 666 | + | export function pushRows( | |
| 667 | + | pushes: DirectPush[], | |
| 668 | + | history: Record<string, Pick<Commit, "hash" | "parents" | "message">[]>, | |
| 669 | + | span: Pick<Span, "from" | "now">, | |
| 670 | + | byG1t: (commit: Pick<Commit, "hash" | "parents" | "message">) => boolean = () => false, | |
| 671 | + | ): LandedChange[] { | |
| 672 | + | const rows: LandedChange[] = []; | |
| 673 | + | for (const { repoId, repo, push, by } of pushes) { | |
| 674 | + | if (!pushedByPerson(push) || !within(push.time, span)) continue; | |
| 675 | + | const commits = history[repoId] ?? []; | |
| 676 | + | const start = commits.findIndex((commit) => commit.hash === push.data.after); | |
| 677 | + | if (start === -1) continue; | |
| 678 | + | const brought = pushedCommits(commits.slice(start), push.data.before, byG1t); | |
| 679 | + | if (brought.length === 0) continue; | |
| 680 | + | const newest = brought[0]!; | |
| 681 | + | const session = brought.flatMap((commit) => sessionTrailers(commit.message))[0] ?? null; | |
| 682 | + | const branch = repo.defaultBranch; | |
| 683 | + | rows.push({ | |
| 684 | + | key: `push:${repoId}:${push.id}`, | |
| 685 | + | kind: "push", | |
| 686 | + | title: brought.length === 1 ? subject(newest.message) || `1 commit pushed to ${branch}` : `${plural(brought.length, "commit")} pushed to ${branch}`, | |
| 687 | + | detail: `${repo.name} · ${brought.length === 1 ? `pushed to ${branch}` : subject(newest.message)} · by ${by}`, | |
| 688 | + | to: brought.length === 1 ? `/${repo.namespace}/${repo.name}/commit/${newest.hash}` : `/${repo.namespace}/${repo.name}/activity`, | |
| 689 | + | at: Date.parse(push.time), | |
| 690 | + | sha: push.data.after, | |
| 691 | + | repoId, | |
| 692 | + | agent: session ? { harness: null, to: `/${repo.namespace}/${repo.name}/work/sessions/${session}` } : null, | |
| 693 | + | }); | |
| 694 | + | } | |
| 695 | + | return rows; | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | // --- Shipped: merge to deploy -------------------------------------------------- | |
| 699 | + | ||
| 700 | + | /** | |
| 701 | + | * Each merge and push with whether it is live yet, from what deployments | |
| 702 | + | * recorded as production deployed (`shipped`). A project that does not | |
| 703 | + | * deploy says nothing; a change deployments did not answer for keeps none. | |
| 704 | + | */ | |
| 705 | + | export function withShipped(rows: LandedChange[], answers: ShippedRepo[] | null): LandedChange[] { | |
| 706 | + | if (!answers) return rows; | |
| 707 | + | const byRepo = new Map(answers.map((answer) => [answer.repo_id, answer])); | |
| 708 | + | return rows.map((row) => { | |
| 709 | + | if (!row.sha || !row.repoId || (row.kind !== "merge" && row.kind !== "push")) return row; | |
| 710 | + | const answer = byRepo.get(row.repoId); | |
| 711 | + | const commit = answer?.deploys ? answer.commits.find((c) => c.sha === row.sha) : null; | |
| 712 | + | if (!commit) return row; | |
| 713 | + | return { ...row, shipped: { state: commit.state, at: commit.deployed_at ? Date.parse(commit.deployed_at) : null, to: commit.url } }; | |
| 714 | + | }); | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | /** What to ask deployments: each repository's merges and pushes with their commit and when they landed. */ | |
| 718 | + | export function shippedAsks(rows: LandedChange[]): { repo_id: string; commits: { sha: string; landed_at: string }[] }[] { | |
| 719 | + | const byRepo = new Map<string, { sha: string; landed_at: string }[]>(); | |
| 720 | + | for (const row of rows) { | |
| 721 | + | if (!row.sha || !row.repoId || (row.kind !== "merge" && row.kind !== "push")) continue; | |
| 722 | + | const list = byRepo.get(row.repoId) ?? []; | |
| 723 | + | list.push({ sha: row.sha, landed_at: new Date(row.at).toISOString() }); | |
| 724 | + | byRepo.set(row.repoId, list); | |
| 725 | + | } | |
| 726 | + | return [...byRepo].map(([repo_id, commits]) => ({ repo_id, commits })); | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | /** How long something took, short: "1 min", "45 min", "3 h", "2 d". */ | |
| 730 | + | function took(ms: number): string { | |
| 731 | + | const minutes = Math.max(1, Math.round(ms / MINUTE)); | |
| 732 | + | if (minutes < 60) return `${minutes} min`; | |
| 733 | + | const hours = Math.round(minutes / 60); | |
| 734 | + | if (hours < 48) return `${hours} h`; | |
| 735 | + | return `${Math.round(hours / 24)} d`; | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | /** "Live 9 min after merge", "Waiting to deploy", "Deploying", "Deploy failed". */ | |
| 739 | + | export function shippedLabel(ship: ShipInfo, row: Pick<LandedChange, "kind" | "at">): string { | |
| 740 | + | switch (ship.state) { | |
| 741 | + | case "live": | |
| 742 | + | return ship.at != null ? `Live ${took(ship.at - row.at)} after ${row.kind === "merge" ? "merge" : "push"}` : "Live"; | |
| 743 | + | case "deploying": | |
| 744 | + | return "Deploying"; | |
| 745 | + | case "failed": | |
| 746 | + | return "Deploy failed"; | |
| 747 | + | default: | |
| 748 | + | return "Waiting to deploy"; | |
| 749 | + | } | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | // --- Done with an agent -------------------------------------------------------- | |
| 753 | + | ||
| 754 | + | /** | |
| 755 | + | * Each merged pull request with the harness session that made it, when | |
| 756 | + | * work tied one to it (`pullSessions`): the first, by when it started. | |
| 757 | + | */ | |
| 758 | + | export function withSessions(rows: LandedChange[], sessions: { repo: RepoPath; pulls: PullSessions[] }[] | null): LandedChange[] { | |
| 759 | + | if (!sessions) return rows; | |
| 760 | + | const found = new Map<string, AgentMark>(); | |
| 761 | + | for (const { repo, pulls } of sessions) { | |
| 762 | + | for (const pull of pulls) { | |
| 763 | + | const first = pull.sessions[0]; | |
| 764 | + | if (first) found.set(`landed:${pullKey(repo, pull.number)}`, { harness: first.harness, to: `/${repo.namespace}/${repo.name}/work/sessions/${first.id}` }); | |
| 765 | + | } | |
| 766 | + | } | |
| 767 | + | return rows.map((row) => (row.agent || !found.has(row.key) ? row : { ...row, agent: found.get(row.key)! })); | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | /** The agent mark's words: "Made in a Claude Code session", or "Made in a session". */ | |
| 771 | + | export function agentLabel(mark: AgentMark): string { | |
| 772 | + | return mark.harness ? `Made in a ${HARNESS_NAMES[mark.harness] ?? "harness"} session` : "Made in a session"; | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // --- Day by day ---------------------------------------------------------------- | |
| 776 | + | ||
| 777 | + | /** What landed on one day, newest first: "Today", "Yesterday", "Mon, Oct 12". */ | |
| 778 | + | export type LandedDay = { key: string; label: string; rows: LandedChange[] }; | |
| 779 | + | ||
| 780 | + | /** What landed, by the day it landed in the reader's time zone, newest day first. */ | |
| 781 | + | export function landedByDay(rows: LandedChange[], now: number, timeZone: string | null): LandedDay[] { | |
| 782 | + | const days: LandedDay[] = []; | |
| 783 | + | const today = dayIn(now, timeZone); | |
| 784 | + | const yesterday = dayIn(now - DAY, timeZone); | |
| 785 | + | for (const row of [...rows].sort((a, b) => b.at - a.at || a.key.localeCompare(b.key))) { | |
| 786 | + | const key = dayIn(row.at, timeZone); | |
| 787 | + | let day = days.find((d) => d.key === key); | |
| 788 | + | if (!day) { | |
| 789 | + | const label = key === today ? "Today" : key === yesterday ? "Yesterday" : formatIn(row.at, timeZone, { weekday: "short", month: "short", day: "numeric" }); | |
| 790 | + | day = { key, label, rows: [] }; | |
| 791 | + | days.push(day); | |
| 792 | + | } | |
| 793 | + | day.rows.push(row); | |
| 794 | + | } | |
| 795 | + | return days; | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | // --- While you were away ------------------------------------------------------- | |
| 799 | + | ||
| 800 | + | /** Something that settled by itself in the span: what it was, why it settled, where. */ | |
| 801 | + | export type SettledRow = { key: string; title: string; why: string; where: string | null; to: string; at: number }; | |
| 802 | + | ||
| 803 | + | /** What g1t settled for you, what shipped, and what still needs you, in the span. */ | |
| 804 | + | export type Away = { | |
| 805 | + | settled: SettledRow[]; | |
| 806 | + | /** Merges and pushes in the span that went live, are waiting, deploying, or whose deployment failed. */ | |
| 807 | + | shipped: number; | |
| 808 | + | waiting: number; | |
| 809 | + | deploying: number; | |
| 810 | + | failed: number; | |
| 811 | + | /** Rows in Where you're needed. */ | |
| 812 | + | needs: number; | |
| 813 | + | }; | |
| 814 | + | ||
| 815 | + | /** | |
| 816 | + | * Your notifications that settled by themselves in the span (a later run | |
| 817 | + | * passed, it merged, it deployed), newest first; one marked done by hand | |
| 818 | + | * is yours, not g1t's. Only this workspace's, and none about a repository | |
| 819 | + | * for a member without Code access. | |
| 820 | + | */ | |
| 821 | + | export function settledIn(items: InboxItem[] | null, slug: string, code: boolean, span: Pick<Span, "from" | "now">): SettledRow[] { | |
| 822 | + | return (items ?? []) | |
| 823 | + | .filter((item) => item.doneAt != null && settledLine(item) && within(item.doneAt, span)) | |
| 824 | + | .filter((item) => (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug && (code || !item.repo)) | |
| 825 | + | .map((item) => ({ | |
| 826 | + | key: `settled:${item.id}`, | |
| 827 | + | // An issue's or pull request's own title; else the item's ("CI failed on main in acme/web"). | |
| 828 | + | title: item.number != null ? item.body || item.title : item.title, | |
| 829 | + | why: settledLine(item), | |
| 830 | + | where: item.repo ? `${item.repo}${item.number != null ? `#${item.number}` : ""}` : null, | |
| 831 | + | to: item.url, | |
| 832 | + | at: Date.parse(item.doneAt!), | |
| 833 | + | })) | |
| 834 | + | .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | export function awayOf(input: { settled: SettledRow[]; landed: LandedChange[] | null; needs: number }): Away { | |
| 838 | + | const count = (state: ShipState) => (input.landed ?? []).filter((row) => row.shipped?.state === state).length; | |
| 839 | + | return { settled: input.settled, shipped: count("live"), waiting: count("waiting"), deploying: count("deploying"), failed: count("failed"), needs: input.needs }; | |
| 840 | + | } | |
| 841 | + | ||
| 604 | 842 | /** A deployment's maker as a member key: an id becomes `user:<id>`, a key stays as it is. */ | |
| 605 | 843 | export function deployActorKey(createdBy: string | null | undefined): string | null { | |
| 606 | 844 | const by = (createdBy ?? "").trim(); | |
| ⋯ | |||
| 1101 | 1339 | return `${Math.floor(minutes / (24 * 60))} d`; | |
| 1102 | 1340 | } | |
| 1103 | 1341 | ||
| 1342 | + | /** "3:40 PM": the time of day alone, for a row under its day's heading. */ | |
| 1343 | + | export function timeOfDay(at: number, timeZone: string | null): string { | |
| 1344 | + | return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" }); | |
| 1345 | + | } | |
| 1346 | + | ||
| 1104 | 1347 | /** "3:40 PM" when it is the same day as `now`, else "Oct 7". */ | |
| 1105 | 1348 | export function whenShort(at: number, now: number, timeZone: string | null): string { | |
| 1106 | 1349 | if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" }); | |
| 22 | 22 | subscriptionFromForm, | |
| 23 | 23 | subscriptionLine, | |
| 24 | 24 | tabCount, | |
| 25 | + | settledLine, | |
| 25 | 26 | updatesLabel, | |
| 26 | 27 | watchFromForm, | |
| 27 | 28 | watchLabel, | |
| ⋯ | |||
| 157 | 158 | assert.equal(updatesLabel(4), "4 updates"); | |
| 158 | 159 | }); | |
| 159 | 160 | ||
| 161 | + | test("a done item that settled by itself says why, and who did it", () => { | |
| 162 | + | const done = "2026-10-14T12:00:00.000Z"; | |
| 163 | + | assert.equal(settledLine({ doneAt: done, settledReason: "CI passed in run 330", settledBy: null }), "CI passed in run 330"); | |
| 164 | + | assert.equal(settledLine({ doneAt: done, settledReason: "Merged", settledBy: "@syntaqx" }), "Merged by @syntaqx"); | |
| 165 | + | // Marked done by hand, or not done: nothing. | |
| 166 | + | assert.equal(settledLine({ doneAt: done, settledReason: null, settledBy: null }), ""); | |
| 167 | + | assert.equal(settledLine({ doneAt: null, settledReason: "Merged", settledBy: "ana" }), ""); | |
| 168 | + | }); | |
| 169 | + | ||
| 160 | 170 | test("watching is read from the menu, and a custom watch of nothing is the default", () => { | |
| 161 | 171 | const form = (fields: [string, string][]) => { | |
| 162 | 172 | const data = new FormData(); | |
| 209 | 209 | return count && count > 1 ? `${count} updates` : ""; | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | + | /** | |
| 213 | + | * Why a done item settled by itself, as its card says it: the reason, and | |
| 214 | + | * who did it when someone did ("Merged by @ana", "CI passed in run 330"). | |
| 215 | + | * Empty for an item not done, or marked done by hand. | |
| 216 | + | */ | |
| 217 | + | export function settledLine(item: Pick<InboxItem, "doneAt" | "settledReason" | "settledBy">): string { | |
| 218 | + | const reason = item.settledReason?.trim(); | |
| 219 | + | if (!item.doneAt || !reason) return ""; | |
| 220 | + | const by = item.settledBy?.trim().replace(/^@/, ""); | |
| 221 | + | return by ? `${reason} by @${by}` : reason; | |
| 222 | + | } | |
| 223 | + | ||
| 212 | 224 | /** The ways to watch a repository, in the order the menu shows them. */ | |
| 213 | 225 | export const WATCH_CHOICES: { level: WatchLevel; label: string; detail: string }[] = [ | |
| 214 | 226 | { level: "participating", label: "Participating and @mentions", detail: "Only what you take part in, or are mentioned in." }, |
| 495 | 495 | case "preferences": | |
| 496 | 496 | set({ preferences: event.preferences }); | |
| 497 | 497 | break; | |
| 498 | − | case "inbox": | |
| 499 | − | set({ inbox: event.unread }); | |
| 498 | + | case "inbox": { | |
| 499 | + | // Items that settled by themselves: their notifications go from every tab's recent list. | |
| 500 | + | const settled = new Set(Array.isArray(event.settled) ? event.settled : []); | |
| 501 | + | set(settled.size > 0 ? { inbox: event.unread, recent: state.recent.filter((n) => !n.inbox_thread || !settled.has(n.inbox_thread)) } : { inbox: event.unread }); | |
| 500 | 502 | break; | |
| 503 | + | } | |
| 501 | 504 | case "presence": { | |
| 502 | 505 | const people = mergePeople(state.people, event.people, event.full); | |
| 503 | 506 | if (people !== state.people) set({ people }); |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/home"; | |
| 7 | 7 | import { | |
| 8 | + | AwayCard, | |
| 8 | 9 | Card, | |
| 9 | 10 | DeploysCard, | |
| 10 | 11 | LandedCard, | |
| ⋯ | |||
| 22 | 23 | import { sidebarOrNull } from "../../lib/chat.server"; | |
| 23 | 24 | import { | |
| 24 | 25 | type Attention, | |
| 26 | + | type Away, | |
| 25 | 27 | type Digest, | |
| 26 | 28 | attention, | |
| 29 | + | awayOf, | |
| 27 | 30 | deploysIn, | |
| 28 | 31 | digestOf, | |
| 29 | 32 | landed, | |
| ⋯ | |||
| 31 | 34 | landedFromActivity, | |
| 32 | 35 | landedIn, | |
| 33 | 36 | parseWindow, | |
| 37 | + | pushRows, | |
| 38 | + | settledIn, | |
| 39 | + | withSessions, | |
| 40 | + | withShipped, | |
| 34 | 41 | running, | |
| 35 | 42 | runningDeploys, | |
| 36 | 43 | runningWorkflows, | |
| 37 | 44 | spanFor, | |
| 45 | + | within, | |
| 38 | 46 | spanSentence, | |
| 39 | 47 | waitingSentence, | |
| 40 | 48 | } from "../../lib/home"; | |
| ⋯ | |||
| 44 | 52 | loadChatActivity, | |
| 45 | 53 | loadCodeWork, | |
| 46 | 54 | loadDeploys, | |
| 55 | + | loadDirectPushes, | |
| 47 | 56 | loadLastVisit, | |
| 48 | 57 | loadNames, | |
| 58 | + | loadPullSessions, | |
| 49 | 59 | loadRepos, | |
| 60 | + | loadSettled, | |
| 61 | + | loadShipped, | |
| 50 | 62 | loadSpend, | |
| 51 | 63 | loadWorkflowRuns, | |
| 52 | 64 | reachFor, | |
| ⋯ | |||
| 117 | 129 | const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null); | |
| 118 | 130 | const activityP = reposP.then((owned) => loadActivity(slug, span, owned)).catch(() => null); | |
| 119 | 131 | const chatActivityP = loadChatActivity(viewer, slug, span).catch(() => null); | |
| 120 | − | const namesP = Promise.all([activityP, chatActivityP, deploysP]) | |
| 121 | − | .then(([activity, chatActivity, deploys]) => loadNames(actorKeys(activity, chatActivity, deploys))) | |
| 132 | + | // Each push to a default branch, with the commits it brought: the projects the digest says were pushed to. | |
| 133 | + | const pushesP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadDirectPushes(viewer, activity, owned, span)).catch(() => null) : Promise.resolve(null); | |
| 134 | + | const namesP = Promise.all([activityP, chatActivityP, deploysP, pushesP]) | |
| 135 | + | .then(([activity, chatActivity, deploys, pushes]) => loadNames([...actorKeys(activity, chatActivity, deploys), ...(pushes?.pushes ?? []).map((push) => push.actorKey)])) | |
| 122 | 136 | .catch(() => ({}) as Record<string, string>); | |
| 123 | 137 | ||
| 124 | 138 | // What people did, from events and chat. | |
| 125 | 139 | const digest: Promise<Digest | null> = Promise.all([activityP, chatActivityP, namesP, reposP]) | |
| 126 | 140 | .then(([activity, chatActivity, names, owned]) => digestOf({ slug, repos: repoRefs(owned), activity, chat: chatActivity, names, code })) | |
| 127 | 141 | .catch(() => null); | |
| 128 | − | const happened = Promise.all([codeWorkP, deploysP, activityP, namesP, reposP]) | |
| 129 | − | .then(([codeWork, deploys, activity, names, owned]) => { | |
| 142 | + | const happened = Promise.all([codeWorkP, deploysP, activityP, namesP, reposP, pushesP]) | |
| 143 | + | .then(async ([codeWork, deploys, activity, names, owned, pushes]) => { | |
| 130 | 144 | const deployRows = deploys ? deploysIn(deploys.projects, slug, span, names) : null; | |
| 131 | 145 | const merges = codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null; | |
| 146 | + | // Each push on its own where its project's pushes were read; the digest's one row a project elsewhere. | |
| 147 | + | const named = (key: string) => (key ? (names[key] ?? "someone") : "an import"); | |
| 148 | + | const each = pushes ? pushRows(pushes.pushes.map((push) => ({ ...push, by: named(push.actorKey) })), pushes.history, span) : []; | |
| 149 | + | const fromActivity = landedFromActivity(activity, repoRefs(owned), names, slug).filter((row) => !(row.kind === "push" && pushes?.history[row.key.slice("push:".length)])); | |
| 150 | + | const all = merges == null && activity == null ? null : landed([merges ?? [], each, fromActivity, landedDeploys(deployRows ?? [])]); | |
| 151 | + | // Whether each merge and push is live, and which pull requests a harness session made: read together. | |
| 152 | + | const [shipped, sessions] = all | |
| 153 | + | ? await Promise.all([ | |
| 154 | + | loadShipped(viewer, slug, all).catch(() => null), | |
| 155 | + | codeWork && codeWork !== "no_access" ? loadPullSessions(viewer, codeWork.pulls.filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))).catch(() => null) : null, | |
| 156 | + | ]) | |
| 157 | + | : [null, null]; | |
| 132 | 158 | return { | |
| 133 | 159 | // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read. | |
| 134 | − | landed: merges == null && activity == null ? null : landed([merges ?? [], landedFromActivity(activity, repoRefs(owned), names, slug), landedDeploys(deployRows ?? [])]), | |
| 135 | − | landedMissing: [...(code && merges == null ? ["Pull requests"] : []), ...(code && activity == null ? ["Pushes and releases"] : []), ...(code && deployRows == null ? ["Deployments"] : [])], | |
| 160 | + | landed: all ? withSessions(withShipped(all, shipped), sessions) : null, | |
| 161 | + | landedMissing: [ | |
| 162 | + | ...(code && merges == null ? ["Pull requests"] : []), | |
| 163 | + | ...(code && activity == null ? ["Pushes and releases"] : []), | |
| 164 | + | ...(code && deployRows == null ? ["Deployments"] : []), | |
| 165 | + | ], | |
| 136 | 166 | deploys: deployRows, | |
| 137 | 167 | deploysComplete: deploys?.complete ?? true, | |
| 138 | 168 | }; | |
| 139 | 169 | }) | |
| 140 | 170 | .catch(() => null); | |
| 171 | + | // What g1t settled for you in the span, what shipped, and what still needs you. | |
| 172 | + | const settledP = loadSettled(viewer, span).catch(() => null); | |
| 173 | + | const away: Promise<Away | null> = Promise.all([settledP, happened, waiting]) | |
| 174 | + | .then(([settled, value, att]) => (settled == null ? null : awayOf({ settled: settledIn(settled, slug, code, span), landed: value?.landed ?? null, needs: att.rows.length }))) | |
| 175 | + | .catch(() => null); | |
| 141 | 176 | const workflowsP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadWorkflowRuns(viewer, activity, owned)).catch(() => null) : Promise.resolve(null); | |
| 142 | 177 | const runningNow = Promise.all([deploysP, workflowsP]) | |
| 143 | 178 | .then(([deploys, workflows]) => ({ | |
| ⋯ | |||
| 156 | 191 | digest, | |
| 157 | 192 | happened, | |
| 158 | 193 | attention: waiting, | |
| 194 | + | away, | |
| 159 | 195 | running: runningNow, | |
| 160 | 196 | /** The sentence under the heading needs the digest, what landed and what waits. */ | |
| 161 | 197 | head: Promise.all([digest, happened, waiting]), | |
| ⋯ | |||
| 201 | 237 | } | |
| 202 | 238 | ||
| 203 | 239 | export default function Home({ loaderData }: Route.ComponentProps) { | |
| 204 | − | const { slug, now, tz, span, digest, happened, attention: waiting, running: runningNow, head, spend } = loaderData; | |
| 240 | + | const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend } = loaderData; | |
| 205 | 241 | useMarkSeen(slug, now); | |
| 206 | 242 | return ( | |
| 207 | 243 | <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| ⋯ | |||
| 226 | 262 | ||
| 227 | 263 | {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */} | |
| 228 | 264 | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> | |
| 229 | − | <div className="lg:col-start-1 lg:row-start-1"> | |
| 265 | + | <div id="needed" className="scroll-mt-4 lg:col-start-1 lg:row-start-1"> | |
| 230 | 266 | <Card | |
| 231 | 267 | title="Where you're needed" | |
| 232 | 268 | hint="Everything waiting on you, however long ago it started" | |
| ⋯ | |||
| 260 | 296 | {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)} | |
| 261 | 297 | </p> | |
| 262 | 298 | </div> | |
| 299 | + | <Suspense | |
| 300 | + | fallback={ | |
| 301 | + | <Card title={span.key === "last" && span.note == null ? "While you were away" : "Settled for you"}> | |
| 302 | + | <RowsSkeleton rows={2} /> | |
| 303 | + | </Card> | |
| 304 | + | } | |
| 305 | + | > | |
| 306 | + | <Await resolve={away}>{(value) => <AwayCard away={value} span={span} now={now} />}</Await> | |
| 307 | + | </Suspense> | |
| 263 | 308 | <Suspense fallback={<SpanSkeleton />}> | |
| 264 | 309 | <Await resolve={digest}>{(value) => <PeopleCard digest={value} span={span} code={loaderData.code} />}</Await> | |
| 265 | 310 | </Suspense> | |
| ⋯ | |||
| 272 | 317 | </Card> | |
| 273 | 318 | } | |
| 274 | 319 | > | |
| 275 | − | <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} />}</Await> | |
| 320 | + | <Await resolve={happened}> | |
| 321 | + | {(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} byDay={span.key === "7d"} />} | |
| 322 | + | </Await> | |
| 276 | 323 | </Suspense> | |
| 277 | 324 | <Suspense fallback={null}> | |
| 278 | 325 | <Await resolve={happened}> | |
| 252 | 252 | pub saved: bool, | |
| 253 | 253 | /// While this is in the future the item is out of the list. | |
| 254 | 254 | pub snoozed_until: Option<String>, | |
| 255 | + | /// Why it settled by itself, in a few words, such as `CI passed in | |
| 256 | + | /// run 330` or `Merged`: set when the event that left nothing to do | |
| 257 | + | /// marked it done. Absent while it is not done, and when it was marked | |
| 258 | + | /// done by hand. | |
| 259 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 260 | + | pub settled_reason: Option<String>, | |
| 261 | + | /// Who settled it, when a person or g1t did: a username, or `g1t`. | |
| 262 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 263 | + | pub settled_by: Option<String>, | |
| 255 | 264 | } | |
| 256 | 265 | ||
| 257 | 266 | /// One thing that happened on a thread, as the person was told of it. | |
| ⋯ | |||
| 816 | 825 | done_at: None, | |
| 817 | 826 | saved: false, | |
| 818 | 827 | snoozed_until: None, | |
| 828 | + | settled_reason: None, | |
| 829 | + | settled_by: None, | |
| 819 | 830 | }, | |
| 820 | 831 | activity: Vec::new(), | |
| 821 | 832 | subscription: None, | |
| 84 | 84 | "repo.archived", | |
| 85 | 85 | ], | |
| 86 | 86 | ), | |
| 87 | − | // Previews and production deploys (deployments/src/index.ts queue). | |
| 87 | + | // Previews and production deploys (deployments/src/index.ts queue), | |
| 88 | + | // and what each production deployment shipped (deployments/src/shipped.ts). | |
| 88 | 89 | ( | |
| 89 | 90 | "SUBSCRIBER_DEPLOYMENTS", | |
| 90 | 91 | &[ | |
| 91 | 92 | "git.push", | |
| 93 | + | "deployment_status.created", | |
| 92 | 94 | "branch.renamed", | |
| 93 | 95 | "repo.default_branch_changed", | |
| 94 | 96 | "pull.opened", | |
| ⋯ | |||
| 290 | 292 | assert!(!routed("SUBSCRIBER_DOCS", "pull.opened")); | |
| 291 | 293 | assert!(routed("SUBSCRIBER_PROJECTS", "deployment_status.created")); | |
| 292 | 294 | assert!(!routed("SUBSCRIBER_PROJECTS", "deployment.created")); | |
| 295 | + | // A production deployment's success writes down what it shipped. | |
| 296 | + | assert!(routed("SUBSCRIBER_DEPLOYMENTS", "deployment_status.created")); | |
| 297 | + | assert!(!routed("SUBSCRIBER_DEPLOYMENTS", "deployment.created")); | |
| 293 | 298 | // Every subscriber follows what moves or removes a repository. | |
| 294 | 299 | for (binding, _) in ROUTES { | |
| 295 | 300 | for kind in LIFECYCLE { | |
| 767 | 767 | pub root: Option<Session>, | |
| 768 | 768 | } | |
| 769 | 769 | ||
| 770 | + | /// `pull_sessions`: the harness sessions tied to some of a repository's | |
| 771 | + | /// pull requests (`numbers`, at most 100) any way a session is tied to | |
| 772 | + | /// one, for Home's marks on what landed. Returns | |
| 773 | + | /// `Outcome<Vec<PullSessions>>`, one entry for each pull request that has | |
| 774 | + | /// any, its top-level sessions first: a subagent counts as the session | |
| 775 | + | /// that started it. | |
| 776 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 777 | + | pub struct PullSessionsArgs { | |
| 778 | + | pub viewer: Viewer, | |
| 779 | + | pub repo: RepoPath, | |
| 780 | + | pub numbers: Vec<u32>, | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | /// The sessions tied to one pull request. | |
| 784 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 785 | + | #[serde(rename_all = "camelCase")] | |
| 786 | + | pub struct PullSessions { | |
| 787 | + | pub number: u32, | |
| 788 | + | pub sessions: Vec<SessionRef>, | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | /// A session, as a mark on something it made: its id and harness. | |
| 792 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 793 | + | #[serde(rename_all = "camelCase")] | |
| 794 | + | pub struct SessionRef { | |
| 795 | + | pub id: String, | |
| 796 | + | pub harness: Harness, | |
| 797 | + | } | |
| 798 | + | ||
| 770 | 799 | /// `list_work_sessions`: a repository's sessions, most recently heard from | |
| 771 | 800 | /// first. Returns `Outcome<Vec<Session>>`. Subagents are left out unless | |
| 772 | 801 | /// `subagents`; with `issue` or `pull`, the sessions tied to it any way |
| 393 | 393 | gitAccess: (path, viewer, service) => | |
| 394 | 394 | call("git_access", { path, viewer, service }), | |
| 395 | 395 | branches: (path, viewer) => call("branches", { path, viewer }), | |
| 396 | + | pathById: (id) => call("path_by_id", { id }), | |
| 396 | 397 | lastCommits: (path, viewer, ref, treePath) => call("last_commits", { path, viewer, ref, treePath }), | |
| 397 | 398 | branchDrift: (path, viewer, base, heads) => call("branch_drift", { path, viewer, base, heads }), | |
| 398 | 399 | tags: (path, viewer) => call("tags", { path, viewer }), | |
| ⋯ | |||
| 560 | 561 | }), | |
| 561 | 562 | getWorkSession: (repo, id, viewer, afterSeq = 0) => | |
| 562 | 563 | call("get_work_session", { repo, id, viewer, after_seq: afterSeq }), | |
| 564 | + | pullSessions: (repo, viewer, numbers) => call("pull_sessions", { repo, viewer, numbers }), | |
| 563 | 565 | listDecisions: (repo, number, viewer) => call("list_decisions", { repo, number, viewer }), | |
| 564 | 566 | }; | |
| 565 | 567 | } | |
| ⋯ | |||
| 876 | 878 | environments: (repo, viewer) => call("list_environments", { repo, viewer }), | |
| 877 | 879 | createDeployment: (actor, repo, input) => call("create_deployment", { repo, actor, ...input }), | |
| 878 | 880 | createDeploymentStatus: (actor, repo, id, input) => call("create_deployment_status", { repo, actor, id, ...input }), | |
| 881 | + | shipped: (workspace, viewer, repos) => call("shipped", { workspace, viewer, repos }), | |
| 879 | 882 | }; | |
| 880 | 883 | } | |
| 881 | 884 | ||
| 480 | 480 | auto_inactive?: boolean | null; | |
| 481 | 481 | }; | |
| 482 | 482 | ||
| 483 | + | /** | |
| 484 | + | * Whether a change that landed on a default branch is live in production: | |
| 485 | + | * shipped by a production deployment that succeeded (`live`), not yet | |
| 486 | + | * deployed (`waiting`), on its way (`deploying`), or the production | |
| 487 | + | * deployment after it failed and none has shipped it since (`failed`). | |
| 488 | + | */ | |
| 489 | + | export type ShipState = "live" | "waiting" | "deploying" | "failed"; | |
| 490 | + | ||
| 491 | + | /** One change asked about: its commit on the default branch, and when it landed there. */ | |
| 492 | + | export type ShippedAsk = { sha: string; landed_at: string }; | |
| 493 | + | ||
| 494 | + | /** A change's ship state, from what deployments recorded when production deployed. */ | |
| 495 | + | export type ShippedCommit = { | |
| 496 | + | sha: string; | |
| 497 | + | state: ShipState; | |
| 498 | + | /** RFC 3339: when the deployment that shipped it went live; null until then. */ | |
| 499 | + | deployed_at: string | null; | |
| 500 | + | /** Where that deployment is on the site (or the failed or running one, for `failed` and `deploying`). */ | |
| 501 | + | url: string | null; | |
| 502 | + | /** The environment it went to, such as `production`. */ | |
| 503 | + | environment: string | null; | |
| 504 | + | }; | |
| 505 | + | ||
| 506 | + | /** A repository's answer: whether it deploys to production at all, and each change asked about. */ | |
| 507 | + | export type ShippedRepo = { repo_id: string; deploys: boolean; commits: ShippedCommit[] }; | |
| 508 | + | ||
| 483 | 509 | export interface DeploymentsApi { | |
| 484 | 510 | /** Members of the workspace only. */ | |
| 485 | 511 | settings(project: ProjectRef, viewer: Viewer): Promise<Result<DeploySettings>>; | |
| ⋯ | |||
| 518 | 544 | createDeployment(actor: User, repo: RepoPath, input: NewDeployment): Promise<Result<DeploymentDetail>>; | |
| 519 | 545 | /** Adds a status to a reported deployment. Takes the Write role. */ | |
| 520 | 546 | createDeploymentStatus(actor: User, repo: RepoPath, id: string, input: NewDeploymentStatus): Promise<Result<DeploymentStatus>>; | |
| 547 | + | /** | |
| 548 | + | * Whether changes that landed on default branches went live, and when: | |
| 549 | + | * read from what was recorded as each production deployment succeeded | |
| 550 | + | * (src/shipped.ts). Members of the workspace only; at most 20 | |
| 551 | + | * repositories and 300 commits a call. | |
| 552 | + | */ | |
| 553 | + | shipped(workspace: string, viewer: Viewer, repos: { repo_id: string; commits: ShippedAsk[] }[]): Promise<Result<ShippedRepo[]>>; | |
| 521 | 554 | } | |
| 85 | 85 | doneAt: string | null; | |
| 86 | 86 | saved: boolean; | |
| 87 | 87 | snoozedUntil: string | null; | |
| 88 | + | /** | |
| 89 | + | * Why it settled by itself, in a few words (`CI passed in run 330`, | |
| 90 | + | * `Merged`): set when the event that left nothing to do marked it done. | |
| 91 | + | * Absent when it is not done, or was marked done by hand. | |
| 92 | + | */ | |
| 93 | + | settledReason?: string | null; | |
| 94 | + | /** Who settled it, when a person or g1t did: a username, or `g1t`. */ | |
| 95 | + | settledBy?: string | null; | |
| 88 | 96 | }; | |
| 89 | 97 | ||
| 90 | 98 | /** One thing that happened on a thread, as the person was told of it. */ |
| 58 | 58 | /** The thread it is in, for a reply: quick replies go there. */ | |
| 59 | 59 | thread_root?: string | null; | |
| 60 | 60 | /** | |
| 61 | + | * For an inbox item: its thread's key (events' `inbox_items.thread`). | |
| 62 | + | * Once the item settles by itself, events names the thread to notify | |
| 63 | + | * (`set_inbox` with `settled`): the notification is no longer replayed | |
| 64 | + | * to tabs that open later, and open tabs drop it. | |
| 65 | + | */ | |
| 66 | + | inbox_thread?: string | null; | |
| 67 | + | /** | |
| 61 | 68 | * The chat card it is about, when that card has something to press, | |
| 62 | 69 | * such as a draft issue (File issue, Discard). The toast and the notifications panel show these | |
| 63 | 70 | * actions, and pressing one goes to the site's `card_action`, exactly as | |
| ⋯ | |||
| 230 | 237 | | { type: "notification"; notification: FeedNotification; toast: boolean } | |
| 231 | 238 | | ({ type: "counts" } & FeedCounts) | |
| 232 | 239 | | { type: "preferences"; preferences: NotifyPreferences } | |
| 233 | − | /** The person's inbox count as it now is, after items arrive or are marked anywhere. */ | |
| 234 | − | | { type: "inbox"; unread: number } | |
| 235 | 240 | /** | |
| 241 | + | * The person's inbox count as it now is, after items arrive or are | |
| 242 | + | * marked anywhere; `settled`, the threads of items that settled by | |
| 243 | + | * themselves, whose notifications tabs drop. | |
| 244 | + | */ | |
| 245 | + | | { type: "inbox"; unread: number; settled?: string[] } | |
| 246 | + | /** | |
| 236 | 247 | * People in a workspace: everyone known on connect (`full`), then each | |
| 237 | 248 | * one as they change. | |
| 238 | 249 | */ | |
| 331 | 331 | /** The repository's branches, default branch first. */ | |
| 332 | 332 | branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>; | |
| 333 | 333 | ||
| 334 | + | /** Where a repository is now, by id; null when it is gone. For services: it checks nobody's access. */ | |
| 335 | + | pathById(id: string): Promise<RepoPath | null>; | |
| 336 | + | ||
| 334 | 337 | /** Which commit last changed each entry of a directory at `ref` (the default branch when null). */ | |
| 335 | 338 | lastCommits(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<Result<LastCommits>>; | |
| 336 | 339 |
| 1239 | 1239 | /** The harness a session runs in. */ | |
| 1240 | 1240 | export type Harness = "claude_code" | "claude_managed_agents" | "codex" | "cursor" | "copilot" | "other"; | |
| 1241 | 1241 | ||
| 1242 | + | /** A session as a mark on something it made. */ | |
| 1243 | + | export type SessionRef = { id: string; harness: Harness }; | |
| 1244 | + | ||
| 1245 | + | /** The sessions tied to one pull request (`pullSessions`). */ | |
| 1246 | + | export type PullSessions = { number: number; sessions: SessionRef[] }; | |
| 1247 | + | ||
| 1242 | 1248 | /** Active: heard from in 15 minutes; idle: not since; ended: ended, or quiet for 2 hours. */ | |
| 1243 | 1249 | export type SessionState = "active" | "idle" | "ended"; | |
| 1244 | 1250 | ||
| ⋯ | |||
| 1507 | 1513 | filter?: { state?: SessionState; issue?: number; pull?: number; path?: string; subagents?: boolean }, | |
| 1508 | 1514 | ): Promise<Result<WorkSession[]>>; | |
| 1509 | 1515 | getWorkSession(repo: RepoPath, id: string, viewer: Viewer, afterSeq?: number): Promise<Result<WorkSessionDetail>>; | |
| 1516 | + | /** | |
| 1517 | + | * The harness sessions tied to some pull requests (at most 100), any way | |
| 1518 | + | * a session is tied to one; a subagent's as the session that started it. | |
| 1519 | + | * Only pull requests with any are listed. | |
| 1520 | + | */ | |
| 1521 | + | pullSessions(repo: RepoPath, viewer: Viewer, numbers: number[]): Promise<Result<PullSessions[]>>; | |
| 1510 | 1522 | /** An issue's (with its pull requests') or a pull request's, oldest first. */ | |
| 1511 | 1523 | listDecisions(repo: RepoPath, number: number, viewer: Viewer): Promise<Result<Decision[]>>; | |
| 1512 | 1524 | } | |
| 1 | + | -- What went live, and when (src/shipped.ts): when a production deployment | |
| 2 | + | -- succeeds (`deployment_status.created` with `success`, for a g1t.page | |
| 3 | + | -- build or one reported by a workflow or the API), the commits it shipped | |
| 4 | + | -- are written down: those in | |
| 5 | + | -- its commit's history not shipped before. A commit's first production | |
| 6 | + | -- deployment is the one that took it live, so Home can say "Live 9 min | |
| 7 | + | -- after merge" without working anything out on page load. | |
| 8 | + | ||
| 9 | + | -- Each production deployment walked, once, however often its event comes. | |
| 10 | + | -- The first a repository has is when recording began: a change that landed | |
| 11 | + | -- before it is matched to the first production deployment after it instead. | |
| 12 | + | CREATE TABLE shipped_walks ( | |
| 13 | + | deployment_id TEXT PRIMARY KEY, | |
| 14 | + | repo_id TEXT NOT NULL, | |
| 15 | + | sha TEXT NOT NULL, | |
| 16 | + | environment TEXT NOT NULL, | |
| 17 | + | -- RFC 3339: when it went live. | |
| 18 | + | deployed_at TEXT NOT NULL, | |
| 19 | + | -- How many commits it shipped that no deployment had before. | |
| 20 | + | commits INTEGER NOT NULL DEFAULT 0 | |
| 21 | + | ); | |
| 22 | + | CREATE INDEX shipped_walks_by_repo ON shipped_walks (repo_id, deployed_at); | |
| 23 | + | ||
| 24 | + | -- Each commit's first production deployment. | |
| 25 | + | CREATE TABLE shipped_commits ( | |
| 26 | + | repo_id TEXT NOT NULL, | |
| 27 | + | sha TEXT NOT NULL, | |
| 28 | + | deployment_id TEXT NOT NULL, | |
| 29 | + | environment TEXT NOT NULL, | |
| 30 | + | deployed_at TEXT NOT NULL, | |
| 31 | + | -- Where the deployment is on the site: its page, or its workflow run. | |
| 32 | + | url TEXT, | |
| 33 | + | PRIMARY KEY (repo_id, sha) | |
| 34 | + | ); |
| 109 | 109 | import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries"; | |
| 110 | 110 | import { appHost, appUrl, label, uniqueLabel } from "./names"; | |
| 111 | 111 | import { RepoDeployments, sourceOf } from "./repo-deployments"; | |
| 112 | + | import { Shipped, shipmentOf } from "./shipped"; | |
| 112 | 113 | import { telemetryTags, withOtel, type AppTelemetry } from "./telemetry"; | |
| 113 | 114 | import { NEEDS_WRITE, commitOf, jobEndedMessage, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds"; | |
| 114 | 115 | ||
| ⋯ | |||
| 403 | 404 | return new RepoDeployments(this.env); | |
| 404 | 405 | } | |
| 405 | 406 | ||
| 407 | + | /** What each production deployment shipped, and whether a change is live (shipped.ts). */ | |
| 408 | + | get shipped(): Shipped { | |
| 409 | + | return new Shipped(this.env, (slug) => this.workspaceActor(slug), (repo, actor, ref, limit) => reposClient(this.env.REPOS).log(repo, actor, ref, limit)); | |
| 410 | + | } | |
| 411 | + | ||
| 406 | 412 | /** | |
| 407 | 413 | * Publishes a build's change in the repository-wide model | |
| 408 | 414 | * (`deployment.created`, `deployment_status.created`), as a reported | |
| ⋯ | |||
| 1893 | 1899 | case "repo.purged": | |
| 1894 | 1900 | await this.repoPurged(event.data.repoId); | |
| 1895 | 1901 | await this.repoDeployments.purge(event.data.repoId); | |
| 1902 | + | await this.shipped.purge(event.data.repoId); | |
| 1896 | 1903 | break; | |
| 1897 | 1904 | case "repo.default_branch_changed": | |
| 1898 | 1905 | await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t"); | |
| ⋯ | |||
| 1935 | 1942 | await this.deployProduction(project, event.data.after, event.actor ?? "g1t"); | |
| 1936 | 1943 | } | |
| 1937 | 1944 | break; | |
| 1945 | + | // A production deployment went live, wherever it ran: what it shipped is written down. | |
| 1946 | + | case "deployment_status.created": { | |
| 1947 | + | const repo = await reposClient(this.env.REPOS).pathById(event.data.repoId); | |
| 1948 | + | const shipment = repo ? shipmentOf(event.data, repo, this.env.SITE) : null; | |
| 1949 | + | if (shipment) await this.shipped.record(shipment); | |
| 1950 | + | break; | |
| 1951 | + | } | |
| 1938 | 1952 | } | |
| 1939 | 1953 | } | |
| 1940 | 1954 | ||
| ⋯ | |||
| 2922 | 2936 | // For the actions service: a run's deployment to one environment. | |
| 2923 | 2937 | case "actions_deployment": | |
| 2924 | 2938 | return service.repoDeployments.fromActions(args); | |
| 2939 | + | // For Home: whether changes that landed went live (shipped.ts). | |
| 2940 | + | case "shipped": | |
| 2941 | + | return service.shipped.answer(args); | |
| 2925 | 2942 | default: | |
| 2926 | 2943 | return undefined; | |
| 2927 | 2944 | } | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { type Attempt, shipStateOf, shipmentOf, sitePath, walkPage } from "./shipped.ts"; | |
| 5 | + | ||
| 6 | + | const commit = (hash: string, parent: string | null = null) => ({ hash, parents: parent ? [parent] : [] }); | |
| 7 | + | ||
| 8 | + | test("a deployment ships what no deployment shipped before, and stops at the last one's history", () => { | |
| 9 | + | const page = [commit("c3", "c2"), commit("c2", "c1"), commit("c1", "c0")]; | |
| 10 | + | // Nothing shipped yet, a page not full: all of it, and no more pages. | |
| 11 | + | assert.deepEqual(walkPage(page, new Set(), false), { ship: ["c3", "c2", "c1"], more: false, next: "c0" }); | |
| 12 | + | // A full page with nothing known: read on from the last commit's first parent. | |
| 13 | + | assert.deepEqual(walkPage(page, new Set(), true), { ship: ["c3", "c2", "c1"], more: true, next: "c0" }); | |
| 14 | + | // The last deployment shipped c1: only what came after it, and the walk stops. | |
| 15 | + | assert.deepEqual(walkPage(page, new Set(["c1"]), true), { ship: ["c3", "c2"], more: false, next: "c0" }); | |
| 16 | + | // A rollback to a commit shipped before ships nothing. | |
| 17 | + | assert.deepEqual(walkPage([commit("c1", "c0")], new Set(["c1", "c0"]), false).ship, []); | |
| 18 | + | // A merged branch's older commits after the last deployment's commit in the log still ship. | |
| 19 | + | assert.deepEqual(walkPage([commit("m", "c1"), commit("c1", "c0"), commit("f1", "c0")], new Set(["c1"]), false).ship, ["m", "f1"]); | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | const ask = { sha: "abc", landed_at: "2026-10-14T10:00:00.000Z" }; | |
| 23 | + | const at = (minutes: number) => new Date(Date.parse(ask.landed_at) + minutes * 60_000).toISOString(); | |
| 24 | + | const attempt = (minutes: number, outcome: Attempt["outcome"], url = `/acme/web/deployments/d${minutes}`): Attempt => ({ at: at(minutes), outcome, url, environment: "production" }); | |
| 25 | + | ||
| 26 | + | test("a change shipped is live from the deployment that first shipped it", () => { | |
| 27 | + | const shipped = { deployed_at: at(9), url: "/acme/web/actions/runs/run_1", environment: "production" }; | |
| 28 | + | assert.deepEqual(shipStateOf(ask, shipped, [attempt(3, "failed"), attempt(9, "live")], at(-60)), { | |
| 29 | + | sha: "abc", | |
| 30 | + | state: "live", | |
| 31 | + | deployed_at: at(9), | |
| 32 | + | url: "/acme/web/actions/runs/run_1", | |
| 33 | + | environment: "production", | |
| 34 | + | }); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test("a change not shipped is deploying, failed or waiting, by the production attempts after it", () => { | |
| 38 | + | const start = at(-60); | |
| 39 | + | assert.equal(shipStateOf(ask, null, [attempt(2, "running")], start).state, "deploying"); | |
| 40 | + | const failed = shipStateOf(ask, null, [attempt(2, "failed")], start); | |
| 41 | + | assert.equal(failed.state, "failed"); | |
| 42 | + | assert.equal(failed.url, "/acme/web/deployments/d2"); | |
| 43 | + | assert.equal(shipStateOf(ask, null, [], start).state, "waiting"); | |
| 44 | + | // A failure fixed by a later deployment that did not ship it (another branch, say): waiting again. | |
| 45 | + | assert.equal(shipStateOf(ask, null, [attempt(2, "failed"), attempt(5, "live")], start).state, "waiting"); | |
| 46 | + | // Attempts before it landed say nothing about it. | |
| 47 | + | assert.equal(shipStateOf(ask, null, [attempt(-5, "failed")], start).state, "waiting"); | |
| 48 | + | }); | |
| 49 | + | ||
| 50 | + | test("a change that landed before recording began goes live with the first success after it", () => { | |
| 51 | + | const start = at(120); | |
| 52 | + | const live = shipStateOf(ask, null, [attempt(-5, "live"), attempt(4, "failed"), attempt(30, "live")], start); | |
| 53 | + | assert.equal(live.state, "live"); | |
| 54 | + | assert.equal(live.deployed_at, at(30)); | |
| 55 | + | // Even when the first walk wrote it down later, as of that walk. | |
| 56 | + | assert.equal(shipStateOf(ask, { deployed_at: start, url: null, environment: "production" }, [attempt(30, "live")], start).deployed_at, at(30)); | |
| 57 | + | // Nothing recorded at all yet: the same. | |
| 58 | + | assert.equal(shipStateOf(ask, null, [attempt(30, "live")], null).deployed_at, at(30)); | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | test("only a production success is a shipment, linked to its page", () => { | |
| 62 | + | const repo = { namespace: "acme", name: "web" }; | |
| 63 | + | const deployment = { | |
| 64 | + | id: "dep_1", | |
| 65 | + | sha: "abc", | |
| 66 | + | environment: "production", | |
| 67 | + | production_environment: true, | |
| 68 | + | source: "actions" as const, | |
| 69 | + | project: null, | |
| 70 | + | run_url: "https://g1t.sh/acme/web/actions/runs/run_1", | |
| 71 | + | updated_at: "2026-10-14T10:09:00.000Z", | |
| 72 | + | }; | |
| 73 | + | const status = { state: "success" as const, created_at: "2026-10-14T10:09:00.000Z" }; | |
| 74 | + | assert.deepEqual(shipmentOf({ repoId: "rep_1", deployment, deploymentStatus: status }, repo, "https://g1t.sh"), { | |
| 75 | + | deploymentId: "dep_1", | |
| 76 | + | repoId: "rep_1", | |
| 77 | + | repo, | |
| 78 | + | sha: "abc", | |
| 79 | + | environment: "production", | |
| 80 | + | deployedAt: "2026-10-14T10:09:00.000Z", | |
| 81 | + | url: "/acme/web/actions/runs/run_1", | |
| 82 | + | }); | |
| 83 | + | assert.equal(shipmentOf({ repoId: "rep_1", deployment, deploymentStatus: { ...status, state: "in_progress" } }, repo, "https://g1t.sh"), null); | |
| 84 | + | assert.equal(shipmentOf({ repoId: "rep_1", deployment: { ...deployment, production_environment: false }, deploymentStatus: status }, repo, "https://g1t.sh"), null); | |
| 85 | + | const build = { ...deployment, id: "dpl_1", source: "g1t_page" as const, project: "site", run_url: null }; | |
| 86 | + | assert.equal(shipmentOf({ repoId: "rep_1", deployment: build, deploymentStatus: status }, repo, "https://g1t.sh")?.url, "/acme/site/deployments/dpl_1"); | |
| 87 | + | // Reported through the API with no run: the repository's deployments. | |
| 88 | + | assert.equal(shipmentOf({ repoId: "rep_1", deployment: { ...deployment, source: "api" as const, run_url: null }, deploymentStatus: status }, repo, "https://g1t.sh")?.url, "/acme/web/deployments"); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test("addresses stay on the site", () => { | |
| 92 | + | assert.equal(sitePath("https://g1t.sh/acme/web/actions/runs/1", "https://g1t.sh"), "/acme/web/actions/runs/1"); | |
| 93 | + | assert.equal(sitePath("/acme/web", "https://g1t.sh"), "/acme/web"); | |
| 94 | + | assert.equal(sitePath("https://elsewhere.example/x", "https://g1t.sh"), null); | |
| 95 | + | assert.equal(sitePath(null, "https://g1t.sh"), null); | |
| 96 | + | }); |
| 1 | + | /** | |
| 2 | + | * What went live, and when: Home's "Live 9 min after merge". | |
| 3 | + | * | |
| 4 | + | * When a production deployment succeeds (`deployment_status.created` | |
| 5 | + | * with `success` in a production environment: a g1t.page build, or one a | |
| 6 | + | * workflow or the API reported, since builds are published into the same | |
| 7 | + | * model), its commit's history is read back from repos and every | |
| 8 | + | * commit in it that no production deployment shipped before is written | |
| 9 | + | * down as shipped by this one (`shipped_commits`, migration 0017). A | |
| 10 | + | * commit's first production deployment is the one that took it live: a | |
| 11 | + | * rollback to an older commit ships nothing new, and a deployment's event | |
| 12 | + | * delivered twice is walked once (`shipped_walks`). | |
| 13 | + | * | |
| 14 | + | * Asking (`shipped`) reads only what was written, so a page never walks | |
| 15 | + | * history. A change not shipped is `deploying` while a production | |
| 16 | + | * deployment after it is on its way, `failed` when the latest after it | |
| 17 | + | * failed, and `waiting` otherwise. A change that landed before recording | |
| 18 | + | * began for its repository is matched to the first production deployment | |
| 19 | + | * that succeeded after it instead. | |
| 20 | + | */ | |
| 21 | + | import type { | |
| 22 | + | Commit, | |
| 23 | + | DeploymentStatus, | |
| 24 | + | RepoDeployment, | |
| 25 | + | RepoPath, | |
| 26 | + | Result, | |
| 27 | + | ShipState, | |
| 28 | + | ShippedAsk, | |
| 29 | + | ShippedCommit, | |
| 30 | + | ShippedRepo, | |
| 31 | + | User, | |
| 32 | + | Viewer, | |
| 33 | + | } from "@g1t/contracts"; | |
| 34 | + | ||
| 35 | + | /** Reads a commit's history back, newest first (repos' `log`). */ | |
| 36 | + | export type ReadLog = (repo: RepoPath, actor: User, ref: string, limit: number) => Promise<Result<Pick<Commit, "hash" | "parents">[]>>; | |
| 37 | + | ||
| 38 | + | /** Commits read back per page of history, and the most pages one deployment walks. */ | |
| 39 | + | export const WALK_PAGE = 200; | |
| 40 | + | export const WALK_PAGES = 5; | |
| 41 | + | /** The most repositories, and commits in all, one `shipped` call asks about. */ | |
| 42 | + | export const MAX_REPOS = 20; | |
| 43 | + | export const MAX_COMMITS = 300; | |
| 44 | + | ||
| 45 | + | /** A production deployment that went live, as its event says. */ | |
| 46 | + | export type Shipment = { | |
| 47 | + | deploymentId: string; | |
| 48 | + | repoId: string; | |
| 49 | + | repo: RepoPath; | |
| 50 | + | sha: string; | |
| 51 | + | environment: string; | |
| 52 | + | deployedAt: string; | |
| 53 | + | /** Its page on the site. */ | |
| 54 | + | url: string | null; | |
| 55 | + | }; | |
| 56 | + | ||
| 57 | + | /** | |
| 58 | + | * The commits of one page of history this deployment ships: those not | |
| 59 | + | * known to be shipped already, each once. Whether to read the next page: | |
| 60 | + | * only when this page was full and held nothing shipped before, so the | |
| 61 | + | * walk stops at the last deployment's history. | |
| 62 | + | */ | |
| 63 | + | export function walkPage(page: Pick<Commit, "hash" | "parents">[], known: ReadonlySet<string>, full: boolean): { ship: string[]; more: boolean; next: string | null } { | |
| 64 | + | const ship = [...new Set(page.map((commit) => commit.hash).filter((hash) => !known.has(hash)))]; | |
| 65 | + | const reachedKnown = page.some((commit) => known.has(commit.hash)); | |
| 66 | + | const last = page.at(-1); | |
| 67 | + | const next = last?.parents[0] ?? null; | |
| 68 | + | return { ship, more: full && !reachedKnown && next != null, next }; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /** A production attempt after a change landed, as either table keeps it. */ | |
| 72 | + | export type Attempt = { at: string; outcome: "live" | "running" | "failed"; url: string | null; environment: string }; | |
| 73 | + | ||
| 74 | + | /** | |
| 75 | + | * A change's state from what was recorded: shipped (its first production | |
| 76 | + | * deployment), else from the production attempts after it landed (oldest | |
| 77 | + | * first). `recordingStart` is when the repository's first walk went live: | |
| 78 | + | * a change that landed before it was never walked, so the first attempt | |
| 79 | + | * after it that went live shipped it. | |
| 80 | + | */ | |
| 81 | + | export function shipStateOf( | |
| 82 | + | ask: ShippedAsk, | |
| 83 | + | shipped: { deployed_at: string; url: string | null; environment: string } | null, | |
| 84 | + | attempts: Attempt[], | |
| 85 | + | recordingStart: string | null, | |
| 86 | + | ): ShippedCommit { | |
| 87 | + | const base = { sha: ask.sha }; | |
| 88 | + | const after = attempts.filter((attempt) => attempt.at >= ask.landed_at); | |
| 89 | + | // Landed before anything was walked: the first walk wrote its history | |
| 90 | + | // down as of then, so the first deployment after it that went live is nearer. | |
| 91 | + | if (recordingStart == null || ask.landed_at < recordingStart) { | |
| 92 | + | const live = after.find((attempt) => attempt.outcome === "live"); | |
| 93 | + | if (live) return { ...base, state: "live", deployed_at: live.at, url: live.url, environment: live.environment }; | |
| 94 | + | } | |
| 95 | + | if (shipped) return { ...base, state: "live", deployed_at: shipped.deployed_at, url: shipped.url, environment: shipped.environment }; | |
| 96 | + | const running = after.find((attempt) => attempt.outcome === "running"); | |
| 97 | + | if (running) return { ...base, state: "deploying", deployed_at: null, url: running.url, environment: running.environment }; | |
| 98 | + | const latest = after.at(-1); | |
| 99 | + | const state: ShipState = latest?.outcome === "failed" ? "failed" : "waiting"; | |
| 100 | + | return { ...base, state, deployed_at: null, url: state === "failed" ? (latest?.url ?? null) : null, environment: latest?.environment ?? null }; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /** A g1t.page build's outcome. */ | |
| 104 | + | function buildOutcome(status: string): Attempt["outcome"] | null { | |
| 105 | + | if (status === "ready" || status === "replaced" || status === "down") return "live"; | |
| 106 | + | if (status === "queued" || status === "building") return "running"; | |
| 107 | + | if (status === "failed") return "failed"; | |
| 108 | + | return null; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** A reported deployment's outcome: `inactive` is a success a newer one replaced. */ | |
| 112 | + | function reportedOutcome(state: string): Attempt["outcome"] | null { | |
| 113 | + | if (state === "success" || state === "inactive") return "live"; | |
| 114 | + | if (state === "queued" || state === "in_progress" || state === "pending") return "running"; | |
| 115 | + | if (state === "failure" || state === "error") return "failed"; | |
| 116 | + | return null; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /** A site path from an address that may be the site's own, absolute. */ | |
| 120 | + | export function sitePath(url: string | null | undefined, site: string): string | null { | |
| 121 | + | if (!url) return null; | |
| 122 | + | if (url.startsWith("/")) return url; | |
| 123 | + | if (site && url.startsWith(site)) return url.slice(site.length) || "/"; | |
| 124 | + | return null; | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /** | |
| 128 | + | * The shipment a `deployment_status.created` event is, if it is one: a | |
| 129 | + | * success in a production environment. A g1t.page build links to its | |
| 130 | + | * page, a reported deployment to its workflow run, else to the | |
| 131 | + | * repository's deployments. | |
| 132 | + | */ | |
| 133 | + | export function shipmentOf( | |
| 134 | + | data: { repoId: string; deployment: Pick<RepoDeployment, "id" | "sha" | "environment" | "production_environment" | "source" | "project" | "run_url" | "updated_at">; deploymentStatus: Pick<DeploymentStatus, "state" | "created_at"> }, | |
| 135 | + | repo: RepoPath, | |
| 136 | + | site: string, | |
| 137 | + | ): Shipment | null { | |
| 138 | + | const { deployment, deploymentStatus } = data; | |
| 139 | + | if (deploymentStatus?.state !== "success" || !deployment?.production_environment || !deployment.sha) return null; | |
| 140 | + | const own = `/${repo.namespace}/${repo.name}`; | |
| 141 | + | const url = | |
| 142 | + | deployment.source === "g1t_page" && deployment.project | |
| 143 | + | ? `/${repo.namespace}/${deployment.project}/deployments/${deployment.id}` | |
| 144 | + | : (sitePath(deployment.run_url, site) ?? `${own}/deployments`); | |
| 145 | + | return { | |
| 146 | + | deploymentId: deployment.id, | |
| 147 | + | repoId: data.repoId, | |
| 148 | + | repo, | |
| 149 | + | sha: deployment.sha, | |
| 150 | + | environment: deployment.environment, | |
| 151 | + | deployedAt: deploymentStatus.created_at || deployment.updated_at, | |
| 152 | + | url, | |
| 153 | + | }; | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | type Env = { DB: D1Database; SITE: string }; | |
| 157 | + | ||
| 158 | + | /** | |
| 159 | + | * Only types are imported here, so the rules above are tested on their | |
| 160 | + | * own; the service hands in how to read history and as whom. | |
| 161 | + | */ | |
| 162 | + | export class Shipped { | |
| 163 | + | constructor( | |
| 164 | + | private readonly env: Env, | |
| 165 | + | /** Reads a workspace's repositories as the workspace itself. */ | |
| 166 | + | private readonly actorFor: (workspace: string) => Promise<User | null>, | |
| 167 | + | private readonly readLog: ReadLog, | |
| 168 | + | ) {} | |
| 169 | + | ||
| 170 | + | private get db(): D1Database { | |
| 171 | + | return this.env.DB; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /** Writes down what a production deployment shipped. Once per deployment. */ | |
| 175 | + | async record(shipment: Shipment): Promise<number> { | |
| 176 | + | const walked = await this.db | |
| 177 | + | .prepare( | |
| 178 | + | `INSERT INTO shipped_walks (deployment_id, repo_id, sha, environment, deployed_at) VALUES (?, ?, ?, ?, ?) | |
| 179 | + | ON CONFLICT DO NOTHING RETURNING deployment_id`, | |
| 180 | + | ) | |
| 181 | + | .bind(shipment.deploymentId, shipment.repoId, shipment.sha, shipment.environment, shipment.deployedAt) | |
| 182 | + | .first<{ deployment_id: string }>(); | |
| 183 | + | if (!walked) return 0; | |
| 184 | + | const actor = await this.actorFor(shipment.repo.namespace); | |
| 185 | + | if (!actor) return 0; | |
| 186 | + | let ref: string | null = shipment.sha; | |
| 187 | + | let shipped = 0; | |
| 188 | + | try { | |
| 189 | + | for (let page = 0; page < WALK_PAGES && ref; page++) { | |
| 190 | + | const history = await this.readLog(shipment.repo, actor, ref, WALK_PAGE); | |
| 191 | + | if (!history.ok || history.value.length === 0) break; | |
| 192 | + | const hashes = history.value.map((commit) => commit.hash); | |
| 193 | + | const known = await this.db | |
| 194 | + | .prepare("SELECT sha FROM shipped_commits WHERE repo_id = ? AND sha IN (SELECT value FROM json_each(?))") | |
| 195 | + | .bind(shipment.repoId, JSON.stringify(hashes)) | |
| 196 | + | .all<{ sha: string }>(); | |
| 197 | + | const { ship, more, next } = walkPage(history.value, new Set(known.results.map((row) => row.sha)), history.value.length >= WALK_PAGE); | |
| 198 | + | if (ship.length > 0) { | |
| 199 | + | await this.db.batch( | |
| 200 | + | ship.map((sha) => | |
| 201 | + | this.db | |
| 202 | + | .prepare( | |
| 203 | + | `INSERT INTO shipped_commits (repo_id, sha, deployment_id, environment, deployed_at, url) VALUES (?, ?, ?, ?, ?, ?) | |
| 204 | + | ON CONFLICT DO NOTHING`, | |
| 205 | + | ) | |
| 206 | + | .bind(shipment.repoId, sha, shipment.deploymentId, shipment.environment, shipment.deployedAt, shipment.url), | |
| 207 | + | ), | |
| 208 | + | ); | |
| 209 | + | shipped += ship.length; | |
| 210 | + | } | |
| 211 | + | ref = more ? next : null; | |
| 212 | + | } | |
| 213 | + | } finally { | |
| 214 | + | await this.db.prepare("UPDATE shipped_walks SET commits = ? WHERE deployment_id = ?").bind(shipped, shipment.deploymentId).run(); | |
| 215 | + | } | |
| 216 | + | return shipped; | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /** Whether each change asked about is live (see the module's notes). Members of the workspace only. */ | |
| 220 | + | async answer(a: { workspace: string; viewer: Viewer; repos: { repo_id: string; commits: ShippedAsk[] }[] }): Promise<Result<ShippedRepo[]>> { | |
| 221 | + | const workspace = String(a.workspace ?? "").toLowerCase(); | |
| 222 | + | if (!a.viewer?.workspaces?.some((membership) => membership.slug === workspace)) { | |
| 223 | + | return { ok: false, error: { code: "forbidden", message: "Only members can see a workspace's deployments." } }; | |
| 224 | + | } | |
| 225 | + | const asked = (Array.isArray(a.repos) ? a.repos : []).slice(0, MAX_REPOS); | |
| 226 | + | let budget = MAX_COMMITS; | |
| 227 | + | const out: ShippedRepo[] = []; | |
| 228 | + | for (const repo of asked) { | |
| 229 | + | const commits = (Array.isArray(repo.commits) ? repo.commits : []) | |
| 230 | + | .filter((ask) => typeof ask?.sha === "string" && typeof ask?.landed_at === "string") | |
| 231 | + | .slice(0, Math.max(0, budget)); | |
| 232 | + | budget -= commits.length; | |
| 233 | + | out.push(await this.forRepo(String(repo.repo_id), workspace, commits)); | |
| 234 | + | } | |
| 235 | + | return { ok: true, value: out }; | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | private async forRepo(repoId: string, workspace: string, commits: ShippedAsk[]): Promise<ShippedRepo> { | |
| 239 | + | // Only this workspace's repositories: the g1t.page builds and the | |
| 240 | + | // reported deployments say whose they are. | |
| 241 | + | const oldest = commits.reduce<string | null>((min, ask) => (min == null || ask.landed_at < min ? ask.landed_at : min), null); | |
| 242 | + | const [shipped, start, builds, reported] = await Promise.all([ | |
| 243 | + | commits.length | |
| 244 | + | ? this.db | |
| 245 | + | .prepare( | |
| 246 | + | "SELECT sha, deployed_at, url, environment FROM shipped_commits WHERE repo_id = ? AND sha IN (SELECT value FROM json_each(?))", | |
| 247 | + | ) | |
| 248 | + | .bind(repoId, JSON.stringify(commits.map((ask) => ask.sha))) | |
| 249 | + | .all<{ sha: string; deployed_at: string; url: string | null; environment: string }>() | |
| 250 | + | : null, | |
| 251 | + | this.db.prepare("SELECT MIN(deployed_at) AS at FROM shipped_walks WHERE repo_id = ?").bind(repoId).first<{ at: string | null }>(), | |
| 252 | + | this.db | |
| 253 | + | .prepare( | |
| 254 | + | `SELECT id, slug, status, created_at, finished_at FROM deployments | |
| 255 | + | WHERE repo_id = ? AND workspace = ? AND kind = 'production' AND created_at >= ? ORDER BY created_at LIMIT 200`, | |
| 256 | + | ) | |
| 257 | + | .bind(repoId, workspace, oldest ?? "9999") | |
| 258 | + | .all<{ id: string; slug: string; status: string; created_at: string; finished_at: string | null }>(), | |
| 259 | + | this.db | |
| 260 | + | .prepare( | |
| 261 | + | `SELECT id, environment, state, run_url, created_at, updated_at FROM reported_deployments | |
| 262 | + | WHERE repo_id = ? AND production_environment = 1 AND created_at >= ? ORDER BY created_at LIMIT 200`, | |
| 263 | + | ) | |
| 264 | + | .bind(repoId, oldest ?? "9999") | |
| 265 | + | .all<{ id: string; environment: string; state: string; run_url: string | null; created_at: string; updated_at: string }>(), | |
| 266 | + | ]); | |
| 267 | + | const deploys = | |
| 268 | + | (builds.results.length > 0 || reported.results.length > 0 || (start?.at ?? null) != null) || | |
| 269 | + | (await this.deploysAtAll(repoId, workspace)); | |
| 270 | + | const attempts: Attempt[] = [ | |
| 271 | + | ...builds.results.flatMap((row) => { | |
| 272 | + | const outcome = buildOutcome(row.status); | |
| 273 | + | return outcome | |
| 274 | + | ? [{ at: outcome === "running" ? row.created_at : (row.finished_at ?? row.created_at), outcome, url: `/${workspace}/${row.slug}/deployments/${row.id}`, environment: "production" }] | |
| 275 | + | : []; | |
| 276 | + | }), | |
| 277 | + | ...reported.results.flatMap((row) => { | |
| 278 | + | const outcome = reportedOutcome(row.state); | |
| 279 | + | // A success replaced since (`inactive`) was last changed when it was replaced: it went live nearer its start. | |
| 280 | + | const at = outcome === "running" || row.state === "inactive" ? row.created_at : row.updated_at; | |
| 281 | + | return outcome ? [{ at, outcome, url: sitePath(row.run_url, this.env.SITE), environment: row.environment }] : []; | |
| 282 | + | }), | |
| 283 | + | ].sort((x, y) => x.at.localeCompare(y.at)); | |
| 284 | + | const byHash = new Map((shipped?.results ?? []).map((row) => [row.sha, row])); | |
| 285 | + | return { | |
| 286 | + | repo_id: repoId, | |
| 287 | + | deploys, | |
| 288 | + | commits: deploys ? commits.map((ask) => shipStateOf(ask, byHash.get(ask.sha) ?? null, attempts, start?.at ?? null)) : [], | |
| 289 | + | }; | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /** Whether the repository ever deployed to production. */ | |
| 293 | + | private async deploysAtAll(repoId: string, workspace: string): Promise<boolean> { | |
| 294 | + | const [build, reported] = await Promise.all([ | |
| 295 | + | this.db.prepare("SELECT 1 AS x FROM deployments WHERE repo_id = ? AND workspace = ? AND kind = 'production' LIMIT 1").bind(repoId, workspace).first(), | |
| 296 | + | this.db.prepare("SELECT 1 AS x FROM reported_deployments WHERE repo_id = ? AND production_environment = 1 LIMIT 1").bind(repoId).first(), | |
| 297 | + | ]); | |
| 298 | + | return build != null || reported != null; | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | /** A repository purged: what it shipped goes with it. */ | |
| 302 | + | async purge(repoId: string): Promise<void> { | |
| 303 | + | await this.db.batch([ | |
| 304 | + | this.db.prepare("DELETE FROM shipped_commits WHERE repo_id = ?").bind(repoId), | |
| 305 | + | this.db.prepare("DELETE FROM shipped_walks WHERE repo_id = ?").bind(repoId), | |
| 306 | + | ]); | |
| 307 | + | } | |
| 308 | + | } |
| 1 | + | -- Why an item settled by itself (src/inbox.rs, "Settling"), for the Done | |
| 2 | + | -- view and Home's "While you were away": a few words on what ended it, | |
| 3 | + | -- such as "CI passed in run 330" or "Merged", and who did, when a person | |
| 4 | + | -- or g1t did (a username, or g1t). Both are cleared when new activity | |
| 5 | + | -- brings the thread back, and stay empty for an item marked done by hand. | |
| 6 | + | ALTER TABLE inbox_items ADD COLUMN settled_reason TEXT; | |
| 7 | + | ALTER TABLE inbox_items ADD COLUMN settled_by TEXT; |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.