Skip to main content

batty_cli/team/
metrics.rs

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