Skip to content
823 linesCodeBlameRaw
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
158/// An event's actor as a member key: `user:<id>`, or empty for nobody.
159fn 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
166fn 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)]
172struct Tally(BTreeMap<String, u32>);
173
174impl 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)]
193struct Pushing {
194 count: u32,
195 commits: u32,
196 by: BTreeMap<String, (u32, u32)>,
197}
198
199impl 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)]
224struct 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
243impl 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.
370fn 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)]
423mod 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}