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 let rate = metrics.auto_merge_rate.unwrap();
178 assert!((rate - 0.6).abs() < 0.01);
179
180 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 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; 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; write_events(&events_path, &[e1, e2, e3, e4]);
206
207 let metrics = compute_metrics_with_events(&board_dir, &[], Some(&events_path)).unwrap();
208
209 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 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}