1use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10
11use crate::run::{RunState, RunStatus, list_ids};
12
13#[derive(Debug, Clone, Default)]
15pub struct AgentStats {
16 pub agent: String,
18 pub entered: usize,
20 pub wins: usize,
22 pub empty: usize,
24}
25
26impl AgentStats {
27 pub fn win_rate(&self) -> f64 {
29 if self.entered == 0 {
30 0.0
31 } else {
32 100.0 * self.wins as f64 / self.entered as f64
33 }
34 }
35}
36
37#[derive(Debug, Clone, Default)]
39pub struct ReviewerStats {
40 pub agent: String,
42 pub rounds: usize,
47 pub seated: usize,
52 pub submitted: usize,
54 pub adopted: usize,
56 pub unique: usize,
58 pub timeouts: usize,
62}
63
64impl ReviewerStats {
65 pub fn adopted_per_round(&self) -> f64 {
67 if self.rounds == 0 {
68 0.0
69 } else {
70 self.adopted as f64 / self.rounds as f64
71 }
72 }
73
74 pub fn precision(&self) -> f64 {
78 if self.submitted == 0 {
79 0.0
80 } else {
81 100.0 * self.adopted as f64 / self.submitted as f64
82 }
83 }
84
85 pub fn unique_rate(&self) -> f64 {
87 if self.submitted == 0 {
88 0.0
89 } else {
90 100.0 * self.unique as f64 / self.submitted as f64
91 }
92 }
93
94 pub fn timeout_rate(&self) -> f64 {
96 if self.seated == 0 {
97 0.0
98 } else {
99 100.0 * self.timeouts as f64 / self.seated as f64
100 }
101 }
102}
103
104#[derive(Debug, Clone, Default)]
111pub struct AdvisorStats {
112 pub agent: String,
114 pub seated: usize,
116 pub proposed: usize,
118 pub absent: usize,
126 pub faint: usize,
129 pub strong: usize,
132}
133
134impl AdvisorStats {
135 pub fn reflection_rate(&self) -> f64 {
144 if self.proposed == 0 {
145 0.0
146 } else {
147 100.0 * self.strong as f64 / self.proposed as f64
148 }
149 }
150}
151
152#[derive(Debug, Clone, Default)]
154pub struct E2eStats {
155 pub rounds: usize,
157 pub failures: usize,
159 pub sole_detections: usize,
162 pub deferred: usize,
169}
170
171impl E2eStats {
172 pub fn sole_rate(&self) -> f64 {
174 if self.failures == 0 {
175 0.0
176 } else {
177 100.0 * self.sole_detections as f64 / self.failures as f64
178 }
179 }
180}
181
182#[derive(Debug, Clone, Default)]
184pub struct Totals {
185 pub runs: usize,
187 pub merged: usize,
189 pub ready: usize,
191 pub blocked: usize,
193 pub failed: usize,
195 pub stalled: usize,
200 pub verified_noop: usize,
203 pub superseded: usize,
206 pub in_progress: usize,
211 pub tallied: usize,
213 pub split: usize,
215 pub deliberated: usize,
217 pub minds_changed: usize,
219 pub converged: usize,
221 pub review_rounds: usize,
223}
224
225impl Totals {
226 pub fn completion_rate(&self) -> f64 {
228 if self.runs == 0 {
229 0.0
230 } else {
231 100.0 * (self.merged + self.ready) as f64 / self.runs as f64
232 }
233 }
234
235 pub fn split_rate(&self) -> f64 {
237 if self.tallied == 0 {
238 0.0
239 } else {
240 100.0 * self.split as f64 / self.tallied as f64
241 }
242 }
243}
244
245#[derive(Debug, Clone, Default, PartialEq)]
253pub struct NodeDuration {
254 pub node: String,
256 pub runs: usize,
258 pub total_secs: i64,
260 pub max_secs: i64,
262 pub single: usize,
267}
268
269impl NodeDuration {
270 pub fn mean_secs(&self) -> f64 {
274 if self.runs == 0 {
275 0.0
276 } else {
277 self.total_secs as f64 / self.runs as f64
278 }
279 }
280}
281
282pub fn node_durations<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Vec<NodeDuration> {
290 let mut nodes: BTreeMap<String, NodeDuration> = BTreeMap::new();
291
292 for state in states {
293 let mut spans: BTreeMap<&str, (jiff::Timestamp, jiff::Timestamp, usize)> = BTreeMap::new();
294 for e in &state.events {
295 spans
296 .entry(e.node.as_str())
297 .and_modify(|(min, max, count)| {
298 if e.at < *min {
299 *min = e.at;
300 }
301 if e.at > *max {
302 *max = e.at;
303 }
304 *count += 1;
305 })
306 .or_insert((e.at, e.at, 1));
307 }
308
309 for (node, (min, max, count)) in spans {
310 let entry = nodes
311 .entry(node.to_owned())
312 .or_insert_with(|| NodeDuration {
313 node: node.to_owned(),
314 ..NodeDuration::default()
315 });
316 if count < 2 {
317 entry.single += 1;
318 continue;
319 }
320 let span_secs = (max - min).get_seconds();
324 entry.runs += 1;
325 entry.total_secs += span_secs;
326 if span_secs > entry.max_secs {
327 entry.max_secs = span_secs;
328 }
329 }
330 }
331
332 let mut nodes: Vec<NodeDuration> = nodes.into_values().collect();
333 nodes.sort_by(|a, b| b.total_secs.cmp(&a.total_secs).then(a.node.cmp(&b.node)));
334 nodes
335}
336
337#[derive(Debug, Clone, Default)]
346pub struct ReleaseBumpStats {
347 pub merged: usize,
349 pub recorded: usize,
351 pub pr_opened: usize,
353 pub automerge_enabled: usize,
359 pub merged_directly: usize,
366 pub needs_attention: usize,
369}
370
371impl ReleaseBumpStats {
372 pub fn coverage_rate(&self) -> f64 {
374 if self.merged == 0 {
375 0.0
376 } else {
377 100.0 * self.recorded as f64 / self.merged as f64
378 }
379 }
380
381 pub fn automerge_rate(&self) -> f64 {
383 if self.pr_opened == 0 {
384 0.0
385 } else {
386 100.0 * self.automerge_enabled as f64 / self.pr_opened as f64
387 }
388 }
389
390 pub fn attention_rate(&self) -> f64 {
392 if self.recorded == 0 {
393 0.0
394 } else {
395 100.0 * self.needs_attention as f64 / self.recorded as f64
396 }
397 }
398
399 pub fn clean(&self) -> usize {
410 self.recorded.saturating_sub(self.needs_attention)
411 }
412}
413
414#[derive(Debug, Clone, Default)]
416pub struct Stats {
417 pub totals: Totals,
419 pub agents: Vec<AgentStats>,
421 pub reviewers: Vec<ReviewerStats>,
423 pub advisors: Vec<AdvisorStats>,
429 pub e2e: E2eStats,
431 pub nodes: Vec<NodeDuration>,
433 pub release_bumps: ReleaseBumpStats,
435 pub retired_hidden: Vec<String>,
439}
440
441pub fn roster_of(cfg: &crate::config::Config) -> BTreeSet<String> {
445 cfg.agents.iter().map(|a| a.id.clone()).collect()
446}
447
448pub fn retain_roster(stats: &mut Stats, roster: &BTreeSet<String>) {
453 let mut hidden = BTreeSet::new();
454 let mut keep = |agent: &str| {
455 let keep = roster.contains(agent);
456 if !keep {
457 hidden.insert(agent.to_owned());
458 }
459 keep
460 };
461 stats.agents.retain(|a| keep(&a.agent));
462 stats.reviewers.retain(|a| keep(&a.agent));
463 stats.advisors.retain(|a| keep(&a.agent));
464 stats.retired_hidden = hidden.into_iter().collect();
465}
466
467pub fn retain_current_roster(stats: &mut Stats, repo: &Path) {
470 if let Some(roster) = crate::config::Config::discover(repo, None)
471 .ok()
472 .map(|(cfg, _)| roster_of(&cfg))
473 {
474 retain_roster(stats, &roster);
475 }
476}
477
478pub fn load_all() -> Vec<RunState> {
480 list_ids()
481 .into_iter()
482 .filter_map(|id| RunState::load(&id).ok())
483 .collect()
484}
485
486#[derive(Debug, Clone)]
488pub struct RepoStats {
489 pub repo: PathBuf,
495 pub name: String,
499 pub stats: Stats,
502}
503
504pub fn by_repo(states: &[RunState]) -> Vec<RepoStats> {
511 let mut groups: BTreeMap<PathBuf, Vec<&RunState>> = BTreeMap::new();
512 for state in states {
513 groups.entry(state.repo.clone()).or_default().push(state);
514 }
515
516 let mut out: Vec<RepoStats> = groups
517 .into_iter()
518 .map(|(repo, group)| {
519 let stats = collect_refs(group);
520 let name = repo
521 .file_name()
522 .map(|n| n.to_string_lossy().into_owned())
523 .unwrap_or_else(|| repo.to_string_lossy().into_owned());
524 RepoStats { repo, name, stats }
525 })
526 .collect();
527 out.sort_by(|a, b| {
528 b.stats
529 .totals
530 .runs
531 .cmp(&a.stats.totals.runs)
532 .then(a.repo.cmp(&b.repo))
533 });
534 out
535}
536
537pub fn filter_repo<'a>(states: &'a [RunState], repo: &Path) -> Vec<&'a RunState> {
546 states.iter().filter(|s| s.repo == repo).collect()
547}
548
549pub fn collect(states: &[RunState]) -> Stats {
551 collect_refs(states)
552}
553
554pub fn collect_refs<'a>(states: impl IntoIterator<Item = &'a RunState>) -> Stats {
559 let states: Vec<&'a RunState> = states.into_iter().collect();
560 let mut totals = Totals::default();
561 let mut agents: BTreeMap<String, AgentStats> = BTreeMap::new();
562 let mut reviewers: BTreeMap<String, ReviewerStats> = BTreeMap::new();
563 let mut advisors: BTreeMap<String, AdvisorStats> = BTreeMap::new();
564 let mut e2e = E2eStats::default();
565 let mut release_bumps = ReleaseBumpStats::default();
566
567 for state in &states {
568 totals.runs += 1;
569 if state.status == RunStatus::Merged {
570 release_bumps.merged += 1;
571 if let Some(b) = &state.release_bump {
572 release_bumps.recorded += 1;
573 if b.pr_url.is_some() {
574 release_bumps.pr_opened += 1;
575 }
576 if b.automerge_enabled {
577 release_bumps.automerge_enabled += 1;
578 }
579 if b.merged_directly {
580 release_bumps.merged_directly += 1;
581 }
582 }
583 if state.needs_attention() {
584 release_bumps.needs_attention += 1;
585 }
586 }
587 match state.status {
588 RunStatus::Merged => totals.merged += 1,
589 RunStatus::Ready => totals.ready += 1,
590 RunStatus::Blocked => totals.blocked += 1,
591 RunStatus::Failed => totals.failed += 1,
592 RunStatus::Stalled => totals.stalled += 1,
593 RunStatus::VerifiedNoop => totals.verified_noop += 1,
594 RunStatus::Superseded | RunStatus::AlreadyInBase => totals.superseded += 1,
604 RunStatus::Prep
605 | RunStatus::Implementing
606 | RunStatus::Judging
607 | RunStatus::Deliberating
608 | RunStatus::Voting
609 | RunStatus::Reviewing
610 | RunStatus::Gating
611 | RunStatus::Landing => totals.in_progress += 1,
612 }
613
614 for c in &state.candidates {
615 let entry = agents.entry(c.agent.clone()).or_insert_with(|| AgentStats {
616 agent: c.agent.clone(),
617 ..AgentStats::default()
618 });
619 if c.empty && c.verified_noop.is_none() {
624 entry.empty += 1;
625 }
626 if c.viable() {
627 entry.entered += 1;
628 }
629 }
630
631 if let Some(t) = &state.tally {
632 if t.uncontested.is_none() {
639 totals.tallied += 1;
640 if !t.unanimous_initial {
641 totals.split += 1;
642 }
643 if t.deliberated {
644 totals.deliberated += 1;
645 if t.changed_votes > 0 {
646 totals.minds_changed += 1;
647 }
648 if t.unanimous_final {
649 totals.converged += 1;
650 }
651 }
652 }
653 if let Some(w) = state.candidates.iter().find(|c| c.label == t.winner) {
654 agents
655 .entry(w.agent.clone())
656 .or_insert_with(|| AgentStats {
657 agent: w.agent.clone(),
658 ..AgentStats::default()
659 })
660 .wins += 1;
661 }
662 }
663
664 if let Some(advice) = &state.advice {
665 for rec in &advice.records {
666 let entry = advisors
667 .entry(rec.agent.clone())
668 .or_insert_with(|| AdvisorStats {
669 agent: rec.agent.clone(),
670 ..AdvisorStats::default()
671 });
672 entry.seated += 1;
673 if rec.proposal.is_none() {
674 entry.absent += 1;
675 continue;
676 }
677 entry.proposed += 1;
678 match rec.reflection {
679 crate::advise::Reflection::Strong => entry.strong += 1,
680 crate::advise::Reflection::Faint => entry.faint += 1,
681 crate::advise::Reflection::Absent => entry.faint += 1,
692 }
693 }
694 }
695
696 for round in &state.reviews {
697 totals.review_rounds += 1;
698
699 let report_lost = round.fix.as_ref().is_some_and(|f| f.failed.is_some());
706 let adopted: Vec<&String> = round
707 .fix
708 .as_ref()
709 .map(|f| f.addressed.iter().collect())
710 .unwrap_or_default();
711
712 for rec in &round.reviews {
713 let entry = reviewers
714 .entry(rec.agent.clone())
715 .or_insert_with(|| ReviewerStats {
716 agent: rec.agent.clone(),
717 ..ReviewerStats::default()
718 });
719 entry.seated += 1;
725 if rec.failed.is_some() {
726 entry.timeouts += 1;
727 continue;
728 }
729 if report_lost {
730 continue;
731 }
732 entry.rounds += 1;
733 entry.submitted += rec.findings.len();
734 for f in &rec.findings {
735 if adopted.iter().any(|a| **a == f.id) {
736 entry.adopted += 1;
737 }
738 let overlapped = round
739 .reviews
740 .iter()
741 .filter(|other| other.reviewer != rec.reviewer)
742 .flat_map(|other| other.findings.iter())
743 .any(|g| same_defect(f, g));
744 if !overlapped {
745 entry.unique += 1;
746 }
747 }
748 }
749
750 if round.e2e_deferred {
751 e2e.deferred += 1;
752 } else if !round.e2e.is_empty() {
753 e2e.rounds += 1;
754 if round.e2e.iter().any(|o| !o.ok()) {
755 e2e.failures += 1;
756 if round.blocking == 0 {
757 e2e.sole_detections += 1;
758 }
759 }
760 }
761 }
762 }
763
764 let mut agents: Vec<AgentStats> = agents.into_values().collect();
765 agents.sort_by(|a, b| {
766 b.win_rate()
767 .total_cmp(&a.win_rate())
768 .then(b.entered.cmp(&a.entered))
769 });
770 let mut reviewers: Vec<ReviewerStats> = reviewers.into_values().collect();
771 reviewers.retain(|r| r.rounds > 0 || r.timeouts > 0);
776 reviewers.sort_by(|a, b| {
777 b.adopted_per_round()
778 .total_cmp(&a.adopted_per_round())
779 .then(b.rounds.cmp(&a.rounds))
780 });
781
782 let mut advisors: Vec<AdvisorStats> = advisors.into_values().collect();
783 advisors.sort_by(|a, b| {
784 b.reflection_rate()
785 .total_cmp(&a.reflection_rate())
786 .then(b.proposed.cmp(&a.proposed))
787 });
788
789 let nodes = node_durations(states);
790
791 Stats {
792 totals,
793 agents,
794 reviewers,
795 advisors,
796 e2e,
797 nodes,
798 release_bumps,
799 retired_hidden: Vec::new(),
800 }
801}
802
803fn same_defect(a: &crate::verdict::Finding, b: &crate::verdict::Finding) -> bool {
809 if normalize(&a.title) == normalize(&b.title) {
810 return true;
811 }
812 match (&a.file, &b.file) {
813 (Some(fa), Some(fb)) if fa == fb => match (a.line, b.line) {
814 (Some(la), Some(lb)) => la.abs_diff(lb) <= 5,
815 _ => false,
816 },
817 _ => false,
818 }
819}
820
821fn normalize(title: &str) -> String {
822 title
823 .chars()
824 .filter(|c| c.is_alphanumeric())
825 .map(|c| c.to_ascii_lowercase())
826 .collect()
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
831pub struct DayBucket {
832 pub date: jiff::civil::Date,
834 pub runs: usize,
836 pub merged: usize,
838 pub ready: usize,
840 pub other: usize,
842}
843
844pub fn daily<'a>(
853 states: impl IntoIterator<Item = &'a RunState>,
854 today: jiff::civil::Date,
855 tz: &jiff::tz::TimeZone,
856 days: usize,
857) -> Vec<DayBucket> {
858 let mut buckets: Vec<DayBucket> = (0..days)
859 .rev()
860 .filter_map(|back| {
861 let span = jiff::Span::new().days(i64::try_from(back).ok()?);
862 today.checked_sub(span).ok()
863 })
864 .map(|date| DayBucket {
865 date,
866 runs: 0,
867 merged: 0,
868 ready: 0,
869 other: 0,
870 })
871 .collect();
872 for state in states {
873 let date = state.created_at.to_zoned(tz.clone()).date();
874 let Some(bucket) = buckets.iter_mut().find(|b| b.date == date) else {
875 continue;
876 };
877 bucket.runs += 1;
878 match state.status {
879 RunStatus::Merged => bucket.merged += 1,
880 RunStatus::Ready => bucket.ready += 1,
881 _ => bucket.other += 1,
882 }
883 }
884 buckets
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890 use crate::config::Config;
891 use crate::run::{Candidate, CommandOutcome, FixRecord, ReviewRecord, ReviewRound, Tally};
892 use crate::verdict::{Finding, Severity};
893 use std::path::PathBuf;
894
895 fn mixed_stats() -> Stats {
896 Stats {
897 totals: Totals {
898 runs: 7,
899 ..Totals::default()
900 },
901 agents: ["cc", "cx", "oc"]
902 .map(|a| AgentStats {
903 agent: a.into(),
904 entered: 2,
905 ..AgentStats::default()
906 })
907 .to_vec(),
908 reviewers: ["cx", "cc"]
909 .map(|a| ReviewerStats {
910 agent: a.into(),
911 ..ReviewerStats::default()
912 })
913 .to_vec(),
914 advisors: ["cx", "gone"]
915 .map(|a| AdvisorStats {
916 agent: a.into(),
917 ..AdvisorStats::default()
918 })
919 .to_vec(),
920 ..Stats::default()
921 }
922 }
923
924 fn roster(ids: &[&str]) -> BTreeSet<String> {
925 ids.iter().map(|s| (*s).to_owned()).collect()
926 }
927
928 #[test]
929 fn retain_roster_drops_retired_agents_from_every_table() {
930 let mut st = mixed_stats();
931 retain_roster(&mut st, &roster(&["cc", "oc"]));
932 let agents: Vec<&str> = st.agents.iter().map(|a| a.agent.as_str()).collect();
933 assert_eq!(agents, ["cc", "oc"]);
934 let reviewers: Vec<&str> = st.reviewers.iter().map(|a| a.agent.as_str()).collect();
935 assert_eq!(reviewers, ["cc"]);
936 assert!(st.advisors.is_empty());
937 assert_eq!(st.retired_hidden, ["cx", "gone"]);
938 }
939
940 #[test]
941 fn retain_roster_leaves_non_agent_totals_alone() {
942 let mut st = mixed_stats();
943 retain_roster(&mut st, &roster(&["cc"]));
944 assert_eq!(st.totals.runs, 7);
945 }
946
947 #[test]
948 fn retain_roster_with_everyone_present_hides_nothing() {
949 let mut st = mixed_stats();
950 retain_roster(&mut st, &roster(&["cc", "cx", "oc", "gone"]));
951 assert_eq!(st.agents.len(), 3);
952 assert!(st.retired_hidden.is_empty());
953 }
954
955 #[test]
956 fn a_loaded_empty_roster_hides_every_agent_row() {
957 let dir = tempfile::tempdir().unwrap();
958 std::fs::write(dir.path().join("magi.toml"), "agents = []\n").unwrap();
959 let mut st = mixed_stats();
960 retain_current_roster(&mut st, dir.path());
961 assert!(st.agents.is_empty() && st.reviewers.is_empty() && st.advisors.is_empty());
962 assert_eq!(st.retired_hidden, ["cc", "cx", "gone", "oc"]);
963 assert_eq!(st.totals.runs, 7);
964 }
965
966 #[test]
967 fn an_unreadable_config_does_not_filter() {
968 let dir = tempfile::tempdir().unwrap();
969 std::fs::write(dir.path().join("magi.toml"), "this is = = not toml").unwrap();
970 let mut st = mixed_stats();
971 retain_current_roster(&mut st, dir.path());
972 assert_eq!(st.agents.len(), 3);
973 assert!(st.retired_hidden.is_empty());
974 }
975
976 fn finding(id: &str, file: &str, line: u32, title: &str, sev: Severity) -> Finding {
977 Finding {
978 id: id.to_owned(),
979 severity: sev,
980 file: Some(file.to_owned()),
981 line: Some(line),
982 title: title.to_owned(),
983 detail: String::new(),
984 }
985 }
986
987 fn candidate(label: char, agent: &str) -> Candidate {
988 Candidate {
989 index: 0,
990 label,
991 agent: agent.to_owned(),
992 branch: format!("magi/x/{label}"),
993 worktree: PathBuf::from("/w"),
994 summary: String::new(),
995 stat: String::new(),
996 files: 1,
997 commits: 1,
998 empty: false,
999 failed: None,
1000 verified_noop: None,
1001 duration_ms: 0,
1002 folded: false,
1003 }
1004 }
1005
1006 fn state_with(reviews: Vec<ReviewRound>, winner: char, status: RunStatus) -> RunState {
1007 let mut s = RunState::new(
1008 PathBuf::from("/repo"),
1009 "main".to_owned(),
1010 "abcdef".to_owned(),
1011 "task".to_owned(),
1012 Config::default(),
1013 );
1014 s.candidates = vec![candidate('A', "alpha"), candidate('B', "beta")];
1015 s.tally = Some(Tally {
1016 first_choice: BTreeMap::from([('A', 1), ('B', 2)]),
1017 borda: BTreeMap::new(),
1018 winner,
1019 rankings: 3,
1020 unanimous_initial: false,
1021 deliberated: true,
1022 changed_votes: 1,
1023 unanimous_final: true,
1024 tie_break: None,
1025 judges: 3,
1026 present: 3,
1027 quorum: 2,
1028 met_quorum: true,
1029 uncontested: None,
1030 });
1031 s.reviews = reviews;
1032 s.status = status;
1033 s
1034 }
1035
1036 fn state_with_repo(
1037 reviews: Vec<ReviewRound>,
1038 winner: char,
1039 status: RunStatus,
1040 repo: &str,
1041 ) -> RunState {
1042 let mut s = state_with(reviews, winner, status);
1043 s.repo = PathBuf::from(repo);
1044 s
1045 }
1046
1047 #[test]
1048 fn win_rates_and_completion_are_counted_per_agent() {
1049 let states = vec![
1050 state_with(Vec::new(), 'B', RunStatus::Merged),
1051 state_with(Vec::new(), 'A', RunStatus::Blocked),
1052 ];
1053 let stats = collect(&states);
1054 assert_eq!(stats.totals.runs, 2);
1055 assert_eq!(stats.totals.merged, 1);
1056 assert_eq!(stats.totals.blocked, 1);
1057 assert_eq!(stats.totals.completion_rate(), 50.0);
1058 assert_eq!(stats.totals.split, 2);
1059 assert_eq!(stats.totals.minds_changed, 2);
1060 assert_eq!(stats.totals.converged, 2);
1061
1062 let beta = stats.agents.iter().find(|a| a.agent == "beta").unwrap();
1063 assert_eq!(beta.entered, 2);
1064 assert_eq!(beta.wins, 1);
1065 assert_eq!(beta.win_rate(), 50.0);
1066 }
1067
1068 #[test]
1069 fn reviewer_precision_and_uniqueness() {
1070 let round = ReviewRound {
1071 round: 1,
1072 head: "h".to_owned(),
1073 verified_head: None,
1074 verified_at: None,
1075 reviews: vec![
1076 ReviewRecord {
1077 attempts: 0,
1078 reviewer: 1,
1079 agent: "alpha".to_owned(),
1080 summary: String::new(),
1081 findings: vec![
1082 finding(
1083 "R1-1-1",
1084 "src/a.rs",
1085 10,
1086 "panics on empty",
1087 Severity::Blocker,
1088 ),
1089 finding("R1-1-2", "src/b.rs", 40, "leaks a handle", Severity::Major),
1090 ],
1091 vote: None,
1092 failed: None,
1093 duration_ms: 0,
1094 },
1095 ReviewRecord {
1096 attempts: 0,
1097 reviewer: 2,
1098 agent: "beta".to_owned(),
1099 summary: String::new(),
1100 findings: vec![finding(
1102 "R1-2-1",
1103 "src/a.rs",
1104 13,
1105 "empty input panic",
1106 Severity::Blocker,
1107 )],
1108 vote: None,
1109 failed: None,
1110 duration_ms: 0,
1111 },
1112 ],
1113 e2e: Vec::new(),
1114 verify_retried: false,
1115 e2e_deferred: false,
1116 e2e_defer_reason: None,
1117 fix: Some(FixRecord {
1118 agent: "alpha".to_owned(),
1119 addressed: vec!["R1-1-1".to_owned()],
1120 rejected: Vec::new(),
1121 notes: String::new(),
1122 committed: true,
1123 failed: None,
1124 duration_ms: 0,
1125 continuation: None,
1126 }),
1127 blocking: 3,
1128 answered: 2,
1129 expected: 2,
1130 clean: false,
1131 progressed: true,
1132 vote_split: false,
1133 reconsideration: Vec::new(),
1134 verdict: None,
1135 };
1136 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Ready)]);
1137 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1138 assert_eq!(alpha.submitted, 2);
1139 assert_eq!(alpha.adopted, 1);
1140 assert_eq!(alpha.precision(), 50.0);
1141 assert_eq!(alpha.adopted_per_round(), 1.0);
1142 assert_eq!(alpha.unique, 1);
1144
1145 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1146 assert_eq!(beta.submitted, 1);
1147 assert_eq!(beta.adopted, 0);
1148 assert_eq!(beta.unique, 0);
1149 }
1150
1151 #[test]
1152 fn a_lost_fix_report_does_not_count_as_zero_adoption() {
1153 let submitted = ReviewRound {
1154 round: 1,
1155 head: "h".to_owned(),
1156 verified_head: None,
1157 verified_at: None,
1158 reviews: vec![ReviewRecord {
1159 attempts: 0,
1160 reviewer: 1,
1161 agent: "alpha".to_owned(),
1162 summary: String::new(),
1163 findings: vec![finding(
1164 "R1-1-1",
1165 "src/a.rs",
1166 10,
1167 "panics on empty",
1168 Severity::Blocker,
1169 )],
1170 vote: None,
1171 failed: None,
1172 duration_ms: 0,
1173 }],
1174 e2e: Vec::new(),
1175 verify_retried: false,
1176 e2e_deferred: false,
1177 e2e_defer_reason: None,
1178 fix: Some(FixRecord {
1181 agent: "alpha".to_owned(),
1182 addressed: Vec::new(),
1183 rejected: Vec::new(),
1184 notes: String::new(),
1185 committed: true,
1186 failed: Some("unparsable fix report".to_owned()),
1187 duration_ms: 0,
1188 continuation: None,
1189 }),
1190 blocking: 4,
1191 answered: 1,
1192 expected: 1,
1193 clean: false,
1194 progressed: false,
1195 vote_split: false,
1196 reconsideration: Vec::new(),
1197 verdict: None,
1198 };
1199 let stats = collect(&[state_with(vec![submitted], 'A', RunStatus::Ready)]);
1200 assert!(
1201 stats.reviewers.is_empty(),
1202 "a round with no adoption signal must not enter any reviewer's \
1203 denominator: {:?}",
1204 stats.reviewers
1205 );
1206 }
1207
1208 #[test]
1209 fn timed_out_seat_counts_as_a_timeout_not_a_clean_submission() {
1210 let round = ReviewRound {
1211 round: 1,
1212 head: "h".to_owned(),
1213 verified_head: None,
1214 verified_at: None,
1215 reviews: vec![
1216 ReviewRecord {
1217 attempts: 0,
1218 reviewer: 1,
1219 agent: "alpha".to_owned(),
1220 summary: String::new(),
1221 findings: Vec::new(),
1222 vote: None,
1223 failed: None,
1224 duration_ms: 0,
1225 },
1226 ReviewRecord {
1227 attempts: 0,
1228 reviewer: 2,
1229 agent: "beta".to_owned(),
1230 summary: String::new(),
1231 findings: Vec::new(),
1232 vote: None,
1233 failed: Some("agent timed out".to_owned()),
1234 duration_ms: 0,
1235 },
1236 ],
1237 e2e: Vec::new(),
1238 verify_retried: false,
1239 e2e_deferred: false,
1240 e2e_defer_reason: None,
1241 fix: None,
1242 blocking: 0,
1243 answered: 1,
1244 expected: 2,
1245 clean: false,
1246 progressed: false,
1247 vote_split: false,
1248 reconsideration: Vec::new(),
1249 verdict: None,
1250 };
1251 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1252
1253 let alpha = stats.reviewers.iter().find(|r| r.agent == "alpha").unwrap();
1254 assert_eq!(alpha.seated, 1);
1255 assert_eq!(alpha.rounds, 1);
1256 assert_eq!(alpha.timeouts, 0);
1257 assert_eq!(alpha.submitted, 0);
1258
1259 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1260 assert_eq!(beta.seated, 1);
1261 assert_eq!(beta.timeouts, 1);
1262 assert_eq!(beta.submitted, 0);
1263 assert_eq!(beta.rounds, 0);
1267 assert_eq!(beta.timeout_rate(), 100.0);
1268 }
1269
1270 #[test]
1271 fn a_timeout_is_still_recorded_when_the_round_also_lost_its_fix_report() {
1272 let round = ReviewRound {
1278 round: 1,
1279 head: "h".to_owned(),
1280 verified_head: None,
1281 verified_at: None,
1282 reviews: vec![
1283 ReviewRecord {
1284 attempts: 0,
1285 reviewer: 1,
1286 agent: "alpha".to_owned(),
1287 summary: String::new(),
1288 findings: vec![finding(
1289 "R1-1-1",
1290 "src/a.rs",
1291 10,
1292 "panics on empty",
1293 Severity::Blocker,
1294 )],
1295 vote: None,
1296 failed: None,
1297 duration_ms: 0,
1298 },
1299 ReviewRecord {
1300 attempts: 0,
1301 reviewer: 2,
1302 agent: "beta".to_owned(),
1303 summary: String::new(),
1304 findings: Vec::new(),
1305 vote: None,
1306 failed: Some("agent timed out".to_owned()),
1307 duration_ms: 0,
1308 },
1309 ],
1310 e2e: Vec::new(),
1311 verify_retried: false,
1312 e2e_deferred: false,
1313 e2e_defer_reason: None,
1314 fix: Some(FixRecord {
1315 agent: "alpha".to_owned(),
1316 addressed: Vec::new(),
1317 rejected: Vec::new(),
1318 notes: String::new(),
1319 committed: true,
1320 failed: Some("unparsable fix report".to_owned()),
1321 duration_ms: 0,
1322 continuation: None,
1323 }),
1324 blocking: 1,
1325 answered: 1,
1326 expected: 2,
1327 clean: false,
1328 progressed: false,
1329 vote_split: false,
1330 reconsideration: Vec::new(),
1331 verdict: None,
1332 };
1333 let stats = collect(&[state_with(vec![round], 'A', RunStatus::Blocked)]);
1334
1335 let beta = stats.reviewers.iter().find(|r| r.agent == "beta").unwrap();
1336 assert_eq!(beta.timeouts, 1);
1337 assert_eq!(beta.timeout_rate(), 100.0);
1338 assert!(
1341 !stats.reviewers.iter().any(|r| r.agent == "alpha"),
1342 "{:?}",
1343 stats.reviewers
1344 );
1345 }
1346
1347 #[test]
1348 fn e2e_sole_detection_needs_a_clean_static_review() {
1349 let fail = CommandOutcome {
1350 command: "cargo test".to_owned(),
1351 code: Some(101),
1352 output_tail: "boom".to_owned(),
1353 duration_ms: 1,
1354 resource_blocked: false,
1355 };
1356 let sole = ReviewRound {
1357 round: 1,
1358 head: "h".to_owned(),
1359 verified_head: None,
1360 verified_at: None,
1361 reviews: Vec::new(),
1362 e2e: vec![fail.clone()],
1363 verify_retried: false,
1364 e2e_deferred: false,
1365 e2e_defer_reason: None,
1366 fix: None,
1367 blocking: 0,
1368 answered: 0,
1369 expected: 0,
1370 clean: false,
1371 progressed: false,
1372 vote_split: false,
1373 reconsideration: Vec::new(),
1374 verdict: None,
1375 };
1376 let alongside = ReviewRound {
1377 round: 2,
1378 head: "h".to_owned(),
1379 verified_head: None,
1380 verified_at: None,
1381 reviews: Vec::new(),
1382 e2e: vec![fail],
1383 verify_retried: false,
1384 e2e_deferred: false,
1385 e2e_defer_reason: None,
1386 fix: None,
1387 blocking: 2,
1388 answered: 0,
1389 expected: 0,
1390 clean: false,
1391 progressed: false,
1392 vote_split: false,
1393 reconsideration: Vec::new(),
1394 verdict: None,
1395 };
1396 let stats = collect(&[state_with(vec![sole, alongside], 'A', RunStatus::Ready)]);
1397 assert_eq!(stats.e2e.rounds, 2);
1398 assert_eq!(stats.e2e.failures, 2);
1399 assert_eq!(stats.e2e.sole_detections, 1);
1400 assert_eq!(stats.e2e.sole_rate(), 50.0);
1401 }
1402
1403 #[test]
1404 fn every_run_status_lands_in_exactly_one_breakdown_bucket() {
1405 let states = vec![
1406 state_with(Vec::new(), 'A', RunStatus::Merged),
1407 state_with(Vec::new(), 'A', RunStatus::Ready),
1408 state_with(Vec::new(), 'A', RunStatus::Blocked),
1409 state_with(Vec::new(), 'A', RunStatus::Failed),
1410 state_with(Vec::new(), 'A', RunStatus::Stalled),
1411 state_with(Vec::new(), 'A', RunStatus::VerifiedNoop),
1412 state_with(Vec::new(), 'A', RunStatus::Superseded),
1413 state_with(Vec::new(), 'A', RunStatus::Implementing),
1414 state_with(Vec::new(), 'A', RunStatus::Landing),
1415 ];
1416 let stats = collect(&states);
1417 let t = &stats.totals;
1418 assert_eq!(t.runs, 9);
1419 assert_eq!(t.merged, 1);
1420 assert_eq!(t.ready, 1);
1421 assert_eq!(t.blocked, 1);
1422 assert_eq!(t.failed, 1);
1423 assert_eq!(t.stalled, 1);
1424 assert_eq!(t.verified_noop, 1);
1425 assert_eq!(t.superseded, 1);
1426 assert_eq!(t.in_progress, 2);
1429 assert_eq!(
1430 t.merged
1431 + t.ready
1432 + t.blocked
1433 + t.failed
1434 + t.stalled
1435 + t.verified_noop
1436 + t.superseded
1437 + t.in_progress,
1438 t.runs,
1439 "every run must land in exactly one bucket of the breakdown"
1440 );
1441 }
1442
1443 #[test]
1444 fn empty_input_yields_zeroed_rates_not_nan() {
1445 let stats = collect(&[]);
1446 assert_eq!(stats.totals.completion_rate(), 0.0);
1447 assert_eq!(stats.totals.split_rate(), 0.0);
1448 assert_eq!(stats.e2e.sole_rate(), 0.0);
1449 assert!(stats.agents.is_empty());
1450 assert!(stats.advisors.is_empty());
1451 assert_eq!(AdvisorStats::default().reflection_rate(), 0.0);
1452 }
1453
1454 fn advisor_record(
1455 seat: &str,
1456 agent: &str,
1457 proposal: Option<crate::verdict::Proposal>,
1458 reflection: crate::advise::Reflection,
1459 ) -> crate::advise::AdvisorRecord {
1460 crate::advise::AdvisorRecord {
1461 seat: seat.to_owned(),
1462 agent: agent.to_owned(),
1463 proposal,
1464 error: None,
1465 duration_ms: 0,
1466 reflection,
1467 }
1468 }
1469
1470 fn a_proposal() -> crate::verdict::Proposal {
1471 crate::verdict::Proposal {
1472 approach: "do the thing".to_owned(),
1473 key_tradeoff: "speed over memory".to_owned(),
1474 risks: Vec::new(),
1475 touches: Vec::new(),
1476 why_not_naive: "the naive version breaks under load".to_owned(),
1477 }
1478 }
1479
1480 #[test]
1481 fn advisor_stats_count_proposed_absent_and_reflection_split() {
1482 use crate::advise::{Advice, Reflection};
1483
1484 let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1485 s.advice = Some(Advice {
1486 records: vec![
1487 advisor_record("advisor-1", "alpha", Some(a_proposal()), Reflection::Strong),
1488 advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1489 advisor_record("advisor-3", "alpha", None, Reflection::Absent),
1490 ],
1491 synthesis: Some("blended brief".to_owned()),
1492 });
1493
1494 let stats = collect(&[s]);
1495 let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1496 assert_eq!(alpha.seated, 3);
1497 assert_eq!(alpha.proposed, 2);
1498 assert_eq!(alpha.absent, 1);
1499 assert_eq!(alpha.strong, 1);
1500 assert_eq!(alpha.faint, 1);
1501 assert_eq!(alpha.reflection_rate(), 50.0);
1502 }
1503
1504 #[test]
1505 fn advisor_stats_count_absent_from_the_proposal_not_the_reflection_default() {
1506 use crate::advise::{Advice, Reflection};
1518
1519 let mut s = state_with(Vec::new(), 'A', RunStatus::Merged);
1520 s.advice = Some(Advice {
1521 records: vec![
1522 advisor_record("advisor-1", "alpha", None, Reflection::Absent),
1523 advisor_record("advisor-2", "alpha", Some(a_proposal()), Reflection::Faint),
1524 advisor_record("advisor-3", "alpha", Some(a_proposal()), Reflection::Absent),
1525 ],
1526 synthesis: None,
1527 });
1528
1529 let stats = collect(&[s]);
1530 let alpha = stats.advisors.iter().find(|a| a.agent == "alpha").unwrap();
1531 assert_eq!(alpha.seated, 3);
1532 assert_eq!(alpha.proposed, 2);
1533 assert_eq!(alpha.absent, 1);
1534 assert_eq!(alpha.faint, 2);
1535 assert_eq!(alpha.strong, 0);
1536 }
1537
1538 #[test]
1539 fn advisor_stats_ignore_runs_with_advise_off() {
1540 let s = state_with(Vec::new(), 'A', RunStatus::Merged);
1541 assert!(s.advice.is_none());
1542 let stats = collect(&[s]);
1543 assert!(stats.advisors.is_empty());
1544 }
1545
1546 #[test]
1547 fn same_defect_matches_titles_across_files() {
1548 let a = finding("1", "src/a.rs", 1, "Panics On Empty!", Severity::Major);
1549 let b = finding("2", "src/z.rs", 900, "panics on empty", Severity::Nit);
1550 assert!(same_defect(&a, &b));
1551 let c = finding("3", "src/z.rs", 900, "totally different", Severity::Nit);
1552 assert!(!same_defect(&a, &c));
1553 }
1554
1555 #[test]
1556 fn release_bump_stats_split_clean_from_attention_and_track_coverage() {
1557 use crate::run::ReleaseBump;
1558
1559 let unrecorded = state_with(Vec::new(), 'A', RunStatus::Merged);
1561
1562 let mut automerged = state_with(Vec::new(), 'A', RunStatus::Merged);
1564 automerged.release_bump = Some(ReleaseBump {
1565 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
1566 version: Some("1.2.3".to_owned()),
1567 automerge_enabled: true,
1568 merged_directly: false,
1569 local: false,
1570 release: None,
1571 problem: None,
1572 action_required: None,
1573 });
1574
1575 let mut merged_directly = state_with(Vec::new(), 'A', RunStatus::Merged);
1579 merged_directly.release_bump = Some(ReleaseBump {
1580 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
1581 version: Some("1.2.4".to_owned()),
1582 automerge_enabled: false,
1583 merged_directly: true,
1584 local: false,
1585 release: None,
1586 problem: None,
1587 action_required: None,
1588 });
1589
1590 let mut blocked_with_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1592 blocked_with_pr.release_bump = Some(ReleaseBump {
1593 pr_url: Some("https://github.com/o/r/pull/3".to_owned()),
1594 version: Some("1.2.5".to_owned()),
1595 automerge_enabled: false,
1596 merged_directly: false,
1597 local: false,
1598 release: None,
1599 problem: Some("checks red".to_owned()),
1600 action_required: Some("look at the PR".to_owned()),
1601 });
1602
1603 let mut blocked_without_pr = state_with(Vec::new(), 'A', RunStatus::Merged);
1605 blocked_without_pr.release_bump = Some(ReleaseBump {
1606 pr_url: None,
1607 version: Some("1.2.6".to_owned()),
1608 automerge_enabled: false,
1609 merged_directly: false,
1610 local: false,
1611 release: None,
1612 problem: Some("gh pr create failed".to_owned()),
1613 action_required: Some("open the PR by hand".to_owned()),
1614 });
1615
1616 let stats = collect(&[
1617 unrecorded,
1618 automerged,
1619 merged_directly,
1620 blocked_with_pr,
1621 blocked_without_pr,
1622 ]);
1623 let b = &stats.release_bumps;
1624 assert_eq!(b.merged, 5);
1625 assert_eq!(b.recorded, 4);
1626 assert_eq!(b.pr_opened, 3);
1627 assert_eq!(b.automerge_enabled, 1);
1628 assert_eq!(b.merged_directly, 1);
1629 assert_eq!(b.needs_attention, 2);
1630 assert_eq!(b.clean(), 2);
1631 assert_eq!(b.clean() + b.needs_attention, b.recorded);
1633 assert_eq!(b.coverage_rate(), 80.0);
1634 assert!((b.automerge_rate() - 33.333_333_333_333_336).abs() < 1e-9);
1635 assert_eq!(b.attention_rate(), 50.0);
1636 }
1637
1638 #[test]
1639 fn release_bump_ignores_runs_that_are_not_merged() {
1640 use crate::run::ReleaseBump;
1641
1642 let mut blocked = state_with(Vec::new(), 'A', RunStatus::Blocked);
1643 blocked.release_bump = Some(ReleaseBump {
1644 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
1645 version: Some("9.9.9".to_owned()),
1646 automerge_enabled: true,
1647 merged_directly: false,
1648 local: false,
1649 release: None,
1650 problem: None,
1651 action_required: None,
1652 });
1653
1654 let stats = collect(&[blocked]);
1655 let b = &stats.release_bumps;
1656 assert_eq!(b.merged, 0);
1657 assert_eq!(b.recorded, 0);
1658 assert_eq!(b.pr_opened, 0);
1659 }
1660
1661 #[test]
1662 fn release_bump_stats_are_zero_on_merged_runs_with_no_bump_or_no_runs() {
1663 let stats = collect(&[state_with(Vec::new(), 'A', RunStatus::Merged)]);
1664 let b = &stats.release_bumps;
1665 assert_eq!(b.merged, 1);
1666 assert_eq!(b.recorded, 0);
1667 assert_eq!(b.coverage_rate(), 0.0);
1668 assert_eq!(b.automerge_rate(), 0.0);
1669 assert_eq!(b.attention_rate(), 0.0);
1670 assert_eq!(b.clean(), 0);
1671
1672 let empty = collect(&[]);
1673 let b = &empty.release_bumps;
1674 assert_eq!(b.merged, 0);
1675 assert_eq!(b.coverage_rate(), 0.0);
1676 assert_eq!(b.automerge_rate(), 0.0);
1677 assert_eq!(b.attention_rate(), 0.0);
1678 }
1679
1680 fn event(node: &str, at_secs: i64, message: &str) -> crate::run::Event {
1681 crate::run::Event {
1682 at: jiff::Timestamp::from_second(at_secs).unwrap(),
1683 node: node.to_owned(),
1684 message: message.to_owned(),
1685 }
1686 }
1687
1688 fn state_with_events(events: Vec<crate::run::Event>) -> RunState {
1689 let mut s = RunState::new(
1690 PathBuf::from("/repo"),
1691 "main".to_owned(),
1692 "abcdef".to_owned(),
1693 "task".to_owned(),
1694 Config::default(),
1695 );
1696 s.events = events;
1697 s
1698 }
1699
1700 #[test]
1701 fn a_node_with_multiple_events_spans_first_to_last() {
1702 let s = state_with_events(vec![
1703 event("implement", 1_000, "start"),
1704 event("implement", 1_030, "still running"),
1705 event("implement", 1_090, "done"),
1706 ]);
1707 let nodes = node_durations(&[s]);
1708 assert_eq!(nodes.len(), 1);
1709 assert_eq!(nodes[0].node, "implement");
1710 assert_eq!(nodes[0].runs, 1);
1711 assert_eq!(nodes[0].total_secs, 90);
1712 assert_eq!(nodes[0].max_secs, 90);
1713 assert_eq!(nodes[0].single, 0);
1714 assert_eq!(nodes[0].mean_secs(), 90.0);
1715 }
1716
1717 #[test]
1718 fn a_node_with_a_single_event_is_unmeasured_not_zero() {
1719 let s = state_with_events(vec![event("gate", 2_000, "ran once")]);
1720 let nodes = node_durations(&[s]);
1721 assert_eq!(nodes.len(), 1);
1722 assert_eq!(nodes[0].node, "gate");
1723 assert_eq!(nodes[0].runs, 0);
1724 assert_eq!(nodes[0].total_secs, 0);
1725 assert_eq!(nodes[0].single, 1);
1726 assert_eq!(nodes[0].mean_secs(), 0.0);
1729 }
1730
1731 #[test]
1732 fn a_run_with_no_events_produces_no_node_rows() {
1733 let s = state_with_events(vec![]);
1734 let nodes = node_durations(&[s]);
1735 assert!(nodes.is_empty());
1736 }
1737
1738 #[test]
1739 fn multiple_runs_aggregate_the_same_node() {
1740 let a = state_with_events(vec![event("judge", 0, "start"), event("judge", 60, "done")]);
1741 let b = state_with_events(vec![
1742 event("judge", 0, "start"),
1743 event("judge", 200, "done"),
1744 ]);
1745 let c = state_with_events(vec![event("judge", 5, "start")]);
1748 let nodes = node_durations(&[a, b, c]);
1749 assert_eq!(nodes.len(), 1);
1750 let judge = &nodes[0];
1751 assert_eq!(judge.node, "judge");
1752 assert_eq!(judge.runs, 2);
1753 assert_eq!(judge.total_secs, 260);
1754 assert_eq!(judge.max_secs, 200);
1755 assert_eq!(judge.single, 1);
1756 assert_eq!(judge.mean_secs(), 130.0);
1757 }
1758
1759 #[test]
1760 fn by_repo_splits_states_and_group_totals_sum_to_the_whole() {
1761 let states = vec![
1762 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1763 state_with_repo(Vec::new(), 'A', RunStatus::Blocked, "/repos/a"),
1764 state_with_repo(Vec::new(), 'B', RunStatus::Merged, "/repos/b"),
1765 ];
1766 let groups = by_repo(&states);
1767 assert_eq!(groups.len(), 2);
1768
1769 let total_runs: usize = groups.iter().map(|g| g.stats.totals.runs).sum();
1770 assert_eq!(total_runs, collect(&states).totals.runs);
1771 let total_merged: usize = groups.iter().map(|g| g.stats.totals.merged).sum();
1772 assert_eq!(total_merged, collect(&states).totals.merged);
1773
1774 assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
1776 assert_eq!(groups[0].name, "a");
1777 assert_eq!(groups[0].stats.totals.runs, 2);
1778 assert_eq!(groups[1].repo, PathBuf::from("/repos/b"));
1779 assert_eq!(groups[1].name, "b");
1780 assert_eq!(groups[1].stats.totals.runs, 1);
1781 }
1782
1783 #[test]
1784 fn by_repo_breaks_a_run_count_tie_by_path() {
1785 let states = vec![
1786 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/z"),
1787 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1788 ];
1789 let groups = by_repo(&states);
1790 assert_eq!(groups.len(), 2);
1791 assert_eq!(groups[0].repo, PathBuf::from("/repos/a"));
1792 assert_eq!(groups[1].repo, PathBuf::from("/repos/z"));
1793 }
1794
1795 #[test]
1796 fn by_repo_on_empty_input_yields_no_groups() {
1797 assert!(by_repo(&[]).is_empty());
1798 }
1799
1800 #[test]
1801 fn filter_repo_matches_the_full_path_exactly() {
1802 let states = vec![
1803 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/a"),
1804 state_with_repo(Vec::new(), 'A', RunStatus::Merged, "/repos/ab"),
1805 ];
1806 let hits = filter_repo(&states, Path::new("/repos/a"));
1807 assert_eq!(hits.len(), 1);
1808 assert_eq!(hits[0].repo, PathBuf::from("/repos/a"));
1809 }
1810
1811 #[test]
1812 fn filter_repo_returns_nothing_for_an_unknown_repo_or_empty_input() {
1813 let states = vec![state_with_repo(
1814 Vec::new(),
1815 'A',
1816 RunStatus::Merged,
1817 "/repos/a",
1818 )];
1819 assert!(filter_repo(&states, Path::new("/repos/nope")).is_empty());
1820 assert!(filter_repo(&[], Path::new("/repos/a")).is_empty());
1821 }
1822
1823 #[test]
1824 fn daily_cuts_by_local_date_zero_fills_and_ignores_the_window_outside() {
1825 let tz = jiff::tz::TimeZone::fixed(jiff::tz::offset(9));
1826 let today = jiff::civil::date(2026, 3, 2);
1827 let at = |ts: &str, status| {
1828 let mut s = state_with(Vec::new(), 'A', status);
1829 s.created_at = ts.parse().unwrap();
1830 s
1831 };
1832 let states = vec![
1833 at("2026-03-01T23:30:00Z", RunStatus::Merged),
1835 at("2026-03-02T01:00:00Z", RunStatus::Ready),
1836 at("2026-03-02T02:00:00Z", RunStatus::Implementing),
1837 at("2026-03-02T03:00:00Z", RunStatus::Failed),
1838 at("2026-02-01T00:00:00Z", RunStatus::Merged),
1840 at("2026-01-01T00:00:00Z", RunStatus::Merged),
1842 at("2026-03-05T00:00:00Z", RunStatus::Merged),
1843 ];
1844 let d = daily(&states, today, &tz, 30);
1845 assert_eq!(d.len(), 30);
1846 assert_eq!(d.last().unwrap().date, today);
1847 assert_eq!(d[0].date, jiff::civil::date(2026, 2, 1));
1848 assert!(d.windows(2).all(|w| w[0].date < w[1].date));
1849 let last = d.last().unwrap();
1850 assert_eq!(
1851 (last.runs, last.merged, last.ready, last.other),
1852 (4, 1, 1, 2)
1853 );
1854 assert_eq!(d[0].runs, 1);
1855 assert_eq!(d.iter().map(|b| b.runs).sum::<usize>(), 5);
1856 assert!(d.iter().all(|b| b.merged + b.ready + b.other == b.runs));
1857 }
1858
1859 #[test]
1860 fn daily_survives_a_dst_boundary() {
1861 let tz = jiff::tz::TimeZone::get("America/New_York").unwrap();
1862 let today = jiff::civil::date(2026, 3, 9);
1863 let d = daily(&[], today, &tz, 30);
1864 assert_eq!(d.len(), 30);
1865 assert!(
1866 d.windows(2)
1867 .all(|w| w[0].date.tomorrow().unwrap() == w[1].date)
1868 );
1869 }
1870}