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.

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