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