Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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