Merge remote-tracking branch 'g1t/main' into HEAD
60 files+1092−10928 files not counted0/60 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 | |
| 477 | 477 | ||
| 478 | 478 | Each Worker keeps a share of its invocations' logs, set by | |
| 479 | 479 | `head_sampling_rate` in its `wrangler.jsonc`: every one for billing, | |
| 480 | − | identity, runner, actions, deployments, status and sudo, and a tenth for | |
| 481 | − | the rest. Repos and work keep every one for now, while rare ~60 s stalls | |
| 482 | − | are traced, and go back to a tenth once they are found. Metrics are not sampled. To chase a rare error on a sampled | |
| 483 | − | Worker, raise its rate to 1 while you look. | |
| 480 | + | identity, runner, actions, deployments, status and sudo, a quarter for | |
| 481 | + | repos and work, which every page asks, and a tenth for the rest. Metrics | |
| 482 | + | are not sampled. To chase a rare error on a sampled Worker, raise its rate | |
| 483 | + | to 1 while you look. | |
| 484 | + | ||
| 485 | + | Slow work in repos logs itself as fields, so the invocations kept can be | |
| 486 | + | counted by what was slow: an RPC answer that took a second or more logs | |
| 487 | + | `rpc` (the method) and `answered_ms`, and a slow or partial About run logs | |
| 488 | + | `about_run` (the repository's id), `took_ms` and `partial`. In Workers | |
| 489 | + | Logs, filter `g1t-repos` on `answered_ms` greater than 0, and multiply | |
| 490 | + | what you count by four. |
| 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 | ||
| 33 | 33 | ||
| 34 | 34 | The license, security policy, languages and contributors are read from the | |
| 35 | 35 | default branch in the background each time it moves, and kept by commit, so | |
| 36 | − | the page never waits for them. A repository pushed to for the first time | |
| 37 | − | shows **Reading the default branch…** for a few seconds. | |
| 36 | + | the page never waits for them. A push to the default branch starts the | |
| 37 | + | reading as soon as git has its answer; a branch that moved otherwise, such | |
| 38 | + | as by merging a pull request, is read again when its page is next opened. | |
| 39 | + | A repository pushed to for the first time shows **Reading the default | |
| 40 | + | branch…** for a few seconds. | |
| 38 | 41 | ||
| 39 | 42 | ### Languages | |
| 40 | 43 |
| 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 |
| 94 | 94 | request when it caught up with `main` is credited to whoever wrote it on | |
| 95 | 95 | `main`, not to the merge. | |
| 96 | 96 | ||
| 97 | + | Blame reads up to 400 commits back, for up to six seconds. A line older | |
| 98 | + | than that is credited to the oldest commit read that has it, and the view | |
| 99 | + | says the history is long. While it is worked out the file shows as it is, | |
| 100 | + | with "Working out who changed each line" above it, and each line's commit | |
| 101 | + | fills in when it is ready. A file's blame at a commit is kept once worked | |
| 102 | + | out, so opening it again, or from a commit's link, shows at once. Search | |
| 103 | + | engines and other crawlers are asked to leave blame pages alone | |
| 104 | + | (`Disallow: /*?blame=`, and `/*/*/blame/` for the blame data the view | |
| 105 | + | fetches, in `/robots.txt`); the file itself stays open to them. | |
| 106 | + | ||
| 97 | 107 | ### Why over the API | |
| 98 | 108 | ||
| 99 | 109 | The `repository` tool's `why` action, or |
Binary or large file; its contents are not shown.
| 639 | 639 | </Link> | |
| 640 | 640 | <Link | |
| 641 | 641 | to={`${fileBase}/${file.path}?blame=1`} | |
| 642 | + | rel="nofollow" | |
| 642 | 643 | className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg" | |
| 643 | 644 | > | |
| 644 | 645 | Blame |
| 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)} | |
| 1 | − | import { BookOpen, Check, ChevronDown, Code2, CornerLeftUp, File, FileArchive, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal, Tag as TagIcon } from "lucide-react"; | |
| 1 | + | import { BookOpen, Check, ChevronDown, Code2, CornerLeftUp, File, FileArchive, Folder, FolderGit2, GitBranch, History, LoaderCircle, Search, SquareTerminal, Tag as TagIcon } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, Suspense } from "react"; | |
| 3 | 3 | import { Await, Form, Link } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 449 | 449 | blob: Blob; | |
| 450 | 450 | /** Syntax-highlighted HTML per line, when the language is known. */ | |
| 451 | 451 | html: string[] | null; | |
| 452 | − | /** Shown instead of the plain file when asked for, with HTML per line. */ | |
| 453 | − | blame?: { blame: ShownBlame; lines: string[] | null } | null; | |
| 452 | + | /** Shown instead of the plain file when asked for, with HTML per line; | |
| 453 | + | * "waiting" while it streams in, "failed" when it could not be had (the | |
| 454 | + | * plain file shows meanwhile). */ | |
| 455 | + | blame?: { blame: ShownBlame; lines: string[] | null } | "waiting" | "failed" | null; | |
| 454 | 456 | }) { | |
| 455 | 457 | const { repo, ref, path, size, text } = blob; | |
| 456 | 458 | const lines = text?.replace(/\n$/, "").split("\n"); | |
| ⋯ | |||
| 460 | 462 | const toggle = (label: string, on: boolean, search: string) => ( | |
| 461 | 463 | <Link | |
| 462 | 464 | to={{ search }} | |
| 465 | + | // Blame is costly to work out and adds nothing to a search index. | |
| 466 | + | rel={search ? "nofollow" : undefined} | |
| 463 | 467 | preventScrollReset | |
| 464 | 468 | className={`rounded px-2.5 py-1.5 transition-colors sm:px-2 sm:py-0.5 ${on ? "bg-raised text-fg" : "hover:text-fg"}`} | |
| 465 | 469 | > | |
| ⋯ | |||
| 482 | 486 | <Card tone="plain" className="overflow-hidden"> | |
| 483 | 487 | <div className="flex items-center gap-3 border-b border-line bg-surface px-3 py-2 text-xs text-muted sm:px-4 sm:py-2.5"> | |
| 484 | 488 | {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>} | |
| 489 | + | {blame === "waiting" && ( | |
| 490 | + | <span className="flex items-center gap-1.5" role="status"> | |
| 491 | + | <LoaderCircle size={12} className="animate-spin" aria-hidden /> | |
| 492 | + | Working out who changed each line | |
| 493 | + | </span> | |
| 494 | + | )} | |
| 495 | + | {blame === "failed" && <span role="status">Blame could not be worked out; try again in a moment</span>} | |
| 485 | 496 | <span className="hidden min-[400px]:inline">{size.toLocaleString("en-US")} bytes</span> | |
| 486 | 497 | <span className="ml-auto flex items-center gap-2"> | |
| 487 | 498 | {lines && ( | |
| ⋯ | |||
| 495 | 506 | </a> | |
| 496 | 507 | </span> | |
| 497 | 508 | </div> | |
| 498 | − | {blame && lines ? ( | |
| 509 | + | {typeof blame === "object" && blame && lines ? ( | |
| 499 | 510 | <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} /> | |
| 500 | 511 | ) : lines ? ( | |
| 501 | 512 | <CodeLines lines={lines} html={html} marked={marked} /> | |
| 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 }); |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { robotsTxt } from "./robots.ts"; | |
| 5 | + | ||
| 6 | + | test("everything may be crawled but a file's blame", () => { | |
| 7 | + | const text = robotsTxt("https://g1t.example"); | |
| 8 | + | assert.match(text, /^User-agent: \*\nAllow: \/\n/); | |
| 9 | + | assert.match(text, /^Disallow: \/\*\?blame=$/m); | |
| 10 | + | assert.match(text, /^Disallow: \/\*&blame=$/m); | |
| 11 | + | assert.match(text, /^Disallow: \/\*\/\*\/blame\/$/m); | |
| 12 | + | assert.match(text, /^Sitemap: https:\/\/g1t\.example\/sitemap\.xml$/m); | |
| 13 | + | assert.doesNotMatch(text, /docs\.g1t\.sh/, "a self-hosted g1t has no docs site"); | |
| 14 | + | assert.ok(text.endsWith("\n")); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("g1t.sh names its docs' map too", () => { | |
| 18 | + | assert.match(robotsTxt("https://g1t.sh"), /^Sitemap: https:\/\/docs\.g1t\.sh\/sitemap-index\.xml$/m); | |
| 19 | + | }); |
| 1 | + | /** | |
| 2 | + | * What /robots.txt says: everything may be crawled but a file's blame | |
| 3 | + | * (`?blame=1`, and the `/<owner>/<repo>/blame/…` data it fetches), and | |
| 4 | + | * where the site's maps are. | |
| 5 | + | * | |
| 6 | + | * Blame is the costliest file view to work out, one per file per commit a | |
| 7 | + | * crawler finds, and adds nothing an index needs that the file itself does | |
| 8 | + | * not have: on 2026-10-14 crawlers asked for 1,150 blame pages in four | |
| 9 | + | * hours, against a handful from people. The links to it are `nofollow` too, | |
| 10 | + | * and its page is `noindex` (routes/repo/blob.tsx). | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | /** g1t.sh's docs keep their own map; a self-hosted g1t has no docs site. */ | |
| 14 | + | const DOCS_SITEMAP = "https://docs.g1t.sh/sitemap-index.xml"; | |
| 15 | + | ||
| 16 | + | /** Paths crawlers are kept from, wherever the query puts `blame`. */ | |
| 17 | + | export const DISALLOWED = ["/*?blame=", "/*&blame=", "/*/*/blame/"]; | |
| 18 | + | ||
| 19 | + | export function robotsTxt(site: string): string { | |
| 20 | + | const lines = ["User-agent: *", "Allow: /", ...DISALLOWED.map((path) => `Disallow: ${path}`), "", `Sitemap: ${site}/sitemap.xml`]; | |
| 21 | + | if (site === "https://g1t.sh") lines.push(`Sitemap: ${DOCS_SITEMAP}`); | |
| 22 | + | return `${lines.join("\n")}\n`; | |
| 23 | + | } |
| 1 | + | import { useEffect, useState } from "react"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * `initial` until `promise` settles, then what it resolved to: for a value | |
| 5 | + | * a loader streams in after the page, shown in place when it arrives. A | |
| 6 | + | * promise that rejects leaves `initial`. | |
| 7 | + | */ | |
| 8 | + | export function useArrived<T, I = T>(promise: Promise<T>, initial: I): T | I { | |
| 9 | + | const [arrived, setArrived] = useState<{ from: Promise<T>; value: T } | null>(null); | |
| 10 | + | useEffect(() => { | |
| 11 | + | let current = true; | |
| 12 | + | promise.then( | |
| 13 | + | (value) => current && setArrived({ from: promise, value }), | |
| 14 | + | () => {}, | |
| 15 | + | ); | |
| 16 | + | return () => { | |
| 17 | + | current = false; | |
| 18 | + | }; | |
| 19 | + | }, [promise]); | |
| 20 | + | return arrived?.from === promise ? arrived.value : initial; | |
| 21 | + | } |
| 248 | 248 | route(":owner/:repo/production-elsewhere.jpg", "routes/repo/production-elsewhere.ts"), | |
| 249 | 249 | // A file as it is, sent on to the usercontent origin (lib/usercontent.ts). | |
| 250 | 250 | route(":owner/:repo/raw/:ref/*", "routes/repo/raw.ts"), | |
| 251 | + | // A file's blame as JSON, fetched by its blame view (routes/repo/blob.tsx). | |
| 252 | + | route(":owner/:repo/blame/:ref/*", "routes/repo/blame-data.ts"), | |
| 251 | 253 | // A project: its overview first, its repository's code under Code. The | |
| 252 | 254 | // 1:1 project of a repository has the repository's name, so every | |
| 253 | 255 | // repository address below keeps working. |
| 1 | + | /** | |
| 2 | + | * A file's blame at a ref, as JSON, for the blame view to fetch once the | |
| 3 | + | * file is on screen: `/<owner>/<repo>/blame/<ref>/<path>`. A first blame | |
| 4 | + | * walks the file's history in repos and can take longer than the page's | |
| 5 | + | * stream may stay open (entry.server.tsx `streamTimeout`), so it is asked | |
| 6 | + | * for on its own request instead; once worked out, repos keeps it and it | |
| 7 | + | * comes back at once. Kept out of indexes like the blame view | |
| 8 | + | * (lib/robots.ts). | |
| 9 | + | */ | |
| 10 | + | import type { Route } from "./+types/blame-data"; | |
| 11 | + | import { showCommits } from "../../lib/commit-people.server"; | |
| 12 | + | import { repos } from "../../lib/services.server"; | |
| 13 | + | import { getViewer } from "../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | const HEADERS = { | |
| 16 | + | "cache-control": "private, no-store", | |
| 17 | + | "x-robots-tag": "noindex, nofollow", | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 21 | + | const viewer = getViewer(context); | |
| 22 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 23 | + | const file = params["*"] ?? ""; | |
| 24 | + | const found = await repos.blame(path, viewer, params.ref, file).catch(() => null); | |
| 25 | + | if (!found?.ok) { | |
| 26 | + | const status = found?.error.code === "not_found" ? 404 : 502; | |
| 27 | + | return Response.json({ blame: null }, { status, headers: HEADERS }); | |
| 28 | + | } | |
| 29 | + | // Every commit's people at once. | |
| 30 | + | const blame = { ...found.value, commits: await showCommits(found.value.commits) }; | |
| 31 | + | return Response.json({ blame }, { headers: HEADERS }); | |
| 32 | + | } |
| 1 | 1 | import { isCodeownersPath } from "@g1t/contracts"; | |
| 2 | + | import { useEffect, useState } from "react"; | |
| 2 | 3 | ||
| 3 | 4 | import type { Route } from "./+types/blob"; | |
| 4 | 5 | import { page } from "../../lib/meta"; | |
| 5 | 6 | import { CodeownersFileErrors } from "../../components/codeowners"; | |
| 6 | 7 | import { BlobView } from "../../components/repo-view"; | |
| 8 | + | import type { ShownBlame } from "../../lib/commit-people"; | |
| 7 | 9 | import { highlightLines } from "../../lib/highlight.server"; | |
| 8 | 10 | import { repos, work } from "../../lib/services.server"; | |
| 9 | − | import { showCommits } from "../../lib/commit-people.server"; | |
| 10 | 11 | import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server"; | |
| 11 | 12 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 12 | 13 | ||
| 13 | 14 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 14 | − | return page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` }); | |
| 15 | + | const tags = page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` }); | |
| 16 | + | // A file's blame is kept out of search indexes; the file itself is not (lib/robots.ts). | |
| 17 | + | const blame = new URLSearchParams(args.location.search).has("blame"); | |
| 18 | + | return blame ? [...tags, { name: "robots", content: "noindex, nofollow" }] : tags; | |
| 15 | 19 | } | |
| 16 | 20 | ||
| 17 | 21 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| ⋯ | |||
| 19 | 23 | const path = { namespace: params.owner, name: params.repo }; | |
| 20 | 24 | const file = params["*"] ?? ""; | |
| 21 | 25 | const wantsBlame = new URL(request.url).searchParams.has("blame"); | |
| 22 | − | const [found, blame, list, checked] = await Promise.all([ | |
| 26 | + | const [found, list, checked] = await Promise.all([ | |
| 23 | 27 | repos.blob(path, viewer, params.ref, file), | |
| 24 | − | wantsBlame ? repos.blame(path, viewer, params.ref, file) : null, | |
| 25 | 28 | // For the branch menu; the page still shows without it. | |
| 26 | 29 | repos.branches(path, viewer).catch(() => null), | |
| 27 | 30 | // A CODEOWNERS file is checked as it is shown; the file shows without it. | |
| ⋯ | |||
| 32 | 35 | // A branch that was renamed: the same file on its new name. | |
| 33 | 36 | if (!found.ok && found.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref); | |
| 34 | 37 | const blob = unwrap(found); | |
| 35 | − | if (blame?.ok && blob.text != null) { | |
| 36 | − | return { | |
| 37 | − | blob, | |
| 38 | − | html: null, | |
| 39 | − | blame: { | |
| 40 | − | // Every commit's people at once. | |
| 41 | − | blame: { ...blame.value, commits: await showCommits(blame.value.commits) }, | |
| 42 | − | lines: await highlightLines(blob.path, blob.text), | |
| 43 | − | }, | |
| 44 | − | branches, | |
| 45 | − | codeowners, | |
| 46 | − | }; | |
| 47 | − | } | |
| 48 | − | return { | |
| 49 | − | blob, | |
| 50 | − | html: blob.text == null ? null : await highlightLines(blob.path, blob.text), | |
| 51 | − | blame: null, | |
| 52 | − | branches, | |
| 53 | − | codeowners, | |
| 54 | − | }; | |
| 38 | + | const html = blob.text == null ? null : await highlightLines(blob.path, blob.text); | |
| 39 | + | // The blame itself is fetched by the page once the file shows | |
| 40 | + | // (routes/repo/blame-data.ts): a first one can take longer than the | |
| 41 | + | // page's stream may stay open. | |
| 42 | + | return { blob, html, wantsBlame: wantsBlame && blob.text != null, branches, codeowners }; | |
| 55 | 43 | } | |
| 56 | 44 | ||
| 57 | 45 | export default function Blob({ loaderData, params }: Route.ComponentProps) { | |
| 58 | − | const { codeowners, blob } = loaderData; | |
| 46 | + | const { codeowners, blob, html, wantsBlame } = loaderData; | |
| 59 | 47 | // Its errors' lines are marked, when they are this file's. | |
| 60 | 48 | const own = codeowners != null && codeowners.path === blob.path; | |
| 49 | + | const blame = useBlame(wantsBlame ? `/${params.owner}/${params.repo}/blame/${encodeURIComponent(blob.ref)}/${encodePath(blob.path)}` : null); | |
| 61 | 50 | return ( | |
| 62 | 51 | <BlobView | |
| 63 | 52 | blob={blob} | |
| 64 | − | html={loaderData.html} | |
| 65 | − | blame={loaderData.blame} | |
| 53 | + | html={html} | |
| 54 | + | blame={blame === null ? null : typeof blame === "string" ? blame : { blame, lines: html }} | |
| 66 | 55 | branches={loaderData.branches} | |
| 67 | 56 | notice={ | |
| 68 | 57 | codeowners && ( | |
| ⋯ | |||
| 73 | 62 | /> | |
| 74 | 63 | ); | |
| 75 | 64 | } | |
| 65 | + | ||
| 66 | + | /** The blame at `url`: "waiting" until it comes, "failed" when it could not | |
| 67 | + | * be had, null when none was asked for. */ | |
| 68 | + | function useBlame(url: string | null): ShownBlame | "waiting" | "failed" | null { | |
| 69 | + | const [answered, setAnswered] = useState<{ url: string; blame: ShownBlame | "failed" } | null>(null); | |
| 70 | + | useEffect(() => { | |
| 71 | + | if (!url) return; | |
| 72 | + | const stop = new AbortController(); | |
| 73 | + | fetch(url, { signal: stop.signal, headers: { accept: "application/json" } }) | |
| 74 | + | .then(async (response) => { | |
| 75 | + | const body = (await response.json()) as { blame: ShownBlame | null }; | |
| 76 | + | setAnswered({ url, blame: response.ok && body.blame ? body.blame : "failed" }); | |
| 77 | + | }) | |
| 78 | + | .catch(() => { | |
| 79 | + | if (!stop.signal.aborted) setAnswered({ url, blame: "failed" }); | |
| 80 | + | }); | |
| 81 | + | return () => stop.abort(); | |
| 82 | + | }, [url]); | |
| 83 | + | if (!url) return null; | |
| 84 | + | return answered?.url === url ? answered.blame : "waiting"; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function encodePath(path: string): string { | |
| 88 | + | return path.split("/").map(encodeURIComponent).join("/"); | |
| 89 | + | } | |
| 22 | 22 | Users, | |
| 23 | 23 | Wrench, | |
| 24 | 24 | } from "lucide-react"; | |
| 25 | − | import { Suspense, useEffect, useState } from "react"; | |
| 25 | + | import { Suspense, useState } from "react"; | |
| 26 | 26 | import { Await, Form, Link, redirect } from "react-router"; | |
| 27 | 27 | ||
| 28 | 28 | import { | |
| ⋯ | |||
| 48 | 48 | import { highlightFirstFiles } from "../../lib/highlight.server"; | |
| 49 | 49 | import { excerpt, page } from "../../lib/meta"; | |
| 50 | 50 | import { openedBy } from "../../lib/opened-by"; | |
| 51 | + | import { useArrived } from "../../lib/use-arrived"; | |
| 51 | 52 | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 52 | 53 | import { DiffView } from "../../components/diff-view"; | |
| 53 | 54 | import { DetailsDisclosure, detailsSummary } from "../../components/details-disclosure"; | |
| ⋯ | |||
| 685 | 686 | } | |
| 686 | 687 | ||
| 687 | 688 | /** The box at the foot of the conversation saying what stands before a merge. */ | |
| 688 | − | /** `initial` until `promise` settles, then what it resolved to. */ | |
| 689 | − | function useArrived<T>(promise: Promise<T>, initial: T): T { | |
| 690 | − | const [arrived, setArrived] = useState<{ from: Promise<T>; value: T } | null>(null); | |
| 691 | − | useEffect(() => { | |
| 692 | − | let current = true; | |
| 693 | − | promise.then( | |
| 694 | − | (value) => current && setArrived({ from: promise, value }), | |
| 695 | − | () => {}, | |
| 696 | − | ); | |
| 697 | − | return () => { | |
| 698 | − | current = false; | |
| 699 | − | }; | |
| 700 | − | }, [promise]); | |
| 701 | − | return arrived?.from === promise ? arrived.value : initial; | |
| 702 | − | } | |
| 703 | 689 | ||
| 704 | 690 | function StatusBox({ children }: { children: React.ReactNode }) { | |
| 705 | 691 | return ( | |
| 1 | 1 | /** | |
| 2 | − | * /robots.txt: everything may be crawled, and where the site's maps are. | |
| 3 | − | * Served from the request's own origin, so a self-hosted g1t names itself. | |
| 2 | + | * /robots.txt (lib/robots.ts). Served from the request's own origin, so a | |
| 3 | + | * self-hosted g1t names itself. | |
| 4 | 4 | */ | |
| 5 | 5 | import type { Route } from "./+types/robots-txt"; | |
| 6 | − | ||
| 7 | − | /** g1t.sh's docs keep their own map; a self-hosted g1t has no docs site. */ | |
| 8 | − | const DOCS_SITEMAP = "https://docs.g1t.sh/sitemap-index.xml"; | |
| 6 | + | import { robotsTxt } from "../lib/robots"; | |
| 9 | 7 | ||
| 10 | 8 | export function loader({ request }: Route.LoaderArgs) { | |
| 11 | − | const site = new URL(request.url).origin; | |
| 12 | − | const lines = ["User-agent: *", "Allow: /", "", `Sitemap: ${site}/sitemap.xml`]; | |
| 13 | − | if (site === "https://g1t.sh") lines.push(`Sitemap: ${DOCS_SITEMAP}`); | |
| 14 | − | return new Response(`${lines.join("\n")}\n`, { | |
| 9 | + | return new Response(robotsTxt(new URL(request.url).origin), { | |
| 15 | 10 | headers: { | |
| 16 | 11 | "content-type": "text/plain; charset=utf-8", | |
| 17 | 12 | "cache-control": "public, max-age=86400", |
| 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, | |
| 793 | 793 | pub ranges: Vec<BlameRange>, | |
| 794 | 794 | /// The commits the ranges name, each once. | |
| 795 | 795 | pub commits: Vec<Commit>, | |
| 796 | − | /// True when the history was too long to read in full, so the oldest | |
| 797 | − | /// lines are given to the oldest commit read. | |
| 796 | + | /// True when the history was too long to read in full (too many | |
| 797 | + | /// commits, or too long to read), so the oldest lines are given to the | |
| 798 | + | /// oldest commit read that has them. | |
| 798 | 799 | pub partial: bool, | |
| 799 | 800 | } | |
| 800 | 801 |
| 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 |
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…
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…
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.