Skip to content
839 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, 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.
163fn member_key(actor: Option<&str>) -> String {
164 match actor {
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(),
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::*;
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 }
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}