Skip to content
839 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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.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
16use std::collections::{BTreeMap, BTreeSet};
17
18use 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};
23use g1t_contracts::id_floor;
24use g1t_contracts::time::parse_rfc3339;
25use serde_json::Value;
26use worker::{D1Database, Error, Result};
27
28use crate::EventRow;
29
30/// The repository events a digest counts.
31const 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.
46const FOLIO_TYPES: [&str; 2] = ["folio.created", "folio.updated"];
47
48/// Reads the span's events and counts them.
49pub 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.
133pub 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
Four small things seen today. An agent can attach a file it made to any doc the person who asked can edit, whatever the space's agent mode: attaching changes nothing in the doc, so the suggest mode that stopped every PDF and spreadsheet no longer does; the ability is its own, attach, beside read, suggest and edit. Home no longer counts robots among the people: events that name an agent by its own id, or g1t's upkeep as the workspace or as g1t, read as the agent and as g1t, in the digest and on the page, so the faces say who instead of someone. Tooltips are the inverted bubble, the page's foreground behind the page's background, with no border. The Spend page's slice tabs keep their ring inside the row instead of losing its top to the scroll edge, and the Apps launcher keeps its search box and footer in place while it reads which apps there are, with skeleton tiles between.158/// An event's actor as a member key, or empty for nobody. Actors are
159/// people (`user:<id>`), but some events name an agent by its own id, and
160/// g1t's own upkeep names the workspace or `g1t`: those read as the agent
161/// (`agent:<id>`) and as g1t (`user:usr_g1t_agent`), so the digest never
162/// counts a robot among the people.
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.163fn member_key(actor: Option<&str>) -> String {
164 match actor {
Four small things seen today. An agent can attach a file it made to any doc the person who asked can edit, whatever the space's agent mode: attaching changes nothing in the doc, so the suggest mode that stopped every PDF and spreadsheet no longer does; the ability is its own, attach, beside read, suggest and edit. Home no longer counts robots among the people: events that name an agent by its own id, or g1t's upkeep as the workspace or as g1t, read as the agent and as g1t, in the digest and on the page, so the faces say who instead of someone. Tooltips are the inverted bubble, the page's foreground behind the page's background, with no border. The Spend page's slice tabs keep their ring inside the row instead of losing its top to the scroll edge, and the Apps launcher keeps its search box and footer in place while it reads which apps there are, with skeleton tiles between.165 Some(id) if id.starts_with("agt_") => format!("agent:{id}"),
166 Some(id) if id == "g1t" || id.starts_with("wsp_") => "user:usr_g1t_agent".to_owned(),
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.167 Some(id) if !id.is_empty() => format!("user:{id}"),
168 _ => String::new(),
169 }
170}
171
172fn text<'a>(data: &'a Value, key: &str) -> Option<&'a str> {
173 data.get(key).and_then(Value::as_str)
174}
175
176/// A running count per actor.
177#[derive(Default)]
178struct Tally(BTreeMap<String, u32>);
179
180impl Tally {
181 fn add(&mut self, actor: String) {
182 *self.0.entry(actor).or_insert(0) += 1;
183 }
184
185 /// Most first, then by actor, so the order never depends on the log's.
186 fn into_counts(self) -> Vec<ActorCount> {
187 let mut counts: Vec<ActorCount> = self
188 .0
189 .into_iter()
190 .map(|(actor, count)| ActorCount { actor, count })
191 .collect();
192 counts.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.actor.cmp(&b.actor)));
193 counts
194 }
195}
196
197/// Pushes per actor, with their commits.
198#[derive(Default)]
199struct Pushing {
200 count: u32,
201 commits: u32,
202 by: BTreeMap<String, (u32, u32)>,
203}
204
205impl Pushing {
206 fn add(&mut self, actor: String, commits: u32) {
207 self.count += 1;
208 self.commits += commits;
209 let entry = self.by.entry(actor).or_insert((0, 0));
210 entry.0 += 1;
211 entry.1 += commits;
212 }
213
214 fn into_by(self) -> Vec<PushesBy> {
215 let mut by: Vec<PushesBy> = self
216 .by
217 .into_iter()
218 .map(|(actor, (count, commits))| PushesBy {
219 actor,
220 count,
221 commits,
222 })
223 .collect();
224 by.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.actor.cmp(&b.actor)));
225 by
226 }
227}
228
229#[derive(Default)]
230struct Counting {
231 pushes: Pushing,
232 branches: BTreeSet<String>,
233 default_branch: Pushing,
234 default_branch_last: Option<String>,
235 pulls_opened: Tally,
236 pulls_merged: Tally,
237 pulls_closed: Tally,
238 issues_opened: Tally,
239 issues_closed: Tally,
240 reviews: Tally,
241 comments: Tally,
242 deploys_succeeded: Tally,
243 deploys_failed: Tally,
244 production: u32,
245 releases: Vec<ReleaseDigest>,
246 packages: Vec<PackageDigest>,
247}
248
249impl Counting {
250 fn count(&mut self, event: &Event) {
251 let data = &event.data;
252 let actor = member_key(event.actor.as_deref());
253 match event.kind.as_str() {
254 "git.push" => {
255 let Some(branch) = text(data, "ref").and_then(|r| r.strip_prefix("refs/heads/"))
256 else {
257 return;
258 };
259 // A push whose commits were not counted brought at least one.
260 let commits = data
261 .get("commits")
262 .and_then(Value::as_u64)
263 .map_or(1, |n| n.min(u64::from(u32::MAX)) as u32);
264 self.branches.insert(branch.to_owned());
265 self.pushes.add(actor.clone(), commits);
266 if data.get("defaultBranch").and_then(Value::as_bool) == Some(true) {
267 self.default_branch.add(actor, commits);
268 if self
269 .default_branch_last
270 .as_deref()
271 .is_none_or(|last| event.time.as_str() > last)
272 {
273 self.default_branch_last = Some(event.time.clone());
274 }
275 }
276 }
277 "pull.opened" => self.pulls_opened.add(actor),
278 "pull.merged" => self.pulls_merged.add(actor),
279 "pull.closed" => self.pulls_closed.add(actor),
280 "issue.opened" => self.issues_opened.add(actor),
281 "issue.closed" => self.issues_closed.add(actor),
282 "review.completed" => self.reviews.add(actor),
283 "comment.created" => {
284 // One of the workspace's agents, as itself: the event's actor
285 // is the person it acted for.
286 let by = match data.get("agent").and_then(|agent| text(agent, "id")) {
287 Some(id) => format!("agent:{id}"),
288 None => actor,
289 };
290 if data
291 .get("verdict")
292 .is_some_and(|verdict| !verdict.is_null())
293 {
294 self.reviews.add(by);
295 } else {
296 self.comments.add(by);
297 }
298 }
299 "deployment.succeeded" => {
300 if text(data, "kind") == Some("production") {
301 self.production += 1;
302 }
303 self.deploys_succeeded.add(actor);
304 }
305 "deployment.failed" => self.deploys_failed.add(actor),
306 "release.published" => self.releases.push(ReleaseDigest {
307 tag: text(data, "tagName").unwrap_or_default().to_owned(),
308 name: data
309 .get("release")
310 .and_then(|release| text(release, "name"))
311 .filter(|name| !name.is_empty())
312 .map(str::to_owned),
313 actor,
314 at: event.time.clone(),
315 }),
316 "package.published" => self.packages.push(PackageDigest {
317 ecosystem: text(data, "ecosystem").unwrap_or_default().to_owned(),
318 name: text(data, "name").unwrap_or_default().to_owned(),
319 version: text(data, "version").unwrap_or_default().to_owned(),
320 actor,
321 at: event.time.clone(),
322 }),
323 _ => {}
324 }
325 }
326
327 fn into_digest(mut self, repo_id: &str) -> RepoDigest {
328 // Newest first, the most recent `MAX_DIGEST_LISTED`.
329 self.releases
330 .sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.tag.cmp(&b.tag)));
331 self.releases.truncate(MAX_DIGEST_LISTED);
332 self.packages.sort_by(|a, b| {
333 b.at.cmp(&a.at)
334 .then_with(|| a.name.cmp(&b.name))
335 .then_with(|| a.version.cmp(&b.version))
336 });
337 self.packages.truncate(MAX_DIGEST_LISTED);
338 RepoDigest {
339 repo_id: repo_id.to_owned(),
340 pushes: PushDigest {
341 count: self.pushes.count,
342 commits: self.pushes.commits,
343 branches: self.branches.into_iter().collect(),
344 by: self.pushes.into_by(),
345 default_branch: DefaultBranchPushes {
346 count: self.default_branch.count,
347 commits: self.default_branch.commits,
348 by: self.default_branch.into_by(),
349 last_at: self.default_branch_last,
350 },
351 },
352 pulls: PullDigest {
353 opened: self.pulls_opened.into_counts(),
354 merged: self.pulls_merged.into_counts(),
355 closed: self.pulls_closed.into_counts(),
356 },
357 issues: IssueDigest {
358 opened: self.issues_opened.into_counts(),
359 closed: self.issues_closed.into_counts(),
360 },
361 reviews: self.reviews.into_counts(),
362 comments: self.comments.into_counts(),
363 deployments: DeploymentDigest {
364 succeeded: self.deploys_succeeded.into_counts(),
365 failed: self.deploys_failed.into_counts(),
366 production: self.production,
367 },
368 releases: self.releases,
369 packages: self.packages,
370 }
371 }
372}
373
374/// A workspace's folio events counted: who made folios, and which folios'
375/// content changed with everyone whose changes are in them.
376fn folio_digest(events: &[Event]) -> FolioDigest {
377 let mut created = Tally::default();
378 let mut edited: BTreeMap<&str, (String, BTreeSet<String>)> = BTreeMap::new();
379 for event in events {
380 let data = &event.data;
381 match event.kind.as_str() {
382 "folio.created" => created.add(member_key(event.actor.as_deref())),
383 "folio.updated" => {
384 let Some(folio_id) = text(data, "folioId") else {
385 continue;
386 };
387 let kind = text(data, "kind").unwrap_or("doc").to_owned();
388 let entry = edited
389 .entry(folio_id)
390 .or_insert_with(|| (kind, BTreeSet::new()));
391 let authors = data
392 .get("authors")
393 .and_then(Value::as_array)
394 .into_iter()
395 .flatten()
396 .filter_map(Value::as_str);
397 let mut any = false;
398 for author in authors {
399 any = true;
400 entry.1.insert(author.to_owned());
401 }
402 // A version with no authors named is the actor's.
403 if !any {
404 let actor = member_key(event.actor.as_deref());
405 if !actor.is_empty() {
406 entry.1.insert(actor);
407 }
408 }
409 }
410 _ => {}
411 }
412 }
413 FolioDigest {
414 created: created.into_counts(),
415 edited_count: edited.len() as u32,
416 edited: edited
417 .into_iter()
418 .take(MAX_DIGEST_LISTED)
419 .map(|(folio_id, (kind, authors))| FolioEdited {
420 folio_id: folio_id.to_owned(),
421 kind,
422 authors: authors.into_iter().collect(),
423 })
424 .collect(),
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
Four small things seen today. An agent can attach a file it made to any doc the person who asked can edit, whatever the space's agent mode: attaching changes nothing in the doc, so the suggest mode that stopped every PDF and spreadsheet no longer does; the ability is its own, attach, beside read, suggest and edit. Home no longer counts robots among the people: events that name an agent by its own id, or g1t's upkeep as the workspace or as g1t, read as the agent and as g1t, in the digest and on the page, so the faces say who instead of someone. Tooltips are the inverted bubble, the page's foreground behind the page's background, with no border. The Spend page's slice tabs keep their ring inside the row instead of losing its top to the scroll edge, and the Apps launcher keeps its search box and footer in place while it reads which apps there are, with skeleton tiles between.431
432 #[test]
433 fn robots_never_count_among_the_people() {
434 assert_eq!(member_key(Some("usr_ana")), "user:usr_ana");
435 assert_eq!(member_key(Some("agt_margo")), "agent:agt_margo");
436 assert_eq!(member_key(Some("wsp_acme")), "user:usr_g1t_agent");
437 assert_eq!(member_key(Some("g1t")), "user:usr_g1t_agent");
438 assert_eq!(member_key(Some("")), "");
439 assert_eq!(member_key(None), "");
440 }
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.441 use serde_json::json;
442
443 fn event(
444 id: &str,
445 kind: &str,
446 repo_id: Option<&str>,
447 actor: Option<&str>,
448 data: Value,
449 ) -> Event {
450 Event {
451 id: id.to_owned(),
452 kind: kind.to_owned(),
453 source: "test".to_owned(),
454 time: format!("2026-10-09T1{}:00:00.000Z", &id[id.len() - 1..]),
455 repo_id: repo_id.map(str::to_owned),
456 actor: actor.map(str::to_owned),
457 data,
458 }
459 }
460
461 fn args() -> ActivityDigestArgs {
462 ActivityDigestArgs {
463 repo_ids: vec!["rep_1".into(), "rep_2".into()],
464 workspace: Some("acme".into()),
465 from: "2026-10-09T00:00:00.000Z".into(),
466 until: "2026-10-10T00:00:00.000Z".into(),
467 }
468 }
469
470 #[test]
471 fn pushes_are_counted_by_branch_and_actor_with_their_commits() {
472 let events = vec![
473 event(
474 "evt_1",
475 "git.push",
476 Some("rep_1"),
477 Some("usr_ana"),
478 json!({ "ref": "refs/heads/main", "after": "b", "before": "a", "defaultBranch": true, "commits": 7 }),
479 ),
480 event(
481 "evt_2",
482 "git.push",
483 Some("rep_1"),
484 Some("usr_ana"),
485 json!({ "ref": "refs/heads/fix", "after": "c", "defaultBranch": false, "commits": 2 }),
486 ),
487 // No count recorded: at least one commit.
488 event(
489 "evt_3",
490 "git.push",
491 Some("rep_1"),
492 Some("usr_bo"),
493 json!({ "ref": "refs/heads/main", "after": "d", "before": "b", "defaultBranch": true }),
494 ),
495 // A mirror's push names nobody.
496 event(
497 "evt_4",
498 "git.push",
499 Some("rep_1"),
500 None,
501 json!({ "ref": "refs/heads/main", "after": "e", "before": "d", "defaultBranch": true, "mirrored": true, "commits": 3 }),
502 ),
503 // A tag is not a push to a branch.
504 event(
505 "evt_5",
506 "git.push",
507 Some("rep_1"),
508 Some("usr_ana"),
509 json!({ "ref": "refs/tags/v1", "after": "e", "defaultBranch": false }),
510 ),
511 ];
512 let digest = shape(&args(), &events, None, true);
513 assert_eq!(digest.repos.len(), 1);
514 let pushes = &digest.repos[0].pushes;
515 assert_eq!((pushes.count, pushes.commits), (4, 13));
516 assert_eq!(pushes.branches, vec!["fix", "main"]);
517 assert_eq!(
518 pushes.by,
519 vec![
520 PushesBy {
521 actor: "user:usr_ana".into(),
522 count: 2,
523 commits: 9
524 },
525 PushesBy {
526 actor: "".into(),
527 count: 1,
528 commits: 3
529 },
530 PushesBy {
531 actor: "user:usr_bo".into(),
532 count: 1,
533 commits: 1
534 },
535 ]
536 );
537 assert_eq!(
538 (pushes.default_branch.count, pushes.default_branch.commits),
539 (3, 11)
540 );
541 assert_eq!(
542 pushes.default_branch.last_at.as_deref(),
543 Some("2026-10-09T14:00:00.000Z")
544 );
545 assert!(digest.folios.is_none());
546 assert!(digest.complete);
547 }
548
549 #[test]
550 fn work_is_counted_per_repository_by_who_did_it() {
551 let g1t = Some("usr_g1t_agent");
552 let events = vec![
553 event(
554 "evt_1",
555 "pull.opened",
556 Some("rep_1"),
557 Some("usr_ana"),
558 json!({ "number": 1 }),
559 ),
560 event(
561 "evt_2",
562 "pull.opened",
563 Some("rep_1"),
564 g1t,
565 json!({ "number": 2 }),
566 ),
567 event(
568 "evt_3",
569 "pull.merged",
570 Some("rep_1"),
571 Some("usr_ana"),
572 json!({ "number": 2 }),
573 ),
574 event(
575 "evt_4",
576 "pull.closed",
577 Some("rep_2"),
578 Some("usr_bo"),
579 json!({ "number": 3 }),
580 ),
581 event(
582 "evt_5",
583 "issue.opened",
584 Some("rep_2"),
585 Some("usr_bo"),
586 json!({ "number": 4 }),
587 ),
588 event(
589 "evt_6",
590 "issue.closed",
591 Some("rep_2"),
592 g1t,
593 json!({ "number": 4 }),
594 ),
595 // A person's review is a comment with a verdict; g1t's is review.completed.
596 event(
597 "evt_7",
598 "comment.created",
599 Some("rep_1"),
600 Some("usr_bo"),
601 json!({ "number": 1, "verdict": "approve" }),
602 ),
603 event(
604 "evt_8",
605 "review.completed",
606 Some("rep_1"),
607 g1t,
608 json!({ "number": 1, "verdict": "approve" }),
609 ),
610 // A workspace agent's comment as itself is the agent's, not the person's it acted for.
611 event(
612 "evt_9",
613 "comment.created",
614 Some("rep_1"),
615 Some("usr_ana"),
616 json!({ "number": 1, "agent": { "id": "agt_margo", "handle": "margo" } }),
617 ),
618 event(
619 "evt_a",
620 "comment.created",
621 Some("rep_1"),
622 Some("usr_ana"),
623 json!({ "number": 1 }),
624 ),
625 event(
626 "evt_b",
627 "deployment.succeeded",
628 Some("rep_1"),
629 Some("usr_ana"),
630 json!({ "kind": "production" }),
631 ),
632 event(
633 "evt_c",
634 "deployment.succeeded",
635 Some("rep_1"),
636 Some("usr_ana"),
637 json!({ "kind": "preview" }),
638 ),
639 event(
640 "evt_d",
641 "deployment.failed",
642 Some("rep_1"),
643 g1t,
644 json!({ "kind": "production" }),
645 ),
646 event(
647 "evt_e",
648 "release.published",
649 Some("rep_1"),
650 Some("usr_ana"),
651 json!({ "tagName": "v1.2.0", "release": { "name": "Autumn" } }),
652 ),
653 event(
654 "evt_f",
655 "package.published",
656 Some("rep_1"),
657 Some("usr_bo"),
658 json!({ "ecosystem": "container", "name": "web", "version": "1.2.0" }),
659 ),
660 // Not counted.
661 event(
662 "evt_g",
663 "pull.updated",
664 Some("rep_1"),
665 Some("usr_ana"),
666 json!({ "number": 1 }),
667 ),
668 ];
669 let digest = shape(&args(), &events, None, false);
670 assert!(!digest.complete);
671 assert_eq!(digest.repos.len(), 2);
672 let web = &digest.repos[0];
673 assert_eq!(web.repo_id, "rep_1");
674 assert_eq!(
675 web.pulls.opened,
676 vec![
677 ActorCount {
678 actor: "user:usr_ana".into(),
679 count: 1
680 },
681 ActorCount {
682 actor: "user:usr_g1t_agent".into(),
683 count: 1
684 }
685 ]
686 );
687 assert_eq!(
688 web.pulls.merged,
689 vec![ActorCount {
690 actor: "user:usr_ana".into(),
691 count: 1
692 }]
693 );
694 assert!(web.pulls.closed.is_empty());
695 assert_eq!(
696 web.reviews,
697 vec![
698 ActorCount {
699 actor: "user:usr_bo".into(),
700 count: 1
701 },
702 ActorCount {
703 actor: "user:usr_g1t_agent".into(),
704 count: 1
705 }
706 ]
707 );
708 assert_eq!(
709 web.comments,
710 vec![
711 ActorCount {
712 actor: "agent:agt_margo".into(),
713 count: 1
714 },
715 ActorCount {
716 actor: "user:usr_ana".into(),
717 count: 1
718 }
719 ]
720 );
721 assert_eq!(
722 web.deployments.succeeded,
723 vec![ActorCount {
724 actor: "user:usr_ana".into(),
725 count: 2
726 }]
727 );
728 assert_eq!(
729 web.deployments.failed,
730 vec![ActorCount {
731 actor: "user:usr_g1t_agent".into(),
732 count: 1
733 }]
734 );
735 assert_eq!(web.deployments.production, 1);
736 assert_eq!(
737 web.releases,
738 vec![ReleaseDigest {
739 tag: "v1.2.0".into(),
740 name: Some("Autumn".into()),
741 actor: "user:usr_ana".into(),
742 at: "2026-10-09T1e:00:00.000Z".into()
743 }]
744 );
745 assert_eq!(web.packages[0].name, "web");
746 assert_eq!(web.packages[0].actor, "user:usr_bo");
747 let api = &digest.repos[1];
748 assert_eq!(
749 api.pulls.closed,
750 vec![ActorCount {
751 actor: "user:usr_bo".into(),
752 count: 1
753 }]
754 );
755 assert_eq!(
756 api.issues.opened,
757 vec![ActorCount {
758 actor: "user:usr_bo".into(),
759 count: 1
760 }]
761 );
762 assert_eq!(
763 api.issues.closed,
764 vec![ActorCount {
765 actor: "user:usr_g1t_agent".into(),
766 count: 1
767 }]
768 );
769 assert_eq!(api.pushes.count, 0);
770 }
771
772 #[test]
773 fn folios_made_and_the_folios_whose_content_changed() {
774 let folios = vec![
775 event(
776 "evt_1",
777 "folio.created",
778 None,
779 Some("usr_ana"),
780 json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc" }),
781 ),
782 event(
783 "evt_2",
784 "folio.updated",
785 None,
786 Some("usr_ana"),
787 json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc", "authors": ["user:usr_ana", "agent:agt_margo"] }),
788 ),
789 event(
790 "evt_3",
791 "folio.updated",
792 None,
793 Some("usr_bo"),
794 json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc", "authors": ["user:usr_bo"] }),
795 ),
796 // No authors named: the actor's.
797 event(
798 "evt_4",
799 "folio.updated",
800 None,
801 Some("usr_bo"),
802 json!({ "workspace": "acme", "folioId": "fol_2", "kind": "slides", "authors": [] }),
803 ),
804 ];
805 let digest = shape(&args(), &[], Some(&folios), true);
806 let folios = digest.folios.unwrap();
807 assert_eq!(
808 folios.created,
809 vec![ActorCount {
810 actor: "user:usr_ana".into(),
811 count: 1
812 }]
813 );
814 assert_eq!(folios.edited_count, 2);
815 assert_eq!(
816 folios.edited,
817 vec![
818 FolioEdited {
819 folio_id: "fol_1".into(),
820 kind: "doc".into(),
821 authors: vec![
822 "agent:agt_margo".into(),
823 "user:usr_ana".into(),
824 "user:usr_bo".into()
825 ]
826 },
827 FolioEdited {
828 folio_id: "fol_2".into(),
829 kind: "slides".into(),
830 authors: vec!["user:usr_bo".into()]
831 },
832 ]
833 );
834 // A workspace named but quiet still answers for its artifacts.
835 let quiet = shape(&args(), &[], Some(&[]), true);
836 assert_eq!(quiet.folios, Some(FolioDigest::default()));
837 assert!(quiet.repos.is_empty());
838 }
839}