Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.
25 files+2771−2590/25 viewed
| 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**: the agents' work and how much | |
| 12 | − | of it was accepted the first time, what landed, deploys, decisions and | |
| 13 | − | spend, since you were last here. | |
| 14 | − | 3. **What is running now**: agent sessions, agents' changes and builds | |
| 15 | − | still going. | |
| 11 | + | 2. **What happened while you were away**: what people did and what | |
| 12 | + | agents did, side by side; what landed; deploys, decisions and spend, | |
| 13 | + | since you were last here. | |
| 14 | + | 3. **What is running now**: agent sessions, agents' changes, workflow | |
| 15 | + | runs and builds still going. | |
| 16 | 16 | ||
| 17 | 17 | **Start here** picks the one item to do first, and **Ask g1t** at the top | |
| 18 | 18 | sends a message to g1t from the page. | |
| ⋯ | |||
| 92 | 92 | ||
| 93 | 93 | The sentence under the heading sums it up, for example: | |
| 94 | 94 | ||
| 95 | − | > 8 things need you. Since Tuesday evening, agents finished 35 tasks and | |
| 96 | − | > 12 changes landed. 4 needed a fix after review. | |
| 95 | + | > 8 things need you. Since Tuesday evening, 3 people pushed 41 commits and | |
| 96 | + | > merged 5 changes, agents finished 7 tasks, and 12 changes landed. | |
| 97 | + | ||
| 98 | + | It says what people did first (the commits they pushed and the changes | |
| 99 | + | they merged; if neither, the messages they sent), then the agents' tasks | |
| 100 | + | that settled well, then everything that [landed](#landed). When nothing at | |
| 101 | + | all was counted, it says so plainly: | |
| 102 | + | ||
| 103 | + | > Quiet since Tuesday evening: nothing pushed, merged, deployed or said. | |
| 104 | + | ||
| 105 | + | When Code and Chat both failed to answer, the sentence ends with "what | |
| 106 | + | people did couldn't be read" rather than reading as quiet. | |
| 107 | + | ||
| 108 | + | ### People and agents | |
| 109 | + | ||
| 110 | + | The first card has two columns, **People** and **Agents**. Each lists what | |
| 111 | + | that side did in the span, one line per kind of thing with its count, and | |
| 112 | + | each line opens the place that lists them: a project's Activity tab for | |
| 113 | + | pushes, its pull requests or issues, Artifacts, Chat. Beside the heading | |
| 114 | + | are the faces of the five most active, and how many more there were. | |
| 115 | + | ||
| 116 | + | | Line | What counts | From | | |
| 117 | + | | --- | --- | --- | | |
| 118 | + | | *3 people pushed 41 commits to 4 projects* | Every push to a branch of one of the workspace's repositories (`git.push`; tags are not pushes). The commits are the ones each push brought to its branch, along the branch's first-parent line from where it is now back to where it was, at most 50, and one for a new branch. A push recorded before commits were counted counts as one commit. | Events | | |
| 119 | + | | *opened 2 pull requests, merged 5, closed 1 without merging* | `pull.opened`, `pull.merged`, `pull.closed` | Events | | |
| 120 | + | | *reviewed 3 pull requests* | A person's review is a comment with a verdict; an agent's is `review.completed` | Events | | |
| 121 | + | | *opened 4 issues, closed 6* | `issue.opened`, `issue.closed` | Events | | |
| 122 | + | | *left 9 comments* | `comment.created` without a verdict | Events | | |
| 123 | + | | *2 deploys went out, 1 failed* | `deployment.succeeded` and `deployment.failed`, by who set the build off | Events | | |
| 124 | + | | *published 1 release*, *published 2 package versions* | `release.published`, `package.published` | Events | | |
| 125 | + | | *created 1 doc, edited 7 docs* | Artifacts made (`folio.created`), and artifacts whose content changed (`folio.updated`): each artifact once, and everyone whose changes a version holds edited it. "Docs" when every one is a doc, else "artifacts". Counts only: Home never shows an artifact's title here. | Events | | |
| 126 | + | | *sent 120 messages in 5 conversations* (people), *replied 15 times in chat* (agents) | Text messages and thread replies that were not deleted, in the conversations you can read: every public channel, and the private channels and direct messages you are in. Cards agents post are not messages. Conversations are those with at least one message. | Chat | | |
| 127 | + | | *finished 7 tasks, 1 didn't finish*, *ran 2 sessions* (agents only) | The agents' [tasks](#tasks) that settled in the span, and of those the sessions | Code and Agents | | |
| 128 | + | ||
| 129 | + | How a side is decided: everything is recorded with who did it. One of the | |
| 130 | + | workspace's agents acting as itself, or g1t, is an agent; everyone else is | |
| 131 | + | a person. A comment a workspace agent writes is the agent's, not the | |
| 132 | + | person's it was working for. A push copied in by a mirror names nobody, so | |
| 133 | + | it is counted under People without a face. | |
| 134 | + | ||
| 135 | + | Under the Agents column's lines, a compact row says how their work was | |
| 136 | + | received: accepted first time, fixed after review, didn't finish, and the | |
| 137 | + | share accepted first time with its change from the 7 days before (see | |
| 138 | + | [Accepted first time](#accepted-first-time)), then one mark per task in | |
| 139 | + | the order they settled. | |
| 140 | + | ||
| 141 | + | Each column says when it has nothing to show, and names the part that | |
| 142 | + | didn't answer (Code, Chat or Agents) so an empty column never reads as a | |
| 143 | + | quiet day. | |
| 97 | 144 | ||
| 98 | 145 | ### Tasks | |
| 99 | 146 | ||
| ⋯ | |||
| 125 | 172 | ||
| 126 | 173 | ### Accepted first time | |
| 127 | 174 | ||
| 128 | − | The large percentage is: | |
| 175 | + | The share in the Agents column is: | |
| 129 | 176 | ||
| 130 | 177 | > pull requests accepted first time ÷ agents' pull requests that merged or | |
| 131 | 178 | > closed in the span | |
| 132 | 179 | ||
| 133 | 180 | A pull request closed without merging counts against it. Sessions are not | |
| 134 | − | part of it. With no agent pull request settled in the span, it shows as a | |
| 135 | − | dash. | |
| 181 | + | part of it. With no agent pull request settled in the span, the row says | |
| 182 | + | so instead of a share. | |
| 136 | 183 | ||
| 137 | − | Under it, the change from **the 7 days before** is the same share over the | |
| 184 | + | Beside it, the change from **the 7 days before** is the same share over the | |
| 138 | 185 | seven days before the span began, in whole percentage points. It is left | |
| 139 | 186 | out when nothing settled in those days, or when g1t could not read back far | |
| 140 | 187 | enough to count them all. | |
| 141 | 188 | ||
| 142 | − | Beside the percentage are the counts (tasks settled, accepted first time, | |
| 143 | − | fixed after review, didn't finish) and a strip with one mark for each task, | |
| 144 | − | coloured by its outcome, in the order they settled. Point at a mark to see | |
| 145 | − | what it is, and select it to open it. A session in a conversation you are | |
| 146 | − | not in shows as "A session in a conversation you're not in". | |
| 189 | + | Under it, the counts (accepted first time, fixed after review, didn't | |
| 190 | + | finish) and a strip with one mark for each task, coloured by its outcome, | |
| 191 | + | in the order they settled. Point at a mark to see what it is, and select | |
| 192 | + | it to open it. A session in a conversation you are not in shows as "A | |
| 193 | + | session in a conversation you're not in". | |
| 147 | 194 | ||
| 148 | 195 | ### Landed | |
| 149 | 196 | ||
| 150 | − | Every pull request that merged in the span, a person's or an agent's, | |
| 151 | − | newest first, with who wrote it and who merged it when that was someone | |
| 152 | − | else. The card's line counts them and how many were agents'. | |
| 197 | + | Everything that landed in the span, newest first, a person's or an | |
| 198 | + | agent's: | |
| 199 | + | ||
| 200 | + | | Row | What it is | | |
| 201 | + | | --- | --- | | |
| 202 | + | | A pull request merged | With who wrote it, and who merged it when that was someone else | | |
| 203 | + | | 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 | | |
| 204 | + | | A production build that went live | A project's production build that built and was served (previews stay under Deploys) | | |
| 205 | + | | A release published | Its tag and name, and who released it | | |
| 206 | + | | A package version published | Its name and version, and who published it | | |
| 207 | + | ||
| 208 | + | The card's line counts them by kind and how many were agents'. The first | |
| 209 | + | five show; **Show more** opens the rest. When one of its sources (pull | |
| 210 | + | requests, pushes and releases, deployments) didn't answer, the card says | |
| 211 | + | so under the list. | |
| 153 | 212 | ||
| 154 | 213 | ### Deploys | |
| 155 | 214 | ||
| ⋯ | |||
| 194 | 253 | | --- | --- | | |
| 195 | 254 | | Session | An agent session at the root of its tree that is queued, working, or waiting on the helpers it started. One stopped at its spend cap waits on a person, so it is under Where you're needed instead. | | |
| 196 | 255 | | Change | An agent's pull request that is in the agent's hands: being worked on, waiting on its checks, or a draft still being made | | |
| 256 | + | | Workflow run | A [workflow](/guides/actions/) run that is queued, waiting, running, or waiting for approval, in the ten projects pushed to most in the span (their newest 20 runs each) | | |
| 197 | 257 | | Build | A project's newest build, queued or building | | |
| 198 | 258 | ||
| 199 | 259 | ## Without Code access | |
| ⋯ | |||
| 201 | 261 | A member whose membership does not include Code sees Home without anything | |
| 202 | 262 | from Code: | |
| 203 | 263 | ||
| 204 | − | - Tasks are sessions only. The large number is **Finished**, and the | |
| 205 | − | counts are sessions finished and didn't finish. Accepted first time is | |
| 206 | − | not shown, since it is measured on pull requests. | |
| 264 | + | - People shows what was said and the artifacts edited; nothing from | |
| 265 | + | repositories. Agents shows sessions only: finished, didn't finish, and | |
| 266 | + | what they said and edited. Accepted first time is not shown, since it is | |
| 267 | + | measured on pull requests. | |
| 207 | 268 | - Where you're needed has no rows from Code, and no notifications about a | |
| 208 | 269 | repository. | |
| 209 | 270 | - Landed, Deploys and memory decisions are not shown, and Running now has | |
| ⋯ | |||
| 217 | 278 | ||
| 218 | 279 | - Where you're needed and Running now name the parts that didn't answer, | |
| 219 | 280 | so an empty list never reads as nothing waiting. | |
| 220 | − | - The agents' work counts what could be read and says which part is | |
| 221 | − | missing. If neither answered, it says so instead of showing counts. | |
| 281 | + | - People and Agents count what could be read, and each column names the | |
| 282 | + | part that is missing (Code, Chat or Agents). If nothing could be read, | |
| 283 | + | the card says so instead of showing columns, and the sentence under the | |
| 284 | + | heading says what people did couldn't be read. | |
| 222 | 285 | - Landed, Deploys and Spent each say their part didn't answer. | |
| 223 | 286 | - If your last visit can't be read, the span is the last 24 hours and the | |
| 224 | 287 | page says why. | |
| 225 | 288 | ||
| 226 | 289 | When a list g1t reads is longer than it reads at once (200 sessions, 200 | |
| 227 | 290 | revise runs, 100 pull requests per project and state, 50 projects, builds | |
| 228 | − | of 20 projects), the page says that some work may be missing. | |
| 291 | + | of 20 projects, 5,000 events of the span), the page says that some work | |
| 292 | + | may be missing or some counts are low. | |
| 293 | + | ||
| 294 | + | ## How it scales | |
| 295 | + | ||
| 296 | + | Every event in g1t is in one log, and its ids sort by time, so a span is a | |
| 297 | + | range of ids. Home asks the events service for one digest of the span: for | |
| 298 | + | each of the workspace's repositories (the first 50) and each kind of event | |
| 299 | + | it counts, that is one range scan of an index on repository, type and id, | |
| 300 | + | and the workspace's artifact events are the same range under no | |
| 301 | + | repository. A busy week of tens of repositories is a few thousand rows, | |
| 302 | + | read in one round trip; chat's count is one query over the conversations | |
| 303 | + | you can read. When a workspace has hundreds of repositories, the next step | |
| 304 | + | is a daily rollup per workspace that the digest reads instead of the log. | |
| 305 | + | It is not built yet. | |
| 306 | + | ||
| 307 | + | ## For services: activity_digest and activity | |
| 308 | + | ||
| 309 | + | Home's counts come from two methods other services can call too. | |
| 310 | + | ||
| 311 | + | **`activity_digest`** on the events service (`repo_ids`, `workspace`, | |
| 312 | + | `from`, `until`; RFC 3339, `[from, until)`; the caller has checked the | |
| 313 | + | viewer may read each repository) returns, per repository that had any | |
| 314 | + | event: `pushes` (`count`, `commits`, `branches`, `by` with each actor's | |
| 315 | + | pushes and commits, and `default_branch` with the same for pushes straight | |
| 316 | + | to it and `last_at`), `pulls` (`opened`, `merged`, `closed`), `issues` | |
| 317 | + | (`opened`, `closed`), `reviews`, `comments`, `deployments` (`succeeded`, | |
| 318 | + | `failed`, `production`), `releases` and `packages` (newest first, at most | |
| 319 | + | 50 each); and for the workspace named, `folios` (`created`, `edited` with | |
| 320 | + | each folio's authors, `edited_count`). Every count by actor is a list of | |
| 321 | + | `{ actor, count }`, where `actor` is a member key: `user:<id>` for an | |
| 322 | + | account (g1t is `user:usr_g1t_agent`), `agent:<id>` for one of the | |
| 323 | + | workspace's agents acting as itself, or empty for nobody. `complete` is | |
| 324 | + | false when more than 5,000 events fell in the span, or more than 50 | |
| 325 | + | repositories were named. | |
| 326 | + | ||
| 327 | + | **`activity`** on the chat service (`workspace`, `viewer`, `from`, | |
| 328 | + | `until`) returns `messages`, `channels` (conversations with at least one) | |
| 329 | + | and `authors` (`key`, `messages`; most first), over text messages and | |
| 330 | + | thread replies not deleted in the conversations the viewer can read. | |
| 66 | 66 | ||
| 67 | 67 | | Event | When | | |
| 68 | 68 | | --- | --- | | |
| 69 | − | | `git.push` | A branch moved. `data.ref`, `data.after`, `data.default_branch`. A push of more than 3 tags, or more than 1,000 branches, sends fewer; see [pushes of many branches or tags](/guides/git/#pushes-of-many-branches-or-tags). | | |
| 69 | + | | `git.push` | A branch moved. `data.ref`, `data.after`, `data.default_branch`, and `data.commits` (how many commits the push brought to the branch, at most 50; absent for a tag, a deleted branch or a merge). A push of more than 3 tags, or more than 1,000 branches, sends fewer; see [pushes of many branches or tags](/guides/git/#pushes-of-many-branches-or-tags). | | |
| 70 | 70 | | `repo.created`, `repo.forked` | A repository was made, or forked for a pull request. | | |
| 71 | 71 | | `repo.updated` | Its description, website, topics, protection or visibility changed. | | |
| 72 | 72 | | `repo.visibility_changed` | It was made public or private. | |
| 1 | 1 | /** | |
| 2 | 2 | * Home's parts (routes/workspace/home.tsx): Where you're needed, Start | |
| 3 | − | * here, what happened in the span (the agents' work, what landed, builds, | |
| 4 | − | * decisions, spend) and Running now. Each takes what lib/home.ts worked | |
| 5 | − | * out and only draws it. | |
| 3 | + | * here, what happened in the span (what people and agents did, what | |
| 4 | + | * landed, builds, decisions, spend) and Running now. Each takes what | |
| 5 | + | * lib/home.ts worked out and only draws it. | |
| 6 | 6 | */ | |
| 7 | 7 | import { | |
| 8 | 8 | AlertTriangle, | |
| ⋯ | |||
| 11 | 11 | Bell, | |
| 12 | 12 | Bot, | |
| 13 | 13 | CircleDollarSign, | |
| 14 | + | GitCommitHorizontal, | |
| 14 | 15 | GitMerge, | |
| 15 | 16 | GitPullRequest, | |
| 16 | 17 | Hourglass, | |
| 17 | 18 | Lightbulb, | |
| 18 | 19 | Loader, | |
| 19 | 20 | Mail, | |
| 21 | + | Package, | |
| 20 | 22 | Rocket, | |
| 21 | 23 | Server, | |
| 22 | 24 | Sparkles, | |
| 23 | 25 | Store, | |
| 26 | + | Tag, | |
| 24 | 27 | User, | |
| 28 | + | Users, | |
| 29 | + | Workflow, | |
| 25 | 30 | } from "lucide-react"; | |
| 26 | 31 | import type { ReactNode } from "react"; | |
| 27 | 32 | import { Link } from "react-router"; | |
| ⋯ | |||
| 32 | 37 | type AttentionRow, | |
| 33 | 38 | type DecisionRow, | |
| 34 | 39 | type DeployRow, | |
| 40 | + | type Digest, | |
| 41 | + | type DigestActor, | |
| 42 | + | type DigestColumn, | |
| 35 | 43 | type LandedChange, | |
| 36 | 44 | type RunningRow, | |
| 37 | 45 | type Span, | |
| ⋯ | |||
| 42 | 50 | OUTCOME_LABEL, | |
| 43 | 51 | SOURCE_LABEL, | |
| 44 | 52 | WINDOWS, | |
| 53 | + | listOf, | |
| 45 | 54 | trendLabel, | |
| 46 | 55 | waited, | |
| 47 | 56 | whenShort, | |
| 48 | 57 | } from "../lib/home"; | |
| 49 | 58 | import { cn } from "../lib/cn"; | |
| 50 | 59 | import { money } from "../lib/money"; | |
| 60 | + | import { type AgentLike, AgentAvatar } from "./agent-avatar"; | |
| 51 | 61 | import { ButtonLink } from "./ui"; | |
| 62 | + | import { Avatar } from "./ui/avatar"; | |
| 63 | + | import { Badge } from "./ui/badge"; | |
| 52 | 64 | import { Skeleton } from "./ui/skeleton"; | |
| 53 | 65 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 54 | 66 | ||
| ⋯ | |||
| 89 | 101 | return <p className="px-5 pb-5 text-sm text-muted">{children}</p>; | |
| 90 | 102 | } | |
| 91 | 103 | ||
| 92 | − | const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`); | |
| 93 | − | ||
| 94 | 104 | function Missing({ names, what }: { names: string[]; what: string }) { | |
| 95 | 105 | if (names.length === 0) return null; | |
| 96 | 106 | return ( | |
| ⋯ | |||
| 341 | 351 | return span.key === "last" && span.note == null ? "since you were last here" : span.words.charAt(0).toLowerCase() + span.words.slice(1); | |
| 342 | 352 | } | |
| 343 | 353 | ||
| 344 | − | export function SpanCard({ work, span }: { work: SpanWork | null; span: Pick<Span, "key" | "note" | "words"> }) { | |
| 345 | − | if (!work) { | |
| 346 | − | return ( | |
| 347 | − | <section className="rounded-2xl border border-line bg-surface p-5"> | |
| 348 | − | <p className="text-sm text-muted">The agents' work couldn't be read right now. It shows here once Agents answers.</p> | |
| 349 | − | </section> | |
| 350 | − | ); | |
| 354 | + | /** The faces of a column's most active, with a tooltip naming each, and how many more there were. */ | |
| 355 | + | const FACES = 5; | |
| 356 | + | ||
| 357 | + | /** An agent's face by its member key, from the workspace's agents; g1t wears its mark. */ | |
| 358 | + | function faceOf(actor: DigestActor, faces: AgentLike[]): ReactNode { | |
| 359 | + | if (actor.kind === "agent") { | |
| 360 | + | const agent = faces.find((face) => `agent:${face.id}` === actor.key); | |
| 361 | + | return <AgentAvatar agent={agent ?? { handle: actor.name, builtin: actor.name === "g1t" }} size={22} />; | |
| 351 | 362 | } | |
| 363 | + | return <Avatar name={actor.name} size={22} />; | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | function Faces({ actors, faces }: { actors: DigestActor[]; faces: AgentLike[] }) { | |
| 367 | + | if (actors.length === 0) return null; | |
| 368 | + | const shown = actors.slice(0, FACES); | |
| 369 | + | const more = actors.length - shown.length; | |
| 370 | + | return ( | |
| 371 | + | <div className="flex items-center gap-1.5"> | |
| 372 | + | <ul className="flex -space-x-1.5" aria-label="Most active"> | |
| 373 | + | {shown.map((actor) => ( | |
| 374 | + | <li key={actor.key} className="rounded-full ring-2 ring-surface"> | |
| 375 | + | <Tooltip> | |
| 376 | + | <TooltipTrigger asChild> | |
| 377 | + | <span className="block">{faceOf(actor, faces)}</span> | |
| 378 | + | </TooltipTrigger> | |
| 379 | + | <TooltipContent> | |
| 380 | + | {actor.kind === "agent" ? `@${actor.name}` : actor.name} · {actor.count.toLocaleString("en-US")} {actor.count === 1 ? "thing" : "things"} | |
| 381 | + | </TooltipContent> | |
| 382 | + | </Tooltip> | |
| 383 | + | </li> | |
| 384 | + | ))} | |
| 385 | + | </ul> | |
| 386 | + | {more > 0 && <span className="text-xs text-muted">+{more.toLocaleString("en-US")}</span>} | |
| 387 | + | </div> | |
| 388 | + | ); | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | /** One column's lines: each what was done, with its count in the words, linked to where it is listed. */ | |
| 392 | + | function Lines({ column }: { column: DigestColumn }) { | |
| 393 | + | return ( | |
| 394 | + | <ul className="mt-3 space-y-1.5"> | |
| 395 | + | {column.lines.map((line) => ( | |
| 396 | + | <li key={line.key} className="flex items-start gap-2 text-sm text-fg"> | |
| 397 | + | <span className="mt-1.75 size-1.5 shrink-0 rounded-full bg-fg-soft/60" aria-hidden /> | |
| 398 | + | {line.to ? ( | |
| 399 | + | <Link to={line.to} className="hover:underline"> | |
| 400 | + | {line.text} | |
| 401 | + | </Link> | |
| 402 | + | ) : ( | |
| 403 | + | <span>{line.text}</span> | |
| 404 | + | )} | |
| 405 | + | </li> | |
| 406 | + | ))} | |
| 407 | + | </ul> | |
| 408 | + | ); | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | /** The agents' acceptance, in a compact row: the counts, the share accepted first time and its trend, and a mark per task. */ | |
| 412 | + | function Acceptance({ work, span }: { work: SpanWork; span: Pick<Span, "key" | "note" | "words"> }) { | |
| 352 | 413 | const { counts, rate, before, tasks } = work; | |
| 353 | 414 | const trend = trendLabel(rate, before); | |
| 354 | 415 | const up = rate && before ? Math.round(rate.value * 100) - Math.round(before.value * 100) : 0; | |
| 355 | 416 | // Without Code, nothing is reviewed: no accepted or fixed to count. | |
| 356 | 417 | const reviewed = work.code === "read"; | |
| 357 | 418 | const shown = (Object.keys(MARK) as TaskOutcome[]).filter((o) => counts[o] > 0 || (reviewed && (o === "accepted" || o === "fixed"))); | |
| 358 | − | const when = inSpan(span); | |
| 359 | 419 | return ( | |
| 360 | − | <section className="grid overflow-hidden rounded-2xl border border-line bg-surface md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)]"> | |
| 361 | − | <div className="border-b border-line p-5 md:border-r md:border-b-0"> | |
| 362 | − | {work.code === "no_access" ? ( | |
| 420 | + | <div className="mt-4 border-t border-line pt-4"> | |
| 421 | + | <div className="grid grid-cols-3 gap-3"> | |
| 422 | + | {reviewed ? ( | |
| 363 | 423 | <> | |
| 364 | − | <h3 className="text-sm font-semibold text-fg">Finished</h3> | |
| 365 | − | <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{(counts.accepted + counts.fixed + counts.finished).toLocaleString("en-US")}</div> | |
| 366 | − | <p className="mt-2 text-xs leading-relaxed text-muted">How much was accepted the first time is measured on pull requests, which are part of Code.</p> | |
| 424 | + | <Stat label="Accepted first time" value={counts.accepted} mark={MARK.accepted} /> | |
| 425 | + | <Stat label="Fixed after review" value={counts.fixed} mark={MARK.fixed} /> | |
| 367 | 426 | </> | |
| 368 | 427 | ) : ( | |
| 369 | 428 | <> | |
| 370 | − | <h3 className="text-sm font-semibold text-fg">Accepted first time</h3> | |
| 371 | − | {rate ? ( | |
| 372 | − | <> | |
| 373 | − | <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{percent(rate.value)}</div> | |
| 374 | − | <p className="mt-2 text-xs text-muted"> | |
| 375 | − | of {rate.of.toLocaleString("en-US")} agent {rate.of === 1 ? "pull request" : "pull requests"} merged or closed {when} | |
| 376 | − | </p> | |
| 377 | − | <p className={`mt-1.5 text-xs font-medium ${up > 0 ? "text-success" : up < 0 ? "text-warn" : "text-muted"}`}> | |
| 378 | − | {trend ?? (work.code === "read" ? "Nothing settled in the 7 days before to compare." : "")} | |
| 379 | − | </p> | |
| 380 | − | </> | |
| 381 | − | ) : ( | |
| 382 | − | <> | |
| 383 | − | <div className="mt-2 text-5xl font-semibold tracking-tight text-faint">—</div> | |
| 384 | − | <p className="mt-2 text-xs leading-relaxed text-muted"> | |
| 385 | − | {work.code === "unavailable" ? "Code didn't answer, so pull requests aren't counted." : `No agent pull request merged or closed ${when}.`} | |
| 386 | − | {before ? ` The 7 days before: ${percent(before.value)} of ${before.of}.` : ""} | |
| 387 | − | </p> | |
| 388 | − | </> | |
| 389 | − | )} | |
| 429 | + | <Stat label="Sessions finished" value={counts.finished} mark={MARK.finished} /> | |
| 430 | + | <Stat label="Tasks settled" value={tasks.length} /> | |
| 390 | 431 | </> | |
| 391 | 432 | )} | |
| 433 | + | <Stat label="Didn't finish" value={counts.dropped} mark={MARK.dropped} /> | |
| 392 | 434 | </div> | |
| 393 | − | <div className="min-w-0 p-5"> | |
| 394 | − | <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| 395 | − | <Stat label="Tasks settled" value={tasks.length} /> | |
| 396 | − | {reviewed ? ( | |
| 435 | + | {reviewed && ( | |
| 436 | + | <p className="mt-2 text-xs text-muted"> | |
| 437 | + | {rate ? ( | |
| 397 | 438 | <> | |
| 398 | − | <Stat label="Accepted first time" value={counts.accepted} mark={MARK.accepted} /> | |
| 399 | − | <Stat label="Fixed after review" value={counts.fixed} mark={MARK.fixed} /> | |
| 439 | + | <span className="font-medium text-fg">{percent(rate.value)}</span> of {rate.of.toLocaleString("en-US")} agent {rate.of === 1 ? "pull request" : "pull requests"} accepted first time | |
| 440 | + | {trend && <span className={cn("ml-1.5 font-medium", up > 0 ? "text-success" : up < 0 ? "text-warn" : "text-muted")}>{trend}</span>} | |
| 400 | 441 | </> | |
| 401 | 442 | ) : ( | |
| 402 | − | <Stat label="Sessions finished" value={counts.finished} mark={MARK.finished} /> | |
| 443 | + | <> | |
| 444 | + | No agent pull request merged or closed {inSpan(span)}.{before ? ` The 7 days before: ${percent(before.value)} of ${before.of}.` : ""} | |
| 445 | + | </> | |
| 403 | 446 | )} | |
| 404 | − | <Stat label="Didn't finish" value={counts.dropped} mark={MARK.dropped} /> | |
| 447 | + | </p> | |
| 448 | + | )} | |
| 449 | + | {tasks.length > 0 && ( | |
| 450 | + | <ul className="mt-3 flex flex-wrap gap-0.75" aria-label="Agent work that settled, one mark per task, in the order they settled"> | |
| 451 | + | {tasks.map((task) => ( | |
| 452 | + | <li key={task.key}> | |
| 453 | + | <Tooltip> | |
| 454 | + | <TooltipTrigger asChild> | |
| 455 | + | {task.to ? ( | |
| 456 | + | <Link to={task.to} aria-label={`${task.title ?? "A session"}: ${OUTCOME_LABEL[task.outcome]}`} className={`block h-4 w-2 rounded-xs ${MARK[task.outcome]} transition-opacity hover:opacity-70`} /> | |
| 457 | + | ) : ( | |
| 458 | + | <span className={`block h-4 w-2 rounded-xs ${MARK[task.outcome]}`} /> | |
| 459 | + | )} | |
| 460 | + | </TooltipTrigger> | |
| 461 | + | <TooltipContent> | |
| 462 | + | <span className="block font-medium">{task.title ?? "A session in a conversation you're not in"}</span> | |
| 463 | + | <span className="text-muted"> | |
| 464 | + | {OUTCOME_LABEL[task.outcome]} · {SOURCE_LABEL[task.source]} | |
| 465 | + | </span> | |
| 466 | + | </TooltipContent> | |
| 467 | + | </Tooltip> | |
| 468 | + | </li> | |
| 469 | + | ))} | |
| 470 | + | </ul> | |
| 471 | + | )} | |
| 472 | + | {tasks.length > 0 && ( | |
| 473 | + | <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[0.6875rem] text-muted"> | |
| 474 | + | {shown.map((outcome) => ( | |
| 475 | + | <span key={outcome} className="inline-flex items-center gap-1"> | |
| 476 | + | <span className={`size-1.5 rounded-xs ${MARK[outcome]}`} aria-hidden /> | |
| 477 | + | {OUTCOME_LABEL[outcome]} <span className="text-fg-soft tabular-nums">{counts[outcome]}</span> | |
| 478 | + | </span> | |
| 479 | + | ))} | |
| 405 | 480 | </div> | |
| 406 | − | {tasks.length === 0 ? ( | |
| 407 | − | <p className="mt-5 text-sm text-muted">No agent work settled {when}.</p> | |
| 408 | − | ) : ( | |
| 409 | − | <div className="mt-5"> | |
| 410 | − | <ul className="flex flex-wrap gap-[3px]" aria-label="Agent work that settled"> | |
| 411 | − | {tasks.map((task) => ( | |
| 412 | − | <li key={task.key}> | |
| 413 | − | <Tooltip> | |
| 414 | − | <TooltipTrigger asChild> | |
| 415 | − | {task.to ? ( | |
| 416 | − | <Link to={task.to} aria-label={`${task.title ?? "A session"}: ${OUTCOME_LABEL[task.outcome]}`} className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]} transition-opacity hover:opacity-70`} /> | |
| 417 | − | ) : ( | |
| 418 | − | <span className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]}`} /> | |
| 419 | − | )} | |
| 420 | − | </TooltipTrigger> | |
| 421 | − | <TooltipContent> | |
| 422 | − | <span className="block font-medium">{task.title ?? "A session in a conversation you're not in"}</span> | |
| 423 | − | <span className="text-muted"> | |
| 424 | − | {OUTCOME_LABEL[task.outcome]} · {SOURCE_LABEL[task.source]} | |
| 425 | − | </span> | |
| 426 | − | </TooltipContent> | |
| 427 | − | </Tooltip> | |
| 428 | − | </li> | |
| 429 | − | ))} | |
| 430 | − | </ul> | |
| 431 | − | <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted"> | |
| 432 | − | <span className="text-faint">One mark per task, in the order they settled</span> | |
| 433 | − | {shown.map((outcome) => ( | |
| 434 | − | <span key={outcome} className="inline-flex items-center gap-1.5"> | |
| 435 | − | <span className={`size-2 rounded-[2px] ${MARK[outcome]}`} aria-hidden /> | |
| 436 | − | {OUTCOME_LABEL[outcome]} <span className="text-fg-soft tabular-nums">{counts[outcome]}</span> | |
| 437 | − | </span> | |
| 438 | − | ))} | |
| 439 | − | </div> | |
| 440 | − | </div> | |
| 481 | + | )} | |
| 482 | + | </div> | |
| 483 | + | ); | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | /** | |
| 487 | + | * What people did and what agents did in the span, side by side: lines | |
| 488 | + | * with counts linked to where each thing is listed, the most active faces, | |
| 489 | + | * and in the Agents column how their work was received. Each column says | |
| 490 | + | * when it is empty, and which part didn't answer. | |
| 491 | + | */ | |
| 492 | + | export function PeopleAgentsCard({ | |
| 493 | + | digest, | |
| 494 | + | work, | |
| 495 | + | span, | |
| 496 | + | faces, | |
| 497 | + | code, | |
| 498 | + | }: { | |
| 499 | + | digest: Digest | null; | |
| 500 | + | work: SpanWork | null; | |
| 501 | + | span: Pick<Span, "key" | "note" | "words">; | |
| 502 | + | faces: AgentLike[]; | |
| 503 | + | code: boolean; | |
| 504 | + | }) { | |
| 505 | + | const when = inSpan(span); | |
| 506 | + | if (!digest) { | |
| 507 | + | return ( | |
| 508 | + | <section className="rounded-2xl border border-line bg-surface p-5"> | |
| 509 | + | <p className="text-sm text-muted">What happened couldn't be read right now. It shows here once Code, Chat and Agents answer.</p> | |
| 510 | + | </section> | |
| 511 | + | ); | |
| 512 | + | } | |
| 513 | + | const missing = (names: string[]) => names.filter((name) => digest.missing.includes(name)); | |
| 514 | + | const peopleMissing = missing(["Code", "Chat"]); | |
| 515 | + | const agentsMissing = missing(["Code", "Chat", "Agents"]); | |
| 516 | + | const column = (title: string, icon: ReactNode, col: DigestColumn, silent: string[], empty: string, children?: ReactNode) => ( | |
| 517 | + | <div className="min-w-0 p-5"> | |
| 518 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 519 | + | <h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-fg"> | |
| 520 | + | {icon} | |
| 521 | + | {title} | |
| 522 | + | </h3> | |
| 523 | + | {col.actors.length > 0 && ( | |
| 524 | + | <Badge tone="neutral" size="md" className="tabular-nums"> | |
| 525 | + | {col.actors.length.toLocaleString("en-US")} active | |
| 526 | + | </Badge> | |
| 441 | 527 | )} | |
| 442 | − | {(work.partial || work.sessions === "unavailable" || work.code === "unavailable") && ( | |
| 443 | − | <p className="mt-3 text-xs text-faint"> | |
| 444 | − | {work.sessions === "unavailable" | |
| 445 | − | ? "Agent sessions couldn't be read, so only pull requests are counted. " | |
| 446 | − | : work.code === "unavailable" | |
| 447 | − | ? "Code didn't answer, so only agent sessions are counted. " | |
| 448 | − | : ""} | |
| 449 | − | {work.partial ? "A list was cut short, so some work may be missing." : ""} | |
| 450 | − | </p> | |
| 528 | + | <span className="ml-auto"> | |
| 529 | + | <Faces actors={col.actors} faces={faces} /> | |
| 530 | + | </span> | |
| 531 | + | </div> | |
| 532 | + | {col.lines.length > 0 ? <Lines column={col} /> : <p className="mt-3 text-sm text-muted">{silent.length > 0 ? `Nothing from what could be read ${when}.` : empty}</p>} | |
| 533 | + | {silent.length > 0 && ( | |
| 534 | + | <p className="mt-3 flex items-center gap-1.5 text-xs text-faint"> | |
| 535 | + | <AlertTriangle size={12} className="shrink-0" /> | |
| 536 | + | {listOf(silent)} didn't answer, so {silent.length === 1 ? "its part" : "their parts"} {silent.length === 1 ? "isn't" : "aren't"} counted. | |
| 537 | + | </p> | |
| 538 | + | )} | |
| 539 | + | {children} | |
| 540 | + | </div> | |
| 541 | + | ); | |
| 542 | + | return ( | |
| 543 | + | <section className="grid overflow-hidden rounded-2xl border border-line bg-surface md:grid-cols-2 md:divide-x md:divide-line" aria-label="What people and agents did"> | |
| 544 | + | <div className="border-b border-line md:border-b-0"> | |
| 545 | + | {column( | |
| 546 | + | "People", | |
| 547 | + | <Users size={15} className="text-muted" />, | |
| 548 | + | digest.people, | |
| 549 | + | peopleMissing, | |
| 550 | + | code ? `Nobody pushed, opened, merged, reviewed, edited a doc or said anything ${when}.` : `Nobody edited a doc or said anything ${when}.`, | |
| 451 | 551 | )} | |
| 452 | 552 | </div> | |
| 553 | + | <div> | |
| 554 | + | {column( | |
| 555 | + | "Agents", | |
| 556 | + | <Bot size={15} className="text-accent" />, | |
| 557 | + | digest.agents, | |
| 558 | + | agentsMissing, | |
| 559 | + | `No agent settled a task, pushed, opened a pull request or replied ${when}.`, | |
| 560 | + | <> | |
| 561 | + | {work && (work.tasks.length > 0 || work.code === "read") && <Acceptance work={work} span={span} />} | |
| 562 | + | {work?.partial && <p className="mt-3 text-xs text-faint">A list was cut short, so some agent work may be missing.</p>} | |
| 563 | + | </>, | |
| 564 | + | )} | |
| 565 | + | </div> | |
| 566 | + | {digest.partial && !work?.partial && <p className="border-t border-line px-5 py-2.5 text-xs text-faint md:col-span-2">More happened than Home reads at once, so some counts are low.</p>} | |
| 453 | 567 | </section> | |
| 454 | 568 | ); | |
| 455 | 569 | } | |
| 456 | 570 | ||
| 457 | 571 | export function SpanSkeleton() { | |
| 458 | 572 | return ( | |
| 459 | − | <section className="grid rounded-2xl border border-line bg-surface md:grid-cols-[15rem_1fr]" aria-busy="true"> | |
| 460 | − | <div className="space-y-3 border-b border-line p-5 md:border-r md:border-b-0"> | |
| 461 | − | <Skeleton className="h-3.5 w-32" /> | |
| 462 | − | <Skeleton className="h-12 w-24" /> | |
| 463 | − | <Skeleton className="h-3 w-40" /> | |
| 464 | − | </div> | |
| 465 | − | <div className="space-y-5 p-5"> | |
| 466 | − | <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| 467 | − | {[0, 1, 2, 3].map((i) => ( | |
| 468 | − | <div key={i} className="space-y-2"> | |
| 469 | − | <Skeleton className="h-3 w-20" /> | |
| 470 | − | <Skeleton className="h-5 w-8" /> | |
| 471 | − | </div> | |
| 472 | − | ))} | |
| 573 | + | <section className="grid rounded-2xl border border-line bg-surface md:grid-cols-2 md:divide-x md:divide-line" aria-busy="true"> | |
| 574 | + | {[0, 1].map((column) => ( | |
| 575 | + | <div key={column} className={cn("space-y-3 p-5", column === 0 && "border-b border-line md:border-b-0")}> | |
| 576 | + | <div className="flex items-center justify-between"> | |
| 577 | + | <Skeleton className="h-3.5 w-20" /> | |
| 578 | + | <Skeleton className="h-5 w-24 rounded-full" /> | |
| 579 | + | </div> | |
| 580 | + | <Skeleton className="h-3.5 w-3/4" /> | |
| 581 | + | <Skeleton className="h-3.5 w-1/2" /> | |
| 582 | + | <Skeleton className="h-3.5 w-2/3" /> | |
| 473 | 583 | </div> | |
| 474 | − | <Skeleton className="h-6 w-3/4" /> | |
| 475 | − | </div> | |
| 584 | + | ))} | |
| 476 | 585 | </section> | |
| 477 | 586 | ); | |
| 478 | 587 | } | |
| ⋯ | |||
| 525 | 634 | ||
| 526 | 635 | const rowClass = "flex items-center gap-3 border-t border-line px-5 py-2.5 first:border-t-0"; | |
| 527 | 636 | ||
| 528 | − | export function LandedCard({ landed, code, now, tz }: { landed: LandedChange[] | null; code: boolean; now: number; tz: string | null }) { | |
| 637 | + | const LANDED_ICON: Record<LandedChange["kind"], (agent: boolean) => ReactNode> = { | |
| 638 | + | merge: (agent) => <GitMerge size={15} className={cn("shrink-0", agent ? "text-accent" : "text-success")} />, | |
| 639 | + | push: (agent) => <GitCommitHorizontal size={15} className={cn("shrink-0", agent ? "text-accent" : "text-success")} />, | |
| 640 | + | deploy: () => <Rocket size={15} className="shrink-0 text-success" />, | |
| 641 | + | release: () => <Tag size={15} className="shrink-0 text-info" />, | |
| 642 | + | package: () => <Package size={15} className="shrink-0 text-info" />, | |
| 643 | + | }; | |
| 644 | + | ||
| 645 | + | /** A few words on the card's line: how many of each kind landed. */ | |
| 646 | + | function landedHint(landed: LandedChange[]): string { | |
| 647 | + | const count = (kind: LandedChange["kind"]) => landed.filter((change) => change.kind === kind).length; | |
| 648 | + | const parts: string[] = []; | |
| 649 | + | const merged = count("merge"); | |
| 650 | + | const pushes = count("push"); | |
| 651 | + | const deploys = count("deploy"); | |
| 652 | + | const published = count("release") + count("package"); | |
| 653 | + | if (merged > 0) parts.push(`${merged.toLocaleString("en-US")} merged`); | |
| 654 | + | if (pushes > 0) parts.push(`${pushes.toLocaleString("en-US")} pushed direct`); | |
| 655 | + | if (deploys > 0) parts.push(`${deploys.toLocaleString("en-US")} ${deploys === 1 ? "deploy" : "deploys"}`); | |
| 656 | + | if (published > 0) parts.push(`${published.toLocaleString("en-US")} published`); | |
| 657 | + | const agents = landed.filter((change) => change.agent).length; | |
| 658 | + | parts.push(`${agents.toLocaleString("en-US")} by agents`); | |
| 659 | + | return parts.join(" · "); | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | /** | |
| 663 | + | * Everything that landed in the span, newest first: pull requests merged, | |
| 664 | + | * commits pushed straight to a default branch, production builds that went | |
| 665 | + | * live, releases and package versions published. | |
| 666 | + | */ | |
| 667 | + | export function LandedCard({ landed, missing, code, now, tz }: { landed: LandedChange[] | null; missing: string[]; code: boolean; now: number; tz: string | null }) { | |
| 529 | 668 | if (!code) return null; | |
| 530 | − | const agents = landed?.filter((change) => change.agent).length ?? 0; | |
| 531 | 669 | return ( | |
| 532 | − | <Card title="Landed" hint={landed && landed.length > 0 ? `${landed.length.toLocaleString("en-US")} merged · ${agents.toLocaleString("en-US")} by agents` : undefined}> | |
| 670 | + | <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}> | |
| 533 | 671 | {!landed ? ( | |
| 534 | 672 | <Quiet>Code didn't answer, so what landed can't be listed right now.</Quiet> | |
| 535 | 673 | ) : landed.length === 0 ? ( | |
| 536 | − | <Quiet>Nothing merged in this span.</Quiet> | |
| 674 | + | <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> | |
| 537 | 675 | ) : ( | |
| 538 | 676 | <Folded | |
| 539 | 677 | items={landed} | |
| 540 | 678 | render={(change) => ( | |
| 541 | 679 | <li key={change.key} className={rowClass}> | |
| 542 | − | <GitMerge size={15} className={cn("shrink-0", change.agent ? "text-accent" : "text-success")} /> | |
| 680 | + | {LANDED_ICON[change.kind](change.agent)} | |
| 543 | 681 | <div className="min-w-0 grow"> | |
| 544 | 682 | <Link to={change.to} className="block truncate text-sm text-fg hover:underline"> | |
| 545 | 683 | {change.title} | |
| 546 | 684 | </Link> | |
| 547 | − | <p className="truncate text-xs text-muted"> | |
| 548 | − | {change.repo.name}#{change.number} · {change.agent ? `by @${change.author}` : `by ${change.author}`} | |
| 549 | − | {change.mergedBy && change.mergedBy !== change.author ? `, merged by ${change.mergedBy}` : ""} | |
| 550 | − | </p> | |
| 685 | + | <p className="truncate text-xs text-muted">{change.detail}</p> | |
| 551 | 686 | </div> | |
| 552 | 687 | <When at={change.at} now={now} tz={tz} /> | |
| 553 | 688 | </li> | |
| 554 | 689 | )} | |
| 555 | 690 | /> | |
| 556 | 691 | )} | |
| 692 | + | {landed && <Missing names={missing} what="everything that landed" />} | |
| 557 | 693 | </Card> | |
| 558 | 694 | ); | |
| 559 | 695 | } | |
| ⋯ | |||
| 701 | 837 | const RUNNING_ICON: Record<RunningRow["kind"], ReactNode> = { | |
| 702 | 838 | session: <Sparkles size={15} className="text-accent" />, | |
| 703 | 839 | change: <GitPullRequest size={15} className="text-info" />, | |
| 840 | + | workflow: <Workflow size={15} className="text-info" />, | |
| 704 | 841 | deploy: <Rocket size={15} className="text-info" />, | |
| 705 | 842 | }; | |
| 706 | 843 | ||
| ⋯ | |||
| 708 | 845 | return ( | |
| 709 | 846 | <> | |
| 710 | 847 | {rows.length === 0 ? ( | |
| 711 | − | <Quiet>{missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Agent sessions, agents' changes and builds show here while they go."}</Quiet> | |
| 848 | + | <Quiet>{missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Agent sessions, agents' changes, workflow runs and builds show here while they go."}</Quiet> | |
| 712 | 849 | ) : ( | |
| 713 | 850 | <Folded | |
| 714 | 851 | items={rows} | |
| 5 | 5 | */ | |
| 6 | 6 | import { env } from "cloudflare:workers"; | |
| 7 | 7 | ||
| 8 | − | import { type AgentSession, type AgentsOverview, type Deployment, type InstallRequests, type Memory, type ProjectDeploys, type Repo, type User, notifyClient } from "@g1t/contracts"; | |
| 8 | + | import { | |
| 9 | + | type ActivityDigest, | |
| 10 | + | type AgentSession, | |
| 11 | + | type AgentsOverview, | |
| 12 | + | type ChatActivity, | |
| 13 | + | type Deployment, | |
| 14 | + | type InstallRequests, | |
| 15 | + | MAX_DIGEST_REPOS, | |
| 16 | + | type Memory, | |
| 17 | + | type ProjectDeploys, | |
| 18 | + | type Repo, | |
| 19 | + | type RepoPath, | |
| 20 | + | type User, | |
| 21 | + | type WorkflowRun, | |
| 22 | + | type WorkspaceAgent, | |
| 23 | + | notifyClient, | |
| 24 | + | } from "@g1t/contracts"; | |
| 9 | 25 | ||
| 10 | − | import { type CodePull, type CodeWork, type SessionWork, type Span, type Spend, pullKey, spanMonths, spendIn } from "./home"; | |
| 26 | + | import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type SessionWork, type Span, type Spend, pullKey, spanMonths, spendIn } from "./home"; | |
| 11 | 27 | import { monthSpan, spentMicros } from "./spend"; | |
| 12 | − | import { agents, billing, deployments, repos, work, workspaceAgents } from "./services.server"; | |
| 28 | + | import { actions, agents, billing, chat, deployments, events, identity, repos, work, workspaceAgents } from "./services.server"; | |
| 13 | 29 | ||
| 14 | 30 | /** Pull requests read per project and list: `LIST_PAGE` in services/work. */ | |
| 15 | 31 | const PULL_PAGE = 100; | |
| ⋯ | |||
| 17 | 33 | const MAX_PROJECTS = 50; | |
| 18 | 34 | /** The most projects whose builds are read for the span. */ | |
| 19 | 35 | const MAX_DEPLOY_PROJECTS = 20; | |
| 36 | + | /** The most projects whose workflow runs are read for Running now: those pushed to most in the span. */ | |
| 37 | + | const MAX_WORKFLOW_PROJECTS = 10; | |
| 38 | + | /** Runs read per project: enough to find the ones not finished. */ | |
| 39 | + | const WORKFLOW_RUNS = 20; | |
| 20 | 40 | /** Revise runs and sessions read: the services' most in one list. */ | |
| 21 | 41 | const RUN_PAGE = 200; | |
| 22 | 42 | const SESSION_PAGE = 200; | |
| 43 | + | /** The most member keys named in one lookup. */ | |
| 44 | + | const MAX_NAMES = 200; | |
| 23 | 45 | const DAY = 24 * 60 * 60 * 1000; | |
| 24 | 46 | ||
| 25 | 47 | const warn = (what: string) => (error: unknown) => { | |
| ⋯ | |||
| 47 | 69 | } | |
| 48 | 70 | ||
| 49 | 71 | /** | |
| 72 | + | * The workspace's own repositories the viewer may read (no pull request | |
| 73 | + | * working copies), as every Code section reads them. Null when repos did | |
| 74 | + | * not answer. | |
| 75 | + | */ | |
| 76 | + | export async function loadRepos(viewer: User, slug: string): Promise<Repo[] | null> { | |
| 77 | + | const list = await repos.list(viewer, { namespace: slug }).catch(warn("repos")); | |
| 78 | + | return list ? list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug) : null; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /** The repositories by id, as the digest's lines and links need them. */ | |
| 82 | + | export function repoRefs(list: Repo[] | null): Record<string, RepoRef> { | |
| 83 | + | const refs: Record<string, RepoRef> = {}; | |
| 84 | + | for (const repo of list ?? []) refs[repo.id] = { namespace: repo.namespace, name: repo.name, defaultBranch: repo.defaultBranch }; | |
| 85 | + | return refs; | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** | |
| 50 | 89 | * The workspace's projects' pull requests, open and recently merged or | |
| 51 | 90 | * closed, any author's, with how many times each agent's was sent back to | |
| 52 | 91 | * revise. `complete` when every list reaches back to `reach`. Null when | |
| 53 | 92 | * Code did not answer. | |
| 54 | 93 | */ | |
| 55 | − | export async function loadCodeWork(viewer: User, slug: string, reach: number): Promise<CodeWork | null> { | |
| 56 | − | const [list, runs] = await Promise.all([ | |
| 57 | − | repos.list(viewer, { namespace: slug }).catch(warn("repos")), | |
| 58 | − | agents.listRuns(viewer, { workspace: slug, kind: "revise", limit: RUN_PAGE }).catch(warn("revise runs")), | |
| 59 | − | ]); | |
| 60 | − | if (!list || !runs?.ok) return null; | |
| 61 | − | const owned = list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug); | |
| 94 | + | export async function loadCodeWork(viewer: User, slug: string, reach: number, owned: Repo[] | null): Promise<CodeWork | null> { | |
| 95 | + | const runs = await agents.listRuns(viewer, { workspace: slug, kind: "revise", limit: RUN_PAGE }).catch(warn("revise runs")); | |
| 96 | + | if (!owned || !runs?.ok) return null; | |
| 62 | 97 | const chosen = owned.slice(0, MAX_PROJECTS); | |
| 63 | 98 | const byId = new Map<string, Repo>(chosen.map((repo) => [repo.id, repo])); | |
| 64 | 99 | const batch = chosen.length > 0 ? await work.pullsForRepos([...byId.keys()], viewer, PULL_PAGE).catch(warn("pull requests")) : []; | |
| ⋯ | |||
| 100 | 135 | return { pulls, revisions, complete }; | |
| 101 | 136 | } | |
| 102 | 137 | ||
| 138 | + | /** | |
| 139 | + | * What happened in the span, counted by who did it: the events service's | |
| 140 | + | * digest of the workspace's repositories (the first MAX_DIGEST_REPOS of | |
| 141 | + | * them; none without Code access) and of its artifacts. Null when events | |
| 142 | + | * did not answer. | |
| 143 | + | */ | |
| 144 | + | export async function loadActivity(slug: string, span: Pick<Span, "from" | "now">, owned: Repo[] | null): Promise<ActivityDigest | null> { | |
| 145 | + | const digest = await events | |
| 146 | + | .activityDigest({ | |
| 147 | + | repo_ids: (owned ?? []).slice(0, MAX_DIGEST_REPOS).map((repo) => repo.id), | |
| 148 | + | workspace: slug, | |
| 149 | + | from: new Date(span.from).toISOString(), | |
| 150 | + | until: new Date(span.now + 1).toISOString(), | |
| 151 | + | }) | |
| 152 | + | .catch(warn("activity digest")); | |
| 153 | + | if (!digest) return null; | |
| 154 | + | // More repositories than the digest reads: it says so. | |
| 155 | + | return owned && owned.length > MAX_DIGEST_REPOS ? { ...digest, complete: false } : digest; | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** What was said in the span where the viewer can read, counted. Null when chat did not answer. */ | |
| 159 | + | export async function loadChatActivity(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<ChatActivity | null> { | |
| 160 | + | const activity = await chat | |
| 161 | + | .activity(slug, viewer, { from: new Date(span.from).toISOString(), until: new Date(span.now + 1).toISOString() }) | |
| 162 | + | .catch(warn("chat activity")); | |
| 163 | + | return activity?.ok ? activity.value : null; | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /** Every member key the digest and chat name. */ | |
| 167 | + | export function actorKeys(activity: ActivityDigest | null, chatActivity: ChatActivity | null): string[] { | |
| 168 | + | const keys = new Set<string>(); | |
| 169 | + | for (const repo of activity?.repos ?? []) { | |
| 170 | + | for (const by of [...repo.pushes.by, ...repo.pushes.default_branch.by]) keys.add(by.actor); | |
| 171 | + | for (const list of [repo.pulls.opened, repo.pulls.merged, repo.pulls.closed, repo.issues.opened, repo.issues.closed, repo.reviews, repo.comments, repo.deployments.succeeded, repo.deployments.failed]) { | |
| 172 | + | for (const entry of list) keys.add(entry.actor); | |
| 173 | + | } | |
| 174 | + | for (const release of repo.releases) keys.add(release.actor); | |
| 175 | + | for (const pkg of repo.packages) keys.add(pkg.actor); | |
| 176 | + | } | |
| 177 | + | for (const entry of activity?.folios?.created ?? []) keys.add(entry.actor); | |
| 178 | + | for (const folio of activity?.folios?.edited ?? []) for (const author of folio.authors) keys.add(author); | |
| 179 | + | for (const author of chatActivity?.authors ?? []) keys.add(author.key); | |
| 180 | + | keys.delete(""); | |
| 181 | + | return [...keys]; | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /** | |
| 185 | + | * The names behind member keys: people's usernames from identity (one | |
| 186 | + | * lookup for them all), agents' handles from the workspace's agents, g1t | |
| 187 | + | * as itself. A key identity does not know is left out, and shows as | |
| 188 | + | * "someone". | |
| 189 | + | */ | |
| 190 | + | export async function loadNames(keys: string[], agentsList: Pick<WorkspaceAgent, "id" | "handle">[] | null): Promise<Record<string, string>> { | |
| 191 | + | const names: Record<string, string> = {}; | |
| 192 | + | const userIds: string[] = []; | |
| 193 | + | const byAgentId = new Map((agentsList ?? []).map((agent) => [agent.id, agent.handle])); | |
| 194 | + | for (const key of keys.slice(0, MAX_NAMES)) { | |
| 195 | + | if (key === G1T_ACTOR) names[key] = "g1t"; | |
| 196 | + | else if (key.startsWith("agent:")) { | |
| 197 | + | const handle = byAgentId.get(key.slice("agent:".length)); | |
| 198 | + | if (handle) names[key] = handle; | |
| 199 | + | } else if (key.startsWith("user:")) userIds.push(key.slice("user:".length)); | |
| 200 | + | } | |
| 201 | + | if (userIds.length > 0) { | |
| 202 | + | const found = await identity.usernames(userIds).catch(warn("usernames")); | |
| 203 | + | for (const [id, username] of Object.entries(found ?? {})) names[`user:${id}`] = username; | |
| 204 | + | } | |
| 205 | + | return names; | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /** | |
| 209 | + | * The newest workflow runs of the projects pushed to most in the span (at | |
| 210 | + | * most MAX_WORKFLOW_PROJECTS), for the ones still going. A project whose | |
| 211 | + | * runs could not be read is left out; `complete` says whether every one was. | |
| 212 | + | */ | |
| 213 | + | export async function loadWorkflowRuns( | |
| 214 | + | viewer: User, | |
| 215 | + | activity: ActivityDigest | null, | |
| 216 | + | owned: Repo[] | null, | |
| 217 | + | ): Promise<{ projects: { repo: RepoPath; runs: WorkflowRun[] }[]; complete: boolean }> { | |
| 218 | + | const byId = new Map((owned ?? []).map((repo) => [repo.id, repo])); | |
| 219 | + | const active = [...(activity?.repos ?? [])] | |
| 220 | + | .filter((repo) => repo.pushes.count > 0 && byId.has(repo.repo_id)) | |
| 221 | + | .sort((a, b) => b.pushes.count - a.pushes.count || a.repo_id.localeCompare(b.repo_id)) | |
| 222 | + | .slice(0, MAX_WORKFLOW_PROJECTS); | |
| 223 | + | const lists = await Promise.all( | |
| 224 | + | active.map((digest) => { | |
| 225 | + | const repo = byId.get(digest.repo_id)!; | |
| 226 | + | const path = { namespace: repo.namespace, name: repo.name }; | |
| 227 | + | return actions | |
| 228 | + | .runs(path, viewer, { limit: WORKFLOW_RUNS }) | |
| 229 | + | .then((result) => (result.ok ? { repo: path, runs: result.value } : null)) | |
| 230 | + | .catch(warn(`workflow runs of ${repo.name}`)); | |
| 231 | + | }), | |
| 232 | + | ); | |
| 233 | + | const projects = lists.filter((entry) => entry != null); | |
| 234 | + | return { projects, complete: projects.length === active.length }; | |
| 235 | + | } | |
| 236 | + | ||
| 103 | 237 | /** The workspace's newest sessions; `complete` when the list reaches back before `from`. */ | |
| 104 | 238 | export async function loadSessions(viewer: User, slug: string, from: number): Promise<SessionWork | null> { | |
| 105 | 239 | const listed = await workspaceAgents.sessions(slug, viewer, { limit: SESSION_PAGE }).catch(warn("sessions")); | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import type { AgentSession, Deployment, InboxItem, InstallRequest, Memory } from "@g1t/contracts"; | |
| 4 | + | import type { ActivityDigest, ActorCount, AgentSession, ChatActivity, Deployment, InboxItem, InstallRequest, Memory, RepoDigest, WorkflowRun } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | 7 | type AttentionRow, | |
| 8 | 8 | type CodeNeed, | |
| 9 | 9 | type CodePull, | |
| 10 | 10 | acceptance, | |
| 11 | + | actorKind, | |
| 11 | 12 | agentRow, | |
| 12 | 13 | attention, | |
| 13 | 14 | chatRow, | |
| ⋯ | |||
| 15 | 16 | dayIn, | |
| 16 | 17 | decisionsIn, | |
| 17 | 18 | deploysIn, | |
| 19 | + | digestOf, | |
| 18 | 20 | duration, | |
| 19 | 21 | installRequestRow, | |
| 22 | + | landed, | |
| 23 | + | landedDeploys, | |
| 24 | + | landedFromActivity, | |
| 20 | 25 | landedIn, | |
| 21 | 26 | limitRow, | |
| 22 | 27 | notificationRow, | |
| ⋯ | |||
| 26 | 31 | running, | |
| 27 | 32 | runningDeploys, | |
| 28 | 33 | runningSessions, | |
| 34 | + | runningWorkflows, | |
| 29 | 35 | sessionOutcome, | |
| 30 | 36 | sinceWords, | |
| 31 | 37 | spanFor, | |
| ⋯ | |||
| 365 | 371 | ]); | |
| 366 | 372 | assert.equal(work.code, "read"); | |
| 367 | 373 | assert.equal(work.partial, false); | |
| 368 | − | assert.equal( | |
| 369 | − | spanSentence({ words: "Since Tuesday evening" }, work, 3), | |
| 370 | − | "Since Tuesday evening, agents finished 3 tasks and 3 changes landed. 1 needed a fix after review.", | |
| 371 | − | ); | |
| 372 | 374 | }); | |
| 373 | 375 | ||
| 374 | 376 | test("no trend when the 7 days before could not all be read, and none without Code", () => { | |
| ⋯ | |||
| 389 | 391 | assert.equal(trendLabel(null, { value: 0.5 }), null); | |
| 390 | 392 | }); | |
| 391 | 393 | ||
| 392 | − | test("the sentence for a quiet span, and for what needs you", () => { | |
| 393 | − | const quiet = spanWork({ span: SPAN, slug: "acme", code: { pulls: [], revisions: {}, complete: true }, sessions: { sessions: [], complete: true } }); | |
| 394 | − | assert.equal(spanSentence({ words: "Since an hour ago" }, quiet, 0), "Since an hour ago, nothing new has settled."); | |
| 395 | − | assert.equal(spanSentence({ words: "In the last 24 hours" }, quiet, 2), "In the last 24 hours, 2 changes landed."); | |
| 396 | − | assert.equal(spanSentence({ words: "Since this morning" }, null, null), "Since this morning, agent work couldn't be read."); | |
| 394 | + | // --- People and agents ------------------------------------------------------------ | |
| 395 | + | ||
| 396 | + | const ANA = "user:usr_ana"; | |
| 397 | + | const BO = "user:usr_bo"; | |
| 398 | + | const CY = "user:usr_cy"; | |
| 399 | + | const MARGO = "agent:agt_margo"; | |
| 400 | + | const G1T = "user:usr_g1t_agent"; | |
| 401 | + | const NAMES = { [ANA]: "ana", [BO]: "bo", [CY]: "cy", [MARGO]: "margo" }; | |
| 402 | + | const REPOS = { | |
| 403 | + | rep_web: { namespace: "acme", name: "web", defaultBranch: "main" }, | |
| 404 | + | rep_api: { namespace: "acme", name: "api", defaultBranch: "main" }, | |
| 405 | + | rep_docs: { namespace: "acme", name: "docs", defaultBranch: "main" }, | |
| 406 | + | rep_cli: { namespace: "acme", name: "cli", defaultBranch: "trunk" }, | |
| 407 | + | }; | |
| 408 | + | ||
| 409 | + | const count = (actor: string, n: number): ActorCount => ({ actor, count: n }); | |
| 410 | + | const quietRepo = (repo_id: string): RepoDigest => ({ | |
| 411 | + | repo_id, | |
| 412 | + | pushes: { count: 0, commits: 0, branches: [], by: [], default_branch: { count: 0, commits: 0, by: [], last_at: null } }, | |
| 413 | + | pulls: { opened: [], merged: [], closed: [] }, | |
| 414 | + | issues: { opened: [], closed: [] }, | |
| 415 | + | reviews: [], | |
| 416 | + | comments: [], | |
| 417 | + | deployments: { succeeded: [], failed: [], production: 0 }, | |
| 418 | + | releases: [], | |
| 419 | + | packages: [], | |
| 420 | + | }); | |
| 421 | + | ||
| 422 | + | /** | |
| 423 | + | * A day on a busy workspace: 3 people pushed 41 commits to 4 projects, | |
| 424 | + | * merged 5 pull requests and opened 2; agents (g1t and Margo) pushed 6, | |
| 425 | + | * opened 3 pull requests and reviewed 2; 2 production deploys went out; | |
| 426 | + | * 120 messages were sent, 15 of them Margo's; 7 docs were edited. | |
| 427 | + | */ | |
| 428 | + | function busyDay(): ActivityDigest { | |
| 429 | + | return { | |
| 430 | + | from: iso(SPAN.from), | |
| 431 | + | until: iso(SPAN.now), | |
| 432 | + | complete: true, | |
| 433 | + | repos: [ | |
| 434 | + | { | |
| 435 | + | ...quietRepo("rep_web"), | |
| 436 | + | pushes: { | |
| 437 | + | count: 14, | |
| 438 | + | commits: 33, | |
| 439 | + | branches: ["fix-login", "main"], | |
| 440 | + | by: [ | |
| 441 | + | { actor: ANA, count: 8, commits: 24 }, | |
| 442 | + | { actor: BO, count: 4, commits: 5 }, | |
| 443 | + | { actor: G1T, count: 2, commits: 4 }, | |
| 444 | + | ], | |
| 445 | + | default_branch: { count: 3, commits: 9, by: [{ actor: ANA, count: 3, commits: 9 }], last_at: iso(NOW - 2 * HOUR) }, | |
| 446 | + | }, | |
| 447 | + | pulls: { opened: [count(ANA, 1), count(G1T, 2), count(MARGO, 1)], merged: [count(ANA, 3), count(BO, 1)], closed: [] }, | |
| 448 | + | issues: { opened: [count(BO, 2)], closed: [count(ANA, 1), count(G1T, 1)] }, | |
| 449 | + | reviews: [count(BO, 2), count(G1T, 2)], | |
| 450 | + | comments: [count(ANA, 4), count(MARGO, 3)], | |
| 451 | + | deployments: { succeeded: [count(ANA, 2)], failed: [], production: 2 }, | |
| 452 | + | releases: [{ tag: "v1.4.0", name: "Autumn", actor: ANA, at: iso(NOW - 3 * HOUR) }], | |
| 453 | + | packages: [], | |
| 454 | + | }, | |
| 455 | + | { | |
| 456 | + | ...quietRepo("rep_api"), | |
| 457 | + | pushes: { count: 5, commits: 8, branches: ["main"], by: [{ actor: CY, count: 5, commits: 8 }], default_branch: { count: 5, commits: 8, by: [{ actor: CY, count: 5, commits: 8 }], last_at: iso(NOW - 5 * HOUR) } }, | |
| 458 | + | pulls: { opened: [count(CY, 1)], merged: [count(CY, 1)], closed: [] }, | |
| 459 | + | packages: [{ ecosystem: "container", name: "api", version: "1.4.0", actor: G1T, at: iso(NOW - HOUR) }], | |
| 460 | + | }, | |
| 461 | + | { | |
| 462 | + | ...quietRepo("rep_docs"), | |
| 463 | + | pushes: { count: 2, commits: 2, branches: ["typo"], by: [{ actor: BO, count: 2, commits: 2 }], default_branch: { count: 0, commits: 0, by: [], last_at: null } }, | |
| 464 | + | }, | |
| 465 | + | { | |
| 466 | + | ...quietRepo("rep_cli"), | |
| 467 | + | pushes: { count: 3, commits: 4, branches: ["trunk"], by: [{ actor: ANA, count: 1, commits: 2 }, { actor: MARGO, count: 2, commits: 2 }], default_branch: { count: 3, commits: 4, by: [{ actor: ANA, count: 1, commits: 2 }, { actor: MARGO, count: 2, commits: 2 }], last_at: iso(NOW - 30 * 60_000) } }, | |
| 468 | + | }, | |
| 469 | + | ], | |
| 470 | + | folios: { | |
| 471 | + | created: [count(ANA, 1)], | |
| 472 | + | edited_count: 7, | |
| 473 | + | edited: [ | |
| 474 | + | { folio_id: "fol_1", kind: "doc", authors: [ANA, MARGO] }, | |
| 475 | + | { folio_id: "fol_2", kind: "doc", authors: [ANA] }, | |
| 476 | + | { folio_id: "fol_3", kind: "doc", authors: [BO] }, | |
| 477 | + | { folio_id: "fol_4", kind: "doc", authors: [BO] }, | |
| 478 | + | { folio_id: "fol_5", kind: "doc", authors: [CY] }, | |
| 479 | + | { folio_id: "fol_6", kind: "doc", authors: [CY] }, | |
| 480 | + | { folio_id: "fol_7", kind: "doc", authors: [MARGO] }, | |
| 481 | + | ], | |
| 482 | + | }, | |
| 483 | + | }; | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | const busyChat: ChatActivity = { | |
| 487 | + | from: iso(SPAN.from), | |
| 488 | + | until: iso(SPAN.now), | |
| 489 | + | messages: 120, | |
| 490 | + | channels: 5, | |
| 491 | + | authors: [ | |
| 492 | + | { key: ANA, messages: 60 }, | |
| 493 | + | { key: BO, messages: 30 }, | |
| 494 | + | { key: CY, messages: 15 }, | |
| 495 | + | { key: MARGO, messages: 15 }, | |
| 496 | + | ], | |
| 497 | + | }; | |
| 498 | + | ||
| 499 | + | /** Seven agent tasks that settled well, and one that did not. */ | |
| 500 | + | function busyWork() { | |
| 501 | + | return spanWork({ | |
| 502 | + | span: SPAN, | |
| 503 | + | slug: "acme", | |
| 504 | + | code: { | |
| 505 | + | pulls: [1, 2, 3, 4, 5].map((n) => pull({ number: n, mergedAt: iso(NOW - n * HOUR), updatedAt: iso(NOW - n * HOUR) })).concat(pull({ number: 6, status: "closed", mergedAt: null })), | |
| 506 | + | revisions: { "acme/web#2": 1 }, | |
| 507 | + | complete: true, | |
| 508 | + | }, | |
| 509 | + | sessions: { sessions: [session(), session({ id: "s2", kind: "routine" })], complete: true }, | |
| 510 | + | }); | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | test("an actor is an agent when it is one of the workspace's agents or g1t; everyone else is a person", () => { | |
| 514 | + | assert.equal(actorKind(MARGO), "agent"); | |
| 515 | + | assert.equal(actorKind(G1T), "agent"); | |
| 516 | + | assert.equal(actorKind(ANA), "person"); | |
| 517 | + | assert.equal(actorKind(""), "person"); | |
| 518 | + | assert.equal(actorKind("user:usr_bot", new Set(["user:usr_bot"])), "agent"); | |
| 519 | + | }); | |
| 520 | + | ||
| 521 | + | test("the digest: what people did, in lines with counts, and who", () => { | |
| 522 | + | const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true }); | |
| 523 | + | assert.deepEqual( | |
| 524 | + | digest.people.lines.map((line) => [line.text, line.to]), | |
| 525 | + | [ | |
| 526 | + | ["3 people pushed 41 commits to 4 projects", "/acme/-/repositories"], | |
| 527 | + | ["opened 2 pull requests, merged 5", "/acme/-/repositories"], | |
| 528 | + | ["reviewed 2 pull requests", "/acme/web/pulls"], | |
| 529 | + | ["opened 2 issues, closed 1", "/acme/web/issues"], | |
| 530 | + | ["left 4 comments", "/acme/web/issues"], | |
| 531 | + | ["2 deploys went out", "/acme/web/deployments"], | |
| 532 | + | ["published 1 release", "/acme/web/releases"], | |
| 533 | + | ["created 1 doc, edited 6 docs", "/acme/-/artifacts"], | |
| 534 | + | ["sent 105 messages in 5 conversations", "/acme/-/chat"], | |
| 535 | + | ], | |
| 536 | + | ); | |
| 537 | + | // Faces: most active first, by name. | |
| 538 | + | assert.deepEqual( | |
| 539 | + | digest.people.actors.map((actor) => [actor.name, actor.kind]), | |
| 540 | + | [ | |
| 541 | + | ["ana", "person"], | |
| 542 | + | ["bo", "person"], | |
| 543 | + | ["cy", "person"], | |
| 544 | + | ], | |
| 545 | + | ); | |
| 546 | + | assert.deepEqual(digest.missing, []); | |
| 547 | + | assert.equal(digest.partial, false); | |
| 548 | + | }); | |
| 549 | + | ||
| 550 | + | test("the digest: what agents did, their tasks first, and g1t and the workspace's agents as agents", () => { | |
| 551 | + | const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true }); | |
| 552 | + | assert.deepEqual( | |
| 553 | + | digest.agents.lines.map((line) => line.text), | |
| 554 | + | [ | |
| 555 | + | "finished 7 tasks, 1 didn't finish", | |
| 556 | + | "ran 2 sessions", | |
| 557 | + | "pushed 6 commits to 2 projects", | |
| 558 | + | "opened 3 pull requests", | |
| 559 | + | "reviewed 2 pull requests", | |
| 560 | + | "closed 1 issue", | |
| 561 | + | "left 3 comments", | |
| 562 | + | "published 1 package version", | |
| 563 | + | "edited 2 docs", | |
| 564 | + | "replied 15 times in chat", | |
| 565 | + | ], | |
| 566 | + | ); | |
| 567 | + | assert.deepEqual( | |
| 568 | + | digest.agents.actors.map((actor) => [actor.name, actor.kind, actor.key]), | |
| 569 | + | [ | |
| 570 | + | ["margo", "agent", MARGO], | |
| 571 | + | ["g1t", "agent", G1T], | |
| 572 | + | ], | |
| 573 | + | ); | |
| 574 | + | assert.deepEqual(digest.summary, { people: 3, commits: 41, merged: 5, finished: 7, dropped: 1, said: 105, anything: true, peopleRead: true }); | |
| 575 | + | }); | |
| 576 | + | ||
| 577 | + | test("the digest says which part didn't answer, and counts what could be read", () => { | |
| 578 | + | const noChat = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: null, names: NAMES, work: busyWork(), code: true }); | |
| 579 | + | assert.deepEqual(noChat.missing, ["Chat"]); | |
| 580 | + | assert.equal(noChat.people.lines.some((line) => line.key === "chat"), false); | |
| 581 | + | const noCode = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: busyChat, names: NAMES, work: null, code: true }); | |
| 582 | + | assert.deepEqual(noCode.missing, ["Code", "Agents"]); | |
| 583 | + | assert.deepEqual( | |
| 584 | + | noCode.people.lines.map((line) => line.text), | |
| 585 | + | ["sent 105 messages in 5 conversations"], | |
| 586 | + | ); | |
| 587 | + | assert.equal(noCode.summary.peopleRead, true); | |
| 588 | + | // Without Code access, events are read for artifacts only: no repositories, nothing missing. | |
| 589 | + | const member = digestOf({ slug: "acme", repos: {}, activity: { ...busyDay(), repos: [] }, chat: busyChat, names: NAMES, work: busyWork(), code: false }); | |
| 590 | + | assert.deepEqual(member.missing, []); | |
| 591 | + | assert.deepEqual( | |
| 592 | + | member.people.lines.map((line) => line.key), | |
| 593 | + | ["docs", "chat"], | |
| 594 | + | ); | |
| 595 | + | // A digest cut short says so. | |
| 596 | + | const cut = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), complete: false }, chat: busyChat, names: NAMES, work: busyWork(), code: true }); | |
| 597 | + | assert.equal(cut.partial, true); | |
| 598 | + | // Nobody named: a mirror's pushes are still commits. | |
| 599 | + | const mirror = digestOf({ | |
| 600 | + | slug: "acme", | |
| 601 | + | repos: REPOS, | |
| 602 | + | activity: { ...busyDay(), folios: null, repos: [{ ...quietRepo("rep_web"), pushes: { count: 2, commits: 7, branches: ["main"], by: [{ actor: "", count: 2, commits: 7 }], default_branch: { count: 2, commits: 7, by: [{ actor: "", count: 2, commits: 7 }], last_at: iso(NOW - HOUR) } } }] }, | |
| 603 | + | chat: null, | |
| 604 | + | names: {}, | |
| 605 | + | work: null, | |
| 606 | + | code: true, | |
| 607 | + | }); | |
| 608 | + | assert.deepEqual( | |
| 609 | + | mirror.people.lines.map((line) => [line.text, line.to]), | |
| 610 | + | [["7 commits pushed to 1 project", "/acme/web/activity"]], | |
| 611 | + | ); | |
| 612 | + | assert.equal(mirror.summary.people, 0); | |
| 613 | + | }); | |
| 614 | + | ||
| 615 | + | test("the sentence: people first, then the agents' tasks, then what landed; honest when quiet or unread", () => { | |
| 616 | + | const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true }); | |
| 617 | + | assert.equal( | |
| 618 | + | spanSentence({ words: "Since Tuesday evening" }, digest, 12), | |
| 619 | + | "Since Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents finished 7 tasks, and 12 changes landed.", | |
| 620 | + | ); | |
| 621 | + | const quiet = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), repos: [], folios: { created: [], edited: [], edited_count: 0 } }, chat: { ...busyChat, messages: 0, channels: 0, authors: [] }, names: {}, work: busyWork(), code: true }); | |
| 622 | + | quiet.summary.finished = 0; | |
| 623 | + | quiet.summary.dropped = 0; | |
| 624 | + | quiet.summary.anything = false; | |
| 625 | + | assert.equal(spanSentence({ words: "Since Tuesday evening" }, quiet, 0), "Quiet since Tuesday evening: nothing pushed, merged, deployed or said."); | |
| 626 | + | assert.equal(spanSentence({ words: "In the last 24 hours" }, quiet, 0), "Quiet in the last 24 hours: nothing pushed, merged, deployed or said."); | |
| 627 | + | // Only agents' tasks and merges. | |
| 628 | + | const agentsOnly = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), repos: [], folios: null }, chat: { ...busyChat, authors: [], messages: 0, channels: 0 }, names: {}, work: busyWork(), code: true }); | |
| 629 | + | assert.equal(spanSentence({ words: "Since this morning" }, agentsOnly, 5), "Since this morning, agents finished 7 tasks and 5 changes landed."); | |
| 630 | + | // People only talked. | |
| 631 | + | const talked = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), repos: [], folios: null }, chat: busyChat, names: NAMES, work: null, code: true }); | |
| 632 | + | assert.equal(spanSentence({ words: "Since an hour ago" }, talked, 0), "Since an hour ago, 3 people sent 105 messages."); | |
| 633 | + | // Neither events nor chat answered. | |
| 634 | + | const unread = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: null, names: {}, work: busyWork(), code: true }); | |
| 635 | + | assert.equal(spanSentence({ words: "Since this morning" }, unread, 5), "Since this morning, agents finished 7 tasks and 5 changes landed; what people did couldn't be read."); | |
| 636 | + | const nothingRead = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: null, names: {}, work: null, code: true }); | |
| 637 | + | assert.equal(spanSentence({ words: "Since this morning" }, nothingRead, 0), "Since this morning, what people did couldn't be read, and no agent task settled."); | |
| 638 | + | assert.equal(spanSentence({ words: "Since this morning" }, null, null), "Since this morning, what happened couldn't be read."); | |
| 397 | 639 | assert.equal(waitingSentence(0), "Nothing needs you right now."); | |
| 398 | 640 | assert.equal(waitingSentence(1), "1 thing needs you."); | |
| 399 | 641 | assert.equal(waitingSentence(8), "8 things need you."); | |
| 400 | 642 | }); | |
| 401 | 643 | ||
| 402 | 644 | test("what landed: every merge in the span, a person's or an agent's, newest first", () => { | |
| 403 | − | const landed = landedIn( | |
| 645 | + | const merged = landedIn( | |
| 404 | 646 | [ | |
| 405 | 647 | pull({ number: 1, mergedAt: iso(NOW - 2 * DAY), mergedBy: "bo" }), | |
| 406 | 648 | pull({ number: 2, author: { username: "bo", kind: "user" }, mergedAt: iso(NOW - HOUR) }), | |
| ⋯ | |||
| 409 | 651 | ], | |
| 410 | 652 | SPAN, | |
| 411 | 653 | ); | |
| 412 | − | assert.deepEqual(landed.map((change) => [change.number, change.agent]), [ | |
| 413 | − | [2, false], | |
| 414 | − | [1, true], | |
| 654 | + | assert.deepEqual(merged.map((change) => [change.kind, change.title, change.detail, change.agent]), [ | |
| 655 | + | ["merge", "Fix the flaky test", "web#2 · by bo", false], | |
| 656 | + | ["merge", "Fix the flaky test", "web#1 · by @g1t, merged by bo", true], | |
| 415 | 657 | ]); | |
| 416 | − | assert.equal(landed[1]!.mergedBy, "bo"); | |
| 417 | 658 | }); | |
| 418 | 659 | ||
| 660 | + | test("what landed, besides merges: direct pushes, releases, packages and production builds, newest first", () => { | |
| 661 | + | const fromActivity = landedFromActivity(busyDay(), REPOS, NAMES, "acme"); | |
| 662 | + | assert.deepEqual( | |
| 663 | + | fromActivity.map((change) => [change.kind, change.title, change.detail, change.to, change.agent]), | |
| 664 | + | [ | |
| 665 | + | ["push", "9 commits pushed to main", "web · 3 pushes by ana", "/acme/web/activity", false], | |
| 666 | + | ["release", "v1.4.0 · Autumn", "web · released by ana", "/acme/web/releases/tag/v1.4.0", false], | |
| 667 | + | ["push", "8 commits pushed to main", "api · 5 pushes by cy", "/acme/api/activity", false], | |
| 668 | + | ["package", "api 1.4.0", "container package · published by @g1t", "/acme/-/packages", true], | |
| 669 | + | ["push", "4 commits pushed to trunk", "cli · 3 pushes by ana and @margo", "/acme/cli/activity", false], | |
| 670 | + | ], | |
| 671 | + | ); | |
| 672 | + | const builds = landedDeploys([ | |
| 673 | + | { key: "deploy:site:d1", project: "site", kind: "production", outcome: "live", commit: "abcdef1", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d1", by: "g1t" }, | |
| 674 | + | { key: "deploy:site:d2", project: "site", kind: "preview", outcome: "live", commit: "abcdef2", branch: "fix", at: NOW - HOUR, to: "/acme/site/deployments/d2", by: "bo" }, | |
| 675 | + | { key: "deploy:site:d3", project: "site", kind: "production", outcome: "failed", commit: "abcdef3", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d3", by: "bo" }, | |
| 676 | + | ]); | |
| 677 | + | assert.deepEqual(builds.map((change) => [change.kind, change.title, change.agent]), [["deploy", "Production of site went live", true]]); | |
| 678 | + | const all = landed([fromActivity, builds]); | |
| 679 | + | assert.deepEqual( | |
| 680 | + | all.map((change) => change.key), | |
| 681 | + | ["push:rep_cli", "landed:deploy:site:d1", "package:rep_api:container:api:1.4.0", "push:rep_web", "release:rep_web:v1.4.0", "push:rep_api"], | |
| 682 | + | ); | |
| 683 | + | }); | |
| 684 | + | ||
| 419 | 685 | const deploy = (over: Partial<Deployment>): Deployment => ({ | |
| 420 | 686 | id: "d1", | |
| 421 | 687 | kind: "production", | |
| ⋯ | |||
| 544 | 810 | "acme", | |
| 545 | 811 | ); | |
| 546 | 812 | assert.deepEqual(builds.map((row) => row.status), ["Building"]); | |
| 547 | − | // Longest-going first within a kind. | |
| 548 | − | assert.deepEqual(running([builds, sessions]).map((row) => row.key), ["session:e", "session:a", "deploy:site:d1"]); | |
| 813 | + | const run = (over: Partial<WorkflowRun>): WorkflowRun => ({ | |
| 814 | + | id: "run_1", | |
| 815 | + | workflowId: "wf_1", | |
| 816 | + | path: ".g1t/workflows/ci.yml", | |
| 817 | + | name: "CI", | |
| 818 | + | title: "Fix the flaky test", | |
| 819 | + | number: 12, | |
| 820 | + | attempt: 1, | |
| 821 | + | event: "push", | |
| 822 | + | ref: "refs/heads/main", | |
| 823 | + | sha: "abcdef1", | |
| 824 | + | pull: null, | |
| 825 | + | status: "in_progress", | |
| 826 | + | conclusion: null, | |
| 827 | + | error: null, | |
| 828 | + | actor: "ana", | |
| 829 | + | createdAt: iso(NOW - 10 * 60_000), | |
| 830 | + | startedAt: iso(NOW - 9 * 60_000), | |
| 831 | + | finishedAt: null, | |
| 832 | + | ...over, | |
| 833 | + | }); | |
| 834 | + | const workflows = runningWorkflows([ | |
| 835 | + | { repo, runs: [run({}), run({ id: "run_2", status: "queued", startedAt: null, title: "" }), run({ id: "run_3", status: "completed", conclusion: "success" }), run({ id: "run_4", status: "action_required" })] }, | |
| 836 | + | ]); | |
| 837 | + | assert.deepEqual( | |
| 838 | + | workflows.map((row) => [row.key, row.title, row.detail, row.status, row.to]), | |
| 839 | + | [ | |
| 840 | + | ["workflow:acme/web:run_1", "CI: Fix the flaky test", "acme/web main · by ana", "Running", "/acme/web/actions/runs/run_1"], | |
| 841 | + | ["workflow:acme/web:run_2", "CI", "acme/web main · by ana", "Queued", "/acme/web/actions/runs/run_2"], | |
| 842 | + | ["workflow:acme/web:run_4", "CI: Fix the flaky test", "acme/web main · by ana", "Needs approval", "/acme/web/actions/runs/run_4"], | |
| 843 | + | ], | |
| 844 | + | ); | |
| 845 | + | // Longest-going first within a kind: sessions, then changes, then workflow runs, then builds. | |
| 846 | + | assert.deepEqual(running([builds, workflows.slice(0, 1), sessions]).map((row) => row.key), ["session:e", "session:a", "workflow:acme/web:run_1", "deploy:site:d1"]); | |
| 549 | 847 | }); | |
| 550 | 848 | ||
| 551 | 849 | ||
| 8 | 8 | * Home guide (apps/docs/.../guides/home.md). Imports only types. | |
| 9 | 9 | */ | |
| 10 | 10 | import type { | |
| 11 | + | ActivityDigest, | |
| 12 | + | ActorCount, | |
| 11 | 13 | AgentSession, | |
| 12 | 14 | AgentSessionKind, | |
| 15 | + | ChatActivity, | |
| 13 | 16 | Deployment, | |
| 14 | 17 | InboxItem, | |
| 15 | 18 | InstallRequest, | |
| 16 | 19 | Memory, | |
| 17 | 20 | ProjectDeploys, | |
| 18 | 21 | Pull, | |
| 22 | + | RepoDigest, | |
| 19 | 23 | RepoPath, | |
| 20 | 24 | Statement, | |
| 25 | + | WorkflowRun, | |
| 21 | 26 | } from "@g1t/contracts"; | |
| 22 | 27 | ||
| 23 | 28 | import { money } from "./money.ts"; | |
| ⋯ | |||
| 360 | 365 | return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`; | |
| 361 | 366 | } | |
| 362 | 367 | ||
| 363 | − | // --- What else happened in the span ------------------------------------------- | |
| 368 | + | // --- People and agents in the span ----------------------------------------------- | |
| 369 | + | ||
| 370 | + | /** | |
| 371 | + | * Who an actor is. Actors are member keys, as the events and chat services | |
| 372 | + | * return them: `user:<id>` for an account, `agent:<id>` for one of the | |
| 373 | + | * workspace's agents acting as itself, `""` for nobody (a push a mirror | |
| 374 | + | * copied in). An agent is one of the workspace's agents, or g1t itself; | |
| 375 | + | * everyone else is a person. `agents` names more keys that are agents' | |
| 376 | + | * (accounts of kind `agent`), when known. | |
| 377 | + | */ | |
| 378 | + | export type ActorKind = "person" | "agent"; | |
| 379 | + | ||
| 380 | + | /** g1t's own account, as events name it (identity's `AGENT_ID`). */ | |
| 381 | + | export const G1T_ACTOR = "user:usr_g1t_agent"; | |
| 382 | + | ||
| 383 | + | export function actorKind(key: string, agents?: ReadonlySet<string>): ActorKind { | |
| 384 | + | return key.startsWith("agent:") || key === G1T_ACTOR || agents?.has(key) ? "agent" : "person"; | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /** Whether a username is an agent's: g1t, or an agent account. */ | |
| 388 | + | export function isAgentName(name: string | null | undefined): boolean { | |
| 389 | + | return name === "g1t" || (name != null && name.endsWith("-agent")); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | /** A repository as the digest's links and lines need it. */ | |
| 393 | + | export type RepoRef = RepoPath & { defaultBranch: string }; | |
| 394 | + | ||
| 395 | + | /** Someone who did something in the span: for the faces beside a column. */ | |
| 396 | + | export type DigestActor = { | |
| 397 | + | key: string; | |
| 398 | + | kind: ActorKind; | |
| 399 | + | /** Their username or handle; `someone` when it could not be read. */ | |
| 400 | + | name: string; | |
| 401 | + | /** How many things they did, over every line: the order of the faces. */ | |
| 402 | + | count: number; | |
| 403 | + | }; | |
| 404 | + | ||
| 405 | + | /** One line of a column: what was done, with its count, and where that is listed. */ | |
| 406 | + | export type DigestLine = { | |
| 407 | + | key: string; | |
| 408 | + | text: string; | |
| 409 | + | to: string | null; | |
| 410 | + | }; | |
| 411 | + | ||
| 412 | + | export type DigestColumn = { | |
| 413 | + | kind: ActorKind; | |
| 414 | + | lines: DigestLine[]; | |
| 415 | + | /** Most active first. */ | |
| 416 | + | actors: DigestActor[]; | |
| 417 | + | }; | |
| 418 | + | ||
| 419 | + | /** What the sentence under the heading needs. */ | |
| 420 | + | export type DigestSummary = { | |
| 421 | + | /** Distinct people who did anything. */ | |
| 422 | + | people: number; | |
| 423 | + | /** Commits people pushed. */ | |
| 424 | + | commits: number; | |
| 425 | + | /** Pull requests people merged. */ | |
| 426 | + | merged: number; | |
| 427 | + | /** Agent tasks that settled well, and that did not. */ | |
| 428 | + | finished: number; | |
| 429 | + | dropped: number; | |
| 430 | + | /** Messages people sent. */ | |
| 431 | + | said: number; | |
| 432 | + | /** Whether anything at all was counted, in either column. */ | |
| 433 | + | anything: boolean; | |
| 434 | + | /** Whether what people did could be read at all (events and chat both silent: no). */ | |
| 435 | + | peopleRead: boolean; | |
| 436 | + | }; | |
| 437 | + | ||
| 438 | + | export type Digest = { | |
| 439 | + | people: DigestColumn; | |
| 440 | + | agents: DigestColumn; | |
| 441 | + | summary: DigestSummary; | |
| 442 | + | /** Sources that did not answer, by name, for the card to say so: Code, Chat, Agents. */ | |
| 443 | + | missing: string[]; | |
| 444 | + | /** True when a source was cut short, so some counts are low. */ | |
| 445 | + | partial: boolean; | |
| 446 | + | }; | |
| 447 | + | ||
| 448 | + | export type DigestInput = { | |
| 449 | + | slug: string; | |
| 450 | + | /** The workspace's repositories by id: for links, and to count projects. */ | |
| 451 | + | repos: Record<string, RepoRef>; | |
| 452 | + | /** Events' digest; null when events did not answer. Without Code access it has no repositories. */ | |
| 453 | + | activity: ActivityDigest | null; | |
| 454 | + | /** Chat's; null when chat did not answer. */ | |
| 455 | + | chat: ChatActivity | null; | |
| 456 | + | /** Names for member keys: a username, or an agent's handle. */ | |
| 457 | + | names: Record<string, string>; | |
| 458 | + | /** Keys of accounts that are agents, beyond g1t and the workspace's agents. */ | |
| 459 | + | agentKeys?: ReadonlySet<string>; | |
| 460 | + | /** The agents' tasks, for their column; null when neither Code nor Agents answered. */ | |
| 461 | + | work: SpanWork | null; | |
| 462 | + | code: boolean; | |
| 463 | + | }; | |
| 464 | + | ||
| 465 | + | const people = (n: number) => plural(n, "person", "people"); | |
| 466 | + | ||
| 467 | + | /** `a, b and c`. */ | |
| 468 | + | export const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`); | |
| 469 | + | ||
| 470 | + | /** What one kind did, from a tally: how many, and by whom (non-empty keys). */ | |
| 471 | + | function part(counts: ActorCount[], kind: ActorKind, agents?: ReadonlySet<string>): { count: number; actors: Map<string, number> } { | |
| 472 | + | const actors = new Map<string, number>(); | |
| 473 | + | let count = 0; | |
| 474 | + | for (const entry of counts) { | |
| 475 | + | if (actorKind(entry.actor, agents) !== kind) continue; | |
| 476 | + | count += entry.count; | |
| 477 | + | if (entry.actor) actors.set(entry.actor, (actors.get(entry.actor) ?? 0) + entry.count); | |
| 478 | + | } | |
| 479 | + | return { count, actors }; | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | /** Where a line points: the one project's page, or the workspace's projects when several. */ | |
| 483 | + | function placeFor(slug: string, repos: Record<string, RepoRef>, repoIds: Iterable<string>, page: string): string { | |
| 484 | + | const ids = [...new Set(repoIds)]; | |
| 485 | + | const only = ids.length === 1 ? repos[ids[0]!] : null; | |
| 486 | + | return only ? `/${only.namespace}/${only.name}/${page}` : `/${slug}/-/repositories`; | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | /** | |
| 490 | + | * One column: what people, or agents, did in the span, as lines with | |
| 491 | + | * counts and links, and who did it. Pure: everything comes from the | |
| 492 | + | * services' counts and the names looked up for them. | |
| 493 | + | */ | |
| 494 | + | export function digestColumn(kind: ActorKind, input: DigestInput): DigestColumn { | |
| 495 | + | const { slug, repos, activity, chat, names, agentKeys, work } = input; | |
| 496 | + | const lines: DigestLine[] = []; | |
| 497 | + | const did = new Map<string, number>(); | |
| 498 | + | const credit = (actors: Map<string, number>) => { | |
| 499 | + | for (const [key, n] of actors) did.set(key, (did.get(key) ?? 0) + n); | |
| 500 | + | }; | |
| 501 | + | ||
| 502 | + | // The agents' tasks come first in their column: what Home has always counted. | |
| 503 | + | if (kind === "agent" && work) { | |
| 504 | + | const { accepted, fixed, finished, dropped } = work.counts; | |
| 505 | + | const done = accepted + fixed + finished; | |
| 506 | + | if (done > 0 || dropped > 0) { | |
| 507 | + | const text = done > 0 ? `finished ${plural(done, "task")}${dropped > 0 ? `, ${dropped.toLocaleString("en-US")} didn't finish` : ""}` : `finished none of ${plural(dropped, "task")}`; | |
| 508 | + | lines.push({ key: "tasks", text, to: `/${slug}/-/agents` }); | |
| 509 | + | } | |
| 510 | + | const sessions = work.tasks.filter((task) => task.source !== "code").length; | |
| 511 | + | if (sessions > 0) lines.push({ key: "sessions", text: `ran ${plural(sessions, "session")}`, to: `/${slug}/-/agents` }); | |
| 512 | + | } | |
| 364 | 513 | ||
| 365 | − | /** A change that landed: a pull request merged, by a person or an agent. */ | |
| 514 | + | if (activity) { | |
| 515 | + | // Pushes: commits, projects, and who. | |
| 516 | + | const pushers = new Map<string, number>(); | |
| 517 | + | const pushed = new Set<string>(); | |
| 518 | + | let commits = 0; | |
| 519 | + | let pushes = 0; | |
| 520 | + | for (const repo of activity.repos) { | |
| 521 | + | let any = false; | |
| 522 | + | for (const by of repo.pushes.by) { | |
| 523 | + | if (actorKind(by.actor, agentKeys) !== kind) continue; | |
| 524 | + | any = true; | |
| 525 | + | commits += by.commits; | |
| 526 | + | pushes += by.count; | |
| 527 | + | if (by.actor) pushers.set(by.actor, (pushers.get(by.actor) ?? 0) + by.count); | |
| 528 | + | } | |
| 529 | + | if (any) pushed.add(repo.repo_id); | |
| 530 | + | } | |
| 531 | + | if (pushes > 0) { | |
| 532 | + | const where = `to ${plural(pushed.size, "project")}`; | |
| 533 | + | const text = | |
| 534 | + | kind === "person" | |
| 535 | + | ? pushers.size > 0 | |
| 536 | + | ? `${people(pushers.size)} pushed ${plural(commits, "commit")} ${where}` | |
| 537 | + | : `${plural(commits, "commit")} pushed ${where}` | |
| 538 | + | : `pushed ${plural(commits, "commit")} ${where}`; | |
| 539 | + | lines.push({ key: "pushes", text, to: placeFor(slug, repos, pushed, "activity") }); | |
| 540 | + | credit(pushers); | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | // Pull requests, reviews, issues, comments, deploys, releases, packages: summed over projects. | |
| 544 | + | const sum = (pick: (repo: RepoDigest) => ActorCount[]) => { | |
| 545 | + | const total = { count: 0, actors: new Map<string, number>(), repoIds: [] as string[] }; | |
| 546 | + | for (const repo of activity.repos) { | |
| 547 | + | const { count, actors } = part(pick(repo), kind, agentKeys); | |
| 548 | + | if (count === 0) continue; | |
| 549 | + | total.count += count; | |
| 550 | + | total.repoIds.push(repo.repo_id); | |
| 551 | + | for (const [key, n] of actors) total.actors.set(key, (total.actors.get(key) ?? 0) + n); | |
| 552 | + | } | |
| 553 | + | return total; | |
| 554 | + | }; | |
| 555 | + | const opened = sum((repo) => repo.pulls.opened); | |
| 556 | + | const merged = sum((repo) => repo.pulls.merged); | |
| 557 | + | const closed = sum((repo) => repo.pulls.closed); | |
| 558 | + | if (opened.count + merged.count + closed.count > 0) { | |
| 559 | + | const parts: string[] = []; | |
| 560 | + | if (opened.count > 0) parts.push(`opened ${plural(opened.count, "pull request")}`); | |
| 561 | + | if (merged.count > 0) parts.push(`merged ${opened.count > 0 ? merged.count.toLocaleString("en-US") : plural(merged.count, "pull request")}`); | |
| 562 | + | if (closed.count > 0) parts.push(`closed ${closed.count.toLocaleString("en-US")} without merging`); | |
| 563 | + | lines.push({ key: "pulls", text: parts.join(", "), to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds], "pulls") }); | |
| 564 | + | credit(opened.actors); | |
| 565 | + | credit(merged.actors); | |
| 566 | + | credit(closed.actors); | |
| 567 | + | } | |
| 568 | + | const reviews = sum((repo) => repo.reviews); | |
| 569 | + | if (reviews.count > 0) { | |
| 570 | + | lines.push({ key: "reviews", text: `reviewed ${plural(reviews.count, "pull request")}`, to: placeFor(slug, repos, reviews.repoIds, "pulls") }); | |
| 571 | + | credit(reviews.actors); | |
| 572 | + | } | |
| 573 | + | const issuesOpened = sum((repo) => repo.issues.opened); | |
| 574 | + | const issuesClosed = sum((repo) => repo.issues.closed); | |
| 575 | + | if (issuesOpened.count + issuesClosed.count > 0) { | |
| 576 | + | const parts: string[] = []; | |
| 577 | + | if (issuesOpened.count > 0) parts.push(`opened ${plural(issuesOpened.count, "issue")}`); | |
| 578 | + | if (issuesClosed.count > 0) parts.push(`closed ${issuesOpened.count > 0 ? issuesClosed.count.toLocaleString("en-US") : plural(issuesClosed.count, "issue")}`); | |
| 579 | + | lines.push({ key: "issues", text: parts.join(", "), to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds], "issues") }); | |
| 580 | + | credit(issuesOpened.actors); | |
| 581 | + | credit(issuesClosed.actors); | |
| 582 | + | } | |
| 583 | + | const comments = sum((repo) => repo.comments); | |
| 584 | + | if (comments.count > 0) { | |
| 585 | + | lines.push({ key: "comments", text: `left ${plural(comments.count, "comment")}`, to: placeFor(slug, repos, comments.repoIds, "issues") }); | |
| 586 | + | credit(comments.actors); | |
| 587 | + | } | |
| 588 | + | const succeeded = sum((repo) => repo.deployments.succeeded); | |
| 589 | + | const failed = sum((repo) => repo.deployments.failed); | |
| 590 | + | if (succeeded.count + failed.count > 0) { | |
| 591 | + | const text = succeeded.count > 0 ? `${plural(succeeded.count, "deploy")} went out${failed.count > 0 ? `, ${failed.count.toLocaleString("en-US")} failed` : ""}` : `${plural(failed.count, "deploy")} failed`; | |
| 592 | + | lines.push({ key: "deploys", text, to: placeFor(slug, repos, [...succeeded.repoIds, ...failed.repoIds], "deployments") }); | |
| 593 | + | credit(succeeded.actors); | |
| 594 | + | credit(failed.actors); | |
| 595 | + | } | |
| 596 | + | const releases = sum((repo) => tallyOf(repo.releases.map((release) => release.actor))); | |
| 597 | + | if (releases.count > 0) { | |
| 598 | + | lines.push({ key: "releases", text: `published ${plural(releases.count, "release")}`, to: placeFor(slug, repos, releases.repoIds, "releases") }); | |
| 599 | + | credit(releases.actors); | |
| 600 | + | } | |
| 601 | + | const packages = sum((repo) => tallyOf(repo.packages.map((pkg) => pkg.actor))); | |
| 602 | + | if (packages.count > 0) { | |
| 603 | + | lines.push({ key: "packages", text: `published ${plural(packages.count, "package version")}`, to: `/${slug}/-/packages` }); | |
| 604 | + | credit(packages.actors); | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | // Artifacts: made, and whose content changed. | |
| 608 | + | if (activity.folios) { | |
| 609 | + | const made = part(activity.folios.created, kind, agentKeys); | |
| 610 | + | const edited = new Set<string>(); | |
| 611 | + | const editors = new Map<string, number>(); | |
| 612 | + | let allDocs = true; | |
| 613 | + | for (const folio of activity.folios.edited) { | |
| 614 | + | const mine = folio.authors.filter((author) => actorKind(author, agentKeys) === kind); | |
| 615 | + | if (mine.length === 0) continue; | |
| 616 | + | edited.add(folio.folio_id); | |
| 617 | + | if (folio.kind !== "doc") allDocs = false; | |
| 618 | + | for (const author of mine) editors.set(author, (editors.get(author) ?? 0) + 1); | |
| 619 | + | } | |
| 620 | + | const what = (n: number) => plural(n, allDocs ? "doc" : "artifact"); | |
| 621 | + | if (made.count + edited.size > 0) { | |
| 622 | + | const parts: string[] = []; | |
| 623 | + | if (made.count > 0) parts.push(`created ${what(made.count)}`); | |
| 624 | + | if (edited.size > 0) parts.push(`edited ${what(edited.size)}`); | |
| 625 | + | lines.push({ key: "docs", text: parts.join(", "), to: `/${slug}/-/artifacts` }); | |
| 626 | + | credit(made.actors); | |
| 627 | + | credit(editors); | |
| 628 | + | } | |
| 629 | + | } | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // Chat: what each kind said. | |
| 633 | + | if (chat) { | |
| 634 | + | const said = part( | |
| 635 | + | chat.authors.map((author) => ({ actor: author.key, count: author.messages })), | |
| 636 | + | kind, | |
| 637 | + | agentKeys, | |
| 638 | + | ); | |
| 639 | + | if (said.count > 0) { | |
| 640 | + | const text = kind === "person" ? `sent ${plural(said.count, "message")} in ${plural(chat.channels, "conversation")}` : `replied ${plural(said.count, "time")} in chat`; | |
| 641 | + | lines.push({ key: "chat", text, to: `/${slug}/-/chat` }); | |
| 642 | + | credit(said.actors); | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | const actors: DigestActor[] = [...did] | |
| 647 | + | .map(([key, count]) => ({ key, kind, name: names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"), count })) | |
| 648 | + | .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); | |
| 649 | + | return { kind, lines, actors }; | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | /** A tally with one of each actor, for lists that name an actor per item. */ | |
| 653 | + | function tallyOf(actors: string[]): ActorCount[] { | |
| 654 | + | const counts = new Map<string, number>(); | |
| 655 | + | for (const actor of actors) counts.set(actor, (counts.get(actor) ?? 0) + 1); | |
| 656 | + | return [...counts].map(([actor, count]) => ({ actor, count })); | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | /** Both columns, what the sentence needs, and which sources were silent. */ | |
| 660 | + | export function digestOf(input: DigestInput): Digest { | |
| 661 | + | const people = digestColumn("person", input); | |
| 662 | + | const agents = digestColumn("agent", input); | |
| 663 | + | const missing: string[] = []; | |
| 664 | + | if (input.code && !input.activity) missing.push("Code"); | |
| 665 | + | if (!input.chat) missing.push("Chat"); | |
| 666 | + | if (!input.work) missing.push("Agents"); | |
| 667 | + | let commits = 0; | |
| 668 | + | let merged = 0; | |
| 669 | + | for (const repo of input.activity?.repos ?? []) { | |
| 670 | + | for (const by of repo.pushes.by) if (actorKind(by.actor, input.agentKeys) === "person") commits += by.commits; | |
| 671 | + | merged += part(repo.pulls.merged, "person", input.agentKeys).count; | |
| 672 | + | } | |
| 673 | + | const said = part( | |
| 674 | + | (input.chat?.authors ?? []).map((author) => ({ actor: author.key, count: author.messages })), | |
| 675 | + | "person", | |
| 676 | + | input.agentKeys, | |
| 677 | + | ).count; | |
| 678 | + | const counts = input.work?.counts; | |
| 679 | + | return { | |
| 680 | + | people, | |
| 681 | + | agents, | |
| 682 | + | summary: { | |
| 683 | + | people: people.actors.length, | |
| 684 | + | commits, | |
| 685 | + | merged, | |
| 686 | + | finished: counts ? counts.accepted + counts.fixed + counts.finished : 0, | |
| 687 | + | dropped: counts?.dropped ?? 0, | |
| 688 | + | said, | |
| 689 | + | anything: people.lines.length > 0 || agents.lines.length > 0, | |
| 690 | + | peopleRead: input.activity != null || input.chat != null, | |
| 691 | + | }, | |
| 692 | + | missing, | |
| 693 | + | partial: (input.activity != null && !input.activity.complete) || (input.work?.partial ?? false), | |
| 694 | + | }; | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | // --- What landed in the span ----------------------------------------------------- | |
| 698 | + | ||
| 699 | + | /** | |
| 700 | + | * A change that landed: a pull request merged; commits pushed straight to | |
| 701 | + | * a default branch; a production build that went live; a release or a | |
| 702 | + | * package version published. By a person or an agent. | |
| 703 | + | */ | |
| 366 | 704 | export type LandedChange = { | |
| 367 | 705 | key: string; | |
| 706 | + | kind: "merge" | "push" | "deploy" | "release" | "package"; | |
| 368 | 707 | title: string; | |
| 369 | − | repo: RepoPath; | |
| 370 | − | number: number; | |
| 708 | + | /** One line under it: where, and by whom. */ | |
| 709 | + | detail: string; | |
| 371 | 710 | to: string; | |
| 372 | 711 | at: number; | |
| 373 | − | /** An agent's change, or a person's. */ | |
| 712 | + | /** An agent's doing, or a person's. */ | |
| 374 | 713 | agent: boolean; | |
| 375 | − | author: string; | |
| 376 | − | mergedBy: string | null; | |
| 377 | 714 | }; | |
| 378 | 715 | ||
| 379 | 716 | /** Every pull request that merged in the span, newest first. */ | |
| 380 | 717 | export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] { | |
| 381 | 718 | return pulls | |
| 382 | 719 | .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span)) | |
| 383 | − | .map((pull) => ({ | |
| 384 | − | key: `landed:${pullKey(pull.repo, pull.number)}`, | |
| 385 | − | title: pull.title, | |
| 386 | − | repo: pull.repo, | |
| 387 | − | number: pull.number, | |
| 388 | − | to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`, | |
| 389 | − | at: Date.parse(pull.mergedAt!), | |
| 390 | − | agent: isAgentPull(pull), | |
| 391 | − | author: pull.author.username, | |
| 392 | − | mergedBy: pull.mergedBy ?? null, | |
| 393 | − | })) | |
| 720 | + | .map((pull) => { | |
| 721 | + | const agent = isAgentPull(pull); | |
| 722 | + | const mergedBy = pull.mergedBy && pull.mergedBy !== pull.author.username ? `, merged by ${pull.mergedBy}` : ""; | |
| 723 | + | return { | |
| 724 | + | key: `landed:${pullKey(pull.repo, pull.number)}`, | |
| 725 | + | kind: "merge" as const, | |
| 726 | + | title: pull.title, | |
| 727 | + | detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`, | |
| 728 | + | to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`, | |
| 729 | + | at: Date.parse(pull.mergedAt!), | |
| 730 | + | agent, | |
| 731 | + | }; | |
| 732 | + | }) | |
| 394 | 733 | .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 395 | 734 | } | |
| 396 | 735 | ||
| 736 | + | /** The names behind member keys, for a line: `ana and bo`, `@margo`, `a mirror` for nobody. */ | |
| 737 | + | function namesOf(keys: string[], names: Record<string, string>, agentKeys?: ReadonlySet<string>): string { | |
| 738 | + | const shown = keys.map((key) => { | |
| 739 | + | if (!key) return "a mirror"; | |
| 740 | + | const name = names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"); | |
| 741 | + | return actorKind(key, agentKeys) === "agent" ? `@${name}` : name; | |
| 742 | + | }); | |
| 743 | + | return listOf([...new Set(shown)]); | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | /** | |
| 747 | + | * Commits pushed straight to a default branch, one row per project with | |
| 748 | + | * any: what landed without a pull request. Releases and package versions | |
| 749 | + | * published, one row each. Pushes within the span only: the digest was | |
| 750 | + | * asked for the span. | |
| 751 | + | */ | |
| 752 | + | export function landedFromActivity(activity: ActivityDigest | null, repos: Record<string, RepoRef>, names: Record<string, string>, slug: string, agentKeys?: ReadonlySet<string>): LandedChange[] { | |
| 753 | + | const rows: LandedChange[] = []; | |
| 754 | + | for (const repo of activity?.repos ?? []) { | |
| 755 | + | const path = repos[repo.repo_id]; | |
| 756 | + | if (!path) continue; | |
| 757 | + | const direct = repo.pushes.default_branch; | |
| 758 | + | if (direct.count > 0 && direct.last_at) { | |
| 759 | + | const by = direct.by.map((entry) => entry.actor); | |
| 760 | + | rows.push({ | |
| 761 | + | key: `push:${repo.repo_id}`, | |
| 762 | + | kind: "push", | |
| 763 | + | title: `${plural(direct.commits, "commit")} pushed to ${path.defaultBranch}`, | |
| 764 | + | detail: `${path.name} · ${plural(direct.count, "push", "pushes")} by ${namesOf(by, names, agentKeys)}`, | |
| 765 | + | to: `/${path.namespace}/${path.name}/activity`, | |
| 766 | + | at: Date.parse(direct.last_at), | |
| 767 | + | agent: by.length > 0 && by.every((key) => key && actorKind(key, agentKeys) === "agent"), | |
| 768 | + | }); | |
| 769 | + | } | |
| 770 | + | for (const release of repo.releases) { | |
| 771 | + | rows.push({ | |
| 772 | + | key: `release:${repo.repo_id}:${release.tag}`, | |
| 773 | + | kind: "release", | |
| 774 | + | title: release.name ? `${release.tag} · ${release.name}` : release.tag, | |
| 775 | + | detail: `${path.name} · released by ${namesOf([release.actor], names, agentKeys)}`, | |
| 776 | + | to: `/${path.namespace}/${path.name}/releases/tag/${encodeURIComponent(release.tag)}`, | |
| 777 | + | at: Date.parse(release.at), | |
| 778 | + | agent: actorKind(release.actor, agentKeys) === "agent", | |
| 779 | + | }); | |
| 780 | + | } | |
| 781 | + | for (const pkg of repo.packages) { | |
| 782 | + | rows.push({ | |
| 783 | + | key: `package:${repo.repo_id}:${pkg.ecosystem}:${pkg.name}:${pkg.version}`, | |
| 784 | + | kind: "package", | |
| 785 | + | title: `${pkg.name} ${pkg.version}`, | |
| 786 | + | detail: `${pkg.ecosystem} package · published by ${namesOf([pkg.actor], names, agentKeys)}`, | |
| 787 | + | to: `/${slug}/-/packages`, | |
| 788 | + | at: Date.parse(pkg.at), | |
| 789 | + | agent: actorKind(pkg.actor, agentKeys) === "agent", | |
| 790 | + | }); | |
| 791 | + | } | |
| 792 | + | } | |
| 793 | + | return rows; | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | /** Production builds that went live in the span, as landed changes; previews stay in Deploys. */ | |
| 797 | + | export function landedDeploys(deploys: DeployRow[]): LandedChange[] { | |
| 798 | + | return deploys | |
| 799 | + | .filter((row) => row.kind === "production" && row.outcome === "live") | |
| 800 | + | .map((row) => ({ | |
| 801 | + | key: `landed:${row.key}`, | |
| 802 | + | kind: "deploy" as const, | |
| 803 | + | title: `Production of ${row.project} went live`, | |
| 804 | + | detail: `${row.commit} · by ${row.by}`, | |
| 805 | + | to: row.to, | |
| 806 | + | at: row.at, | |
| 807 | + | agent: isAgentName(row.by), | |
| 808 | + | })); | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | /** Everything that landed, newest first. */ | |
| 812 | + | export function landed(parts: LandedChange[][]): LandedChange[] { | |
| 813 | + | return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 814 | + | } | |
| 815 | + | ||
| 397 | 816 | /** A build that finished in the span. */ | |
| 398 | 817 | export type DeployRow = { | |
| 399 | 818 | key: string; | |
| ⋯ | |||
| 473 | 892 | ||
| 474 | 893 | // --- Running now ---------------------------------------------------------------- | |
| 475 | 894 | ||
| 476 | − | /** Something going right now: a session, an agent's change, a build. */ | |
| 895 | + | /** Something going right now: a session, an agent's change, a workflow run, a build. */ | |
| 477 | 896 | export type RunningRow = { | |
| 478 | 897 | key: string; | |
| 479 | − | kind: "session" | "change" | "deploy"; | |
| 898 | + | kind: "session" | "change" | "workflow" | "deploy"; | |
| 480 | 899 | title: string; | |
| 481 | 900 | /** One line: who and where. */ | |
| 482 | 901 | detail: string; | |
| ⋯ | |||
| 538 | 957 | }); | |
| 539 | 958 | } | |
| 540 | 959 | ||
| 541 | − | /** Everything running, longest-going first within each kind: sessions, then changes, then builds. */ | |
| 960 | + | const RUN_STATUS: Partial<Record<WorkflowRun["status"], string>> = { | |
| 961 | + | pending: "Queued", | |
| 962 | + | queued: "Queued", | |
| 963 | + | action_required: "Needs approval", | |
| 964 | + | in_progress: "Running", | |
| 965 | + | waiting: "Waiting", | |
| 966 | + | }; | |
| 967 | + | ||
| 968 | + | /** Workflow runs not finished, in the projects read: queued, waiting, running, or waiting for approval. */ | |
| 969 | + | export function runningWorkflows(projects: { repo: RepoPath; runs: WorkflowRun[] }[]): RunningRow[] { | |
| 970 | + | return projects.flatMap(({ repo, runs }) => | |
| 971 | + | runs.flatMap((run) => { | |
| 972 | + | const status = RUN_STATUS[run.status]; | |
| 973 | + | if (!status) return []; | |
| 974 | + | return [ | |
| 975 | + | { | |
| 976 | + | key: `workflow:${repo.namespace}/${repo.name}:${run.id}`.toLowerCase(), | |
| 977 | + | kind: "workflow" as const, | |
| 978 | + | title: run.title ? `${run.name}: ${run.title}` : run.name, | |
| 979 | + | detail: `${repo.namespace}/${repo.name} ${run.ref.replace(/^refs\/heads\//, "")}${run.actor ? ` · by ${run.actor}` : ""}`, | |
| 980 | + | status, | |
| 981 | + | at: Date.parse(run.startedAt ?? run.createdAt), | |
| 982 | + | to: `/${repo.namespace}/${repo.name}/actions/runs/${run.id}`, | |
| 983 | + | }, | |
| 984 | + | ]; | |
| 985 | + | }), | |
| 986 | + | ); | |
| 987 | + | } | |
| 988 | + | ||
| 989 | + | /** Everything running, longest-going first within each kind: sessions, then changes, then workflow runs, then builds. */ | |
| 542 | 990 | export function running(parts: RunningRow[][]): RunningRow[] { | |
| 543 | − | const order = { session: 0, change: 1, deploy: 2 } as const; | |
| 991 | + | const order = { session: 0, change: 1, workflow: 2, deploy: 3 } as const; | |
| 544 | 992 | return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key)); | |
| 545 | 993 | } | |
| 546 | 994 | ||
| ⋯ | |||
| 1038 | 1486 | // --- The sentence ----------------------------------------------------------- | |
| 1039 | 1487 | ||
| 1040 | 1488 | /** | |
| 1041 | − | * The sentence under the heading: "Since Tuesday evening, agents finished | |
| 1042 | − | * 35 tasks and 12 changes landed. 4 needed a fix after review." Finished | |
| 1043 | − | * counts every task that settled well: accepted, fixed, and sessions that | |
| 1044 | − | * finished. | |
| 1489 | + | * The sentence under the heading, honest about what happened: "Since | |
| 1490 | + | * Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents | |
| 1491 | + | * finished 7 tasks, and 12 changes landed." What people did comes first | |
| 1492 | + | * (commits and merges; else what they said), then the agents' tasks that | |
| 1493 | + | * settled well (accepted, fixed, sessions finished), then everything that | |
| 1494 | + | * landed. When nothing at all was counted: "Quiet since Tuesday evening: | |
| 1495 | + | * nothing pushed, merged, deployed or said." When what people did could | |
| 1496 | + | * not be read, it says so. | |
| 1045 | 1497 | */ | |
| 1046 | − | export function spanSentence(span: Pick<Span, "words">, work: Pick<SpanWork, "counts" | "tasks"> | null, landed: number | null): string { | |
| 1498 | + | export function spanSentence(span: Pick<Span, "words">, digest: Pick<Digest, "summary"> | null, landed: number | null): string { | |
| 1047 | 1499 | const lead = span.words; | |
| 1048 | − | if (!work) return `${lead}, agent work couldn't be read.`; | |
| 1049 | − | const { accepted, fixed, finished, dropped } = work.counts; | |
| 1050 | − | const done = accepted + fixed + finished; | |
| 1051 | − | const changes = landed && landed > 0 ? `${plural(landed, "change")} landed` : null; | |
| 1052 | − | if (work.tasks.length === 0 && !changes) return `${lead}, nothing new has settled.`; | |
| 1053 | − | const first = | |
| 1054 | − | done > 0 | |
| 1055 | − | ? `${lead}, agents finished ${plural(done, "task")}${changes ? ` and ${changes}` : ""}.` | |
| 1056 | − | : changes | |
| 1057 | − | ? `${lead}, ${changes}${work.tasks.length > 0 ? "; agents finished none of their tasks" : ""}.` | |
| 1058 | − | : `${lead}, agents finished no tasks.`; | |
| 1059 | − | const after: string[] = []; | |
| 1060 | − | if (fixed > 0) after.push(`${fixed.toLocaleString("en-US")} needed a fix after review`); | |
| 1061 | − | if (dropped > 0) after.push(`${dropped.toLocaleString("en-US")} didn't finish`); | |
| 1062 | − | if (after.length === 0) return first; | |
| 1063 | − | const text = after.length > 1 ? `${after[0]}, and ${after[1]}` : after[0]!; | |
| 1064 | − | return `${first} ${text.charAt(0).toUpperCase()}${text.slice(1)}.`; | |
| 1500 | + | if (!digest) return `${lead}, what happened couldn't be read.`; | |
| 1501 | + | const { people: count, commits, merged, finished, dropped, said, anything, peopleRead } = digest.summary; | |
| 1502 | + | const landedCount = landed ?? 0; | |
| 1503 | + | if (!anything && landedCount === 0) { | |
| 1504 | + | if (!peopleRead) return `${lead}, what people did couldn't be read, and no agent task settled.`; | |
| 1505 | + | return `Quiet ${lead.charAt(0).toLowerCase()}${lead.slice(1)}: nothing pushed, merged, deployed or said.`; | |
| 1506 | + | } | |
| 1507 | + | const parts: string[] = []; | |
| 1508 | + | if (count > 0) { | |
| 1509 | + | if (commits > 0 || merged > 0) { | |
| 1510 | + | const did: string[] = []; | |
| 1511 | + | if (commits > 0) did.push(`pushed ${plural(commits, "commit")}`); | |
| 1512 | + | if (merged > 0) did.push(`merged ${plural(merged, "change")}`); | |
| 1513 | + | parts.push(`${people(count)} ${did.join(" and ")}`); | |
| 1514 | + | } else if (said > 0) parts.push(`${people(count)} sent ${plural(said, "message")}`); | |
| 1515 | + | else parts.push(`${people(count)} ${count === 1 ? "was" : "were"} active`); | |
| 1516 | + | } | |
| 1517 | + | if (finished > 0) parts.push(`agents finished ${plural(finished, "task")}`); | |
| 1518 | + | else if (dropped > 0) parts.push(`agents finished none of ${plural(dropped, "task")}`); | |
| 1519 | + | if (landedCount > 0) parts.push(`${plural(landedCount, "change")} landed`); | |
| 1520 | + | const tail = peopleRead ? "" : "; what people did couldn't be read"; | |
| 1521 | + | if (parts.length === 0) return `${lead}, work went on, but nothing landed and no agent task finished${tail}.`; | |
| 1522 | + | const joined = parts.length > 2 ? `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}` : parts.join(" and "); | |
| 1523 | + | return `${lead}, ${joined}${tail}.`; | |
| 1065 | 1524 | } | |
| 1066 | 1525 | ||
| 1067 | 1526 | /** "8 things need you.", or that nothing does. */ | |
| 119 | 119 | "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + | |
| 121 | 121 | "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs " + | |
| 122 | − | "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit team_agent_index" | |
| 122 | + | "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit team_agent_index " + | |
| 123 | + | "activity_digest activity" | |
| 123 | 124 | ).split(" "), | |
| 124 | 125 | ); | |
| 125 | 126 |
| 11 | 11 | DeploysCard, | |
| 12 | 12 | LandedCard, | |
| 13 | 13 | NeedsList, | |
| 14 | + | PeopleAgentsCard, | |
| 14 | 15 | RowsSkeleton, | |
| 15 | 16 | RunningList, | |
| 16 | 17 | Sources, | |
| 17 | − | SpanCard, | |
| 18 | 18 | SpanSkeleton, | |
| 19 | 19 | SpendCard, | |
| 20 | 20 | SpendSkeleton, | |
| ⋯ | |||
| 28 | 28 | import { sidebarOrNull } from "../../lib/chat.server"; | |
| 29 | 29 | import { | |
| 30 | 30 | type Attention, | |
| 31 | + | type Digest, | |
| 31 | 32 | type SpanWork, | |
| 32 | 33 | attention, | |
| 33 | 34 | decisionsIn, | |
| 34 | 35 | deploysIn, | |
| 36 | + | digestOf, | |
| 37 | + | landed, | |
| 38 | + | landedDeploys, | |
| 39 | + | landedFromActivity, | |
| 35 | 40 | landedIn, | |
| 36 | 41 | parseWindow, | |
| 37 | 42 | running, | |
| 38 | 43 | runningChanges, | |
| 39 | 44 | runningDeploys, | |
| 40 | 45 | runningSessions, | |
| 46 | + | runningWorkflows, | |
| 41 | 47 | spanFor, | |
| 42 | 48 | spanSentence, | |
| 43 | 49 | spanWork, | |
| 44 | 50 | waitingSentence, | |
| 45 | 51 | } from "../../lib/home"; | |
| 46 | 52 | import { | |
| 53 | + | actorKeys, | |
| 54 | + | loadActivity, | |
| 47 | 55 | loadAgentsOverview, | |
| 56 | + | loadChatActivity, | |
| 48 | 57 | loadCodeWork, | |
| 49 | 58 | loadDeploys, | |
| 50 | 59 | loadInstallRequests, | |
| 51 | 60 | loadLastVisit, | |
| 52 | 61 | loadMemories, | |
| 62 | + | loadNames, | |
| 63 | + | loadRepos, | |
| 53 | 64 | loadSessions, | |
| 54 | 65 | loadSpend, | |
| 66 | + | loadWorkflowRuns, | |
| 55 | 67 | reachFor, | |
| 68 | + | repoRefs, | |
| 56 | 69 | } from "../../lib/home.server"; | |
| 57 | 70 | import { readCookie } from "../../lib/mission"; | |
| 58 | 71 | import { loadMissionControl } from "../../lib/mission-control.server"; | |
| ⋯ | |||
| 71 | 84 | /** | |
| 72 | 85 | * Home: the workspace's front page. Where you're needed now, with no time | |
| 73 | 86 | * window; what happened since you were last here (or over the last 24 | |
| 74 | − | * hours or 7 days, `?window=`); what is running; what the span cost; and | |
| 75 | − | * one place to start. The last visit is kept by notify, per person and | |
| 87 | + | * hours or 7 days, `?window=`): what people did and what agents did, from | |
| 88 | + | * the events service's digest of the span, chat's, and the agents' tasks, | |
| 89 | + | * then what landed; what is running; what the span cost; and one place to | |
| 90 | + | * start. The last visit is kept by notify, per person and | |
| 76 | 91 | * workspace, and marked by the page once it has been looked at | |
| 77 | 92 | * (routes/workspace/home-seen.ts), so a refresh never wipes it. Only the | |
| 78 | 93 | * agents list (for Ask g1t) and the last visit (for the span) are waited | |
| ⋯ | |||
| 126 | 141 | ||
| 127 | 142 | const span = spanFor(windowKey, now, await lastSeenP, tz); | |
| 128 | 143 | ||
| 129 | − | // What happened in the span. | |
| 130 | − | const codeWorkP = code ? loadCodeWork(viewer, slug, reachFor(span)).catch(() => null) : Promise.resolve("no_access" as const); | |
| 144 | + | // What happened in the span: the repositories once, for every section that reads them. | |
| 145 | + | const reposP = code ? loadRepos(viewer, slug).catch(() => null) : Promise.resolve(null); | |
| 146 | + | const codeWorkP = code ? reposP.then((owned) => loadCodeWork(viewer, slug, reachFor(span), owned)).catch(() => null) : Promise.resolve("no_access" as const); | |
| 131 | 147 | const sessionsP = loadSessions(viewer, slug, span.from).catch(() => null); | |
| 132 | 148 | const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null); | |
| 133 | 149 | const memoriesP = code ? loadMemories(viewer, slug).catch(() => null) : Promise.resolve(null); | |
| 150 | + | const activityP = reposP.then((owned) => loadActivity(slug, span, owned)).catch(() => null); | |
| 151 | + | const chatActivityP = loadChatActivity(viewer, slug, span).catch(() => null); | |
| 152 | + | const namesP = Promise.all([activityP, chatActivityP, agentsP]) | |
| 153 | + | .then(([activity, chatActivity, agents]) => loadNames(actorKeys(activity, chatActivity), agents)) | |
| 154 | + | .catch(() => ({}) as Record<string, string>); | |
| 134 | 155 | ||
| 135 | 156 | const work: Promise<SpanWork | null> = Promise.all([codeWorkP, sessionsP]) | |
| 136 | 157 | .then(([codeWork, sessions]) => (codeWork == null && sessions == null ? null : spanWork({ span, slug, code: codeWork, sessions }))) | |
| 137 | 158 | .catch(() => null); | |
| 138 | − | const happened = Promise.all([codeWorkP, deploysP, memoriesP, requestsP]) | |
| 139 | − | .then(([codeWork, deploys, memories, requests]) => ({ | |
| 140 | − | landed: codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null, | |
| 141 | − | deploys: deploys ? deploysIn(deploys.projects, slug, span) : null, | |
| 142 | − | deploysComplete: deploys?.complete ?? true, | |
| 143 | − | decisions: decisionsIn({ memories, requests: requests?.requests ?? null }, slug, span), | |
| 144 | − | decisionsRead: { memories: !code || memories != null, requests: requests != null }, | |
| 145 | − | })) | |
| 159 | + | // People and agents: what each did, from events, chat and the agents' tasks. | |
| 160 | + | const digest: Promise<Digest | null> = Promise.all([activityP, chatActivityP, namesP, work, reposP]) | |
| 161 | + | .then(([activity, chatActivity, names, spanWork, owned]) => digestOf({ slug, repos: repoRefs(owned), activity, chat: chatActivity, names, work: spanWork, code })) | |
| 162 | + | .catch(() => null); | |
| 163 | + | const happened = Promise.all([codeWorkP, deploysP, memoriesP, requestsP, activityP, namesP, reposP]) | |
| 164 | + | .then(([codeWork, deploys, memories, requests, activity, names, owned]) => { | |
| 165 | + | const deployRows = deploys ? deploysIn(deploys.projects, slug, span) : null; | |
| 166 | + | const merges = codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null; | |
| 167 | + | return { | |
| 168 | + | // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read. | |
| 169 | + | landed: merges == null && activity == null ? null : landed([merges ?? [], landedFromActivity(activity, repoRefs(owned), names, slug), landedDeploys(deployRows ?? [])]), | |
| 170 | + | landedMissing: [...(code && merges == null ? ["Pull requests"] : []), ...(code && activity == null ? ["Pushes and releases"] : []), ...(code && deployRows == null ? ["Deployments"] : [])], | |
| 171 | + | deploys: deployRows, | |
| 172 | + | deploysComplete: deploys?.complete ?? true, | |
| 173 | + | decisions: decisionsIn({ memories, requests: requests?.requests ?? null }, slug, span), | |
| 174 | + | decisionsRead: { memories: !code || memories != null, requests: requests != null }, | |
| 175 | + | }; | |
| 176 | + | }) | |
| 146 | 177 | .catch(() => null); | |
| 147 | − | const runningNow = Promise.all([sessionsP, missionP, deploysP]) | |
| 148 | − | .then(([sessions, mission, deploys]) => ({ | |
| 178 | + | const workflowsP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadWorkflowRuns(viewer, activity, owned)).catch(() => null) : Promise.resolve(null); | |
| 179 | + | const runningNow = Promise.all([sessionsP, missionP, deploysP, workflowsP]) | |
| 180 | + | .then(([sessions, mission, deploys, workflows]) => ({ | |
| 149 | 181 | rows: running([ | |
| 150 | 182 | runningSessions(sessions?.sessions ?? [], slug), | |
| 151 | 183 | runningChanges(mission?.waiting ?? []), | |
| 184 | + | runningWorkflows(workflows?.projects ?? []), | |
| 152 | 185 | runningDeploys(deploys?.overview ?? [], slug), | |
| 153 | 186 | ]), | |
| 154 | − | missing: [...(sessions ? [] : ["Agents"]), ...(code && !mission ? ["Code"] : []), ...(code && !deploys ? ["Deployments"] : [])], | |
| 187 | + | missing: [ | |
| 188 | + | ...(sessions ? [] : ["Agents"]), | |
| 189 | + | ...(code && !mission ? ["Code"] : []), | |
| 190 | + | ...(code && (!workflows || !workflows.complete) ? ["Workflows"] : []), | |
| 191 | + | ...(code && !deploys ? ["Deployments"] : []), | |
| 192 | + | ], | |
| 155 | 193 | })) | |
| 156 | 194 | .catch(() => ({ rows: [], missing: ["Agents"] })); | |
| 157 | 195 | const spend = loadSpend(viewer, slug, span).catch(() => null); | |
| ⋯ | |||
| 164 | 202 | tz, | |
| 165 | 203 | span, | |
| 166 | 204 | orchestrator: agents?.some(isOrchestrator) ?? false, | |
| 205 | + | /** The agents' faces, for the Agents column. */ | |
| 206 | + | faces: (agents ?? []).map(({ id, handle, display_name, avatar, avatar_seed, builtin }) => ({ id, handle, display_name, avatar, avatar_seed, builtin })), | |
| 167 | 207 | work, | |
| 208 | + | /** The People and Agents card needs both the digest and the agents' tasks. */ | |
| 209 | + | since: Promise.all([digest, work]), | |
| 168 | 210 | happened, | |
| 169 | 211 | attention: waiting, | |
| 170 | 212 | running: runningNow, | |
| 171 | − | /** The sentence under the heading needs the work, what landed and what waits. */ | |
| 172 | − | head: Promise.all([work, happened, waiting]), | |
| 213 | + | /** The sentence under the heading needs the digest, what landed and what waits. */ | |
| 214 | + | head: Promise.all([digest, happened, waiting]), | |
| 173 | 215 | spend, | |
| 174 | 216 | }; | |
| 175 | 217 | } | |
| ⋯ | |||
| 276 | 318 | } | |
| 277 | 319 | ||
| 278 | 320 | export default function Home({ loaderData, actionData }: Route.ComponentProps) { | |
| 279 | − | const { slug, now, tz, span, orchestrator, work, happened, attention: waiting, running: runningNow, head, spend } = loaderData; | |
| 321 | + | const { slug, now, tz, span, orchestrator, faces, work, since, happened, attention: waiting, running: runningNow, head, spend } = loaderData; | |
| 280 | 322 | useMarkSeen(slug, now); | |
| 281 | 323 | return ( | |
| 282 | 324 | <div className="mx-auto w-full max-w-[1040px] space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| ⋯ | |||
| 286 | 328 | <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted"> | |
| 287 | 329 | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> | |
| 288 | 330 | <Await resolve={head}> | |
| 289 | − | {([work, happened, att]) => ( | |
| 331 | + | {([digest, happened, att]) => ( | |
| 290 | 332 | <> | |
| 291 | − | <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, work, happened?.landed?.length ?? null)} | |
| 333 | + | <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)} | |
| 292 | 334 | </> | |
| 293 | 335 | )} | |
| 294 | 336 | </Await> | |
| ⋯ | |||
| 337 | 379 | </p> | |
| 338 | 380 | </div> | |
| 339 | 381 | <Suspense fallback={<SpanSkeleton />}> | |
| 340 | − | <Await resolve={work}>{(value) => <SpanCard work={value} span={span} />}</Await> | |
| 382 | + | <Await resolve={since}>{([value, spanWork]) => <PeopleAgentsCard digest={value} work={spanWork} span={span} faces={faces} code={loaderData.code} />}</Await> | |
| 341 | 383 | </Suspense> | |
| 342 | 384 | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> | |
| 343 | 385 | <div className="space-y-5"> | |
| ⋯ | |||
| 348 | 390 | </Card> | |
| 349 | 391 | } | |
| 350 | 392 | > | |
| 351 | − | <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} code={loaderData.code} now={now} tz={tz} />}</Await> | |
| 393 | + | <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} />}</Await> | |
| 352 | 394 | </Suspense> | |
| 353 | 395 | <Suspense fallback={null}> | |
| 354 | 396 | <Await resolve={happened}> | |
| 119 | 119 | /// a repository that leads. | |
| 120 | 120 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 121 | 121 | pub mirror: Option<crate::mirrors::RepoMirror>, | |
| 122 | + | /// How many commits the push brought to the branch, counted along its | |
| 123 | + | /// first-parent line from `after` back to `before` (a merge commit is | |
| 124 | + | /// one), at most 50; one for a new branch. Absent when it was not | |
| 125 | + | /// counted: a tag, a branch deleted, a pull request landed, a push the | |
| 126 | + | /// store could not read back, or one from before this was recorded. | |
| 127 | + | /// Home's digest (`activity_digest`) sums it. | |
| 128 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 129 | + | pub commits: Option<u32>, | |
| 122 | 130 | } | |
| 123 | 131 | ||
| 124 | 132 | /// The payload of `issue.opened`, `issue.updated`, `issue.assigned`, | |
| ⋯ | |||
| 1022 | 1030 | pub owners: Vec<String>, | |
| 1023 | 1031 | } | |
| 1024 | 1032 | ||
| 1033 | + | /// `activity_digest`: what happened in some repositories, and to a | |
| 1034 | + | /// workspace's artifacts, over a span, counted. Returns [`ActivityDigest`]. | |
| 1035 | + | /// Home reads it for what people and agents did since you were last there. | |
| 1036 | + | /// Fields are snake_case, as every service's arguments are. | |
| 1037 | + | /// | |
| 1038 | + | /// The span is `[from, until)` in RFC 3339. Event ids sort by time, so the | |
| 1039 | + | /// service reads each repository's events of each type it counts as one | |
| 1040 | + | /// range of the `(repo_id, type, id)` index, from `id_floor(from)` to | |
| 1041 | + | /// `id_floor(until)`; a workspace's folio events (which carry no | |
| 1042 | + | /// repository) are the same range under a null repository, narrowed to | |
| 1043 | + | /// the workspace. That is indexed range scans per repository and type, and | |
| 1044 | + | /// it suits a workspace of tens of repositories. When a workspace has | |
| 1045 | + | /// hundreds, the next step is a daily rollup per workspace that this | |
| 1046 | + | /// method reads instead of the log; it is not built yet. | |
| 1047 | + | #[derive(Debug, Default, Serialize, serde::Deserialize)] | |
| 1048 | + | pub struct ActivityDigestArgs { | |
| 1049 | + | /// The repositories to count, by id. The caller has checked the | |
| 1050 | + | /// viewer may read each. At most `MAX_DIGEST_REPOS` are read. | |
| 1051 | + | #[serde(default)] | |
| 1052 | + | pub repo_ids: Vec<String>, | |
| 1053 | + | /// The workspace's slug, for its `folio.*` events; none leaves | |
| 1054 | + | /// artifacts out. | |
| 1055 | + | #[serde(default)] | |
| 1056 | + | pub workspace: Option<String>, | |
| 1057 | + | /// The start of the span, inclusive. RFC 3339. | |
| 1058 | + | pub from: String, | |
| 1059 | + | /// The end of the span, exclusive. RFC 3339. | |
| 1060 | + | pub until: String, | |
| 1061 | + | } | |
| 1062 | + | ||
| 1063 | + | /// The most repositories one digest reads. | |
| 1064 | + | pub const MAX_DIGEST_REPOS: usize = 50; | |
| 1065 | + | /// The most events one digest reads of repositories, and of folios; past | |
| 1066 | + | /// either the digest says it is not complete. | |
| 1067 | + | pub const MAX_DIGEST_EVENTS: u32 = 5000; | |
| 1068 | + | /// The most releases, packages and edited folios a digest lists. | |
| 1069 | + | pub const MAX_DIGEST_LISTED: usize = 50; | |
| 1070 | + | ||
| 1071 | + | /// What `activity_digest` returns. | |
| 1072 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq)] | |
| 1073 | + | pub struct ActivityDigest { | |
| 1074 | + | pub from: String, | |
| 1075 | + | pub until: String, | |
| 1076 | + | /// One per repository that had any event counted; none for a quiet one. | |
| 1077 | + | pub repos: Vec<RepoDigest>, | |
| 1078 | + | /// The workspace's artifacts, when a workspace was named. | |
| 1079 | + | pub folios: Option<FolioDigest>, | |
| 1080 | + | /// False when more events fell in the span than were read, or more | |
| 1081 | + | /// repositories were named than are read, so counts are low. | |
| 1082 | + | pub complete: bool, | |
| 1083 | + | } | |
| 1084 | + | ||
| 1085 | + | /// How many times one actor did something. The actor is a member key: | |
| 1086 | + | /// `user:<id>` for a person (or g1t, `user:usr_g1t_agent`), `agent:<id>` | |
| 1087 | + | /// for one of the workspace's agents acting as itself, or empty when the | |
| 1088 | + | /// event named nobody (a push copied in by a mirror). | |
| 1089 | + | #[derive(Debug, Clone, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1090 | + | pub struct ActorCount { | |
| 1091 | + | pub actor: String, | |
| 1092 | + | pub count: u32, | |
| 1093 | + | } | |
| 1094 | + | ||
| 1095 | + | /// Pushes by one actor, with the commits they brought. | |
| 1096 | + | #[derive(Debug, Clone, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1097 | + | pub struct PushesBy { | |
| 1098 | + | pub actor: String, | |
| 1099 | + | pub count: u32, | |
| 1100 | + | pub commits: u32, | |
| 1101 | + | } | |
| 1102 | + | ||
| 1103 | + | /// Pushes to a repository's branches (`git.push` events for `refs/heads/`; | |
| 1104 | + | /// tags are not pushes here). A push whose commits were not counted | |
| 1105 | + | /// counts as one commit. | |
| 1106 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1107 | + | pub struct PushDigest { | |
| 1108 | + | pub count: u32, | |
| 1109 | + | pub commits: u32, | |
| 1110 | + | /// The branches pushed to, by name, sorted. | |
| 1111 | + | pub branches: Vec<String>, | |
| 1112 | + | /// Most pushes first. | |
| 1113 | + | pub by: Vec<PushesBy>, | |
| 1114 | + | /// Of those, the pushes straight to the default branch: what landed | |
| 1115 | + | /// without a pull request. | |
| 1116 | + | pub default_branch: DefaultBranchPushes, | |
| 1117 | + | } | |
| 1118 | + | ||
| 1119 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1120 | + | pub struct DefaultBranchPushes { | |
| 1121 | + | pub count: u32, | |
| 1122 | + | pub commits: u32, | |
| 1123 | + | pub by: Vec<PushesBy>, | |
| 1124 | + | /// When the newest of them was. RFC 3339. | |
| 1125 | + | pub last_at: Option<String>, | |
| 1126 | + | } | |
| 1127 | + | ||
| 1128 | + | /// Pull requests opened, merged and closed without merging, each by who did it. | |
| 1129 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1130 | + | pub struct PullDigest { | |
| 1131 | + | pub opened: Vec<ActorCount>, | |
| 1132 | + | pub merged: Vec<ActorCount>, | |
| 1133 | + | pub closed: Vec<ActorCount>, | |
| 1134 | + | } | |
| 1135 | + | ||
| 1136 | + | /// Issues opened and closed, each by who did it. | |
| 1137 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1138 | + | pub struct IssueDigest { | |
| 1139 | + | pub opened: Vec<ActorCount>, | |
| 1140 | + | pub closed: Vec<ActorCount>, | |
| 1141 | + | } | |
| 1142 | + | ||
| 1143 | + | /// Builds that finished, by who set them off, and how many of the | |
| 1144 | + | /// successes were production. | |
| 1145 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1146 | + | pub struct DeploymentDigest { | |
| 1147 | + | pub succeeded: Vec<ActorCount>, | |
| 1148 | + | pub failed: Vec<ActorCount>, | |
| 1149 | + | pub production: u32, | |
| 1150 | + | } | |
| 1151 | + | ||
| 1152 | + | /// A release published in the span. | |
| 1153 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1154 | + | pub struct ReleaseDigest { | |
| 1155 | + | pub tag: String, | |
| 1156 | + | pub name: Option<String>, | |
| 1157 | + | pub actor: String, | |
| 1158 | + | /// RFC 3339. | |
| 1159 | + | pub at: String, | |
| 1160 | + | } | |
| 1161 | + | ||
| 1162 | + | /// A package version published in the span. | |
| 1163 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1164 | + | pub struct PackageDigest { | |
| 1165 | + | pub ecosystem: String, | |
| 1166 | + | pub name: String, | |
| 1167 | + | pub version: String, | |
| 1168 | + | pub actor: String, | |
| 1169 | + | /// RFC 3339. | |
| 1170 | + | pub at: String, | |
| 1171 | + | } | |
| 1172 | + | ||
| 1173 | + | /// One repository's span. | |
| 1174 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq)] | |
| 1175 | + | pub struct RepoDigest { | |
| 1176 | + | pub repo_id: String, | |
| 1177 | + | pub pushes: PushDigest, | |
| 1178 | + | pub pulls: PullDigest, | |
| 1179 | + | pub issues: IssueDigest, | |
| 1180 | + | /// Reviews: `review.completed` (g1t's), and a comment with a verdict (a person's). | |
| 1181 | + | pub reviews: Vec<ActorCount>, | |
| 1182 | + | /// Comments without a verdict; one by an agent as itself is the agent's. | |
| 1183 | + | pub comments: Vec<ActorCount>, | |
| 1184 | + | pub deployments: DeploymentDigest, | |
| 1185 | + | /// Newest first, at most `MAX_DIGEST_LISTED`. | |
| 1186 | + | pub releases: Vec<ReleaseDigest>, | |
| 1187 | + | /// Newest first, at most `MAX_DIGEST_LISTED`. | |
| 1188 | + | pub packages: Vec<PackageDigest>, | |
| 1189 | + | } | |
| 1190 | + | ||
| 1191 | + | /// One folio (an artifact: a doc, slides, a design, a dashboard) whose | |
| 1192 | + | /// content changed, with everyone whose changes are in it, as member | |
| 1193 | + | /// keys. Never its title. | |
| 1194 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1195 | + | pub struct FolioEdited { | |
| 1196 | + | pub folio_id: String, | |
| 1197 | + | pub kind: String, | |
| 1198 | + | pub authors: Vec<String>, | |
| 1199 | + | } | |
| 1200 | + | ||
| 1201 | + | /// A workspace's artifacts over the span: `folio.created` by who made | |
| 1202 | + | /// them, and the folios whose content changed (`folio.updated`). | |
| 1203 | + | #[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)] | |
| 1204 | + | pub struct FolioDigest { | |
| 1205 | + | pub created: Vec<ActorCount>, | |
| 1206 | + | /// Each folio once, at most `MAX_DIGEST_LISTED`. | |
| 1207 | + | pub edited: Vec<FolioEdited>, | |
| 1208 | + | /// How many folios changed, counting those past the cap. | |
| 1209 | + | pub edited_count: u32, | |
| 1210 | + | } | |
| 1211 | + | ||
| 1025 | 1212 | #[cfg(test)] | |
| 1026 | 1213 | mod tests { | |
| 1027 | 1214 | use super::*; | |
| ⋯ | |||
| 1109 | 1296 | caused_by_job: job_run_of(&actor).map(str::to_owned), | |
| 1110 | 1297 | mirrored: false, | |
| 1111 | 1298 | mirror: None, | |
| 1299 | + | commits: Some(3), | |
| 1112 | 1300 | }; | |
| 1113 | 1301 | let push = serde_json::to_value(push).unwrap(); | |
| 1114 | 1302 | assert_eq!(caused_by_job(&push), Some("run_9")); | |
| ⋯ | |||
| 1128 | 1316 | caused_by_job: None, | |
| 1129 | 1317 | mirrored: false, | |
| 1130 | 1318 | mirror: None, | |
| 1319 | + | commits: None, | |
| 1131 | 1320 | }; | |
| 1132 | 1321 | let quiet = serde_json::to_value(push(false)).unwrap(); | |
| 1133 | 1322 | assert!(quiet.get("unscanned").is_none()); | |
| 39 | 39 | bytes[6] = 0x70 | (counter >> 8) as u8; // version 7 | |
| 40 | 40 | bytes[7] = counter as u8; | |
| 41 | 41 | bytes[8] = 0x80 | (bytes[8] & 0x3f); // RFC 9562 variant | |
| 42 | + | encode(prefix, bytes) | |
| 43 | + | } | |
| 42 | 44 | ||
| 45 | + | /// The smallest id with `prefix` that could have been made at or after | |
| 46 | + | /// `now_ms`: its time, and every other bit zero. Ids sort by time, so | |
| 47 | + | /// `id >= id_floor(p, from) AND id < id_floor(p, until)` picks the ids made | |
| 48 | + | /// in `[from, until)` as a range of any index that ends in the id, without | |
| 49 | + | /// a time column. No real id ever equals one: a real id's version bits are | |
| 50 | + | /// set. | |
| 51 | + | pub fn id_floor(prefix: &str, now_ms: u64) -> String { | |
| 52 | + | let mut bytes = [0u8; 16]; | |
| 53 | + | bytes[..6].copy_from_slice(&now_ms.to_be_bytes()[2..]); | |
| 54 | + | encode(prefix, bytes) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | fn encode(prefix: &str, bytes: [u8; 16]) -> String { | |
| 43 | 58 | let value = u128::from_be_bytes(bytes); | |
| 44 | 59 | let mut id = String::with_capacity(prefix.len() + 27); | |
| 45 | 60 | id.push_str(prefix); | |
| ⋯ | |||
| 76 | 91 | let same_ms: Vec<String> = (0..100).map(|_| new_id("evt", 1_790_000_000_002)).collect(); | |
| 77 | 92 | assert!(same_ms.windows(2).all(|pair| pair[0] < pair[1])); | |
| 78 | 93 | } | |
| 94 | + | ||
| 95 | + | #[test] | |
| 96 | + | fn a_floor_bounds_the_ids_of_a_span() { | |
| 97 | + | let from = 1_790_000_000_000; | |
| 98 | + | let before = new_id("evt", from - 1); | |
| 99 | + | let at = new_id("evt", from); | |
| 100 | + | let later = new_id("evt", from + 60_000); | |
| 101 | + | let floor = id_floor("evt", from); | |
| 102 | + | let ceiling = id_floor("evt", from + 60_000); | |
| 103 | + | assert_eq!(floor.len(), at.len()); | |
| 104 | + | assert!(before < floor); | |
| 105 | + | assert!(floor < at && at < ceiling); | |
| 106 | + | // An id made at the very millisecond of the ceiling is past it. | |
| 107 | + | assert!(later >= ceiling); | |
| 108 | + | } | |
| 79 | 109 | } | |
| 49 | 49 | pub mod webhooks; | |
| 50 | 50 | pub mod work; | |
| 51 | 51 | ||
| 52 | − | pub use ids::new_id; | |
| 52 | + | pub use ids::{id_floor, new_id}; | |
| 53 | 53 | pub use names::{ | |
| 54 | 54 | Username, aliasable_name, claimable_namespace, claimable_username, is_namespace_shaped, is_reserved_name, is_route_name, | |
| 55 | 55 | is_valid_namespace, is_valid_repo_name, |
| 288 | 288 | can: ChatPermissions; | |
| 289 | 289 | }; | |
| 290 | 290 | ||
| 291 | + | /** | |
| 292 | + | * `activity`: what was said over a span, in the conversations the viewer | |
| 293 | + | * can read (every public channel, and the private channels and direct | |
| 294 | + | * messages they are in). Messages are text messages and thread replies | |
| 295 | + | * that were not deleted; cards agents post are not messages. Home reads it | |
| 296 | + | * for what people and agents said since you were last there. | |
| 297 | + | */ | |
| 298 | + | export type ChatActivity = { | |
| 299 | + | /** RFC 3339: `[from, until)`. */ | |
| 300 | + | from: string; | |
| 301 | + | until: string; | |
| 302 | + | messages: number; | |
| 303 | + | /** Conversations with at least one message. */ | |
| 304 | + | channels: number; | |
| 305 | + | /** Who said how much: member keys (`user:<id>`, `agent:<id>`), most first. */ | |
| 306 | + | authors: { key: string; messages: number }[]; | |
| 307 | + | }; | |
| 308 | + | ||
| 291 | 309 | export type MessagePage = { | |
| 292 | 310 | messages: ChatMessage[]; | |
| 293 | 311 | /** Pass as `before` to read further back; null at the beginning. */ | |
| ⋯ | |||
| 426 | 444 | ||
| 427 | 445 | export type ChatApi = { | |
| 428 | 446 | sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>; | |
| 447 | + | /** What was said in `[from, until)` (RFC 3339) where the viewer can read, counted. */ | |
| 448 | + | activity(workspace: string, viewer: User, span: { from: string; until: string }): Promise<Result<ChatActivity>>; | |
| 429 | 449 | channel( | |
| 430 | 450 | workspace: string, | |
| 431 | 451 | channelId: string, | |
| ⋯ | |||
| 620 | 640 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 621 | 641 | return { | |
| 622 | 642 | sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }), | |
| 643 | + | activity: (workspace, viewer, span) => call("activity", { workspace, viewer, from: span.from, until: span.until }), | |
| 623 | 644 | channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }), | |
| 624 | 645 | channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }), | |
| 625 | 646 | browse: (workspace, viewer, options) => call("browse", { workspace, viewer, archived: options?.archived ?? false }), | |
| 757 | 757 | return { | |
| 758 | 758 | publish: (events) => call("publish", { events }), | |
| 759 | 759 | list: (query) => call("list", query), | |
| 760 | + | activityDigest: (args) => call("activity_digest", args), | |
| 760 | 761 | }; | |
| 761 | 762 | } | |
| 762 | 763 |
| 193 | 193 | mirrored?: boolean; | |
| 194 | 194 | /** The repository's mirror state when it landed; absent for one that leads. */ | |
| 195 | 195 | mirror?: RepoMirror; | |
| 196 | + | /** | |
| 197 | + | * How many commits the push brought to the branch, along its | |
| 198 | + | * first-parent line from `after` back to `before` (a merge commit is | |
| 199 | + | * one), at most 50; one for a new branch. Absent when it was not | |
| 200 | + | * counted: a tag, a branch deleted, a pull request landed, a push the | |
| 201 | + | * store could not read back, or one from before this was recorded. | |
| 202 | + | */ | |
| 203 | + | commits?: number; | |
| 196 | 204 | }; | |
| 197 | 205 | /** | |
| 198 | 206 | * `author` is who opened it: g1t, for one its agent filed while at work, | |
| ⋯ | |||
| 579 | 587 | limit?: number; | |
| 580 | 588 | }; | |
| 581 | 589 | ||
| 590 | + | /** | |
| 591 | + | * `activity_digest`: what happened in some repositories, and to a | |
| 592 | + | * workspace's artifacts, over `[from, until)`, counted by who did it. Home | |
| 593 | + | * reads it for what people and agents did since you were last there. | |
| 594 | + | * snake_case, as the Rust service reads it | |
| 595 | + | * (`g1t_contracts::events::ActivityDigestArgs`). | |
| 596 | + | */ | |
| 597 | + | export type ActivityDigestArgs = { | |
| 598 | + | /** By id; the caller has checked the viewer may read each. At most `MAX_DIGEST_REPOS` are read. */ | |
| 599 | + | repo_ids: string[]; | |
| 600 | + | /** The workspace's slug, for its `folio.*` events (which carry no repository); null leaves artifacts out. */ | |
| 601 | + | workspace: string | null; | |
| 602 | + | /** RFC 3339, inclusive. */ | |
| 603 | + | from: string; | |
| 604 | + | /** RFC 3339, exclusive. */ | |
| 605 | + | until: string; | |
| 606 | + | }; | |
| 607 | + | ||
| 608 | + | /** The most repositories one digest reads. */ | |
| 609 | + | export const MAX_DIGEST_REPOS = 50; | |
| 610 | + | ||
| 611 | + | /** | |
| 612 | + | * How many times one actor did something. The actor is a member key: | |
| 613 | + | * `user:<id>` for a person (or g1t, `user:usr_g1t_agent`), `agent:<id>` for | |
| 614 | + | * one of the workspace's agents acting as itself, or `""` when the event | |
| 615 | + | * named nobody (a push copied in by a mirror). | |
| 616 | + | */ | |
| 617 | + | export type ActorCount = { actor: string; count: number }; | |
| 618 | + | ||
| 619 | + | /** Pushes by one actor, with the commits they brought. */ | |
| 620 | + | export type PushesBy = { actor: string; count: number; commits: number }; | |
| 621 | + | ||
| 622 | + | /** Pushes to a repository's branches (tags are not pushes here). A push whose commits were not counted counts as one commit. */ | |
| 623 | + | export type PushDigest = { | |
| 624 | + | count: number; | |
| 625 | + | commits: number; | |
| 626 | + | /** The branches pushed to, by name, sorted. */ | |
| 627 | + | branches: string[]; | |
| 628 | + | /** Most pushes first. */ | |
| 629 | + | by: PushesBy[]; | |
| 630 | + | /** Of those, the pushes straight to the default branch: what landed without a pull request. */ | |
| 631 | + | default_branch: { count: number; commits: number; by: PushesBy[]; last_at: string | null }; | |
| 632 | + | }; | |
| 633 | + | ||
| 634 | + | export type RepoDigest = { | |
| 635 | + | repo_id: string; | |
| 636 | + | pushes: PushDigest; | |
| 637 | + | /** Pull requests opened, merged and closed without merging, each by who did it. */ | |
| 638 | + | pulls: { opened: ActorCount[]; merged: ActorCount[]; closed: ActorCount[] }; | |
| 639 | + | issues: { opened: ActorCount[]; closed: ActorCount[] }; | |
| 640 | + | /** `review.completed` (g1t's), and a comment with a verdict (a person's). */ | |
| 641 | + | reviews: ActorCount[]; | |
| 642 | + | /** Comments without a verdict; one by an agent as itself is the agent's. */ | |
| 643 | + | comments: ActorCount[]; | |
| 644 | + | /** Builds that finished, by who set them off; `production` is how many successes were production. */ | |
| 645 | + | deployments: { succeeded: ActorCount[]; failed: ActorCount[]; production: number }; | |
| 646 | + | /** Releases published, newest first, at most 50. */ | |
| 647 | + | releases: { tag: string; name: string | null; actor: string; at: string }[]; | |
| 648 | + | /** Package versions published, newest first, at most 50. */ | |
| 649 | + | packages: { ecosystem: string; name: string; version: string; actor: string; at: string }[]; | |
| 650 | + | }; | |
| 651 | + | ||
| 652 | + | /** A workspace's artifacts over the span. Never a title. */ | |
| 653 | + | export type FolioDigest = { | |
| 654 | + | created: ActorCount[]; | |
| 655 | + | /** Each folio whose content changed, once, with everyone whose changes are in it (member keys); at most 50. */ | |
| 656 | + | edited: { folio_id: string; kind: "doc" | "slides" | "design" | "dashboard" | string; authors: string[] }[]; | |
| 657 | + | /** How many folios changed, counting those past the cap. */ | |
| 658 | + | edited_count: number; | |
| 659 | + | }; | |
| 660 | + | ||
| 661 | + | export type ActivityDigest = { | |
| 662 | + | from: string; | |
| 663 | + | until: string; | |
| 664 | + | /** One per repository that had any event counted; none for a quiet one. */ | |
| 665 | + | repos: RepoDigest[]; | |
| 666 | + | /** When a workspace was named. */ | |
| 667 | + | folios: FolioDigest | null; | |
| 668 | + | /** False when more events fell in the span than were read, or more repositories were named than are read, so counts are low. */ | |
| 669 | + | complete: boolean; | |
| 670 | + | }; | |
| 671 | + | ||
| 582 | 672 | /** The event bus and its durable log. */ | |
| 583 | 673 | export interface EventsApi { | |
| 584 | 674 | publish(events: NewEvent[]): Promise<void>; | |
| 585 | 675 | /** Newest first. */ | |
| 586 | 676 | list(query: EventQuery): Promise<G1tEvent[]>; | |
| 677 | + | /** A span of some repositories' and a workspace's artifacts' events, counted by who did it. */ | |
| 678 | + | activityDigest(args: ActivityDigestArgs): Promise<ActivityDigest>; | |
| 587 | 679 | } | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { idFloor, newId } from "./ids.ts"; | |
| 5 | + | ||
| 6 | + | test("an id has its prefix and a 26-character suffix, and sorts by time", () => { | |
| 7 | + | const earlier = newId("evt", 1_790_000_000_000); | |
| 8 | + | const later = newId("evt", 1_790_000_000_001); | |
| 9 | + | assert.match(earlier, /^evt_[0-9a-hjkmnp-tv-z]{26}$/); | |
| 10 | + | assert.ok(earlier < later); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("a floor bounds the ids of a span, as a range of any index that ends in the id", () => { | |
| 14 | + | const from = 1_790_000_000_000; | |
| 15 | + | const before = newId("msg", from - 1); | |
| 16 | + | const at = newId("msg", from); | |
| 17 | + | const later = newId("msg", from + 60_000); | |
| 18 | + | const floor = idFloor("msg", from); | |
| 19 | + | const ceiling = idFloor("msg", from + 60_000); | |
| 20 | + | assert.equal(floor.length, at.length); | |
| 21 | + | assert.ok(before < floor); | |
| 22 | + | assert.ok(floor < at && at < ceiling); | |
| 23 | + | // An id made at the very millisecond of the ceiling is past it. | |
| 24 | + | assert.ok(later >= ceiling); | |
| 25 | + | }); |
| 29 | 29 | bytes[6] = 0x70 | (counter >> 8); // version 7 | |
| 30 | 30 | bytes[7] = counter & 0xff; | |
| 31 | 31 | bytes[8] = 0x80 | (bytes[8] & 0x3f); // RFC 9562 variant | |
| 32 | + | return encode(prefix, bytes); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** | |
| 36 | + | * The smallest id with `prefix` that could have been made at or after | |
| 37 | + | * `now`: its time, and every other bit zero. Ids sort by time, so | |
| 38 | + | * `id >= idFloor(p, from) AND id < idFloor(p, until)` picks the ids made in | |
| 39 | + | * `[from, until)` as a range of any index that ends in the id, without a | |
| 40 | + | * time column. No real id ever equals one: a real id's version bits are | |
| 41 | + | * set. The same as `id_floor` in crates/contracts. | |
| 42 | + | */ | |
| 43 | + | export function idFloor(prefix: IdPrefix, now: number): string { | |
| 44 | + | const bytes = new Uint8Array(16); | |
| 45 | + | const time = BigInt(Math.max(0, Math.floor(now))); | |
| 46 | + | for (let i = 0; i < 6; i++) { | |
| 47 | + | bytes[i] = Number((time >> BigInt(40 - 8 * i)) & 0xffn); | |
| 48 | + | } | |
| 49 | + | return encode(prefix, bytes); | |
| 50 | + | } | |
| 32 | 51 | ||
| 52 | + | function encode(prefix: string, bytes: Uint8Array): string { | |
| 33 | 53 | let value = 0n; | |
| 34 | 54 | for (const byte of bytes) value = (value << 8n) | BigInt(byte); | |
| 35 | 55 | let suffix = ""; |
| 1 | + | import { test } from "node:test"; | |
| 2 | + | import assert from "node:assert/strict"; | |
| 3 | + | ||
| 4 | + | import { activitySpan, chatActivity } from "./activity.ts"; | |
| 5 | + | ||
| 6 | + | const span = { from: "2026-10-08T18:00:00.000Z", until: "2026-10-09T18:00:00.000Z" }; | |
| 7 | + | ||
| 8 | + | test("messages, conversations with any, and who said how much, most first", () => { | |
| 9 | + | const activity = chatActivity( | |
| 10 | + | [ | |
| 11 | + | { channel_id: "chn_general", author: "user:usr_ana", n: 70 }, | |
| 12 | + | { channel_id: "chn_general", author: "agent:agt_margo", n: 15 }, | |
| 13 | + | { channel_id: "chn_dev", author: "user:usr_bo", n: 30 }, | |
| 14 | + | { channel_id: "chn_dev", author: "user:usr_ana", n: 5 }, | |
| 15 | + | // A conversation with nothing in the span is not active. | |
| 16 | + | { channel_id: "chn_quiet", author: "user:usr_bo", n: 0 }, | |
| 17 | + | ], | |
| 18 | + | span, | |
| 19 | + | ); | |
| 20 | + | assert.deepEqual(activity, { | |
| 21 | + | ...span, | |
| 22 | + | messages: 120, | |
| 23 | + | channels: 2, | |
| 24 | + | authors: [ | |
| 25 | + | { key: "user:usr_ana", messages: 75 }, | |
| 26 | + | { key: "user:usr_bo", messages: 30 }, | |
| 27 | + | { key: "agent:agt_margo", messages: 15 }, | |
| 28 | + | ], | |
| 29 | + | }); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("nothing said is zeros, not an error", () => { | |
| 33 | + | assert.deepEqual(chatActivity([], span), { ...span, messages: 0, channels: 0, authors: [] }); | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | test("a span is two times in order", () => { | |
| 37 | + | assert.equal(activitySpan(span.from, span.until)?.fromMs, Date.parse(span.from)); | |
| 38 | + | assert.equal(activitySpan(span.until, span.from), null); | |
| 39 | + | assert.equal(activitySpan("yesterday", span.until), null); | |
| 40 | + | assert.equal(activitySpan(null, span.until), null); | |
| 41 | + | }); |
| 1 | + | /** | |
| 2 | + | * What was said over a span, counted (`activity`, for Home). Pure, so the | |
| 3 | + | * counting is tested apart from the service: the service reads one row per | |
| 4 | + | * (conversation, author) with how many messages, over the conversations | |
| 5 | + | * the viewer can read, and this adds them up. | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | import type { ChatActivity } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | /** One conversation's messages by one author in the span. */ | |
| 11 | + | export type ActivityRow = { channel_id: string; author: string; n: number }; | |
| 12 | + | ||
| 13 | + | /** Messages, conversations with any, and who said how much, most first. */ | |
| 14 | + | export function chatActivity(rows: ActivityRow[], span: { from: string; until: string }): ChatActivity { | |
| 15 | + | const channels = new Set<string>(); | |
| 16 | + | const byAuthor = new Map<string, number>(); | |
| 17 | + | let messages = 0; | |
| 18 | + | for (const row of rows) { | |
| 19 | + | const n = Math.max(0, Math.floor(Number(row.n) || 0)); | |
| 20 | + | if (n === 0) continue; | |
| 21 | + | messages += n; | |
| 22 | + | channels.add(row.channel_id); | |
| 23 | + | byAuthor.set(row.author, (byAuthor.get(row.author) ?? 0) + n); | |
| 24 | + | } | |
| 25 | + | const authors = [...byAuthor] | |
| 26 | + | .map(([key, count]) => ({ key, messages: count })) | |
| 27 | + | .sort((a, b) => b.messages - a.messages || a.key.localeCompare(b.key)); | |
| 28 | + | return { from: span.from, until: span.until, messages, channels: channels.size, authors }; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * The span as a pair of RFC 3339 times, or null when either is not a time | |
| 33 | + | * or the span is empty. | |
| 34 | + | */ | |
| 35 | + | export function activitySpan(from: unknown, until: unknown): { from: string; until: string; fromMs: number; untilMs: number } | null { | |
| 36 | + | if (typeof from !== "string" || typeof until !== "string") return null; | |
| 37 | + | const fromMs = Date.parse(from); | |
| 38 | + | const untilMs = Date.parse(until); | |
| 39 | + | if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return null; | |
| 40 | + | return { from, until, fromMs, untilMs }; | |
| 41 | + | } |
| 17 | 17 | CHAT_VIEWER_HEADER, | |
| 18 | 18 | fail, | |
| 19 | 19 | identityClient, | |
| 20 | + | idFloor, | |
| 20 | 21 | newId, | |
| 21 | 22 | ok, | |
| 22 | 23 | openD1, | |
| ⋯ | |||
| 42 | 43 | type EmojiUpload, | |
| 43 | 44 | type ChatSettings, | |
| 44 | 45 | type ChatSettingsView, | |
| 46 | + | type ChatActivity, | |
| 45 | 47 | type ChatSidebar, | |
| 46 | 48 | type ChatSidebarEntry, | |
| 47 | 49 | CONVERSATION_PEOPLE_SHOWN, | |
| ⋯ | |||
| 63 | 65 | type WorkspaceAgent, | |
| 64 | 66 | } from "@g1t/contracts"; | |
| 65 | 67 | ||
| 68 | + | import { type ActivityRow, activitySpan, chatActivity } from "./activity.ts"; | |
| 66 | 69 | import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts"; | |
| 67 | 70 | import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, type Chain, type Wake } from "./delivery.ts"; | |
| 68 | 71 | import { | |
| ⋯ | |||
| 608 | 611 | return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) }); | |
| 609 | 612 | } | |
| 610 | 613 | ||
| 614 | + | /** | |
| 615 | + | * What was said in `[from, until)` where the viewer can read: every | |
| 616 | + | * public channel of the workspace, and the private channels and direct | |
| 617 | + | * messages they are in (as `place` reads). Text messages and thread | |
| 618 | + | * replies not deleted; never a card. Message ids sort by time, so each | |
| 619 | + | * conversation's span is one range of `messages_by_channel`, read in one | |
| 620 | + | * round trip for them all. Counts only, nothing of what was said. | |
| 621 | + | */ | |
| 622 | + | async activity(a: { workspace: string; viewer: Viewer; from: string; until: string }): Promise<Result<ChatActivity>> { | |
| 623 | + | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 624 | + | if (!found.ok) return found; | |
| 625 | + | const workspace = found.value; | |
| 626 | + | const span = activitySpan(a.from, a.until); | |
| 627 | + | if (!span) return fail("invalid", "from and until must be RFC 3339 times, from before until."); | |
| 628 | + | const me = userKey(a.viewer!); | |
| 629 | + | const rows = await this.db | |
| 630 | + | .prepare( | |
| 631 | + | `SELECT msg.channel_id, msg.author, COUNT(*) AS n | |
| 632 | + | FROM channels c | |
| 633 | + | JOIN messages msg ON msg.channel_id = c.id AND msg.id >= ?3 AND msg.id < ?4 | |
| 634 | + | WHERE c.workspace_id = ?1 AND c.archived_at IS NULL | |
| 635 | + | AND ( | |
| 636 | + | (c.kind = 'channel' AND c.private = 0) | |
| 637 | + | OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2) | |
| 638 | + | ) | |
| 639 | + | AND msg.deleted_at IS NULL AND msg.kind = 'text' | |
| 640 | + | GROUP BY msg.channel_id, msg.author`, | |
| 641 | + | ) | |
| 642 | + | .bind(workspace.id, me, idFloor("msg", span.fromMs), idFloor("msg", span.untilMs)) | |
| 643 | + | .all<ActivityRow>(); | |
| 644 | + | return ok(chatActivity(rows.results, { from: span.from, until: span.until })); | |
| 645 | + | } | |
| 646 | + | ||
| 611 | 647 | // ── Channels ──────────────────────────────────────────────────────────── | |
| 612 | 648 | ||
| 613 | 649 | async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> { | |
| ⋯ | |||
| 2001 | 2037 | switch (method) { | |
| 2002 | 2038 | case "sidebar": | |
| 2003 | 2039 | return Response.json(await service.sidebar(args)); | |
| 2040 | + | case "activity": | |
| 2041 | + | return Response.json(await service.activity(args)); | |
| 2004 | 2042 | case "channel": | |
| 2005 | 2043 | return Response.json(await service.channel(args)); | |
| 2006 | 2044 | case "channel_by_name": | |
| 1 | + | //! `activity_digest`: what happened in a workspace's repositories, and to | |
| 2 | + | //! its artifacts, over a span, counted by who did it. Home reads it for | |
| 3 | + | //! what people and agents did since you were last there | |
| 4 | + | //! (docs.g1t.sh/guides/home/). See `g1t_contracts::events::ActivityDigestArgs`. | |
| 5 | + | //! | |
| 6 | + | //! How it scales: event ids sort by time, so the span is a range of ids, | |
| 7 | + | //! and the `(repo_id, type, id)` index (migrations/0008) makes each | |
| 8 | + | //! repository's events of each counted type one range scan; one statement | |
| 9 | + | //! covers every repository and type asked for, and a second the | |
| 10 | + | //! workspace's folio events, which carry no repository. That is a few | |
| 11 | + | //! thousand rows at most for a busy week of tens of repositories, read in | |
| 12 | + | //! one round trip. A workspace with hundreds of repositories wants a daily | |
| 13 | + | //! rollup per workspace instead, which this method would read in place of | |
| 14 | + | //! the log; that is the next step and is not built yet. | |
| 15 | + | ||
| 16 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 17 | + | ||
| 18 | + | use g1t_contracts::events::{ | |
| 19 | + | ActivityDigest, ActivityDigestArgs, ActorCount, DefaultBranchPushes, DeploymentDigest, Event, | |
| 20 | + | FolioDigest, FolioEdited, IssueDigest, MAX_DIGEST_EVENTS, MAX_DIGEST_LISTED, MAX_DIGEST_REPOS, | |
| 21 | + | PackageDigest, PullDigest, PushDigest, PushesBy, ReleaseDigest, RepoDigest, | |
| 22 | + | }; | |
| 23 | + | use g1t_contracts::id_floor; | |
| 24 | + | use g1t_contracts::time::parse_rfc3339; | |
| 25 | + | use serde_json::Value; | |
| 26 | + | use worker::{D1Database, Error, Result}; | |
| 27 | + | ||
| 28 | + | use crate::EventRow; | |
| 29 | + | ||
| 30 | + | /// The repository events a digest counts. | |
| 31 | + | const REPO_TYPES: [&str; 12] = [ | |
| 32 | + | "git.push", | |
| 33 | + | "pull.opened", | |
| 34 | + | "pull.merged", | |
| 35 | + | "pull.closed", | |
| 36 | + | "issue.opened", | |
| 37 | + | "issue.closed", | |
| 38 | + | "review.completed", | |
| 39 | + | "comment.created", | |
| 40 | + | "deployment.succeeded", | |
| 41 | + | "deployment.failed", | |
| 42 | + | "release.published", | |
| 43 | + | "package.published", | |
| 44 | + | ]; | |
| 45 | + | /// The artifact events a digest counts: published with no repository. | |
| 46 | + | const FOLIO_TYPES: [&str; 2] = ["folio.created", "folio.updated"]; | |
| 47 | + | ||
| 48 | + | /// Reads the span's events and counts them. | |
| 49 | + | pub async fn activity_digest(db: &D1Database, a: ActivityDigestArgs) -> Result<ActivityDigest> { | |
| 50 | + | let from = | |
| 51 | + | parse_rfc3339(&a.from).ok_or_else(|| Error::RustError("from must be RFC 3339".into()))?; | |
| 52 | + | let until = | |
| 53 | + | parse_rfc3339(&a.until).ok_or_else(|| Error::RustError("until must be RFC 3339".into()))?; | |
| 54 | + | let (low, high) = (id_floor("evt", from), id_floor("evt", until)); | |
| 55 | + | let mut complete = a.repo_ids.len() <= MAX_DIGEST_REPOS; | |
| 56 | + | let repo_ids: Vec<&str> = a | |
| 57 | + | .repo_ids | |
| 58 | + | .iter() | |
| 59 | + | .take(MAX_DIGEST_REPOS) | |
| 60 | + | .map(String::as_str) | |
| 61 | + | .collect(); | |
| 62 | + | // One past the cap says whether there was more. | |
| 63 | + | let limit = f64::from(MAX_DIGEST_EVENTS + 1); | |
| 64 | + | let mut statements = Vec::with_capacity(2); | |
| 65 | + | if !repo_ids.is_empty() { | |
| 66 | + | statements.push( | |
| 67 | + | db.prepare( | |
| 68 | + | "SELECT id, type, source, time, repo_id, actor, data FROM events | |
| 69 | + | WHERE repo_id IN (SELECT value FROM json_each(?1)) | |
| 70 | + | AND type IN (SELECT value FROM json_each(?2)) | |
| 71 | + | AND id >= ?3 AND id < ?4 | |
| 72 | + | ORDER BY id LIMIT ?5", | |
| 73 | + | ) | |
| 74 | + | .bind(&[ | |
| 75 | + | serde_json::to_string(&repo_ids)?.into(), | |
| 76 | + | serde_json::to_string(&REPO_TYPES)?.into(), | |
| 77 | + | low.as_str().into(), | |
| 78 | + | high.as_str().into(), | |
| 79 | + | limit.into(), | |
| 80 | + | ])?, | |
| 81 | + | ); | |
| 82 | + | } | |
| 83 | + | if let Some(workspace) = a.workspace.as_deref().filter(|slug| !slug.is_empty()) { | |
| 84 | + | statements.push( | |
| 85 | + | db.prepare( | |
| 86 | + | "SELECT id, type, source, time, repo_id, actor, data FROM events | |
| 87 | + | WHERE repo_id IS NULL | |
| 88 | + | AND type IN (SELECT value FROM json_each(?1)) | |
| 89 | + | AND id >= ?2 AND id < ?3 | |
| 90 | + | AND lower(json_extract(data, '$.workspace')) = ?4 | |
| 91 | + | ORDER BY id LIMIT ?5", | |
| 92 | + | ) | |
| 93 | + | .bind(&[ | |
| 94 | + | serde_json::to_string(&FOLIO_TYPES)?.into(), | |
| 95 | + | low.as_str().into(), | |
| 96 | + | high.as_str().into(), | |
| 97 | + | workspace.to_lowercase().into(), | |
| 98 | + | limit.into(), | |
| 99 | + | ])?, | |
| 100 | + | ); | |
| 101 | + | } | |
| 102 | + | let mut results = if statements.is_empty() { | |
| 103 | + | Vec::new() | |
| 104 | + | } else { | |
| 105 | + | db.batch(statements).await? | |
| 106 | + | } | |
| 107 | + | .into_iter(); | |
| 108 | + | let mut read = |wanted: bool| -> Result<Option<Vec<Event>>> { | |
| 109 | + | if !wanted { | |
| 110 | + | return Ok(None); | |
| 111 | + | } | |
| 112 | + | let Some(result) = results.next() else { | |
| 113 | + | return Ok(Some(Vec::new())); | |
| 114 | + | }; | |
| 115 | + | let mut events: Vec<Event> = result | |
| 116 | + | .results::<EventRow>()? | |
| 117 | + | .into_iter() | |
| 118 | + | .map(Event::from) | |
| 119 | + | .collect(); | |
| 120 | + | if events.len() > MAX_DIGEST_EVENTS as usize { | |
| 121 | + | events.truncate(MAX_DIGEST_EVENTS as usize); | |
| 122 | + | complete = false; | |
| 123 | + | } | |
| 124 | + | Ok(Some(events)) | |
| 125 | + | }; | |
| 126 | + | let repo_events = read(!repo_ids.is_empty())?.unwrap_or_default(); | |
| 127 | + | let folio_events = read(a.workspace.as_deref().is_some_and(|slug| !slug.is_empty()))?; | |
| 128 | + | Ok(shape(&a, &repo_events, folio_events.as_deref(), complete)) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Counts `events` (a repository's, any order) and `folios` (a workspace's | |
| 132 | + | /// folio events) into the digest. Pure, so it is tested on its own. | |
| 133 | + | pub fn shape( | |
| 134 | + | a: &ActivityDigestArgs, | |
| 135 | + | events: &[Event], | |
| 136 | + | folios: Option<&[Event]>, | |
| 137 | + | complete: bool, | |
| 138 | + | ) -> ActivityDigest { | |
| 139 | + | let mut repos: BTreeMap<&str, Counting> = BTreeMap::new(); | |
| 140 | + | for event in events { | |
| 141 | + | let Some(repo_id) = event.repo_id.as_deref() else { | |
| 142 | + | continue; | |
| 143 | + | }; | |
| 144 | + | repos.entry(repo_id).or_default().count(event); | |
| 145 | + | } | |
| 146 | + | ActivityDigest { | |
| 147 | + | from: a.from.clone(), | |
| 148 | + | until: a.until.clone(), | |
| 149 | + | repos: repos | |
| 150 | + | .into_iter() | |
| 151 | + | .map(|(repo_id, counting)| counting.into_digest(repo_id)) | |
| 152 | + | .collect(), | |
| 153 | + | folios: folios.map(folio_digest), | |
| 154 | + | complete, | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// An event's actor as a member key: `user:<id>`, or empty for nobody. | |
| 159 | + | fn member_key(actor: Option<&str>) -> String { | |
| 160 | + | match actor { | |
| 161 | + | Some(id) if !id.is_empty() => format!("user:{id}"), | |
| 162 | + | _ => String::new(), | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | fn text<'a>(data: &'a Value, key: &str) -> Option<&'a str> { | |
| 167 | + | data.get(key).and_then(Value::as_str) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// A running count per actor. | |
| 171 | + | #[derive(Default)] | |
| 172 | + | struct Tally(BTreeMap<String, u32>); | |
| 173 | + | ||
| 174 | + | impl Tally { | |
| 175 | + | fn add(&mut self, actor: String) { | |
| 176 | + | *self.0.entry(actor).or_insert(0) += 1; | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// Most first, then by actor, so the order never depends on the log's. | |
| 180 | + | fn into_counts(self) -> Vec<ActorCount> { | |
| 181 | + | let mut counts: Vec<ActorCount> = self | |
| 182 | + | .0 | |
| 183 | + | .into_iter() | |
| 184 | + | .map(|(actor, count)| ActorCount { actor, count }) | |
| 185 | + | .collect(); | |
| 186 | + | counts.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.actor.cmp(&b.actor))); | |
| 187 | + | counts | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// Pushes per actor, with their commits. | |
| 192 | + | #[derive(Default)] | |
| 193 | + | struct Pushing { | |
| 194 | + | count: u32, | |
| 195 | + | commits: u32, | |
| 196 | + | by: BTreeMap<String, (u32, u32)>, | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | impl Pushing { | |
| 200 | + | fn add(&mut self, actor: String, commits: u32) { | |
| 201 | + | self.count += 1; | |
| 202 | + | self.commits += commits; | |
| 203 | + | let entry = self.by.entry(actor).or_insert((0, 0)); | |
| 204 | + | entry.0 += 1; | |
| 205 | + | entry.1 += commits; | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | fn into_by(self) -> Vec<PushesBy> { | |
| 209 | + | let mut by: Vec<PushesBy> = self | |
| 210 | + | .by | |
| 211 | + | .into_iter() | |
| 212 | + | .map(|(actor, (count, commits))| PushesBy { | |
| 213 | + | actor, | |
| 214 | + | count, | |
| 215 | + | commits, | |
| 216 | + | }) | |
| 217 | + | .collect(); | |
| 218 | + | by.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.actor.cmp(&b.actor))); | |
| 219 | + | by | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[derive(Default)] | |
| 224 | + | struct Counting { | |
| 225 | + | pushes: Pushing, | |
| 226 | + | branches: BTreeSet<String>, | |
| 227 | + | default_branch: Pushing, | |
| 228 | + | default_branch_last: Option<String>, | |
| 229 | + | pulls_opened: Tally, | |
| 230 | + | pulls_merged: Tally, | |
| 231 | + | pulls_closed: Tally, | |
| 232 | + | issues_opened: Tally, | |
| 233 | + | issues_closed: Tally, | |
| 234 | + | reviews: Tally, | |
| 235 | + | comments: Tally, | |
| 236 | + | deploys_succeeded: Tally, | |
| 237 | + | deploys_failed: Tally, | |
| 238 | + | production: u32, | |
| 239 | + | releases: Vec<ReleaseDigest>, | |
| 240 | + | packages: Vec<PackageDigest>, | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | impl Counting { | |
| 244 | + | fn count(&mut self, event: &Event) { | |
| 245 | + | let data = &event.data; | |
| 246 | + | let actor = member_key(event.actor.as_deref()); | |
| 247 | + | match event.kind.as_str() { | |
| 248 | + | "git.push" => { | |
| 249 | + | let Some(branch) = text(data, "ref").and_then(|r| r.strip_prefix("refs/heads/")) | |
| 250 | + | else { | |
| 251 | + | return; | |
| 252 | + | }; | |
| 253 | + | // A push whose commits were not counted brought at least one. | |
| 254 | + | let commits = data | |
| 255 | + | .get("commits") | |
| 256 | + | .and_then(Value::as_u64) | |
| 257 | + | .map_or(1, |n| n.min(u64::from(u32::MAX)) as u32); | |
| 258 | + | self.branches.insert(branch.to_owned()); | |
| 259 | + | self.pushes.add(actor.clone(), commits); | |
| 260 | + | if data.get("defaultBranch").and_then(Value::as_bool) == Some(true) { | |
| 261 | + | self.default_branch.add(actor, commits); | |
| 262 | + | if self | |
| 263 | + | .default_branch_last | |
| 264 | + | .as_deref() | |
| 265 | + | .is_none_or(|last| event.time.as_str() > last) | |
| 266 | + | { | |
| 267 | + | self.default_branch_last = Some(event.time.clone()); | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | "pull.opened" => self.pulls_opened.add(actor), | |
| 272 | + | "pull.merged" => self.pulls_merged.add(actor), | |
| 273 | + | "pull.closed" => self.pulls_closed.add(actor), | |
| 274 | + | "issue.opened" => self.issues_opened.add(actor), | |
| 275 | + | "issue.closed" => self.issues_closed.add(actor), | |
| 276 | + | "review.completed" => self.reviews.add(actor), | |
| 277 | + | "comment.created" => { | |
| 278 | + | // One of the workspace's agents, as itself: the event's actor | |
| 279 | + | // is the person it acted for. | |
| 280 | + | let by = match data.get("agent").and_then(|agent| text(agent, "id")) { | |
| 281 | + | Some(id) => format!("agent:{id}"), | |
| 282 | + | None => actor, | |
| 283 | + | }; | |
| 284 | + | if data | |
| 285 | + | .get("verdict") | |
| 286 | + | .is_some_and(|verdict| !verdict.is_null()) | |
| 287 | + | { | |
| 288 | + | self.reviews.add(by); | |
| 289 | + | } else { | |
| 290 | + | self.comments.add(by); | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | "deployment.succeeded" => { | |
| 294 | + | if text(data, "kind") == Some("production") { | |
| 295 | + | self.production += 1; | |
| 296 | + | } | |
| 297 | + | self.deploys_succeeded.add(actor); | |
| 298 | + | } | |
| 299 | + | "deployment.failed" => self.deploys_failed.add(actor), | |
| 300 | + | "release.published" => self.releases.push(ReleaseDigest { | |
| 301 | + | tag: text(data, "tagName").unwrap_or_default().to_owned(), | |
| 302 | + | name: data | |
| 303 | + | .get("release") | |
| 304 | + | .and_then(|release| text(release, "name")) | |
| 305 | + | .filter(|name| !name.is_empty()) | |
| 306 | + | .map(str::to_owned), | |
| 307 | + | actor, | |
| 308 | + | at: event.time.clone(), | |
| 309 | + | }), | |
| 310 | + | "package.published" => self.packages.push(PackageDigest { | |
| 311 | + | ecosystem: text(data, "ecosystem").unwrap_or_default().to_owned(), | |
| 312 | + | name: text(data, "name").unwrap_or_default().to_owned(), | |
| 313 | + | version: text(data, "version").unwrap_or_default().to_owned(), | |
| 314 | + | actor, | |
| 315 | + | at: event.time.clone(), | |
| 316 | + | }), | |
| 317 | + | _ => {} | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | fn into_digest(mut self, repo_id: &str) -> RepoDigest { | |
| 322 | + | // Newest first, the most recent `MAX_DIGEST_LISTED`. | |
| 323 | + | self.releases | |
| 324 | + | .sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.tag.cmp(&b.tag))); | |
| 325 | + | self.releases.truncate(MAX_DIGEST_LISTED); | |
| 326 | + | self.packages.sort_by(|a, b| { | |
| 327 | + | b.at.cmp(&a.at) | |
| 328 | + | .then_with(|| a.name.cmp(&b.name)) | |
| 329 | + | .then_with(|| a.version.cmp(&b.version)) | |
| 330 | + | }); | |
| 331 | + | self.packages.truncate(MAX_DIGEST_LISTED); | |
| 332 | + | RepoDigest { | |
| 333 | + | repo_id: repo_id.to_owned(), | |
| 334 | + | pushes: PushDigest { | |
| 335 | + | count: self.pushes.count, | |
| 336 | + | commits: self.pushes.commits, | |
| 337 | + | branches: self.branches.into_iter().collect(), | |
| 338 | + | by: self.pushes.into_by(), | |
| 339 | + | default_branch: DefaultBranchPushes { | |
| 340 | + | count: self.default_branch.count, | |
| 341 | + | commits: self.default_branch.commits, | |
| 342 | + | by: self.default_branch.into_by(), | |
| 343 | + | last_at: self.default_branch_last, | |
| 344 | + | }, | |
| 345 | + | }, | |
| 346 | + | pulls: PullDigest { | |
| 347 | + | opened: self.pulls_opened.into_counts(), | |
| 348 | + | merged: self.pulls_merged.into_counts(), | |
| 349 | + | closed: self.pulls_closed.into_counts(), | |
| 350 | + | }, | |
| 351 | + | issues: IssueDigest { | |
| 352 | + | opened: self.issues_opened.into_counts(), | |
| 353 | + | closed: self.issues_closed.into_counts(), | |
| 354 | + | }, | |
| 355 | + | reviews: self.reviews.into_counts(), | |
| 356 | + | comments: self.comments.into_counts(), | |
| 357 | + | deployments: DeploymentDigest { | |
| 358 | + | succeeded: self.deploys_succeeded.into_counts(), | |
| 359 | + | failed: self.deploys_failed.into_counts(), | |
| 360 | + | production: self.production, | |
| 361 | + | }, | |
| 362 | + | releases: self.releases, | |
| 363 | + | packages: self.packages, | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | /// A workspace's folio events counted: who made folios, and which folios' | |
| 369 | + | /// content changed with everyone whose changes are in them. | |
| 370 | + | fn folio_digest(events: &[Event]) -> FolioDigest { | |
| 371 | + | let mut created = Tally::default(); | |
| 372 | + | let mut edited: BTreeMap<&str, (String, BTreeSet<String>)> = BTreeMap::new(); | |
| 373 | + | for event in events { | |
| 374 | + | let data = &event.data; | |
| 375 | + | match event.kind.as_str() { | |
| 376 | + | "folio.created" => created.add(member_key(event.actor.as_deref())), | |
| 377 | + | "folio.updated" => { | |
| 378 | + | let Some(folio_id) = text(data, "folioId") else { | |
| 379 | + | continue; | |
| 380 | + | }; | |
| 381 | + | let kind = text(data, "kind").unwrap_or("doc").to_owned(); | |
| 382 | + | let entry = edited | |
| 383 | + | .entry(folio_id) | |
| 384 | + | .or_insert_with(|| (kind, BTreeSet::new())); | |
| 385 | + | let authors = data | |
| 386 | + | .get("authors") | |
| 387 | + | .and_then(Value::as_array) | |
| 388 | + | .into_iter() | |
| 389 | + | .flatten() | |
| 390 | + | .filter_map(Value::as_str); | |
| 391 | + | let mut any = false; | |
| 392 | + | for author in authors { | |
| 393 | + | any = true; | |
| 394 | + | entry.1.insert(author.to_owned()); | |
| 395 | + | } | |
| 396 | + | // A version with no authors named is the actor's. | |
| 397 | + | if !any { | |
| 398 | + | let actor = member_key(event.actor.as_deref()); | |
| 399 | + | if !actor.is_empty() { | |
| 400 | + | entry.1.insert(actor); | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | _ => {} | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | FolioDigest { | |
| 408 | + | created: created.into_counts(), | |
| 409 | + | edited_count: edited.len() as u32, | |
| 410 | + | edited: edited | |
| 411 | + | .into_iter() | |
| 412 | + | .take(MAX_DIGEST_LISTED) | |
| 413 | + | .map(|(folio_id, (kind, authors))| FolioEdited { | |
| 414 | + | folio_id: folio_id.to_owned(), | |
| 415 | + | kind, | |
| 416 | + | authors: authors.into_iter().collect(), | |
| 417 | + | }) | |
| 418 | + | .collect(), | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | #[cfg(test)] | |
| 423 | + | mod tests { | |
| 424 | + | use super::*; | |
| 425 | + | use serde_json::json; | |
| 426 | + | ||
| 427 | + | fn event( | |
| 428 | + | id: &str, | |
| 429 | + | kind: &str, | |
| 430 | + | repo_id: Option<&str>, | |
| 431 | + | actor: Option<&str>, | |
| 432 | + | data: Value, | |
| 433 | + | ) -> Event { | |
| 434 | + | Event { | |
| 435 | + | id: id.to_owned(), | |
| 436 | + | kind: kind.to_owned(), | |
| 437 | + | source: "test".to_owned(), | |
| 438 | + | time: format!("2026-10-09T1{}:00:00.000Z", &id[id.len() - 1..]), | |
| 439 | + | repo_id: repo_id.map(str::to_owned), | |
| 440 | + | actor: actor.map(str::to_owned), | |
| 441 | + | data, | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | fn args() -> ActivityDigestArgs { | |
| 446 | + | ActivityDigestArgs { | |
| 447 | + | repo_ids: vec!["rep_1".into(), "rep_2".into()], | |
| 448 | + | workspace: Some("acme".into()), | |
| 449 | + | from: "2026-10-09T00:00:00.000Z".into(), | |
| 450 | + | until: "2026-10-10T00:00:00.000Z".into(), | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | #[test] | |
| 455 | + | fn pushes_are_counted_by_branch_and_actor_with_their_commits() { | |
| 456 | + | let events = vec![ | |
| 457 | + | event( | |
| 458 | + | "evt_1", | |
| 459 | + | "git.push", | |
| 460 | + | Some("rep_1"), | |
| 461 | + | Some("usr_ana"), | |
| 462 | + | json!({ "ref": "refs/heads/main", "after": "b", "before": "a", "defaultBranch": true, "commits": 7 }), | |
| 463 | + | ), | |
| 464 | + | event( | |
| 465 | + | "evt_2", | |
| 466 | + | "git.push", | |
| 467 | + | Some("rep_1"), | |
| 468 | + | Some("usr_ana"), | |
| 469 | + | json!({ "ref": "refs/heads/fix", "after": "c", "defaultBranch": false, "commits": 2 }), | |
| 470 | + | ), | |
| 471 | + | // No count recorded: at least one commit. | |
| 472 | + | event( | |
| 473 | + | "evt_3", | |
| 474 | + | "git.push", | |
| 475 | + | Some("rep_1"), | |
| 476 | + | Some("usr_bo"), | |
| 477 | + | json!({ "ref": "refs/heads/main", "after": "d", "before": "b", "defaultBranch": true }), | |
| 478 | + | ), | |
| 479 | + | // A mirror's push names nobody. | |
| 480 | + | event( | |
| 481 | + | "evt_4", | |
| 482 | + | "git.push", | |
| 483 | + | Some("rep_1"), | |
| 484 | + | None, | |
| 485 | + | json!({ "ref": "refs/heads/main", "after": "e", "before": "d", "defaultBranch": true, "mirrored": true, "commits": 3 }), | |
| 486 | + | ), | |
| 487 | + | // A tag is not a push to a branch. | |
| 488 | + | event( | |
| 489 | + | "evt_5", | |
| 490 | + | "git.push", | |
| 491 | + | Some("rep_1"), | |
| 492 | + | Some("usr_ana"), | |
| 493 | + | json!({ "ref": "refs/tags/v1", "after": "e", "defaultBranch": false }), | |
| 494 | + | ), | |
| 495 | + | ]; | |
| 496 | + | let digest = shape(&args(), &events, None, true); | |
| 497 | + | assert_eq!(digest.repos.len(), 1); | |
| 498 | + | let pushes = &digest.repos[0].pushes; | |
| 499 | + | assert_eq!((pushes.count, pushes.commits), (4, 13)); | |
| 500 | + | assert_eq!(pushes.branches, vec!["fix", "main"]); | |
| 501 | + | assert_eq!( | |
| 502 | + | pushes.by, | |
| 503 | + | vec![ | |
| 504 | + | PushesBy { | |
| 505 | + | actor: "user:usr_ana".into(), | |
| 506 | + | count: 2, | |
| 507 | + | commits: 9 | |
| 508 | + | }, | |
| 509 | + | PushesBy { | |
| 510 | + | actor: "".into(), | |
| 511 | + | count: 1, | |
| 512 | + | commits: 3 | |
| 513 | + | }, | |
| 514 | + | PushesBy { | |
| 515 | + | actor: "user:usr_bo".into(), | |
| 516 | + | count: 1, | |
| 517 | + | commits: 1 | |
| 518 | + | }, | |
| 519 | + | ] | |
| 520 | + | ); | |
| 521 | + | assert_eq!( | |
| 522 | + | (pushes.default_branch.count, pushes.default_branch.commits), | |
| 523 | + | (3, 11) | |
| 524 | + | ); | |
| 525 | + | assert_eq!( | |
| 526 | + | pushes.default_branch.last_at.as_deref(), | |
| 527 | + | Some("2026-10-09T14:00:00.000Z") | |
| 528 | + | ); | |
| 529 | + | assert!(digest.folios.is_none()); | |
| 530 | + | assert!(digest.complete); | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | #[test] | |
| 534 | + | fn work_is_counted_per_repository_by_who_did_it() { | |
| 535 | + | let g1t = Some("usr_g1t_agent"); | |
| 536 | + | let events = vec![ | |
| 537 | + | event( | |
| 538 | + | "evt_1", | |
| 539 | + | "pull.opened", | |
| 540 | + | Some("rep_1"), | |
| 541 | + | Some("usr_ana"), | |
| 542 | + | json!({ "number": 1 }), | |
| 543 | + | ), | |
| 544 | + | event( | |
| 545 | + | "evt_2", | |
| 546 | + | "pull.opened", | |
| 547 | + | Some("rep_1"), | |
| 548 | + | g1t, | |
| 549 | + | json!({ "number": 2 }), | |
| 550 | + | ), | |
| 551 | + | event( | |
| 552 | + | "evt_3", | |
| 553 | + | "pull.merged", | |
| 554 | + | Some("rep_1"), | |
| 555 | + | Some("usr_ana"), | |
| 556 | + | json!({ "number": 2 }), | |
| 557 | + | ), | |
| 558 | + | event( | |
| 559 | + | "evt_4", | |
| 560 | + | "pull.closed", | |
| 561 | + | Some("rep_2"), | |
| 562 | + | Some("usr_bo"), | |
| 563 | + | json!({ "number": 3 }), | |
| 564 | + | ), | |
| 565 | + | event( | |
| 566 | + | "evt_5", | |
| 567 | + | "issue.opened", | |
| 568 | + | Some("rep_2"), | |
| 569 | + | Some("usr_bo"), | |
| 570 | + | json!({ "number": 4 }), | |
| 571 | + | ), | |
| 572 | + | event( | |
| 573 | + | "evt_6", | |
| 574 | + | "issue.closed", | |
| 575 | + | Some("rep_2"), | |
| 576 | + | g1t, | |
| 577 | + | json!({ "number": 4 }), | |
| 578 | + | ), | |
| 579 | + | // A person's review is a comment with a verdict; g1t's is review.completed. | |
| 580 | + | event( | |
| 581 | + | "evt_7", | |
| 582 | + | "comment.created", | |
| 583 | + | Some("rep_1"), | |
| 584 | + | Some("usr_bo"), | |
| 585 | + | json!({ "number": 1, "verdict": "approve" }), | |
| 586 | + | ), | |
| 587 | + | event( | |
| 588 | + | "evt_8", | |
| 589 | + | "review.completed", | |
| 590 | + | Some("rep_1"), | |
| 591 | + | g1t, | |
| 592 | + | json!({ "number": 1, "verdict": "approve" }), | |
| 593 | + | ), | |
| 594 | + | // A workspace agent's comment as itself is the agent's, not the person's it acted for. | |
| 595 | + | event( | |
| 596 | + | "evt_9", | |
| 597 | + | "comment.created", | |
| 598 | + | Some("rep_1"), | |
| 599 | + | Some("usr_ana"), | |
| 600 | + | json!({ "number": 1, "agent": { "id": "agt_margo", "handle": "margo" } }), | |
| 601 | + | ), | |
| 602 | + | event( | |
| 603 | + | "evt_a", | |
| 604 | + | "comment.created", | |
| 605 | + | Some("rep_1"), | |
| 606 | + | Some("usr_ana"), | |
| 607 | + | json!({ "number": 1 }), | |
| 608 | + | ), | |
| 609 | + | event( | |
| 610 | + | "evt_b", | |
| 611 | + | "deployment.succeeded", | |
| 612 | + | Some("rep_1"), | |
| 613 | + | Some("usr_ana"), | |
| 614 | + | json!({ "kind": "production" }), | |
| 615 | + | ), | |
| 616 | + | event( | |
| 617 | + | "evt_c", | |
| 618 | + | "deployment.succeeded", | |
| 619 | + | Some("rep_1"), | |
| 620 | + | Some("usr_ana"), | |
| 621 | + | json!({ "kind": "preview" }), | |
| 622 | + | ), | |
| 623 | + | event( | |
| 624 | + | "evt_d", | |
| 625 | + | "deployment.failed", | |
| 626 | + | Some("rep_1"), | |
| 627 | + | g1t, | |
| 628 | + | json!({ "kind": "production" }), | |
| 629 | + | ), | |
| 630 | + | event( | |
| 631 | + | "evt_e", | |
| 632 | + | "release.published", | |
| 633 | + | Some("rep_1"), | |
| 634 | + | Some("usr_ana"), | |
| 635 | + | json!({ "tagName": "v1.2.0", "release": { "name": "Autumn" } }), | |
| 636 | + | ), | |
| 637 | + | event( | |
| 638 | + | "evt_f", | |
| 639 | + | "package.published", | |
| 640 | + | Some("rep_1"), | |
| 641 | + | Some("usr_bo"), | |
| 642 | + | json!({ "ecosystem": "container", "name": "web", "version": "1.2.0" }), | |
| 643 | + | ), | |
| 644 | + | // Not counted. | |
| 645 | + | event( | |
| 646 | + | "evt_g", | |
| 647 | + | "pull.updated", | |
| 648 | + | Some("rep_1"), | |
| 649 | + | Some("usr_ana"), | |
| 650 | + | json!({ "number": 1 }), | |
| 651 | + | ), | |
| 652 | + | ]; | |
| 653 | + | let digest = shape(&args(), &events, None, false); | |
| 654 | + | assert!(!digest.complete); | |
| 655 | + | assert_eq!(digest.repos.len(), 2); | |
| 656 | + | let web = &digest.repos[0]; | |
| 657 | + | assert_eq!(web.repo_id, "rep_1"); | |
| 658 | + | assert_eq!( | |
| 659 | + | web.pulls.opened, | |
| 660 | + | vec![ | |
| 661 | + | ActorCount { | |
| 662 | + | actor: "user:usr_ana".into(), | |
| 663 | + | count: 1 | |
| 664 | + | }, | |
| 665 | + | ActorCount { | |
| 666 | + | actor: "user:usr_g1t_agent".into(), | |
| 667 | + | count: 1 | |
| 668 | + | } | |
| 669 | + | ] | |
| 670 | + | ); | |
| 671 | + | assert_eq!( | |
| 672 | + | web.pulls.merged, | |
| 673 | + | vec![ActorCount { | |
| 674 | + | actor: "user:usr_ana".into(), | |
| 675 | + | count: 1 | |
| 676 | + | }] | |
| 677 | + | ); | |
| 678 | + | assert!(web.pulls.closed.is_empty()); | |
| 679 | + | assert_eq!( | |
| 680 | + | web.reviews, | |
| 681 | + | vec![ | |
| 682 | + | ActorCount { | |
| 683 | + | actor: "user:usr_bo".into(), | |
| 684 | + | count: 1 | |
| 685 | + | }, | |
| 686 | + | ActorCount { | |
| 687 | + | actor: "user:usr_g1t_agent".into(), | |
| 688 | + | count: 1 | |
| 689 | + | } | |
| 690 | + | ] | |
| 691 | + | ); | |
| 692 | + | assert_eq!( | |
| 693 | + | web.comments, | |
| 694 | + | vec![ | |
| 695 | + | ActorCount { | |
| 696 | + | actor: "agent:agt_margo".into(), | |
| 697 | + | count: 1 | |
| 698 | + | }, | |
| 699 | + | ActorCount { | |
| 700 | + | actor: "user:usr_ana".into(), | |
| 701 | + | count: 1 | |
| 702 | + | } | |
| 703 | + | ] | |
| 704 | + | ); | |
| 705 | + | assert_eq!( | |
| 706 | + | web.deployments.succeeded, | |
| 707 | + | vec![ActorCount { | |
| 708 | + | actor: "user:usr_ana".into(), | |
| 709 | + | count: 2 | |
| 710 | + | }] | |
| 711 | + | ); | |
| 712 | + | assert_eq!( | |
| 713 | + | web.deployments.failed, | |
| 714 | + | vec![ActorCount { | |
| 715 | + | actor: "user:usr_g1t_agent".into(), | |
| 716 | + | count: 1 | |
| 717 | + | }] | |
| 718 | + | ); | |
| 719 | + | assert_eq!(web.deployments.production, 1); | |
| 720 | + | assert_eq!( | |
| 721 | + | web.releases, | |
| 722 | + | vec![ReleaseDigest { | |
| 723 | + | tag: "v1.2.0".into(), | |
| 724 | + | name: Some("Autumn".into()), | |
| 725 | + | actor: "user:usr_ana".into(), | |
| 726 | + | at: "2026-10-09T1e:00:00.000Z".into() | |
| 727 | + | }] | |
| 728 | + | ); | |
| 729 | + | assert_eq!(web.packages[0].name, "web"); | |
| 730 | + | assert_eq!(web.packages[0].actor, "user:usr_bo"); | |
| 731 | + | let api = &digest.repos[1]; | |
| 732 | + | assert_eq!( | |
| 733 | + | api.pulls.closed, | |
| 734 | + | vec![ActorCount { | |
| 735 | + | actor: "user:usr_bo".into(), | |
| 736 | + | count: 1 | |
| 737 | + | }] | |
| 738 | + | ); | |
| 739 | + | assert_eq!( | |
| 740 | + | api.issues.opened, | |
| 741 | + | vec![ActorCount { | |
| 742 | + | actor: "user:usr_bo".into(), | |
| 743 | + | count: 1 | |
| 744 | + | }] | |
| 745 | + | ); | |
| 746 | + | assert_eq!( | |
| 747 | + | api.issues.closed, | |
| 748 | + | vec![ActorCount { | |
| 749 | + | actor: "user:usr_g1t_agent".into(), | |
| 750 | + | count: 1 | |
| 751 | + | }] | |
| 752 | + | ); | |
| 753 | + | assert_eq!(api.pushes.count, 0); | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | #[test] | |
| 757 | + | fn folios_made_and_the_folios_whose_content_changed() { | |
| 758 | + | let folios = vec![ | |
| 759 | + | event( | |
| 760 | + | "evt_1", | |
| 761 | + | "folio.created", | |
| 762 | + | None, | |
| 763 | + | Some("usr_ana"), | |
| 764 | + | json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc" }), | |
| 765 | + | ), | |
| 766 | + | event( | |
| 767 | + | "evt_2", | |
| 768 | + | "folio.updated", | |
| 769 | + | None, | |
| 770 | + | Some("usr_ana"), | |
| 771 | + | json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc", "authors": ["user:usr_ana", "agent:agt_margo"] }), | |
| 772 | + | ), | |
| 773 | + | event( | |
| 774 | + | "evt_3", | |
| 775 | + | "folio.updated", | |
| 776 | + | None, | |
| 777 | + | Some("usr_bo"), | |
| 778 | + | json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc", "authors": ["user:usr_bo"] }), | |
| 779 | + | ), | |
| 780 | + | // No authors named: the actor's. | |
| 781 | + | event( | |
| 782 | + | "evt_4", | |
| 783 | + | "folio.updated", | |
| 784 | + | None, | |
| 785 | + | Some("usr_bo"), | |
| 786 | + | json!({ "workspace": "acme", "folioId": "fol_2", "kind": "slides", "authors": [] }), | |
| 787 | + | ), | |
| 788 | + | ]; | |
| 789 | + | let digest = shape(&args(), &[], Some(&folios), true); | |
| 790 | + | let folios = digest.folios.unwrap(); | |
| 791 | + | assert_eq!( | |
| 792 | + | folios.created, | |
| 793 | + | vec![ActorCount { | |
| 794 | + | actor: "user:usr_ana".into(), | |
| 795 | + | count: 1 | |
| 796 | + | }] | |
| 797 | + | ); | |
| 798 | + | assert_eq!(folios.edited_count, 2); | |
| 799 | + | assert_eq!( | |
| 800 | + | folios.edited, | |
| 801 | + | vec![ | |
| 802 | + | FolioEdited { | |
| 803 | + | folio_id: "fol_1".into(), | |
| 804 | + | kind: "doc".into(), | |
| 805 | + | authors: vec![ | |
| 806 | + | "agent:agt_margo".into(), | |
| 807 | + | "user:usr_ana".into(), | |
| 808 | + | "user:usr_bo".into() | |
| 809 | + | ] | |
| 810 | + | }, | |
| 811 | + | FolioEdited { | |
| 812 | + | folio_id: "fol_2".into(), | |
| 813 | + | kind: "slides".into(), | |
| 814 | + | authors: vec!["user:usr_bo".into()] | |
| 815 | + | }, | |
| 816 | + | ] | |
| 817 | + | ); | |
| 818 | + | // A workspace named but quiet still answers for its artifacts. | |
| 819 | + | let quiet = shape(&args(), &[], Some(&[]), true); | |
| 820 | + | assert_eq!(quiet.folios, Some(FolioDigest::default())); | |
| 821 | + | assert!(quiet.repos.is_empty()); | |
| 822 | + | } | |
| 823 | + | } |
| 13 | 13 | //! | |
| 14 | 14 | //! Other services reach it over `POST /rpc/<method>`; see | |
| 15 | 15 | //! `g1t_contracts::events`, `audit` and `inbox` for the methods and their | |
| 16 | − | //! arguments. | |
| 16 | + | //! arguments. `activity_digest` (digest.rs) counts a span of the log for | |
| 17 | + | //! Home. | |
| 17 | 18 | ||
| 18 | 19 | mod audit; | |
| 20 | + | mod digest; | |
| 19 | 21 | mod fanout; | |
| 20 | 22 | mod inbox; | |
| 21 | 23 | mod subscriptions; | |
| ⋯ | |||
| 49 | 51 | /// How long a record of which queues a batch reached is kept for its retries. | |
| 50 | 52 | const FANOUT_KEEP_MS: u64 = 24 * 60 * 60 * 1000; | |
| 51 | 53 | ||
| 54 | + | /// An event as the log's table holds it. | |
| 52 | 55 | #[derive(Deserialize)] | |
| 53 | − | struct EventRow { | |
| 56 | + | pub(crate) struct EventRow { | |
| 54 | 57 | id: String, | |
| 55 | 58 | #[serde(rename = "type")] | |
| 56 | 59 | kind: String, | |
| ⋯ | |||
| 300 | 303 | match method.as_str() { | |
| 301 | 304 | "publish" => reply(&events.publish(args(body)?).await?), | |
| 302 | 305 | "list" => reply(&events.list(args(body)?).await?), | |
| 306 | + | // Home's digest of a span: counted, by who did it (digest.rs). | |
| 307 | + | "activity_digest" => reply(&digest::activity_digest(&events.db, args(body)?).await?), | |
| 303 | 308 | "audit_record" => reply(&audit::record(&events.db, args(body)?).await?), | |
| 304 | 309 | "audit_list" => reply(&audit::list(&events.db, args(body)?).await?), | |
| 305 | 310 | "inbox_list" => { | |
| 498 | 498 | return Ok(Outcome::fail(FailureCode::Conflict, format!("The tag {tag} could not be made: {reason}"))); | |
| 499 | 499 | } | |
| 500 | 500 | self.refs_moved(&repo.id).await; | |
| 501 | − | self.publish_push(&repo, &git_ref, None, &commit.hash, Some(&a.actor)).await?; | |
| 501 | + | self.publish_push(&repo, &git_ref, None, &commit.hash, Some(&a.actor), None).await?; | |
| 502 | 502 | commit.hash | |
| 503 | 503 | } | |
| 504 | 504 | }; |
| 598 | 598 | self.record_git(entry).await; | |
| 599 | 599 | // As any push does: the pull request's head moves, its checks run | |
| 600 | 600 | // again, and whether it merges cleanly is worked out anew. | |
| 601 | − | self.publish_push(&source, &git_ref, Some(&head.hash), &commit_id, Some(&a.actor)) | |
| 601 | + | self.publish_push(&source, &git_ref, Some(&head.hash), &commit_id, Some(&a.actor), Some(1)) | |
| 602 | 602 | .await?; | |
| 603 | 603 | Ok(Outcome::Ok(PullBranchUpdate::Updated { | |
| 604 | 604 | commit: commit_id, |
| 176 | 176 | } | |
| 177 | 177 | entry.result = Some("ok".to_owned()); | |
| 178 | 178 | self.record_git(entry).await; | |
| 179 | − | self.publish_push(&repo, &git_ref, None, &commit_id, Some(&a.actor)).await?; | |
| 179 | + | self.publish_push(&repo, &git_ref, None, &commit_id, Some(&a.actor), Some(1)).await?; | |
| 180 | 180 | Ok(Outcome::Ok(CommittedFile { | |
| 181 | 181 | branch: a.branch, | |
| 182 | 182 | commit: commit_id, |
| 717 | 717 | if repo.mirror.is_some() { | |
| 718 | 718 | self.publish_mirrored_push(&repo, git_ref, None, head).await?; | |
| 719 | 719 | } else { | |
| 720 | − | self.publish_push(&repo, git_ref, None, head, None).await?; | |
| 720 | + | self.publish_push(&repo, git_ref, None, head, None, None).await?; | |
| 721 | 721 | } | |
| 722 | 722 | } | |
| 723 | 723 | Ok(Outcome::Ok(repo)) | |
| ⋯ | |||
| 1598 | 1598 | old.as_deref(), | |
| 1599 | 1599 | &new, | |
| 1600 | 1600 | Some(&a.actor), | |
| 1601 | + | None, | |
| 1601 | 1602 | ) | |
| 1602 | 1603 | .await?; | |
| 1603 | 1604 | Ok(Outcome::Ok(Landed { | |
| ⋯ | |||
| 1679 | 1680 | ||
| 1680 | 1681 | /// Reports that `git_ref` of `repo` (a full ref) now points to `after`, | |
| 1681 | 1682 | /// moved by `actor` (marked when that was a workflow job's token). | |
| 1683 | + | /// `commits` is how many the move brought, when the caller knows: one | |
| 1684 | + | /// for a file committed on the site or a branch brought up to date. | |
| 1682 | 1685 | async fn publish_push( | |
| 1683 | 1686 | &self, | |
| 1684 | 1687 | repo: &Repo, | |
| ⋯ | |||
| 1686 | 1689 | before: Option<&str>, | |
| 1687 | 1690 | after: &str, | |
| 1688 | 1691 | actor: Option<&User>, | |
| 1692 | + | commits: Option<u32>, | |
| 1689 | 1693 | ) -> Result<()> { | |
| 1690 | 1694 | let caused_by_job = actor.and_then(g1t_contracts::events::job_run_of).map(str::to_owned); | |
| 1691 | − | self.publish_git_push(repo, git_ref, before, after, actor.map(|user| user.id.clone()), false, caused_by_job, false) | |
| 1695 | + | self.publish_git_push(repo, git_ref, before, after, actor.map(|user| user.id.clone()), false, caused_by_job, false, commits) | |
| 1692 | 1696 | .await | |
| 1693 | 1697 | } | |
| 1694 | 1698 | ||
| 1695 | 1699 | /// A push copied in from the remote a mirror follows (mirror.rs), or | |
| 1696 | 1700 | /// made by filling a new mirror from it. | |
| 1697 | 1701 | async fn publish_mirrored_push(&self, repo: &Repo, git_ref: &str, before: Option<&str>, after: &str) -> Result<()> { | |
| 1698 | − | self.publish_git_push(repo, git_ref, before, after, None, false, None, true).await | |
| 1702 | + | self.publish_git_push(repo, git_ref, before, after, None, false, None, true, None).await | |
| 1699 | 1703 | } | |
| 1700 | 1704 | ||
| 1701 | 1705 | /// `publish_push`, saying whether the push reached the store without | |
| ⋯ | |||
| 1711 | 1715 | unscanned: bool, | |
| 1712 | 1716 | caused_by_job: Option<String>, | |
| 1713 | 1717 | mirrored: bool, | |
| 1718 | + | commits: Option<u32>, | |
| 1714 | 1719 | ) -> Result<()> { | |
| 1715 | 1720 | self.publish(NewEvent { | |
| 1716 | 1721 | kind: "git.push", | |
| ⋯ | |||
| 1728 | 1733 | caused_by_job, | |
| 1729 | 1734 | mirrored, | |
| 1730 | 1735 | mirror: repo.mirror.clone(), | |
| 1736 | + | commits, | |
| 1731 | 1737 | }, | |
| 1732 | 1738 | }) | |
| 1733 | 1739 | .await | |
| ⋯ | |||
| 2254 | 2260 | head.first().is_none_or(|commit| commit.hash == pushed.after) | |
| 2255 | 2261 | }), | |
| 2256 | 2262 | }; | |
| 2257 | − | // The person's contribution calendar (push_commits.rs). A | |
| 2258 | − | // failure only leaves the day short. | |
| 2259 | − | if moved | |
| 2260 | − | && let (Some(user_id), Some(branch)) = (credit.as_deref(), pushed.branch()) | |
| 2263 | + | // How many commits a branch push brought (push_commits.rs), for | |
| 2264 | + | // the event and the person's contribution calendar. A log the | |
| 2265 | + | // store cannot read leaves the event without a count and the | |
| 2266 | + | // day short. | |
| 2267 | + | let commits = match (moved, pushed.branch()) { | |
| 2268 | + | (true, Some(_)) => match stored.log(&pushed.after, push_commits::MOST_PER_PUSH + 1).await { | |
| 2269 | + | Ok(log) => Some(push_commits::new_commits(&log, pushed.before.as_deref())), | |
| 2270 | + | Err(error) => { | |
| 2271 | + | worker::console_error!("commits pushed to {} not counted: {error}", repo.name); | |
| 2272 | + | None | |
| 2273 | + | } | |
| 2274 | + | }, | |
| 2275 | + | _ => None, | |
| 2276 | + | }; | |
| 2277 | + | if let (Some(commits), Some(user_id), Some(branch)) = (commits, credit.as_deref(), pushed.branch()) | |
| 2261 | 2278 | && push_commits::counts(branch, &repo.default_branch) | |
| 2262 | − | { | |
| 2263 | − | let credited = async { | |
| 2264 | − | let log = stored.log(&pushed.after, push_commits::MOST_PER_PUSH + 1).await?; | |
| 2265 | − | let commits = push_commits::new_commits(&log, pushed.before.as_deref()); | |
| 2279 | + | && let Err(error) = | |
| 2266 | 2280 | push_commits::record(&self.registry.db, user_id, &repo.id, &rfc3339(now_ms())[..10], commits).await | |
| 2267 | − | }; | |
| 2268 | − | if let Err(error) = credited.await { | |
| 2269 | − | worker::console_error!("commits pushed to {} not credited: {error}", repo.name); | |
| 2270 | − | } | |
| 2281 | + | { | |
| 2282 | + | worker::console_error!("commits pushed to {} not credited: {error}", repo.name); | |
| 2271 | 2283 | } | |
| 2272 | 2284 | if moved { | |
| 2273 | 2285 | self.publish_git_push( | |
| ⋯ | |||
| 2279 | 2291 | unscanned, | |
| 2280 | 2292 | caused_by_job.clone(), | |
| 2281 | 2293 | false, | |
| 2294 | + | commits, | |
| 2282 | 2295 | ) | |
| 2283 | 2296 | .await?; | |
| 2284 | 2297 | } | |