Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1 | //! `activity_digest`: what happened in a workspace's repositories, and to |
| 2 | //! its artifacts, over a span, counted by who did it. Home reads it for | |
| 3 | //! what people and agents did since you were last there | |
| 4 | //! (docs.g1t.sh/guides/home/). See `g1t_contracts::events::ActivityDigestArgs`. | |
| 5 | //! | |
| 6 | //! How it scales: event ids sort by time, so the span is a range of ids, | |
| 7 | //! and the `(repo_id, type, id)` index (migrations/0008) makes each | |
| 8 | //! repository's events of each counted type one range scan; one statement | |
| 9 | //! covers every repository and type asked for, and a second the | |
| 10 | //! workspace's folio events, which carry no repository. That is a few | |
| 11 | //! thousand rows at most for a busy week of tens of repositories, read in | |
| 12 | //! one round trip. A workspace with hundreds of repositories wants a daily | |
| 13 | //! rollup per workspace instead, which this method would read in place of | |
| 14 | //! the log; that is the next step and is not built yet. | |
| 15 | ||
| 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 | } |