Skip to content
823 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
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}