| 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 | |
| 16 | use std::collections::{BTreeMap, BTreeSet}; |
| 17 | |
| 18 | use 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 | }; |
| 23 | use g1t_contracts::id_floor; |
| 24 | use g1t_contracts::time::parse_rfc3339; |
| 25 | use serde_json::Value; |
| 26 | use worker::{D1Database, Error, Result}; |
| 27 | |
| 28 | use crate::EventRow; |
| 29 | |
| 30 | /// The repository events a digest counts. |
| 31 | const 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. |
| 46 | const FOLIO_TYPES: [&str; 2] = ["folio.created", "folio.updated"]; |
| 47 | |
| 48 | /// Reads the span's events and counts them. |
| 49 | pub 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. |
| 133 | pub 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. |
| 159 | fn 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 | |
| 166 | fn 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)] |
| 172 | struct Tally(BTreeMap<String, u32>); |
| 173 | |
| 174 | impl 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)] |
| 193 | struct Pushing { |
| 194 | count: u32, |
| 195 | commits: u32, |
| 196 | by: BTreeMap<String, (u32, u32)>, |
| 197 | } |
| 198 | |
| 199 | impl 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)] |
| 224 | struct 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 | |
| 243 | impl 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. |
| 370 | fn 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)] |
| 423 | mod 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 | } |