Skip to main content

batty_cli/team/
metrics.rs

1pub use super::status::{WorkflowMetrics, compute_metrics, compute_metrics_with_events};
2
3#[cfg(test)]
4use super::status::format_metrics;
5
6#[cfg(test)]
7mod tests {
8    use std::path::Path;
9
10    use super::*;
11    use crate::team::config::RoleType;
12    use crate::team::hierarchy::MemberInstance;
13
14    fn make_member(name: &str, role_type: RoleType) -> MemberInstance {
15        MemberInstance {
16            name: name.to_string(),
17            role_name: name.to_string(),
18            role_type,
19            agent: Some("codex".to_string()),
20            prompt: None,
21            reports_to: None,
22            use_worktrees: false,
23        }
24    }
25
26    fn write_task(
27        board_dir: &Path,
28        id: u32,
29        title: &str,
30        status: &str,
31        claimed_by: Option<&str>,
32        blocked: Option<&str>,
33        depends_on: &[u32],
34    ) {
35        let tasks_dir = board_dir.join("tasks");
36        std::fs::create_dir_all(&tasks_dir).unwrap();
37        let mut content =
38            format!("---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: medium\n");
39        if let Some(claimed_by) = claimed_by {
40            content.push_str(&format!("claimed_by: {claimed_by}\n"));
41        }
42        if let Some(blocked) = blocked {
43            content.push_str(&format!("blocked: {blocked}\n"));
44        }
45        if !depends_on.is_empty() {
46            content.push_str("depends_on:\n");
47            for dep in depends_on {
48                content.push_str(&format!("  - {dep}\n"));
49            }
50        }
51        content.push_str("class: standard\n---\n\nTask body.\n");
52        std::fs::write(tasks_dir.join(format!("{id:03}-{title}.md")), content).unwrap();
53    }
54
55    #[test]
56    fn compute_metrics_handles_empty_board() {
57        let tmp = tempfile::tempdir().unwrap();
58        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
59        std::fs::create_dir_all(board_dir.join("tasks")).unwrap();
60
61        let metrics = compute_metrics(&board_dir, &[]).unwrap();
62        assert_eq!(metrics, WorkflowMetrics::default());
63    }
64
65    #[test]
66    fn compute_metrics_counts_mixed_workflow_states() {
67        let tmp = tempfile::tempdir().unwrap();
68        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
69        write_task(&board_dir, 1, "done-dep", "done", None, None, &[]);
70        write_task(&board_dir, 2, "runnable", "todo", None, None, &[1]);
71        write_task(
72            &board_dir,
73            3,
74            "blocked",
75            "blocked",
76            Some("eng-1"),
77            Some("waiting"),
78            &[],
79        );
80        write_task(&board_dir, 4, "review", "review", Some("eng-2"), None, &[]);
81        write_task(
82            &board_dir,
83            5,
84            "active",
85            "in-progress",
86            Some("eng-1"),
87            None,
88            &[],
89        );
90
91        let members = vec![
92            make_member("eng-1", RoleType::Engineer),
93            make_member("eng-2", RoleType::Engineer),
94            make_member("eng-3", RoleType::Engineer),
95        ];
96        let metrics = compute_metrics(&board_dir, &members).unwrap();
97
98        assert_eq!(metrics.runnable_count, 1);
99        assert_eq!(metrics.blocked_count, 1);
100        assert_eq!(metrics.in_review_count, 1);
101        assert_eq!(metrics.in_progress_count, 1);
102        assert_eq!(metrics.idle_with_runnable, vec!["eng-3"]);
103        assert!(metrics.oldest_review_age_secs.is_some());
104        assert!(metrics.oldest_assignment_age_secs.is_some());
105    }
106
107    #[test]
108    fn format_metrics_produces_readable_summary() {
109        let text = format_metrics(&WorkflowMetrics {
110            runnable_count: 2,
111            blocked_count: 1,
112            in_review_count: 3,
113            in_progress_count: 4,
114            idle_with_runnable: vec!["eng-1".to_string(), "eng-2".to_string()],
115            oldest_review_age_secs: Some(120),
116            oldest_assignment_age_secs: Some(360),
117            ..Default::default()
118        });
119
120        assert!(text.contains("Workflow Metrics"));
121        assert!(text.contains("Runnable: 2"));
122        assert!(text.contains("Blocked: 1"));
123        assert!(text.contains("In Review: 3"));
124        assert!(text.contains("In Progress: 4"));
125        assert!(text.contains("Idle With Runnable: eng-1, eng-2"));
126        assert!(text.contains("Oldest Review Age: 120s"));
127        assert!(text.contains("Oldest Assignment Age: 360s"));
128        assert!(text.contains("Review Pipeline"));
129    }
130
131    fn write_events(path: &Path, events: &[crate::team::events::TeamEvent]) {
132        let mut lines = Vec::new();
133        for event in events {
134            lines.push(serde_json::to_string(event).unwrap());
135        }
136        if let Some(parent) = path.parent() {
137            std::fs::create_dir_all(parent).unwrap();
138        }
139        std::fs::write(path, lines.join("\n")).unwrap();
140    }
141
142    #[test]
143    fn review_metrics_count_events() {
144        use crate::team::events::TeamEvent;
145
146        let tmp = tempfile::tempdir().unwrap();
147        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
148        let events_path = tmp.path().join("events.jsonl");
149        write_task(&board_dir, 1, "t1", "done", None, None, &[]);
150
151        write_events(
152            &events_path,
153            &[
154                TeamEvent::task_auto_merged("eng-1", "1", 0.9, 2, 30),
155                TeamEvent::task_auto_merged("eng-1", "2", 0.9, 2, 30),
156                TeamEvent::task_auto_merged("eng-1", "3", 0.9, 2, 30),
157                TeamEvent::task_manual_merged("4"),
158                TeamEvent::task_manual_merged("5"),
159                TeamEvent::task_reworked("eng-1", "6"),
160                TeamEvent::review_nudge_sent("manager", "7"),
161                TeamEvent::review_escalated_by_role("manager", "8"),
162                TeamEvent::review_escalated_by_role("manager", "9"),
163            ],
164        );
165
166        let metrics = compute_metrics_with_events(&board_dir, &[], Some(&events_path)).unwrap();
167
168        assert_eq!(metrics.auto_merge_count, 3);
169        assert_eq!(metrics.manual_merge_count, 2);
170        assert_eq!(metrics.rework_count, 1);
171        assert_eq!(metrics.review_nudge_count, 1);
172        assert_eq!(metrics.review_escalation_count, 2);
173
174        // auto_merge_rate = 3 / (3 + 2) = 0.6
175        let rate = metrics.auto_merge_rate.unwrap();
176        assert!((rate - 0.6).abs() < 0.01);
177
178        // rework_rate = 1 / (5 + 1) ≈ 0.167
179        let rework = metrics.rework_rate.unwrap();
180        assert!((rework - 1.0 / 6.0).abs() < 0.01);
181    }
182
183    #[test]
184    fn review_metrics_compute_latency() {
185        use crate::team::events::TeamEvent;
186
187        let tmp = tempfile::tempdir().unwrap();
188        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
189        let events_path = tmp.path().join("events.jsonl");
190        write_task(&board_dir, 1, "t1", "done", None, None, &[]);
191
192        // task_completed marks review entry, task_auto/manual_merged marks exit
193        let mut e1 = TeamEvent::task_completed("eng-1", Some("10"));
194        e1.ts = 1000;
195        let mut e2 = TeamEvent::task_auto_merged("eng-1", "10", 0.9, 2, 30);
196        e2.ts = 1100; // 100s latency
197
198        let mut e3 = TeamEvent::task_completed("eng-2", Some("20"));
199        e3.ts = 2000;
200        let mut e4 = TeamEvent::task_manual_merged("20");
201        e4.ts = 2300; // 300s latency
202
203        write_events(&events_path, &[e1, e2, e3, e4]);
204
205        let metrics = compute_metrics_with_events(&board_dir, &[], Some(&events_path)).unwrap();
206
207        // avg = (100 + 300) / 2 = 200
208        let avg = metrics.avg_review_latency_secs.unwrap();
209        assert!((avg - 200.0).abs() < 0.01);
210    }
211
212    #[test]
213    fn review_metrics_handle_no_merges() {
214        let tmp = tempfile::tempdir().unwrap();
215        let board_dir = tmp.path().join(".batty").join("team_config").join("board");
216        let events_path = tmp.path().join("events.jsonl");
217        write_task(&board_dir, 1, "t1", "done", None, None, &[]);
218
219        // Empty event file — no merge events
220        std::fs::write(&events_path, "").unwrap();
221
222        let metrics = compute_metrics_with_events(&board_dir, &[], Some(&events_path)).unwrap();
223
224        assert_eq!(metrics.auto_merge_count, 0);
225        assert_eq!(metrics.manual_merge_count, 0);
226        assert!(metrics.auto_merge_rate.is_none());
227        assert!(metrics.rework_rate.is_none());
228        assert!(metrics.avg_review_latency_secs.is_none());
229    }
230
231    #[test]
232    fn status_includes_review_pipeline() {
233        let text = format_metrics(&WorkflowMetrics {
234            in_review_count: 2,
235            auto_merge_count: 3,
236            manual_merge_count: 2,
237            auto_merge_rate: Some(0.6),
238            rework_count: 1,
239            rework_rate: Some(1.0 / 6.0),
240            review_nudge_count: 1,
241            review_escalation_count: 0,
242            avg_review_latency_secs: Some(272.0),
243            ..Default::default()
244        });
245
246        assert!(text.contains("Review Pipeline"));
247        assert!(text.contains("Queue: 2"));
248        assert!(text.contains("Auto-merge Rate: 60%"));
249        assert!(text.contains("Auto: 3"));
250        assert!(text.contains("Manual: 2"));
251        assert!(text.contains("Rework: 1"));
252        assert!(text.contains("Nudges: 1"));
253        assert!(text.contains("Escalations: 0"));
254    }
255
256    #[test]
257    fn retro_includes_review_section() {
258        use crate::team::retrospective::{RunStats, generate_retrospective};
259
260        let tmp = tempfile::tempdir().unwrap();
261        let stats = RunStats {
262            run_start: 100,
263            run_end: 500,
264            total_duration_secs: 400,
265            task_stats: Vec::new(),
266            average_cycle_time_secs: None,
267            fastest_task_id: None,
268            fastest_cycle_time_secs: None,
269            longest_task_id: None,
270            longest_cycle_time_secs: None,
271            idle_time_pct: 0.0,
272            escalation_count: 0,
273            message_count: 0,
274            auto_merge_count: 5,
275            manual_merge_count: 2,
276            rework_count: 1,
277            review_nudge_count: 3,
278            review_escalation_count: 0,
279            avg_review_stall_secs: Some(120),
280            max_review_stall_secs: Some(200),
281            max_review_stall_task: Some("T-1".to_string()),
282            task_rework_counts: vec![("T-2".to_string(), 1)],
283        };
284
285        let path = generate_retrospective(tmp.path(), &stats).unwrap();
286        let content = std::fs::read_to_string(path).unwrap();
287
288        assert!(content.contains("## Review Pipeline"));
289        assert!(content.contains("Auto-merged: 5"));
290        assert!(content.contains("Manually merged: 2"));
291        assert!(content.contains("Auto-merge rate: 71%"));
292        assert!(content.contains("Rework cycles: 1"));
293        assert!(content.contains("Review nudges: 3"));
294        assert!(content.contains("Review escalations: 0"));
295        assert!(content.contains("Avg review stall: 2m 00s"));
296        assert!(content.contains("Max review stall: 3m 20s (T-1)"));
297    }
298}