1use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8
9use super::events::{TeamEvent, read_events};
10use crate::task;
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct RunStats {
14 pub run_start: u64,
15 pub run_end: u64,
16 pub total_duration_secs: u64,
17 pub task_stats: Vec<TaskStats>,
18 pub average_cycle_time_secs: Option<u64>,
19 pub fastest_task_id: Option<String>,
20 pub fastest_cycle_time_secs: Option<u64>,
21 pub longest_task_id: Option<String>,
22 pub longest_cycle_time_secs: Option<u64>,
23 pub idle_time_pct: f64,
24 pub escalation_count: u32,
25 pub message_count: u32,
26 pub auto_merge_count: u32,
28 pub manual_merge_count: u32,
29 pub rework_count: u32,
30 pub review_nudge_count: u32,
31 pub review_escalation_count: u32,
32 pub avg_review_stall_secs: Option<u64>,
34 pub max_review_stall_secs: Option<u64>,
36 pub max_review_stall_task: Option<String>,
37 pub task_rework_counts: Vec<(String, u32)>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TaskStats {
43 pub task_id: String,
44 pub assigned_to: String,
45 pub assigned_at: u64,
46 pub completed_at: Option<u64>,
47 pub cycle_time_secs: Option<u64>,
48 pub retry_count: u32,
49 pub was_escalated: bool,
50}
51
52#[derive(Debug, Clone)]
53struct TaskAccumulator {
54 task_id: String,
55 assigned_to: String,
56 assigned_at: u64,
57 completed_at: Option<u64>,
58 cycle_time_secs: Option<u64>,
59 retry_count: u32,
60 was_escalated: bool,
61}
62
63impl TaskAccumulator {
64 fn new(task_id: String, assigned_to: String, assigned_at: u64, retry_count: u32) -> Self {
65 Self {
66 task_id,
67 assigned_to,
68 assigned_at,
69 completed_at: None,
70 cycle_time_secs: None,
71 retry_count,
72 was_escalated: false,
73 }
74 }
75
76 fn into_stats(self) -> TaskStats {
77 TaskStats {
78 task_id: self.task_id,
79 assigned_to: self.assigned_to,
80 assigned_at: self.assigned_at,
81 completed_at: self.completed_at,
82 cycle_time_secs: self.cycle_time_secs,
83 retry_count: self.retry_count,
84 was_escalated: self.was_escalated,
85 }
86 }
87}
88
89fn task_reference(task: &str) -> String {
90 let line = task
91 .lines()
92 .map(str::trim)
93 .find(|line| !line.is_empty())
94 .unwrap_or_else(|| task.trim());
95
96 task_id_from_assignment_line(line).unwrap_or_else(|| line.to_string())
97}
98
99fn task_id_from_assignment_line(line: &str) -> Option<String> {
100 let suffix = line.strip_prefix("Task #")?;
101 let digits: String = suffix
102 .chars()
103 .take_while(|ch| ch.is_ascii_digit())
104 .collect();
105 if digits.is_empty() {
106 None
107 } else {
108 Some(digits)
109 }
110}
111
112type CycleTimeMetrics = (
113 Option<u64>,
114 Option<String>,
115 Option<u64>,
116 Option<String>,
117 Option<u64>,
118);
119
120fn cycle_time_metrics(task_stats: &[TaskStats]) -> CycleTimeMetrics {
121 let completed: Vec<(&TaskStats, u64)> = task_stats
122 .iter()
123 .filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
124 .collect();
125 if completed.is_empty() {
126 return (None, None, None, None, None);
127 }
128
129 let total_cycle_secs: u64 = completed.iter().map(|(_, cycle)| *cycle).sum();
130 let average_cycle_time_secs = Some(total_cycle_secs / completed.len() as u64);
131 let (fastest_task, fastest_cycle_time_secs) = completed
132 .iter()
133 .min_by_key(|(_, cycle)| *cycle)
134 .map(|(task, cycle)| (task.task_id.clone(), *cycle))
135 .expect("completed is not empty");
136 let (longest_task, longest_cycle_time_secs) = completed
137 .iter()
138 .max_by_key(|(_, cycle)| *cycle)
139 .map(|(task, cycle)| (task.task_id.clone(), *cycle))
140 .expect("completed is not empty");
141
142 (
143 average_cycle_time_secs,
144 Some(fastest_task),
145 Some(fastest_cycle_time_secs),
146 Some(longest_task),
147 Some(longest_cycle_time_secs),
148 )
149}
150
151pub fn analyze_events(events: &[TeamEvent]) -> Option<RunStats> {
154 if events.is_empty() {
155 return None;
156 }
157
158 let last_run_start = events
159 .iter()
160 .rposition(|event| event.event == "daemon_started")
161 .unwrap_or(0);
162 let run_events = &events[last_run_start..];
163 if run_events.is_empty() {
164 return None;
165 }
166
167 let run_start = run_events[0].ts;
168 let run_end = run_events
169 .iter()
170 .rev()
171 .find(|event| event.event == "daemon_stopped")
172 .map(|event| event.ts)
173 .unwrap_or_else(|| run_events.last().map(|event| event.ts).unwrap_or(run_start));
174
175 let mut tasks: HashMap<String, TaskAccumulator> = HashMap::new();
176 let mut active_task_by_role: HashMap<String, String> = HashMap::new();
177 let mut idle_samples = Vec::new();
178 let mut escalation_count = 0u32;
179 let mut message_count = 0u32;
180 let mut auto_merge_count = 0u32;
181 let mut manual_merge_count = 0u32;
182 let mut rework_count = 0u32;
183 let mut review_nudge_count = 0u32;
184 let mut review_escalation_count = 0u32;
185 let mut task_completed_at: HashMap<String, u64> = HashMap::new();
187 let mut review_stall_durations: Vec<(String, u64)> = Vec::new();
188 let mut per_task_rework: HashMap<String, u32> = HashMap::new();
189
190 for event in run_events {
191 match event.event.as_str() {
192 "task_assigned" => {
193 let Some(role) = event.role.as_deref() else {
194 continue;
195 };
196 let Some(task) = event.task.as_deref() else {
197 continue;
198 };
199 let task_id = task_reference(task);
200
201 let entry = tasks.entry(task_id.clone()).or_insert_with(|| {
202 TaskAccumulator::new(task_id.clone(), role.to_string(), event.ts, 0)
203 });
204 entry.retry_count += 1;
205 entry.assigned_to = role.to_string();
206 active_task_by_role.insert(role.to_string(), task_id);
207 }
208 "task_completed" => {
211 let Some(role) = event.role.as_deref() else {
212 continue;
213 };
214 let Some(task_id) = active_task_by_role.remove(role) else {
215 continue;
216 };
217 let Some(task) = tasks.get_mut(&task_id) else {
218 continue;
219 };
220 if task.completed_at.is_none() {
221 task.completed_at = Some(event.ts);
222 task.cycle_time_secs = Some(event.ts.saturating_sub(task.assigned_at));
223 }
224 task_completed_at.insert(task_id, event.ts);
226 }
227 "task_escalated" => {
228 escalation_count += 1;
229 let Some(task_id) = event.task.as_deref() else {
230 continue;
231 };
232 let role = event.role.clone().unwrap_or_default();
233 let entry = tasks.entry(task_id.to_string()).or_insert_with(|| {
234 TaskAccumulator::new(task_id.to_string(), role, event.ts, 0)
235 });
236 entry.was_escalated = true;
237 }
238 "message_routed" => {
239 message_count += 1;
240 }
241 "task_auto_merged" => {
242 auto_merge_count += 1;
243 if let Some(task) = event.task.as_deref() {
244 let task_id = task_reference(task);
245 if let Some(completed_ts) = task_completed_at.get(&task_id) {
246 review_stall_durations
247 .push((task_id, event.ts.saturating_sub(*completed_ts)));
248 }
249 }
250 }
251 "task_manual_merged" => {
252 manual_merge_count += 1;
253 if let Some(task) = event.task.as_deref() {
254 let task_id = task_reference(task);
255 if let Some(completed_ts) = task_completed_at.get(&task_id) {
256 review_stall_durations
257 .push((task_id, event.ts.saturating_sub(*completed_ts)));
258 }
259 }
260 }
261 "task_reworked" => {
262 rework_count += 1;
263 if let Some(task) = event.task.as_deref() {
264 let task_id = task_reference(task);
265 *per_task_rework.entry(task_id).or_insert(0) += 1;
266 }
267 }
268 "review_nudge_sent" => {
269 review_nudge_count += 1;
270 }
271 "review_escalated" => {
272 review_escalation_count += 1;
273 }
274 "load_snapshot" => {
275 let Some(working_members) = event.working_members else {
276 continue;
277 };
278 let Some(total_members) = event.total_members else {
279 continue;
280 };
281 let idle_pct = if total_members == 0 {
282 1.0
283 } else {
284 1.0 - (working_members as f64 / total_members as f64)
285 };
286 idle_samples.push(idle_pct);
287 }
288 _ => {}
289 }
290 }
291
292 let mut task_stats: Vec<TaskStats> =
293 tasks.into_values().map(|task| task.into_stats()).collect();
294 task_stats.sort_by(|left, right| {
295 left.assigned_at
296 .cmp(&right.assigned_at)
297 .then_with(|| left.task_id.cmp(&right.task_id))
298 });
299
300 let idle_time_pct = if idle_samples.is_empty() {
301 0.0
302 } else {
303 idle_samples.iter().sum::<f64>() / idle_samples.len() as f64
304 };
305 let (
306 average_cycle_time_secs,
307 fastest_task_id,
308 fastest_cycle_time_secs,
309 longest_task_id,
310 longest_cycle_time_secs,
311 ) = cycle_time_metrics(&task_stats);
312
313 let (avg_review_stall_secs, max_review_stall_secs, max_review_stall_task) =
315 if review_stall_durations.is_empty() {
316 (None, None, None)
317 } else {
318 let total: u64 = review_stall_durations.iter().map(|(_, d)| *d).sum();
319 let avg = total / review_stall_durations.len() as u64;
320 let (max_task, max_dur) = review_stall_durations
321 .iter()
322 .max_by_key(|(_, d)| *d)
323 .map(|(t, d)| (t.clone(), *d))
324 .expect("non-empty");
325 (Some(avg), Some(max_dur), Some(max_task))
326 };
327
328 let mut task_rework_counts: Vec<(String, u32)> = per_task_rework.into_iter().collect();
330 task_rework_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
331
332 Some(RunStats {
333 run_start,
334 run_end,
335 total_duration_secs: run_end.saturating_sub(run_start),
336 task_stats,
337 average_cycle_time_secs,
338 fastest_task_id,
339 fastest_cycle_time_secs,
340 longest_task_id,
341 longest_cycle_time_secs,
342 idle_time_pct,
343 escalation_count,
344 message_count,
345 auto_merge_count,
346 manual_merge_count,
347 rework_count,
348 review_nudge_count,
349 review_escalation_count,
350 avg_review_stall_secs,
351 max_review_stall_secs,
352 max_review_stall_task,
353 task_rework_counts,
354 })
355}
356
357pub fn analyze_event_log(path: &Path) -> Result<Option<RunStats>> {
359 let events = read_events(path)?;
360 Ok(analyze_events(&events))
361}
362
363pub fn should_generate_retro(
364 project_root: &Path,
365 retro_generated: bool,
366 min_duration_secs: u64,
367) -> Result<Option<RunStats>> {
368 if retro_generated {
369 return Ok(None);
370 }
371
372 let board_dir = project_root
373 .join(".batty")
374 .join("team_config")
375 .join("board");
376 let tasks_dir = board_dir.join("tasks");
377 if !tasks_dir.is_dir() {
378 return Ok(None);
379 }
380
381 let tasks = task::load_tasks_from_dir(&tasks_dir)?;
382 let active_tasks: Vec<&task::Task> = tasks
383 .iter()
384 .filter(|task| task.status != "archived")
385 .collect();
386 if active_tasks.is_empty() || active_tasks.iter().any(|task| task.status != "done") {
387 return Ok(None);
388 }
389
390 let events_path = project_root
391 .join(".batty")
392 .join("team_config")
393 .join("events.jsonl");
394 let stats = analyze_event_log(&events_path)?;
395
396 if let Some(ref stats) = stats {
400 let completed = stats
401 .task_stats
402 .iter()
403 .filter(|t| t.completed_at.is_some())
404 .count();
405 if stats.total_duration_secs < min_duration_secs && completed == 0 {
406 tracing::debug!(
407 duration_secs = stats.total_duration_secs,
408 completed_tasks = completed,
409 "Skipping trivial retrospective: {}s, {} tasks",
410 stats.total_duration_secs,
411 completed,
412 );
413 return Ok(None);
414 }
415 }
416
417 Ok(stats)
418}
419
420pub fn generate_retrospective(project_root: &Path, stats: &RunStats) -> Result<PathBuf> {
421 let retrospectives_dir = project_root.join(".batty").join("retrospectives");
422 fs::create_dir_all(&retrospectives_dir).with_context(|| {
423 format!(
424 "failed to create retrospectives directory: {}",
425 retrospectives_dir.display()
426 )
427 })?;
428
429 let report_path = retrospectives_dir.join(format!("{}.md", stats.run_end));
430 let report = render_retrospective(stats);
431 fs::write(&report_path, report)
432 .with_context(|| format!("failed to write retrospective: {}", report_path.display()))?;
433
434 Ok(report_path)
435}
436
437pub fn format_duration(secs: u64) -> String {
438 let hours = secs / 3_600;
439 let minutes = (secs % 3_600) / 60;
440 let seconds = secs % 60;
441
442 if hours > 0 {
443 format!("{hours}h {minutes:02}m {seconds:02}s")
444 } else if minutes > 0 {
445 format!("{minutes}m {seconds:02}s")
446 } else {
447 format!("{seconds}s")
448 }
449}
450
451fn render_retrospective(stats: &RunStats) -> String {
452 let completed_tasks = stats
453 .task_stats
454 .iter()
455 .filter(|task| task.completed_at.is_some())
456 .count();
457 let average_cycle_time = stats
458 .average_cycle_time_secs
459 .map(format_duration)
460 .unwrap_or_else(|| "-".to_string());
461 let fastest_cycle_time = stats
462 .fastest_cycle_time_secs
463 .map(|cycle| {
464 format!(
465 "{} ({})",
466 format_duration(cycle),
467 stats.fastest_task_id.as_deref().unwrap_or("-")
468 )
469 })
470 .unwrap_or_else(|| "-".to_string());
471 let longest_cycle_time = stats
472 .longest_cycle_time_secs
473 .map(|cycle| {
474 format!(
475 "{} ({})",
476 format_duration(cycle),
477 stats.longest_task_id.as_deref().unwrap_or("-")
478 )
479 })
480 .unwrap_or_else(|| "-".to_string());
481
482 let task_cycle_rows = render_task_cycle_rows(&stats.task_stats);
483 let bottlenecks = render_bottlenecks(&stats.task_stats);
484 let recommendations = render_recommendations(stats);
485 let review_section = render_review_performance(stats);
486
487 format!(
488 "# Batty Retrospective\n\n\
489## Summary\n\n\
490- Duration: {}\n\
491- Tasks completed: {}\n\
492- Average cycle time: {}\n\
493- Fastest task: {}\n\
494- Longest task: {}\n\
495- Messages: {}\n\
496- Escalations: {}\n\
497- Idle: {:.1}%\n\n\
498## Task Cycle Times\n\n\
499| Task | Assignee | Status | Cycle Time | Retries | Escalated |\n\
500| --- | --- | --- | --- | --- | --- |\n\
501{}\
502\n\
503{}\
504## Bottlenecks\n\n\
505{}\
506\n\
507## Recommendations\n\n\
508{}",
509 format_duration(stats.total_duration_secs),
510 completed_tasks,
511 average_cycle_time,
512 fastest_cycle_time,
513 longest_cycle_time,
514 stats.message_count,
515 stats.escalation_count,
516 stats.idle_time_pct * 100.0,
517 task_cycle_rows,
518 review_section,
519 bottlenecks,
520 recommendations
521 )
522}
523
524fn render_review_performance(stats: &RunStats) -> String {
525 let total_merges = stats.auto_merge_count + stats.manual_merge_count;
526 if total_merges == 0 && stats.rework_count == 0 && stats.review_nudge_count == 0 {
527 return String::new();
528 }
529
530 let auto_rate = if total_merges > 0 {
531 format!(
532 "{:.0}%",
533 stats.auto_merge_count as f64 / total_merges as f64 * 100.0
534 )
535 } else {
536 "-".to_string()
537 };
538 let total_reviewed = total_merges + stats.rework_count;
539 let rework_rate = if total_reviewed > 0 {
540 format!(
541 "{:.0}%",
542 stats.rework_count as f64 / total_reviewed as f64 * 100.0
543 )
544 } else {
545 "-".to_string()
546 };
547
548 let avg_stall = stats
549 .avg_review_stall_secs
550 .map(format_duration)
551 .unwrap_or_else(|| "-".to_string());
552 let max_stall = stats
553 .max_review_stall_secs
554 .map(|secs| {
555 format!(
556 "{} ({})",
557 format_duration(secs),
558 stats.max_review_stall_task.as_deref().unwrap_or("-")
559 )
560 })
561 .unwrap_or_else(|| "-".to_string());
562
563 let mut section = format!(
564 "## Review Pipeline\n\n\
565- Auto-merged: {}\n\
566- Manually merged: {}\n\
567- Auto-merge rate: {}\n\
568- Avg review stall: {}\n\
569- Max review stall: {}\n\
570- Rework cycles: {}\n\
571- Rework rate: {}\n\
572- Review nudges: {}\n\
573- Review escalations: {}\n",
574 stats.auto_merge_count,
575 stats.manual_merge_count,
576 auto_rate,
577 avg_stall,
578 max_stall,
579 stats.rework_count,
580 rework_rate,
581 stats.review_nudge_count,
582 stats.review_escalation_count,
583 );
584
585 if !stats.task_rework_counts.is_empty() {
586 section.push_str(
587 "\n### Rework by Task\n\n\
588| Task | Rework Cycles |\n\
589| --- | --- |\n",
590 );
591 for (task_id, count) in &stats.task_rework_counts {
592 section.push_str(&format!("| {} | {} |\n", task_id, count));
593 }
594 }
595
596 section.push('\n');
597 section
598}
599
600fn render_task_cycle_rows(tasks: &[TaskStats]) -> String {
601 if tasks.is_empty() {
602 return "| No tasks recorded | - | - | - | - | - |\n".to_string();
603 }
604
605 let mut rows = String::new();
606 for task in tasks {
607 let status = if task.completed_at.is_some() {
608 "completed"
609 } else {
610 "incomplete"
611 };
612 let cycle_time = task
613 .cycle_time_secs
614 .map(format_duration)
615 .unwrap_or_else(|| "-".to_string());
616 let escalated = if task.was_escalated { "yes" } else { "no" };
617 rows.push_str(&format!(
618 "| {} | {} | {} | {} | {} | {} |\n",
619 task.task_id, task.assigned_to, status, cycle_time, task.retry_count, escalated
620 ));
621 }
622 rows
623}
624
625fn render_bottlenecks(tasks: &[TaskStats]) -> String {
626 let longest_task = tasks
627 .iter()
628 .filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
629 .max_by_key(|(_, cycle)| *cycle);
630
631 let most_retried = tasks.iter().max_by_key(|task| task.retry_count);
632
633 let mut lines = Vec::new();
634 match longest_task {
635 Some((task, cycle)) => lines.push(format!(
636 "- Longest task: `{}` owned by `{}` at {}.",
637 task.task_id,
638 task.assigned_to,
639 format_duration(cycle)
640 )),
641 None => lines.push("- Longest task: no completed tasks recorded.".to_string()),
642 }
643
644 match most_retried {
645 Some(task) if task.retry_count > 1 => lines.push(format!(
646 "- Most retried: `{}` retried {} times.",
647 task.task_id, task.retry_count
648 )),
649 _ => lines.push("- Most retried: no task needed multiple attempts.".to_string()),
650 }
651
652 format!("{}\n", lines.join("\n"))
653}
654
655fn render_recommendations(stats: &RunStats) -> String {
656 let mut lines = Vec::new();
657 let max_retry_count = stats
658 .task_stats
659 .iter()
660 .map(|task| task.retry_count)
661 .max()
662 .unwrap_or(0);
663
664 if stats.idle_time_pct >= 0.5 {
665 lines.push(
666 "- Idle time stayed high. Queue more ready tasks so engineers are not waiting on assignment."
667 .to_string(),
668 );
669 }
670
671 if max_retry_count >= 3 {
672 lines.push(
673 "- Several retries were needed. Break work into smaller tasks with clearer acceptance criteria."
674 .to_string(),
675 );
676 }
677
678 if lines.is_empty() {
679 lines.push(
680 "- No major bottlenecks stood out. Keep the current task sizing and routing cadence."
681 .to_string(),
682 );
683 }
684
685 format!("{}\n", lines.join("\n"))
686}
687
688#[cfg(test)]
689mod tests {
690 use tempfile::tempdir;
691
692 use super::*;
693
694 fn at(mut event: TeamEvent, ts: u64) -> TeamEvent {
695 event.ts = ts;
696 event
697 }
698
699 #[test]
700 fn test_analyze_events_basic_run() {
701 let events = vec![
702 at(TeamEvent::daemon_started(), 100),
703 at(TeamEvent::task_assigned("eng-1", "42"), 110),
704 at(TeamEvent::message_routed("manager", "eng-1"), 115),
705 at(TeamEvent::task_completed("eng-1", None), 150),
706 at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
707 ];
708
709 let stats = analyze_events(&events).unwrap();
710
711 assert_eq!(stats.run_start, 100);
712 assert_eq!(stats.run_end, 160);
713 assert_eq!(stats.total_duration_secs, 60);
714 assert_eq!(stats.escalation_count, 0);
715 assert_eq!(stats.message_count, 1);
716 assert_eq!(stats.task_stats.len(), 1);
717 assert_eq!(stats.average_cycle_time_secs, Some(40));
718 assert_eq!(stats.fastest_task_id.as_deref(), Some("42"));
719 assert_eq!(stats.fastest_cycle_time_secs, Some(40));
720 assert_eq!(stats.longest_task_id.as_deref(), Some("42"));
721 assert_eq!(stats.longest_cycle_time_secs, Some(40));
722 assert_eq!(
723 stats.task_stats[0],
724 TaskStats {
725 task_id: "42".to_string(),
726 assigned_to: "eng-1".to_string(),
727 assigned_at: 110,
728 completed_at: Some(150),
729 cycle_time_secs: Some(40),
730 retry_count: 1,
731 was_escalated: false,
732 }
733 );
734 }
735
736 #[test]
737 fn test_analyze_events_with_retries() {
738 let events = vec![
739 at(TeamEvent::daemon_started(), 100),
740 at(
741 TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
742 110,
743 ),
744 at(
745 TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
746 130,
747 ),
748 at(TeamEvent::task_completed("eng-1", None), 170),
749 at(TeamEvent::daemon_stopped_with_reason("signal", 70), 180),
750 ];
751
752 let stats = analyze_events(&events).unwrap();
753
754 assert_eq!(stats.task_stats.len(), 1);
755 assert_eq!(stats.task_stats[0].retry_count, 2);
756 assert_eq!(stats.task_stats[0].assigned_at, 110);
757 assert_eq!(stats.task_stats[0].cycle_time_secs, Some(60));
758 assert_eq!(stats.task_stats[0].task_id, "42");
759 }
760
761 #[test]
762 fn test_analyze_events_with_escalation() {
763 let events = vec![
764 at(TeamEvent::daemon_started(), 100),
765 at(TeamEvent::task_assigned("eng-1", "42"), 110),
766 at(TeamEvent::task_escalated("eng-1", "42", None), 125),
767 at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
768 ];
769
770 let stats = analyze_events(&events).unwrap();
771
772 assert_eq!(stats.escalation_count, 1);
773 assert_eq!(stats.task_stats.len(), 1);
774 assert!(stats.task_stats[0].was_escalated);
775 assert_eq!(stats.task_stats[0].completed_at, None);
776 }
777
778 #[test]
779 fn test_analyze_events_idle_time() {
780 let events = vec![
781 at(TeamEvent::daemon_started(), 100),
782 at(TeamEvent::load_snapshot(1, 4, true), 110),
783 at(TeamEvent::load_snapshot(3, 4, true), 120),
784 at(TeamEvent::daemon_stopped_with_reason("signal", 25), 125),
785 ];
786
787 let stats = analyze_events(&events).unwrap();
788
789 assert!((stats.idle_time_pct - 0.5).abs() < 1e-9);
790 }
791
792 #[test]
793 fn test_analyze_events_empty() {
794 assert_eq!(analyze_events(&[]), None);
795 }
796
797 #[test]
798 fn test_analyze_events_multiple_runs() {
799 let events = vec![
800 at(TeamEvent::daemon_started(), 100),
801 at(TeamEvent::task_assigned("eng-1", "old-task"), 105),
802 at(TeamEvent::daemon_stopped_with_reason("signal", 10), 110),
803 at(TeamEvent::daemon_started(), 200),
804 at(
805 TeamEvent::task_assigned("eng-2", "Task #12: new-task\n\nTask details."),
806 210,
807 ),
808 at(TeamEvent::task_completed("eng-2", None), 240),
809 at(TeamEvent::daemon_stopped_with_reason("signal", 45), 245),
810 ];
811
812 let stats = analyze_events(&events).unwrap();
813
814 assert_eq!(stats.run_start, 200);
815 assert_eq!(stats.run_end, 245);
816 assert_eq!(stats.task_stats.len(), 1);
817 assert_eq!(stats.task_stats[0].task_id, "12");
818 assert_eq!(stats.task_stats[0].assigned_to, "eng-2");
819 assert_eq!(stats.task_stats[0].cycle_time_secs, Some(30));
820 assert_eq!(stats.average_cycle_time_secs, Some(30));
821 assert_eq!(stats.fastest_task_id.as_deref(), Some("12"));
822 assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
823 }
824
825 #[test]
826 fn test_analyze_events_computes_average_fastest_and_longest_cycle_times() {
827 let events = vec![
828 at(TeamEvent::daemon_started(), 100),
829 at(
830 TeamEvent::task_assigned("eng-1", "Task #11: short task\n\nBody."),
831 110,
832 ),
833 at(TeamEvent::task_completed("eng-1", None), 140),
834 at(
835 TeamEvent::task_assigned("eng-2", "Task #12: long task\n\nBody."),
836 150,
837 ),
838 at(TeamEvent::task_completed("eng-2", None), 240),
839 at(TeamEvent::daemon_stopped_with_reason("signal", 150), 250),
840 ];
841
842 let stats = analyze_events(&events).unwrap();
843
844 assert_eq!(stats.average_cycle_time_secs, Some(60));
845 assert_eq!(stats.fastest_task_id.as_deref(), Some("11"));
846 assert_eq!(stats.fastest_cycle_time_secs, Some(30));
847 assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
848 assert_eq!(stats.longest_cycle_time_secs, Some(90));
849 }
850
851 fn sample_task(task_id: &str, cycle_time_secs: Option<u64>, retry_count: u32) -> TaskStats {
852 TaskStats {
853 task_id: task_id.to_string(),
854 assigned_to: "eng-1".to_string(),
855 assigned_at: 100,
856 completed_at: cycle_time_secs.map(|cycle| 100 + cycle),
857 cycle_time_secs,
858 retry_count,
859 was_escalated: retry_count > 2,
860 }
861 }
862
863 #[test]
864 fn format_duration_variants() {
865 assert_eq!(format_duration(45), "45s");
866 assert_eq!(format_duration(65), "1m 05s");
867 assert_eq!(format_duration(3_665), "1h 01m 05s");
868 }
869
870 #[test]
871 fn generate_retrospective_writes_report_with_sections() {
872 let tmp = tempdir().unwrap();
873 let stats = RunStats {
874 run_start: 1_700_000_000,
875 run_end: 1_700_000_123,
876 total_duration_secs: 123,
877 task_stats: vec![
878 sample_task("T-101", Some(90), 1),
879 sample_task("T-102", Some(30), 2),
880 ],
881 average_cycle_time_secs: Some(60),
882 fastest_task_id: Some("T-102".to_string()),
883 fastest_cycle_time_secs: Some(30),
884 longest_task_id: Some("T-101".to_string()),
885 longest_cycle_time_secs: Some(90),
886 idle_time_pct: 0.25,
887 escalation_count: 1,
888 message_count: 6,
889 auto_merge_count: 0,
890 manual_merge_count: 0,
891 rework_count: 0,
892 review_nudge_count: 0,
893 review_escalation_count: 0,
894 avg_review_stall_secs: None,
895 max_review_stall_secs: None,
896 max_review_stall_task: None,
897 task_rework_counts: Vec::new(),
898 };
899
900 let path = generate_retrospective(tmp.path(), &stats).unwrap();
901 let content = fs::read_to_string(&path).unwrap();
902
903 assert_eq!(
904 path,
905 tmp.path()
906 .join(".batty")
907 .join("retrospectives")
908 .join("1700000123.md")
909 );
910 assert!(content.contains("## Summary"));
911 assert!(content.contains("## Task Cycle Times"));
912 assert!(content.contains("## Bottlenecks"));
913 assert!(content.contains("## Recommendations"));
914 assert!(content.contains("| T-101 | eng-1 | completed | 1m 30s | 1 | no |"));
915 assert!(content.contains("- Tasks completed: 2"));
916 assert!(content.contains("- Average cycle time: 1m 00s"));
917 assert!(content.contains("- Fastest task: 30s (T-102)"));
918 assert!(content.contains("- Longest task: 1m 30s (T-101)"));
919 }
920
921 #[test]
922 fn generate_retrospective_handles_empty_tasks() {
923 let tmp = tempdir().unwrap();
924 let stats = RunStats {
925 run_start: 10,
926 run_end: 20,
927 total_duration_secs: 10,
928 task_stats: Vec::new(),
929 average_cycle_time_secs: None,
930 fastest_task_id: None,
931 fastest_cycle_time_secs: None,
932 longest_task_id: None,
933 longest_cycle_time_secs: None,
934 idle_time_pct: 0.0,
935 escalation_count: 0,
936 message_count: 0,
937 auto_merge_count: 0,
938 manual_merge_count: 0,
939 rework_count: 0,
940 review_nudge_count: 0,
941 review_escalation_count: 0,
942 avg_review_stall_secs: None,
943 max_review_stall_secs: None,
944 max_review_stall_task: None,
945 task_rework_counts: Vec::new(),
946 };
947
948 let path = generate_retrospective(tmp.path(), &stats).unwrap();
949 let content = fs::read_to_string(path).unwrap();
950
951 assert!(content.contains("| No tasks recorded | - | - | - | - | - |"));
952 assert!(content.contains("- Average cycle time: -"));
953 assert!(content.contains("- Fastest task: -"));
954 assert!(content.contains("- Longest task: -"));
955 assert!(content.contains("- Longest task: no completed tasks recorded."));
956 assert!(content.contains("- Most retried: no task needed multiple attempts."));
957 }
958
959 #[test]
960 fn generate_retrospective_adds_high_idle_recommendation() {
961 let tmp = tempdir().unwrap();
962 let stats = RunStats {
963 run_start: 10,
964 run_end: 30,
965 total_duration_secs: 20,
966 task_stats: vec![sample_task("T-201", Some(20), 1)],
967 average_cycle_time_secs: Some(20),
968 fastest_task_id: Some("T-201".to_string()),
969 fastest_cycle_time_secs: Some(20),
970 longest_task_id: Some("T-201".to_string()),
971 longest_cycle_time_secs: Some(20),
972 idle_time_pct: 0.75,
973 escalation_count: 0,
974 message_count: 1,
975 auto_merge_count: 0,
976 manual_merge_count: 0,
977 rework_count: 0,
978 review_nudge_count: 0,
979 review_escalation_count: 0,
980 avg_review_stall_secs: None,
981 max_review_stall_secs: None,
982 max_review_stall_task: None,
983 task_rework_counts: Vec::new(),
984 };
985
986 let path = generate_retrospective(tmp.path(), &stats).unwrap();
987 let content = fs::read_to_string(path).unwrap();
988
989 assert!(content.contains("Idle time stayed high"));
990 assert!(content.contains("Queue more ready tasks"));
991 }
992
993 #[test]
994 fn generate_retrospective_adds_high_retry_recommendation() {
995 let tmp = tempdir().unwrap();
996 let stats = RunStats {
997 run_start: 10,
998 run_end: 40,
999 total_duration_secs: 30,
1000 task_stats: vec![sample_task("T-301", Some(25), 3)],
1001 average_cycle_time_secs: Some(25),
1002 fastest_task_id: Some("T-301".to_string()),
1003 fastest_cycle_time_secs: Some(25),
1004 longest_task_id: Some("T-301".to_string()),
1005 longest_cycle_time_secs: Some(25),
1006 idle_time_pct: 0.1,
1007 escalation_count: 0,
1008 message_count: 2,
1009 auto_merge_count: 0,
1010 manual_merge_count: 0,
1011 rework_count: 0,
1012 review_nudge_count: 0,
1013 review_escalation_count: 0,
1014 avg_review_stall_secs: None,
1015 max_review_stall_secs: None,
1016 max_review_stall_task: None,
1017 task_rework_counts: Vec::new(),
1018 };
1019
1020 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1021 let content = fs::read_to_string(path).unwrap();
1022
1023 assert!(content.contains("Several retries were needed"));
1024 assert!(content.contains("smaller tasks"));
1025 }
1026
1027 fn write_owned_task_file(
1028 project_root: &Path,
1029 task_id: u32,
1030 title: &str,
1031 status: &str,
1032 claimed_by: &str,
1033 ) {
1034 let board_dir = project_root
1035 .join(".batty")
1036 .join("team_config")
1037 .join("board");
1038 let tasks_dir = board_dir.join("tasks");
1039 fs::create_dir_all(&tasks_dir).unwrap();
1040 let slug = title.replace(' ', "-");
1041 let task_path = tasks_dir.join(format!("{task_id:03}-{slug}.md"));
1042 let content = format!(
1043 r#"---
1044id: {task_id}
1045title: "{title}"
1046status: {status}
1047claimed_by: {claimed_by}
1048---
1049
1050Task body.
1051"#
1052 );
1053 fs::write(task_path, content).unwrap();
1054 }
1055
1056 fn write_event_log(project_root: &Path, events: &[TeamEvent]) {
1057 let events_path = project_root
1058 .join(".batty")
1059 .join("team_config")
1060 .join("events.jsonl");
1061 fs::create_dir_all(events_path.parent().unwrap()).unwrap();
1062 let body = events
1063 .iter()
1064 .map(|event| serde_json::to_string(event).unwrap())
1065 .collect::<Vec<_>>()
1066 .join("\n");
1067 fs::write(events_path, format!("{body}\n")).unwrap();
1068 }
1069
1070 #[test]
1071 fn should_generate_retro_when_all_active_tasks_are_done() {
1072 let tmp = tempdir().unwrap();
1073 write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
1074 write_event_log(
1075 tmp.path(),
1076 &[
1077 at(TeamEvent::daemon_started(), 100),
1078 at(TeamEvent::task_assigned("eng-1", "45"), 110),
1079 at(TeamEvent::task_completed("eng-1", None), 150),
1080 at(TeamEvent::daemon_stopped(), 160),
1081 ],
1082 );
1083
1084 let stats = should_generate_retro(tmp.path(), false, 60)
1085 .unwrap()
1086 .unwrap();
1087 assert_eq!(stats.run_start, 100);
1088 assert_eq!(stats.run_end, 160);
1089 assert_eq!(stats.task_stats.len(), 1);
1090 assert_eq!(stats.task_stats[0].task_id, "45");
1091 }
1092
1093 #[test]
1094 fn should_not_generate_retro_when_task_is_not_done() {
1095 let tmp = tempdir().unwrap();
1096 write_owned_task_file(tmp.path(), 45, "retro-task", "in-progress", "eng-1");
1097 write_event_log(tmp.path(), &[at(TeamEvent::daemon_started(), 100)]);
1098
1099 let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1100 assert_eq!(stats, None);
1101 }
1102
1103 #[test]
1104 fn should_not_generate_retro_twice() {
1105 let tmp = tempdir().unwrap();
1106 write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
1107 write_event_log(
1108 tmp.path(),
1109 &[
1110 at(TeamEvent::daemon_started(), 100),
1111 at(TeamEvent::task_assigned("eng-1", "45"), 110),
1112 at(TeamEvent::task_completed("eng-1", None), 150),
1113 at(TeamEvent::daemon_stopped(), 160),
1114 ],
1115 );
1116
1117 let stats = should_generate_retro(tmp.path(), true, 60).unwrap();
1118 assert_eq!(stats, None);
1119 }
1120
1121 #[test]
1122 fn skip_retro_for_short_run() {
1123 let tmp = tempdir().unwrap();
1124 write_owned_task_file(tmp.path(), 50, "short-task", "done", "eng-1");
1125 write_event_log(
1126 tmp.path(),
1127 &[
1128 at(TeamEvent::daemon_started(), 100),
1129 at(TeamEvent::daemon_stopped(), 104),
1130 ],
1131 );
1132
1133 let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1135 assert_eq!(stats, None);
1136 }
1137
1138 #[test]
1139 fn generate_retro_for_long_run() {
1140 let tmp = tempdir().unwrap();
1141 write_owned_task_file(tmp.path(), 51, "long-task", "done", "eng-1");
1142 write_event_log(
1143 tmp.path(),
1144 &[
1145 at(TeamEvent::daemon_started(), 100),
1146 at(TeamEvent::task_assigned("eng-1", "51"), 110),
1147 at(TeamEvent::task_completed("eng-1", None), 200),
1148 at(TeamEvent::task_assigned("eng-1", "52"), 210),
1149 at(TeamEvent::task_completed("eng-1", None), 300),
1150 at(TeamEvent::task_assigned("eng-1", "53"), 310),
1151 at(TeamEvent::task_completed("eng-1", None), 380),
1152 at(TeamEvent::daemon_stopped(), 400),
1153 ],
1154 );
1155
1156 let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1158 assert!(stats.is_some());
1159 let stats = stats.unwrap();
1160 assert_eq!(stats.total_duration_secs, 300);
1161 }
1162
1163 #[test]
1164 fn skip_retro_for_short_run_with_completions() {
1165 let tmp = tempdir().unwrap();
1166 write_owned_task_file(tmp.path(), 55, "quick-task", "done", "eng-1");
1167 write_event_log(
1168 tmp.path(),
1169 &[
1170 at(TeamEvent::daemon_started(), 100),
1171 at(TeamEvent::task_assigned("eng-1", "55"), 105),
1172 at(TeamEvent::task_completed("eng-1", None), 115),
1173 at(TeamEvent::task_assigned("eng-1", "56"), 118),
1174 at(TeamEvent::task_completed("eng-1", None), 125),
1175 at(TeamEvent::daemon_stopped(), 130),
1176 ],
1177 );
1178
1179 let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1181 assert!(stats.is_some());
1182 let stats = stats.unwrap();
1183 assert_eq!(stats.total_duration_secs, 30);
1184 }
1185
1186 #[test]
1187 fn analyze_events_computes_review_stall_duration() {
1188 let events = vec![
1189 at(TeamEvent::daemon_started(), 100),
1190 at(
1191 TeamEvent::task_assigned("eng-1", "Task #10: fast task"),
1192 110,
1193 ),
1194 at(TeamEvent::task_completed("eng-1", None), 150),
1195 at(
1197 TeamEvent::task_auto_merged("eng-1", "Task #10: fast task", 0.9, 2, 10),
1198 180,
1199 ),
1200 at(
1201 TeamEvent::task_assigned("eng-2", "Task #20: slow task"),
1202 120,
1203 ),
1204 at(TeamEvent::task_completed("eng-2", None), 200),
1205 at(TeamEvent::task_manual_merged("Task #20: slow task"), 300),
1207 at(TeamEvent::daemon_stopped_with_reason("signal", 210), 310),
1208 ];
1209
1210 let stats = analyze_events(&events).unwrap();
1211
1212 assert_eq!(stats.auto_merge_count, 1);
1213 assert_eq!(stats.manual_merge_count, 1);
1214 assert_eq!(stats.avg_review_stall_secs, Some(65));
1216 assert_eq!(stats.max_review_stall_secs, Some(100));
1218 assert_eq!(stats.max_review_stall_task.as_deref(), Some("20"));
1219 }
1220
1221 #[test]
1222 fn analyze_events_no_stall_without_merges() {
1223 let events = vec![
1224 at(TeamEvent::daemon_started(), 100),
1225 at(TeamEvent::task_assigned("eng-1", "42"), 110),
1226 at(TeamEvent::task_completed("eng-1", None), 150),
1227 at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1228 ];
1229
1230 let stats = analyze_events(&events).unwrap();
1231
1232 assert_eq!(stats.avg_review_stall_secs, None);
1233 assert_eq!(stats.max_review_stall_secs, None);
1234 assert_eq!(stats.max_review_stall_task, None);
1235 }
1236
1237 #[test]
1238 fn analyze_events_tracks_per_task_rework() {
1239 let events = vec![
1240 at(TeamEvent::daemon_started(), 100),
1241 at(TeamEvent::task_assigned("eng-1", "Task #10: reworked"), 110),
1242 at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 120),
1243 at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 130),
1244 at(TeamEvent::task_assigned("eng-2", "Task #20: once"), 115),
1245 at(TeamEvent::task_reworked("eng-2", "Task #20: once"), 140),
1246 at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1247 ];
1248
1249 let stats = analyze_events(&events).unwrap();
1250
1251 assert_eq!(stats.rework_count, 3);
1252 assert_eq!(stats.task_rework_counts.len(), 2);
1254 assert_eq!(stats.task_rework_counts[0], ("10".to_string(), 2));
1255 assert_eq!(stats.task_rework_counts[1], ("20".to_string(), 1));
1256 }
1257
1258 #[test]
1259 fn analyze_events_empty_rework_list() {
1260 let events = vec![
1261 at(TeamEvent::daemon_started(), 100),
1262 at(TeamEvent::task_assigned("eng-1", "42"), 110),
1263 at(TeamEvent::task_completed("eng-1", None), 150),
1264 at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1265 ];
1266
1267 let stats = analyze_events(&events).unwrap();
1268
1269 assert!(stats.task_rework_counts.is_empty());
1270 }
1271
1272 #[test]
1273 fn render_review_pipeline_section_includes_stall_and_rework() {
1274 let tmp = tempdir().unwrap();
1275 let stats = RunStats {
1276 run_start: 100,
1277 run_end: 500,
1278 total_duration_secs: 400,
1279 task_stats: Vec::new(),
1280 average_cycle_time_secs: None,
1281 fastest_task_id: None,
1282 fastest_cycle_time_secs: None,
1283 longest_task_id: None,
1284 longest_cycle_time_secs: None,
1285 idle_time_pct: 0.0,
1286 escalation_count: 0,
1287 message_count: 0,
1288 auto_merge_count: 3,
1289 manual_merge_count: 1,
1290 rework_count: 2,
1291 review_nudge_count: 1,
1292 review_escalation_count: 0,
1293 avg_review_stall_secs: Some(90),
1294 max_review_stall_secs: Some(180),
1295 max_review_stall_task: Some("T-5".to_string()),
1296 task_rework_counts: vec![("T-5".to_string(), 2)],
1297 };
1298
1299 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1300 let content = fs::read_to_string(path).unwrap();
1301
1302 assert!(content.contains("## Review Pipeline"));
1303 assert!(content.contains("Auto-merged: 3"));
1304 assert!(content.contains("Manually merged: 1"));
1305 assert!(content.contains("Auto-merge rate: 75%"));
1306 assert!(content.contains("Avg review stall: 1m 30s"));
1307 assert!(content.contains("Max review stall: 3m 00s (T-5)"));
1308 assert!(content.contains("Rework cycles: 2"));
1309 assert!(content.contains("### Rework by Task"));
1310 assert!(content.contains("| T-5 | 2 |"));
1311 }
1312
1313 #[test]
1314 fn render_review_pipeline_no_stall_data() {
1315 let tmp = tempdir().unwrap();
1316 let stats = RunStats {
1317 run_start: 100,
1318 run_end: 300,
1319 total_duration_secs: 200,
1320 task_stats: Vec::new(),
1321 average_cycle_time_secs: None,
1322 fastest_task_id: None,
1323 fastest_cycle_time_secs: None,
1324 longest_task_id: None,
1325 longest_cycle_time_secs: None,
1326 idle_time_pct: 0.0,
1327 escalation_count: 0,
1328 message_count: 0,
1329 auto_merge_count: 2,
1330 manual_merge_count: 0,
1331 rework_count: 0,
1332 review_nudge_count: 0,
1333 review_escalation_count: 0,
1334 avg_review_stall_secs: None,
1335 max_review_stall_secs: None,
1336 max_review_stall_task: None,
1337 task_rework_counts: Vec::new(),
1338 };
1339
1340 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1341 let content = fs::read_to_string(path).unwrap();
1342
1343 assert!(content.contains("## Review Pipeline"));
1344 assert!(content.contains("Avg review stall: -"));
1345 assert!(content.contains("Max review stall: -"));
1346 assert!(!content.contains("### Rework by Task"));
1347 }
1348
1349 #[test]
1352 fn task_reference_extracts_id_from_task_prefix() {
1353 assert_eq!(task_reference("Task #42: build feature"), "42");
1354 }
1355
1356 #[test]
1357 fn task_reference_returns_full_line_when_no_prefix() {
1358 assert_eq!(task_reference("build feature"), "build feature");
1359 }
1360
1361 #[test]
1362 fn task_reference_skips_blank_lines() {
1363 assert_eq!(task_reference("\n\n Task #99: test\nbody"), "99");
1364 }
1365
1366 #[test]
1367 fn task_reference_handles_whitespace_only_input() {
1368 assert_eq!(task_reference(" "), "");
1369 }
1370
1371 #[test]
1372 fn task_id_from_assignment_line_valid() {
1373 assert_eq!(
1374 task_id_from_assignment_line("Task #123: some task"),
1375 Some("123".to_string())
1376 );
1377 }
1378
1379 #[test]
1380 fn task_id_from_assignment_line_no_prefix() {
1381 assert_eq!(task_id_from_assignment_line("no prefix here"), None);
1382 }
1383
1384 #[test]
1385 fn task_id_from_assignment_line_empty_digits() {
1386 assert_eq!(task_id_from_assignment_line("Task #abc: letters"), None);
1387 }
1388
1389 #[test]
1390 fn cycle_time_metrics_no_completed_tasks() {
1391 let tasks = vec![sample_task("T-1", None, 1)];
1392 let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1393 assert_eq!(avg, None);
1394 assert_eq!(fastest, None);
1395 assert_eq!(fastest_time, None);
1396 assert_eq!(longest, None);
1397 assert_eq!(longest_time, None);
1398 }
1399
1400 #[test]
1401 fn cycle_time_metrics_single_completed_task() {
1402 let tasks = vec![sample_task("T-1", Some(60), 1)];
1403 let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1404 assert_eq!(avg, Some(60));
1405 assert_eq!(fastest, Some("T-1".to_string()));
1406 assert_eq!(fastest_time, Some(60));
1407 assert_eq!(longest, Some("T-1".to_string()));
1408 assert_eq!(longest_time, Some(60));
1409 }
1410
1411 #[test]
1412 fn cycle_time_metrics_multiple_tasks_picks_extremes() {
1413 let tasks = vec![
1414 sample_task("T-fast", Some(10), 1),
1415 sample_task("T-mid", Some(50), 1),
1416 sample_task("T-slow", Some(90), 1),
1417 sample_task("T-incomplete", None, 1),
1418 ];
1419 let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1420 assert_eq!(avg, Some(50)); assert_eq!(fastest, Some("T-fast".to_string()));
1422 assert_eq!(fastest_time, Some(10));
1423 assert_eq!(longest, Some("T-slow".to_string()));
1424 assert_eq!(longest_time, Some(90));
1425 }
1426
1427 #[test]
1428 fn format_duration_zero() {
1429 assert_eq!(format_duration(0), "0s");
1430 }
1431
1432 #[test]
1433 fn format_duration_exact_minute() {
1434 assert_eq!(format_duration(60), "1m 00s");
1435 }
1436
1437 #[test]
1438 fn format_duration_exact_hour() {
1439 assert_eq!(format_duration(3600), "1h 00m 00s");
1440 }
1441
1442 #[test]
1443 fn format_duration_large() {
1444 assert_eq!(format_duration(7322), "2h 02m 02s");
1445 }
1446
1447 #[test]
1448 fn render_task_cycle_rows_empty() {
1449 let rows = render_task_cycle_rows(&[]);
1450 assert!(rows.contains("No tasks recorded"));
1451 }
1452
1453 #[test]
1454 fn render_task_cycle_rows_completed_and_incomplete() {
1455 let tasks = vec![
1456 sample_task("T-1", Some(120), 1),
1457 sample_task("T-2", None, 2),
1458 ];
1459 let rows = render_task_cycle_rows(&tasks);
1460 assert!(rows.contains("| T-1 | eng-1 | completed | 2m 00s | 1 | no |"));
1461 assert!(rows.contains("| T-2 | eng-1 | incomplete | - | 2 | no |"));
1462 }
1463
1464 #[test]
1465 fn render_task_cycle_rows_escalated_task() {
1466 let tasks = vec![sample_task("T-esc", Some(200), 4)]; let rows = render_task_cycle_rows(&tasks);
1468 assert!(rows.contains("| T-esc | eng-1 | completed | 3m 20s | 4 | yes |"));
1469 }
1470
1471 #[test]
1472 fn render_bottlenecks_no_completed_tasks() {
1473 let tasks = vec![sample_task("T-1", None, 1)];
1474 let output = render_bottlenecks(&tasks);
1475 assert!(output.contains("no completed tasks recorded"));
1476 assert!(output.contains("no task needed multiple attempts"));
1477 }
1478
1479 #[test]
1480 fn render_bottlenecks_with_retries() {
1481 let tasks = vec![
1482 sample_task("T-1", Some(100), 1),
1483 sample_task("T-2", Some(200), 3),
1484 ];
1485 let output = render_bottlenecks(&tasks);
1486 assert!(output.contains("Longest task: `T-2`"));
1487 assert!(output.contains("Most retried: `T-2` retried 3 times"));
1488 }
1489
1490 #[test]
1491 fn render_bottlenecks_single_retry_shows_no_retries_message() {
1492 let tasks = vec![sample_task("T-1", Some(60), 1)];
1493 let output = render_bottlenecks(&tasks);
1494 assert!(output.contains("no task needed multiple attempts"));
1495 }
1496
1497 #[test]
1498 fn render_recommendations_low_idle_low_retries() {
1499 let stats = RunStats {
1500 run_start: 0,
1501 run_end: 100,
1502 total_duration_secs: 100,
1503 task_stats: vec![sample_task("T-1", Some(50), 1)],
1504 average_cycle_time_secs: Some(50),
1505 fastest_task_id: Some("T-1".to_string()),
1506 fastest_cycle_time_secs: Some(50),
1507 longest_task_id: Some("T-1".to_string()),
1508 longest_cycle_time_secs: Some(50),
1509 idle_time_pct: 0.1,
1510 escalation_count: 0,
1511 message_count: 1,
1512 auto_merge_count: 0,
1513 manual_merge_count: 0,
1514 rework_count: 0,
1515 review_nudge_count: 0,
1516 review_escalation_count: 0,
1517 avg_review_stall_secs: None,
1518 max_review_stall_secs: None,
1519 max_review_stall_task: None,
1520 task_rework_counts: Vec::new(),
1521 };
1522 let output = render_recommendations(&stats);
1523 assert!(output.contains("No major bottlenecks"));
1524 }
1525
1526 #[test]
1527 fn render_recommendations_both_high_idle_and_high_retries() {
1528 let stats = RunStats {
1529 run_start: 0,
1530 run_end: 100,
1531 total_duration_secs: 100,
1532 task_stats: vec![sample_task("T-1", Some(50), 5)],
1533 average_cycle_time_secs: Some(50),
1534 fastest_task_id: Some("T-1".to_string()),
1535 fastest_cycle_time_secs: Some(50),
1536 longest_task_id: Some("T-1".to_string()),
1537 longest_cycle_time_secs: Some(50),
1538 idle_time_pct: 0.8,
1539 escalation_count: 0,
1540 message_count: 1,
1541 auto_merge_count: 0,
1542 manual_merge_count: 0,
1543 rework_count: 0,
1544 review_nudge_count: 0,
1545 review_escalation_count: 0,
1546 avg_review_stall_secs: None,
1547 max_review_stall_secs: None,
1548 max_review_stall_task: None,
1549 task_rework_counts: Vec::new(),
1550 };
1551 let output = render_recommendations(&stats);
1552 assert!(output.contains("Idle time stayed high"));
1553 assert!(output.contains("Several retries were needed"));
1554 }
1555
1556 #[test]
1557 fn render_review_performance_empty_when_no_merges() {
1558 let stats = RunStats {
1559 run_start: 0,
1560 run_end: 100,
1561 total_duration_secs: 100,
1562 task_stats: Vec::new(),
1563 average_cycle_time_secs: None,
1564 fastest_task_id: None,
1565 fastest_cycle_time_secs: None,
1566 longest_task_id: None,
1567 longest_cycle_time_secs: None,
1568 idle_time_pct: 0.0,
1569 escalation_count: 0,
1570 message_count: 0,
1571 auto_merge_count: 0,
1572 manual_merge_count: 0,
1573 rework_count: 0,
1574 review_nudge_count: 0,
1575 review_escalation_count: 0,
1576 avg_review_stall_secs: None,
1577 max_review_stall_secs: None,
1578 max_review_stall_task: None,
1579 task_rework_counts: Vec::new(),
1580 };
1581 let section = render_review_performance(&stats);
1582 assert!(section.is_empty());
1583 }
1584
1585 #[test]
1586 fn render_review_performance_100_percent_auto_merge_rate() {
1587 let stats = RunStats {
1588 run_start: 0,
1589 run_end: 100,
1590 total_duration_secs: 100,
1591 task_stats: Vec::new(),
1592 average_cycle_time_secs: None,
1593 fastest_task_id: None,
1594 fastest_cycle_time_secs: None,
1595 longest_task_id: None,
1596 longest_cycle_time_secs: None,
1597 idle_time_pct: 0.0,
1598 escalation_count: 0,
1599 message_count: 0,
1600 auto_merge_count: 5,
1601 manual_merge_count: 0,
1602 rework_count: 0,
1603 review_nudge_count: 0,
1604 review_escalation_count: 0,
1605 avg_review_stall_secs: None,
1606 max_review_stall_secs: None,
1607 max_review_stall_task: None,
1608 task_rework_counts: Vec::new(),
1609 };
1610 let section = render_review_performance(&stats);
1611 assert!(section.contains("Auto-merge rate: 100%"));
1612 assert!(section.contains("Auto-merged: 5"));
1613 assert!(section.contains("Manually merged: 0"));
1614 }
1615
1616 #[test]
1617 fn analyze_events_multiple_tasks_different_engineers() {
1618 let events = vec![
1619 at(TeamEvent::daemon_started(), 100),
1620 at(TeamEvent::task_assigned("eng-1", "Task #10: task-a"), 110),
1621 at(TeamEvent::task_assigned("eng-2", "Task #20: task-b"), 115),
1622 at(TeamEvent::task_completed("eng-1", None), 160),
1623 at(TeamEvent::task_completed("eng-2", None), 200),
1624 at(TeamEvent::daemon_stopped_with_reason("signal", 110), 210),
1625 ];
1626
1627 let stats = analyze_events(&events).unwrap();
1628 assert_eq!(stats.task_stats.len(), 2);
1629
1630 let t10 = stats.task_stats.iter().find(|t| t.task_id == "10").unwrap();
1631 assert_eq!(t10.assigned_to, "eng-1");
1632 assert_eq!(t10.cycle_time_secs, Some(50));
1633
1634 let t20 = stats.task_stats.iter().find(|t| t.task_id == "20").unwrap();
1635 assert_eq!(t20.assigned_to, "eng-2");
1636 assert_eq!(t20.cycle_time_secs, Some(85));
1637 }
1638
1639 #[test]
1640 fn analyze_events_tracks_review_nudges_and_escalations() {
1641 let events = vec![
1642 at(TeamEvent::daemon_started(), 100),
1643 at(
1644 TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
1645 120,
1646 ),
1647 at(
1648 TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
1649 140,
1650 ),
1651 at(
1652 TeamEvent::review_escalated("Task #5: reviewed", "stale"),
1653 160,
1654 ),
1655 at(TeamEvent::daemon_stopped_with_reason("signal", 80), 180),
1656 ];
1657
1658 let stats = analyze_events(&events).unwrap();
1659 assert_eq!(stats.review_nudge_count, 2);
1660 assert_eq!(stats.review_escalation_count, 1);
1661 }
1662
1663 #[test]
1664 fn analyze_events_completion_without_assignment_is_ignored() {
1665 let events = vec![
1666 at(TeamEvent::daemon_started(), 100),
1667 at(TeamEvent::task_completed("eng-1", None), 150),
1669 at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1670 ];
1671
1672 let stats = analyze_events(&events).unwrap();
1673 assert!(stats.task_stats.is_empty());
1674 assert_eq!(stats.average_cycle_time_secs, None);
1675 }
1676
1677 #[test]
1678 fn analyze_events_escalation_without_prior_assignment_creates_task() {
1679 let events = vec![
1680 at(TeamEvent::daemon_started(), 100),
1681 at(
1682 TeamEvent::task_escalated("eng-1", "Task #99: escalated-only", None),
1683 120,
1684 ),
1685 at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1686 ];
1687
1688 let stats = analyze_events(&events).unwrap();
1689 assert_eq!(stats.escalation_count, 1);
1690 assert_eq!(stats.task_stats.len(), 1);
1691 assert!(stats.task_stats[0].was_escalated);
1692 assert_eq!(stats.task_stats[0].task_id, "Task #99: escalated-only");
1694 }
1695
1696 #[test]
1697 fn analyze_events_daemon_started_only() {
1698 let events = vec![at(TeamEvent::daemon_started(), 100)];
1699 let stats = analyze_events(&events).unwrap();
1700 assert_eq!(stats.run_start, 100);
1701 assert_eq!(stats.run_end, 100);
1702 assert_eq!(stats.total_duration_secs, 0);
1703 assert!(stats.task_stats.is_empty());
1704 }
1705
1706 #[test]
1707 fn analyze_events_load_snapshot_all_working() {
1708 let events = vec![
1709 at(TeamEvent::daemon_started(), 100),
1710 at(TeamEvent::load_snapshot(4, 4, true), 110),
1711 at(TeamEvent::load_snapshot(4, 4, true), 120),
1712 at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1713 ];
1714
1715 let stats = analyze_events(&events).unwrap();
1716 assert!((stats.idle_time_pct - 0.0).abs() < 1e-9);
1717 }
1718
1719 #[test]
1720 fn analyze_events_load_snapshot_all_idle() {
1721 let events = vec![
1722 at(TeamEvent::daemon_started(), 100),
1723 at(TeamEvent::load_snapshot(0, 4, true), 110),
1724 at(TeamEvent::load_snapshot(0, 4, true), 120),
1725 at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1726 ];
1727
1728 let stats = analyze_events(&events).unwrap();
1729 assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
1730 }
1731
1732 #[test]
1733 fn analyze_events_load_snapshot_zero_members() {
1734 let events = vec![
1735 at(TeamEvent::daemon_started(), 100),
1736 at(TeamEvent::load_snapshot(0, 0, true), 110),
1737 at(TeamEvent::daemon_stopped_with_reason("signal", 20), 120),
1738 ];
1739
1740 let stats = analyze_events(&events).unwrap();
1741 assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
1742 }
1743
1744 #[test]
1745 fn should_generate_retro_no_board_dir_returns_none() {
1746 let tmp = tempdir().unwrap();
1747 let result = should_generate_retro(tmp.path(), false, 60).unwrap();
1749 assert_eq!(result, None);
1750 }
1751
1752 #[test]
1753 fn should_generate_retro_empty_board_returns_none() {
1754 let tmp = tempdir().unwrap();
1755 let tasks_dir = tmp
1756 .path()
1757 .join(".batty")
1758 .join("team_config")
1759 .join("board")
1760 .join("tasks");
1761 fs::create_dir_all(&tasks_dir).unwrap();
1762 let result = should_generate_retro(tmp.path(), false, 60).unwrap();
1764 assert_eq!(result, None);
1765 }
1766}