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