1use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use rusqlite::{Connection, params};
11
12use super::events::{TeamEvent, read_events};
13use super::telemetry_db;
14use crate::task;
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct RunStats {
18 pub run_start: u64,
19 pub run_end: u64,
20 pub total_duration_secs: u64,
21 pub task_stats: Vec<TaskStats>,
22 pub average_cycle_time_secs: Option<u64>,
23 pub fastest_task_id: Option<String>,
24 pub fastest_cycle_time_secs: Option<u64>,
25 pub longest_task_id: Option<String>,
26 pub longest_cycle_time_secs: Option<u64>,
27 pub idle_time_pct: f64,
28 pub escalation_count: u32,
29 pub message_count: u32,
30 pub auto_merge_count: u32,
32 pub manual_merge_count: u32,
33 pub rework_count: u32,
34 pub review_nudge_count: u32,
35 pub review_escalation_count: u32,
36 pub avg_review_stall_secs: Option<u64>,
38 pub max_review_stall_secs: Option<u64>,
40 pub max_review_stall_task: Option<String>,
41 pub task_rework_counts: Vec<(String, u32)>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct TaskStats {
47 pub task_id: String,
48 pub assigned_to: String,
49 pub assigned_at: u64,
50 pub completed_at: Option<u64>,
51 pub cycle_time_secs: Option<u64>,
52 pub retry_count: u32,
53 pub was_escalated: bool,
54}
55
56#[derive(Debug, Clone)]
57struct TaskAccumulator {
58 task_id: String,
59 assigned_to: String,
60 assigned_at: u64,
61 completed_at: Option<u64>,
62 cycle_time_secs: Option<u64>,
63 retry_count: u32,
64 was_escalated: bool,
65}
66
67impl TaskAccumulator {
68 fn new(task_id: String, assigned_to: String, assigned_at: u64, retry_count: u32) -> Self {
69 Self {
70 task_id,
71 assigned_to,
72 assigned_at,
73 completed_at: None,
74 cycle_time_secs: None,
75 retry_count,
76 was_escalated: false,
77 }
78 }
79
80 fn into_stats(self) -> TaskStats {
81 TaskStats {
82 task_id: self.task_id,
83 assigned_to: self.assigned_to,
84 assigned_at: self.assigned_at,
85 completed_at: self.completed_at,
86 cycle_time_secs: self.cycle_time_secs,
87 retry_count: self.retry_count,
88 was_escalated: self.was_escalated,
89 }
90 }
91}
92
93fn task_reference(task: &str) -> String {
94 let line = task
95 .lines()
96 .map(str::trim)
97 .find(|line| !line.is_empty())
98 .unwrap_or_else(|| task.trim());
99
100 task_id_from_assignment_line(line).unwrap_or_else(|| line.to_string())
101}
102
103fn task_id_from_assignment_line(line: &str) -> Option<String> {
104 let suffix = line.strip_prefix("Task #")?;
105 let digits: String = suffix
106 .chars()
107 .take_while(|ch| ch.is_ascii_digit())
108 .collect();
109 if digits.is_empty() {
110 None
111 } else {
112 Some(digits)
113 }
114}
115
116type CycleTimeMetrics = (
117 Option<u64>,
118 Option<String>,
119 Option<u64>,
120 Option<String>,
121 Option<u64>,
122);
123
124fn cycle_time_metrics(task_stats: &[TaskStats]) -> CycleTimeMetrics {
125 let completed: Vec<(&TaskStats, u64)> = task_stats
126 .iter()
127 .filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
128 .collect();
129 if completed.is_empty() {
130 return (None, None, None, None, None);
131 }
132
133 let total_cycle_secs: u64 = completed.iter().map(|(_, cycle)| *cycle).sum();
134 let average_cycle_time_secs = Some(total_cycle_secs / completed.len() as u64);
135 let (fastest_task, fastest_cycle_time_secs) = completed
136 .iter()
137 .min_by_key(|(_, cycle)| *cycle)
138 .map(|(task, cycle)| (task.task_id.clone(), *cycle))
139 .expect("completed is not empty");
140 let (longest_task, longest_cycle_time_secs) = completed
141 .iter()
142 .max_by_key(|(_, cycle)| *cycle)
143 .map(|(task, cycle)| (task.task_id.clone(), *cycle))
144 .expect("completed is not empty");
145
146 (
147 average_cycle_time_secs,
148 Some(fastest_task),
149 Some(fastest_cycle_time_secs),
150 Some(longest_task),
151 Some(longest_cycle_time_secs),
152 )
153}
154
155pub fn analyze_events(events: &[TeamEvent]) -> Option<RunStats> {
158 if events.is_empty() {
159 return None;
160 }
161
162 let last_run_start = events
163 .iter()
164 .rposition(|event| event.event == "daemon_started")
165 .unwrap_or(0);
166 let run_events = &events[last_run_start..];
167 if run_events.is_empty() {
168 return None;
169 }
170
171 let run_start = run_events[0].ts;
172 let run_end = run_events
173 .iter()
174 .rev()
175 .find(|event| event.event == "daemon_stopped")
176 .map(|event| event.ts)
177 .unwrap_or_else(|| run_events.last().map(|event| event.ts).unwrap_or(run_start));
178
179 let mut tasks: HashMap<String, TaskAccumulator> = HashMap::new();
180 let mut active_task_by_role: HashMap<String, String> = HashMap::new();
181 let mut idle_samples = Vec::new();
182 let mut escalation_count = 0u32;
183 let mut message_count = 0u32;
184 let mut auto_merge_count = 0u32;
185 let mut manual_merge_count = 0u32;
186 let mut rework_count = 0u32;
187 let mut review_nudge_count = 0u32;
188 let mut review_escalation_count = 0u32;
189 let mut task_completed_at: HashMap<String, u64> = HashMap::new();
191 let mut review_stall_durations: Vec<(String, u64)> = Vec::new();
192 let mut per_task_rework: HashMap<String, u32> = HashMap::new();
193
194 for event in run_events {
195 match event.event.as_str() {
196 "task_assigned" => {
197 let Some(role) = event.role.as_deref() else {
198 continue;
199 };
200 let Some(task) = event.task.as_deref() else {
201 continue;
202 };
203 let task_id = task_reference(task);
204
205 let entry = tasks.entry(task_id.clone()).or_insert_with(|| {
206 TaskAccumulator::new(task_id.clone(), role.to_string(), event.ts, 0)
207 });
208 entry.retry_count += 1;
209 entry.assigned_to = role.to_string();
210 active_task_by_role.insert(role.to_string(), task_id);
211 }
212 "task_completed" => {
215 let Some(role) = event.role.as_deref() else {
216 continue;
217 };
218 let Some(task_id) = active_task_by_role.remove(role) else {
219 continue;
220 };
221 let Some(task) = tasks.get_mut(&task_id) else {
222 continue;
223 };
224 if task.completed_at.is_none() {
225 task.completed_at = Some(event.ts);
226 task.cycle_time_secs = Some(event.ts.saturating_sub(task.assigned_at));
227 }
228 task_completed_at.insert(task_id, event.ts);
230 }
231 "task_escalated" => {
232 escalation_count += 1;
233 let Some(task_id) = event.task.as_deref() else {
234 continue;
235 };
236 let role = event.role.clone().unwrap_or_default();
237 let entry = tasks.entry(task_id.to_string()).or_insert_with(|| {
238 TaskAccumulator::new(task_id.to_string(), role, event.ts, 0)
239 });
240 entry.was_escalated = true;
241 }
242 "message_routed" => {
243 message_count += 1;
244 }
245 "task_auto_merged" => {
246 auto_merge_count += 1;
247 if let Some(task) = event.task.as_deref() {
248 let task_id = task_reference(task);
249 if let Some(completed_ts) = task_completed_at.get(&task_id) {
250 review_stall_durations
251 .push((task_id, event.ts.saturating_sub(*completed_ts)));
252 }
253 }
254 }
255 "task_manual_merged" => {
256 manual_merge_count += 1;
257 if let Some(task) = event.task.as_deref() {
258 let task_id = task_reference(task);
259 if let Some(completed_ts) = task_completed_at.get(&task_id) {
260 review_stall_durations
261 .push((task_id, event.ts.saturating_sub(*completed_ts)));
262 }
263 }
264 }
265 "task_reworked" => {
266 rework_count += 1;
267 if let Some(task) = event.task.as_deref() {
268 let task_id = task_reference(task);
269 *per_task_rework.entry(task_id).or_insert(0) += 1;
270 }
271 }
272 "review_nudge_sent" => {
273 review_nudge_count += 1;
274 }
275 "review_escalated" => {
276 review_escalation_count += 1;
277 }
278 "load_snapshot" => {
279 let Some(working_members) = event.working_members else {
280 continue;
281 };
282 let Some(total_members) = event.total_members else {
283 continue;
284 };
285 let idle_pct = if total_members == 0 {
286 1.0
287 } else {
288 1.0 - (working_members as f64 / total_members as f64)
289 };
290 idle_samples.push(idle_pct);
291 }
292 _ => {}
293 }
294 }
295
296 let mut task_stats: Vec<TaskStats> =
297 tasks.into_values().map(|task| task.into_stats()).collect();
298 task_stats.sort_by(|left, right| {
299 left.assigned_at
300 .cmp(&right.assigned_at)
301 .then_with(|| left.task_id.cmp(&right.task_id))
302 });
303
304 let idle_time_pct = if idle_samples.is_empty() {
305 0.0
306 } else {
307 idle_samples.iter().sum::<f64>() / idle_samples.len() as f64
308 };
309 let (
310 average_cycle_time_secs,
311 fastest_task_id,
312 fastest_cycle_time_secs,
313 longest_task_id,
314 longest_cycle_time_secs,
315 ) = cycle_time_metrics(&task_stats);
316
317 let (avg_review_stall_secs, max_review_stall_secs, max_review_stall_task) =
319 if review_stall_durations.is_empty() {
320 (None, None, None)
321 } else {
322 let total: u64 = review_stall_durations.iter().map(|(_, d)| *d).sum();
323 let avg = total / review_stall_durations.len() as u64;
324 let (max_task, max_dur) = review_stall_durations
325 .iter()
326 .max_by_key(|(_, d)| *d)
327 .map(|(t, d)| (t.clone(), *d))
328 .expect("non-empty");
329 (Some(avg), Some(max_dur), Some(max_task))
330 };
331
332 let mut task_rework_counts: Vec<(String, u32)> = per_task_rework.into_iter().collect();
334 task_rework_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
335
336 Some(RunStats {
337 run_start,
338 run_end,
339 total_duration_secs: run_end.saturating_sub(run_start),
340 task_stats,
341 average_cycle_time_secs,
342 fastest_task_id,
343 fastest_cycle_time_secs,
344 longest_task_id,
345 longest_cycle_time_secs,
346 idle_time_pct,
347 escalation_count,
348 message_count,
349 auto_merge_count,
350 manual_merge_count,
351 rework_count,
352 review_nudge_count,
353 review_escalation_count,
354 avg_review_stall_secs,
355 max_review_stall_secs,
356 max_review_stall_task,
357 task_rework_counts,
358 })
359}
360
361pub fn analyze_from_db(conn: &Connection) -> Option<RunStats> {
366 let last_session_start: Option<i64> = conn
368 .query_row(
369 "SELECT MAX(timestamp) FROM events WHERE event_type = 'daemon_started'",
370 [],
371 |row| row.get(0),
372 )
373 .ok()?;
374 let run_start = last_session_start? as u64;
375
376 let run_end: u64 = conn
378 .query_row(
379 "SELECT timestamp FROM events
380 WHERE event_type = 'daemon_stopped' AND timestamp >= ?1
381 ORDER BY timestamp DESC LIMIT 1",
382 params![run_start as i64],
383 |row| row.get::<_, i64>(0),
384 )
385 .unwrap_or_else(|_| {
386 conn.query_row(
387 "SELECT MAX(timestamp) FROM events WHERE timestamp >= ?1",
388 params![run_start as i64],
389 |row| row.get::<_, Option<i64>>(0),
390 )
391 .unwrap_or(None)
392 .unwrap_or(run_start as i64)
393 }) as u64;
394
395 let mut stmt = conn
399 .prepare(
400 "SELECT timestamp, event_type, role, task_id, payload FROM events
401 WHERE timestamp >= ?1 ORDER BY timestamp ASC",
402 )
403 .ok()?;
404
405 type EventRow = (i64, String, Option<String>, Option<String>, String);
406 let rows: Vec<EventRow> = stmt
407 .query_map(params![run_start as i64], |row| {
408 Ok((
409 row.get(0)?,
410 row.get(1)?,
411 row.get(2)?,
412 row.get(3)?,
413 row.get(4)?,
414 ))
415 })
416 .ok()?
417 .filter_map(|r| r.ok())
418 .collect();
419
420 if rows.is_empty() {
421 return None;
422 }
423
424 let mut tasks: HashMap<String, TaskAccumulator> = HashMap::new();
425 let mut active_task_by_role: HashMap<String, String> = HashMap::new();
426 let mut idle_samples = Vec::new();
427 let mut escalation_count = 0u32;
428 let mut message_count = 0u32;
429 let mut auto_merge_count = 0u32;
430 let mut manual_merge_count = 0u32;
431 let mut rework_count = 0u32;
432 let mut review_nudge_count = 0u32;
433 let mut review_escalation_count = 0u32;
434 let mut task_completed_at: HashMap<String, u64> = HashMap::new();
435 let mut review_stall_durations: Vec<(String, u64)> = Vec::new();
436 let mut per_task_rework: HashMap<String, u32> = HashMap::new();
437
438 for (ts, event_type, role, task_id, payload) in &rows {
439 let ts = *ts as u64;
440 match event_type.as_str() {
441 "task_assigned" => {
442 let Some(role) = role.as_deref() else {
443 continue;
444 };
445 let Some(task) = task_id.as_deref() else {
446 continue;
447 };
448 let tid = task_reference(task);
449 let entry = tasks
450 .entry(tid.clone())
451 .or_insert_with(|| TaskAccumulator::new(tid.clone(), role.to_string(), ts, 0));
452 entry.retry_count += 1;
453 entry.assigned_to = role.to_string();
454 active_task_by_role.insert(role.to_string(), tid);
455 }
456 "task_completed" => {
457 let Some(role) = role.as_deref() else {
458 continue;
459 };
460 let Some(tid) = active_task_by_role.remove(role) else {
461 continue;
462 };
463 let Some(task) = tasks.get_mut(&tid) else {
464 continue;
465 };
466 if task.completed_at.is_none() {
467 task.completed_at = Some(ts);
468 task.cycle_time_secs = Some(ts.saturating_sub(task.assigned_at));
469 }
470 task_completed_at.insert(tid, ts);
471 }
472 "task_escalated" => {
473 escalation_count += 1;
474 let Some(task) = task_id.as_deref() else {
475 continue;
476 };
477 let r = role.clone().unwrap_or_default();
478 let entry = tasks
479 .entry(task.to_string())
480 .or_insert_with(|| TaskAccumulator::new(task.to_string(), r, ts, 0));
481 entry.was_escalated = true;
482 }
483 "message_routed" => {
484 message_count += 1;
485 }
486 "task_auto_merged" => {
487 auto_merge_count += 1;
488 if let Some(task) = task_id.as_deref() {
489 let tid = task_reference(task);
490 if let Some(completed_ts) = task_completed_at.get(&tid) {
491 review_stall_durations.push((tid, ts.saturating_sub(*completed_ts)));
492 }
493 }
494 }
495 "task_manual_merged" => {
496 manual_merge_count += 1;
497 if let Some(task) = task_id.as_deref() {
498 let tid = task_reference(task);
499 if let Some(completed_ts) = task_completed_at.get(&tid) {
500 review_stall_durations.push((tid, ts.saturating_sub(*completed_ts)));
501 }
502 }
503 }
504 "task_reworked" => {
505 rework_count += 1;
506 if let Some(task) = task_id.as_deref() {
507 let tid = task_reference(task);
508 *per_task_rework.entry(tid).or_insert(0) += 1;
509 }
510 }
511 "review_nudge_sent" => {
512 review_nudge_count += 1;
513 }
514 "review_escalated" => {
515 review_escalation_count += 1;
516 }
517 "load_snapshot" => {
518 if let Ok(evt) = serde_json::from_str::<TeamEvent>(payload) {
520 let Some(working_members) = evt.working_members else {
521 continue;
522 };
523 let Some(total_members) = evt.total_members else {
524 continue;
525 };
526 let idle_pct = if total_members == 0 {
527 1.0
528 } else {
529 1.0 - (working_members as f64 / total_members as f64)
530 };
531 idle_samples.push(idle_pct);
532 }
533 }
534 _ => {}
535 }
536 }
537
538 let mut task_stats: Vec<TaskStats> = tasks.into_values().map(|t| t.into_stats()).collect();
539 task_stats.sort_by(|a, b| {
540 a.assigned_at
541 .cmp(&b.assigned_at)
542 .then_with(|| a.task_id.cmp(&b.task_id))
543 });
544
545 let idle_time_pct = if idle_samples.is_empty() {
546 0.0
547 } else {
548 idle_samples.iter().sum::<f64>() / idle_samples.len() as f64
549 };
550
551 let (
552 average_cycle_time_secs,
553 fastest_task_id,
554 fastest_cycle_time_secs,
555 longest_task_id,
556 longest_cycle_time_secs,
557 ) = cycle_time_metrics(&task_stats);
558
559 let (avg_review_stall_secs, max_review_stall_secs, max_review_stall_task) =
560 if review_stall_durations.is_empty() {
561 (None, None, None)
562 } else {
563 let total: u64 = review_stall_durations.iter().map(|(_, d)| *d).sum();
564 let avg = total / review_stall_durations.len() as u64;
565 let (max_task, max_dur) = review_stall_durations
566 .iter()
567 .max_by_key(|(_, d)| *d)
568 .map(|(t, d)| (t.clone(), *d))
569 .expect("non-empty");
570 (Some(avg), Some(max_dur), Some(max_task))
571 };
572
573 let mut task_rework_counts: Vec<(String, u32)> = per_task_rework.into_iter().collect();
574 task_rework_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
575
576 Some(RunStats {
577 run_start,
578 run_end,
579 total_duration_secs: run_end.saturating_sub(run_start),
580 task_stats,
581 average_cycle_time_secs,
582 fastest_task_id,
583 fastest_cycle_time_secs,
584 longest_task_id,
585 longest_cycle_time_secs,
586 idle_time_pct,
587 escalation_count,
588 message_count,
589 auto_merge_count,
590 manual_merge_count,
591 rework_count,
592 review_nudge_count,
593 review_escalation_count,
594 avg_review_stall_secs,
595 max_review_stall_secs,
596 max_review_stall_task,
597 task_rework_counts,
598 })
599}
600
601pub fn analyze_project(project_root: &Path) -> Result<Option<RunStats>> {
603 let db_path = project_root.join(".batty").join("telemetry.db");
604 if db_path.exists() {
605 if let Ok(conn) = telemetry_db::open(project_root) {
606 if let Some(stats) = analyze_from_db(&conn) {
607 return Ok(Some(stats));
608 }
609 }
610 }
611
612 let events_path = project_root
614 .join(".batty")
615 .join("team_config")
616 .join("events.jsonl");
617 analyze_event_log(&events_path)
618}
619
620pub fn analyze_event_log(path: &Path) -> Result<Option<RunStats>> {
622 let events = read_events(path)?;
623 Ok(analyze_events(&events))
624}
625
626pub fn should_generate_retro(
627 project_root: &Path,
628 retro_generated: bool,
629 min_duration_secs: u64,
630) -> Result<Option<RunStats>> {
631 if retro_generated {
632 return Ok(None);
633 }
634
635 let board_dir = project_root
636 .join(".batty")
637 .join("team_config")
638 .join("board");
639 let tasks_dir = board_dir.join("tasks");
640 if !tasks_dir.is_dir() {
641 return Ok(None);
642 }
643
644 let tasks = task::load_tasks_from_dir(&tasks_dir)?;
645 let active_tasks: Vec<&task::Task> = tasks
646 .iter()
647 .filter(|task| task.status != "archived")
648 .collect();
649 if active_tasks.is_empty() || active_tasks.iter().any(|task| task.status != "done") {
650 return Ok(None);
651 }
652
653 let stats = analyze_project(project_root)?;
654
655 if let Some(ref stats) = stats {
659 let completed = stats
660 .task_stats
661 .iter()
662 .filter(|t| t.completed_at.is_some())
663 .count();
664 if stats.total_duration_secs < min_duration_secs && completed == 0 {
665 tracing::debug!(
666 duration_secs = stats.total_duration_secs,
667 completed_tasks = completed,
668 "Skipping trivial retrospective: {}s, {} tasks",
669 stats.total_duration_secs,
670 completed,
671 );
672 return Ok(None);
673 }
674 }
675
676 Ok(stats)
677}
678
679pub fn generate_retrospective(project_root: &Path, stats: &RunStats) -> Result<PathBuf> {
680 let retrospectives_dir = project_root.join(".batty").join("retrospectives");
681 fs::create_dir_all(&retrospectives_dir).with_context(|| {
682 format!(
683 "failed to create retrospectives directory: {}",
684 retrospectives_dir.display()
685 )
686 })?;
687
688 let report_path = retrospectives_dir.join(format!("{}.md", stats.run_end));
689 let report = render_retrospective(stats);
690 fs::write(&report_path, report)
691 .with_context(|| format!("failed to write retrospective: {}", report_path.display()))?;
692
693 Ok(report_path)
694}
695
696pub fn format_duration(secs: u64) -> String {
697 let hours = secs / 3_600;
698 let minutes = (secs % 3_600) / 60;
699 let seconds = secs % 60;
700
701 if hours > 0 {
702 format!("{hours}h {minutes:02}m {seconds:02}s")
703 } else if minutes > 0 {
704 format!("{minutes}m {seconds:02}s")
705 } else {
706 format!("{seconds}s")
707 }
708}
709
710fn render_retrospective(stats: &RunStats) -> String {
711 let completed_tasks = stats
712 .task_stats
713 .iter()
714 .filter(|task| task.completed_at.is_some())
715 .count();
716 let average_cycle_time = stats
717 .average_cycle_time_secs
718 .map(format_duration)
719 .unwrap_or_else(|| "-".to_string());
720 let fastest_cycle_time = stats
721 .fastest_cycle_time_secs
722 .map(|cycle| {
723 format!(
724 "{} ({})",
725 format_duration(cycle),
726 stats.fastest_task_id.as_deref().unwrap_or("-")
727 )
728 })
729 .unwrap_or_else(|| "-".to_string());
730 let longest_cycle_time = stats
731 .longest_cycle_time_secs
732 .map(|cycle| {
733 format!(
734 "{} ({})",
735 format_duration(cycle),
736 stats.longest_task_id.as_deref().unwrap_or("-")
737 )
738 })
739 .unwrap_or_else(|| "-".to_string());
740
741 let task_cycle_rows = render_task_cycle_rows(&stats.task_stats);
742 let bottlenecks = render_bottlenecks(&stats.task_stats);
743 let recommendations = render_recommendations(stats);
744 let review_section = render_review_performance(stats);
745
746 format!(
747 "# Batty Retrospective\n\n\
748## Summary\n\n\
749- Duration: {}\n\
750- Tasks completed: {}\n\
751- Average cycle time: {}\n\
752- Fastest task: {}\n\
753- Longest task: {}\n\
754- Messages: {}\n\
755- Escalations: {}\n\
756- Idle: {:.1}%\n\n\
757## Task Cycle Times\n\n\
758| Task | Assignee | Status | Cycle Time | Retries | Escalated |\n\
759| --- | --- | --- | --- | --- | --- |\n\
760{}\
761\n\
762{}\
763## Bottlenecks\n\n\
764{}\
765\n\
766## Recommendations\n\n\
767{}",
768 format_duration(stats.total_duration_secs),
769 completed_tasks,
770 average_cycle_time,
771 fastest_cycle_time,
772 longest_cycle_time,
773 stats.message_count,
774 stats.escalation_count,
775 stats.idle_time_pct * 100.0,
776 task_cycle_rows,
777 review_section,
778 bottlenecks,
779 recommendations
780 )
781}
782
783fn render_review_performance(stats: &RunStats) -> String {
784 let total_merges = stats.auto_merge_count + stats.manual_merge_count;
785 if total_merges == 0 && stats.rework_count == 0 && stats.review_nudge_count == 0 {
786 return String::new();
787 }
788
789 let auto_rate = if total_merges > 0 {
790 format!(
791 "{:.0}%",
792 stats.auto_merge_count as f64 / total_merges as f64 * 100.0
793 )
794 } else {
795 "-".to_string()
796 };
797 let total_reviewed = total_merges + stats.rework_count;
798 let rework_rate = if total_reviewed > 0 {
799 format!(
800 "{:.0}%",
801 stats.rework_count as f64 / total_reviewed as f64 * 100.0
802 )
803 } else {
804 "-".to_string()
805 };
806
807 let avg_stall = stats
808 .avg_review_stall_secs
809 .map(format_duration)
810 .unwrap_or_else(|| "-".to_string());
811 let max_stall = stats
812 .max_review_stall_secs
813 .map(|secs| {
814 format!(
815 "{} ({})",
816 format_duration(secs),
817 stats.max_review_stall_task.as_deref().unwrap_or("-")
818 )
819 })
820 .unwrap_or_else(|| "-".to_string());
821
822 let mut section = format!(
823 "## Review Pipeline\n\n\
824- Auto-merged: {}\n\
825- Manually merged: {}\n\
826- Auto-merge rate: {}\n\
827- Avg review stall: {}\n\
828- Max review stall: {}\n\
829- Rework cycles: {}\n\
830- Rework rate: {}\n\
831- Review nudges: {}\n\
832- Review escalations: {}\n",
833 stats.auto_merge_count,
834 stats.manual_merge_count,
835 auto_rate,
836 avg_stall,
837 max_stall,
838 stats.rework_count,
839 rework_rate,
840 stats.review_nudge_count,
841 stats.review_escalation_count,
842 );
843
844 if !stats.task_rework_counts.is_empty() {
845 section.push_str(
846 "\n### Rework by Task\n\n\
847| Task | Rework Cycles |\n\
848| --- | --- |\n",
849 );
850 for (task_id, count) in &stats.task_rework_counts {
851 section.push_str(&format!("| {} | {} |\n", task_id, count));
852 }
853 }
854
855 section.push('\n');
856 section
857}
858
859fn render_task_cycle_rows(tasks: &[TaskStats]) -> String {
860 if tasks.is_empty() {
861 return "| No tasks recorded | - | - | - | - | - |\n".to_string();
862 }
863
864 let mut rows = String::new();
865 for task in tasks {
866 let status = if task.completed_at.is_some() {
867 "completed"
868 } else {
869 "incomplete"
870 };
871 let cycle_time = task
872 .cycle_time_secs
873 .map(format_duration)
874 .unwrap_or_else(|| "-".to_string());
875 let escalated = if task.was_escalated { "yes" } else { "no" };
876 rows.push_str(&format!(
877 "| {} | {} | {} | {} | {} | {} |\n",
878 task.task_id, task.assigned_to, status, cycle_time, task.retry_count, escalated
879 ));
880 }
881 rows
882}
883
884fn render_bottlenecks(tasks: &[TaskStats]) -> String {
885 let longest_task = tasks
886 .iter()
887 .filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
888 .max_by_key(|(_, cycle)| *cycle);
889
890 let most_retried = tasks.iter().max_by_key(|task| task.retry_count);
891
892 let mut lines = Vec::new();
893 match longest_task {
894 Some((task, cycle)) => lines.push(format!(
895 "- Longest task: `{}` owned by `{}` at {}.",
896 task.task_id,
897 task.assigned_to,
898 format_duration(cycle)
899 )),
900 None => lines.push("- Longest task: no completed tasks recorded.".to_string()),
901 }
902
903 match most_retried {
904 Some(task) if task.retry_count > 1 => lines.push(format!(
905 "- Most retried: `{}` retried {} times.",
906 task.task_id, task.retry_count
907 )),
908 _ => lines.push("- Most retried: no task needed multiple attempts.".to_string()),
909 }
910
911 format!("{}\n", lines.join("\n"))
912}
913
914fn render_recommendations(stats: &RunStats) -> String {
915 let mut lines = Vec::new();
916 let max_retry_count = stats
917 .task_stats
918 .iter()
919 .map(|task| task.retry_count)
920 .max()
921 .unwrap_or(0);
922
923 if stats.idle_time_pct >= 0.5 {
924 lines.push(
925 "- Idle time stayed high. Queue more ready tasks so engineers are not waiting on assignment."
926 .to_string(),
927 );
928 }
929
930 if max_retry_count >= 3 {
931 lines.push(
932 "- Several retries were needed. Break work into smaller tasks with clearer acceptance criteria."
933 .to_string(),
934 );
935 }
936
937 if lines.is_empty() {
938 lines.push(
939 "- No major bottlenecks stood out. Keep the current task sizing and routing cadence."
940 .to_string(),
941 );
942 }
943
944 format!("{}\n", lines.join("\n"))
945}
946
947#[cfg(test)]
948mod tests {
949 use tempfile::tempdir;
950
951 use super::*;
952
953 fn at(mut event: TeamEvent, ts: u64) -> TeamEvent {
954 event.ts = ts;
955 event
956 }
957
958 #[test]
959 fn test_analyze_events_basic_run() {
960 let events = vec![
961 at(TeamEvent::daemon_started(), 100),
962 at(TeamEvent::task_assigned("eng-1", "42"), 110),
963 at(TeamEvent::message_routed("manager", "eng-1"), 115),
964 at(TeamEvent::task_completed("eng-1", None), 150),
965 at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
966 ];
967
968 let stats = analyze_events(&events).unwrap();
969
970 assert_eq!(stats.run_start, 100);
971 assert_eq!(stats.run_end, 160);
972 assert_eq!(stats.total_duration_secs, 60);
973 assert_eq!(stats.escalation_count, 0);
974 assert_eq!(stats.message_count, 1);
975 assert_eq!(stats.task_stats.len(), 1);
976 assert_eq!(stats.average_cycle_time_secs, Some(40));
977 assert_eq!(stats.fastest_task_id.as_deref(), Some("42"));
978 assert_eq!(stats.fastest_cycle_time_secs, Some(40));
979 assert_eq!(stats.longest_task_id.as_deref(), Some("42"));
980 assert_eq!(stats.longest_cycle_time_secs, Some(40));
981 assert_eq!(
982 stats.task_stats[0],
983 TaskStats {
984 task_id: "42".to_string(),
985 assigned_to: "eng-1".to_string(),
986 assigned_at: 110,
987 completed_at: Some(150),
988 cycle_time_secs: Some(40),
989 retry_count: 1,
990 was_escalated: false,
991 }
992 );
993 }
994
995 #[test]
996 fn test_analyze_events_with_retries() {
997 let events = vec![
998 at(TeamEvent::daemon_started(), 100),
999 at(
1000 TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
1001 110,
1002 ),
1003 at(
1004 TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
1005 130,
1006 ),
1007 at(TeamEvent::task_completed("eng-1", None), 170),
1008 at(TeamEvent::daemon_stopped_with_reason("signal", 70), 180),
1009 ];
1010
1011 let stats = analyze_events(&events).unwrap();
1012
1013 assert_eq!(stats.task_stats.len(), 1);
1014 assert_eq!(stats.task_stats[0].retry_count, 2);
1015 assert_eq!(stats.task_stats[0].assigned_at, 110);
1016 assert_eq!(stats.task_stats[0].cycle_time_secs, Some(60));
1017 assert_eq!(stats.task_stats[0].task_id, "42");
1018 }
1019
1020 #[test]
1021 fn test_analyze_events_with_escalation() {
1022 let events = vec![
1023 at(TeamEvent::daemon_started(), 100),
1024 at(TeamEvent::task_assigned("eng-1", "42"), 110),
1025 at(TeamEvent::task_escalated("eng-1", "42", None), 125),
1026 at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1027 ];
1028
1029 let stats = analyze_events(&events).unwrap();
1030
1031 assert_eq!(stats.escalation_count, 1);
1032 assert_eq!(stats.task_stats.len(), 1);
1033 assert!(stats.task_stats[0].was_escalated);
1034 assert_eq!(stats.task_stats[0].completed_at, None);
1035 }
1036
1037 #[test]
1038 fn test_analyze_events_idle_time() {
1039 let events = vec![
1040 at(TeamEvent::daemon_started(), 100),
1041 at(TeamEvent::load_snapshot(1, 4, true), 110),
1042 at(TeamEvent::load_snapshot(3, 4, true), 120),
1043 at(TeamEvent::daemon_stopped_with_reason("signal", 25), 125),
1044 ];
1045
1046 let stats = analyze_events(&events).unwrap();
1047
1048 assert!((stats.idle_time_pct - 0.5).abs() < 1e-9);
1049 }
1050
1051 #[test]
1052 fn test_analyze_events_empty() {
1053 assert_eq!(analyze_events(&[]), None);
1054 }
1055
1056 #[test]
1057 fn test_analyze_events_multiple_runs() {
1058 let events = vec![
1059 at(TeamEvent::daemon_started(), 100),
1060 at(TeamEvent::task_assigned("eng-1", "old-task"), 105),
1061 at(TeamEvent::daemon_stopped_with_reason("signal", 10), 110),
1062 at(TeamEvent::daemon_started(), 200),
1063 at(
1064 TeamEvent::task_assigned("eng-2", "Task #12: new-task\n\nTask details."),
1065 210,
1066 ),
1067 at(TeamEvent::task_completed("eng-2", None), 240),
1068 at(TeamEvent::daemon_stopped_with_reason("signal", 45), 245),
1069 ];
1070
1071 let stats = analyze_events(&events).unwrap();
1072
1073 assert_eq!(stats.run_start, 200);
1074 assert_eq!(stats.run_end, 245);
1075 assert_eq!(stats.task_stats.len(), 1);
1076 assert_eq!(stats.task_stats[0].task_id, "12");
1077 assert_eq!(stats.task_stats[0].assigned_to, "eng-2");
1078 assert_eq!(stats.task_stats[0].cycle_time_secs, Some(30));
1079 assert_eq!(stats.average_cycle_time_secs, Some(30));
1080 assert_eq!(stats.fastest_task_id.as_deref(), Some("12"));
1081 assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
1082 }
1083
1084 #[test]
1085 fn test_analyze_events_computes_average_fastest_and_longest_cycle_times() {
1086 let events = vec![
1087 at(TeamEvent::daemon_started(), 100),
1088 at(
1089 TeamEvent::task_assigned("eng-1", "Task #11: short task\n\nBody."),
1090 110,
1091 ),
1092 at(TeamEvent::task_completed("eng-1", None), 140),
1093 at(
1094 TeamEvent::task_assigned("eng-2", "Task #12: long task\n\nBody."),
1095 150,
1096 ),
1097 at(TeamEvent::task_completed("eng-2", None), 240),
1098 at(TeamEvent::daemon_stopped_with_reason("signal", 150), 250),
1099 ];
1100
1101 let stats = analyze_events(&events).unwrap();
1102
1103 assert_eq!(stats.average_cycle_time_secs, Some(60));
1104 assert_eq!(stats.fastest_task_id.as_deref(), Some("11"));
1105 assert_eq!(stats.fastest_cycle_time_secs, Some(30));
1106 assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
1107 assert_eq!(stats.longest_cycle_time_secs, Some(90));
1108 }
1109
1110 fn sample_task(task_id: &str, cycle_time_secs: Option<u64>, retry_count: u32) -> TaskStats {
1111 TaskStats {
1112 task_id: task_id.to_string(),
1113 assigned_to: "eng-1".to_string(),
1114 assigned_at: 100,
1115 completed_at: cycle_time_secs.map(|cycle| 100 + cycle),
1116 cycle_time_secs,
1117 retry_count,
1118 was_escalated: retry_count > 2,
1119 }
1120 }
1121
1122 #[test]
1123 fn format_duration_variants() {
1124 assert_eq!(format_duration(45), "45s");
1125 assert_eq!(format_duration(65), "1m 05s");
1126 assert_eq!(format_duration(3_665), "1h 01m 05s");
1127 }
1128
1129 #[test]
1130 fn generate_retrospective_writes_report_with_sections() {
1131 let tmp = tempdir().unwrap();
1132 let stats = RunStats {
1133 run_start: 1_700_000_000,
1134 run_end: 1_700_000_123,
1135 total_duration_secs: 123,
1136 task_stats: vec![
1137 sample_task("T-101", Some(90), 1),
1138 sample_task("T-102", Some(30), 2),
1139 ],
1140 average_cycle_time_secs: Some(60),
1141 fastest_task_id: Some("T-102".to_string()),
1142 fastest_cycle_time_secs: Some(30),
1143 longest_task_id: Some("T-101".to_string()),
1144 longest_cycle_time_secs: Some(90),
1145 idle_time_pct: 0.25,
1146 escalation_count: 1,
1147 message_count: 6,
1148 auto_merge_count: 0,
1149 manual_merge_count: 0,
1150 rework_count: 0,
1151 review_nudge_count: 0,
1152 review_escalation_count: 0,
1153 avg_review_stall_secs: None,
1154 max_review_stall_secs: None,
1155 max_review_stall_task: None,
1156 task_rework_counts: Vec::new(),
1157 };
1158
1159 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1160 let content = fs::read_to_string(&path).unwrap();
1161
1162 assert_eq!(
1163 path,
1164 tmp.path()
1165 .join(".batty")
1166 .join("retrospectives")
1167 .join("1700000123.md")
1168 );
1169 assert!(content.contains("## Summary"));
1170 assert!(content.contains("## Task Cycle Times"));
1171 assert!(content.contains("## Bottlenecks"));
1172 assert!(content.contains("## Recommendations"));
1173 assert!(content.contains("| T-101 | eng-1 | completed | 1m 30s | 1 | no |"));
1174 assert!(content.contains("- Tasks completed: 2"));
1175 assert!(content.contains("- Average cycle time: 1m 00s"));
1176 assert!(content.contains("- Fastest task: 30s (T-102)"));
1177 assert!(content.contains("- Longest task: 1m 30s (T-101)"));
1178 }
1179
1180 #[test]
1181 fn generate_retrospective_handles_empty_tasks() {
1182 let tmp = tempdir().unwrap();
1183 let stats = RunStats {
1184 run_start: 10,
1185 run_end: 20,
1186 total_duration_secs: 10,
1187 task_stats: Vec::new(),
1188 average_cycle_time_secs: None,
1189 fastest_task_id: None,
1190 fastest_cycle_time_secs: None,
1191 longest_task_id: None,
1192 longest_cycle_time_secs: None,
1193 idle_time_pct: 0.0,
1194 escalation_count: 0,
1195 message_count: 0,
1196 auto_merge_count: 0,
1197 manual_merge_count: 0,
1198 rework_count: 0,
1199 review_nudge_count: 0,
1200 review_escalation_count: 0,
1201 avg_review_stall_secs: None,
1202 max_review_stall_secs: None,
1203 max_review_stall_task: None,
1204 task_rework_counts: Vec::new(),
1205 };
1206
1207 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1208 let content = fs::read_to_string(path).unwrap();
1209
1210 assert!(content.contains("| No tasks recorded | - | - | - | - | - |"));
1211 assert!(content.contains("- Average cycle time: -"));
1212 assert!(content.contains("- Fastest task: -"));
1213 assert!(content.contains("- Longest task: -"));
1214 assert!(content.contains("- Longest task: no completed tasks recorded."));
1215 assert!(content.contains("- Most retried: no task needed multiple attempts."));
1216 }
1217
1218 #[test]
1219 fn generate_retrospective_adds_high_idle_recommendation() {
1220 let tmp = tempdir().unwrap();
1221 let stats = RunStats {
1222 run_start: 10,
1223 run_end: 30,
1224 total_duration_secs: 20,
1225 task_stats: vec![sample_task("T-201", Some(20), 1)],
1226 average_cycle_time_secs: Some(20),
1227 fastest_task_id: Some("T-201".to_string()),
1228 fastest_cycle_time_secs: Some(20),
1229 longest_task_id: Some("T-201".to_string()),
1230 longest_cycle_time_secs: Some(20),
1231 idle_time_pct: 0.75,
1232 escalation_count: 0,
1233 message_count: 1,
1234 auto_merge_count: 0,
1235 manual_merge_count: 0,
1236 rework_count: 0,
1237 review_nudge_count: 0,
1238 review_escalation_count: 0,
1239 avg_review_stall_secs: None,
1240 max_review_stall_secs: None,
1241 max_review_stall_task: None,
1242 task_rework_counts: Vec::new(),
1243 };
1244
1245 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1246 let content = fs::read_to_string(path).unwrap();
1247
1248 assert!(content.contains("Idle time stayed high"));
1249 assert!(content.contains("Queue more ready tasks"));
1250 }
1251
1252 #[test]
1253 fn generate_retrospective_adds_high_retry_recommendation() {
1254 let tmp = tempdir().unwrap();
1255 let stats = RunStats {
1256 run_start: 10,
1257 run_end: 40,
1258 total_duration_secs: 30,
1259 task_stats: vec![sample_task("T-301", Some(25), 3)],
1260 average_cycle_time_secs: Some(25),
1261 fastest_task_id: Some("T-301".to_string()),
1262 fastest_cycle_time_secs: Some(25),
1263 longest_task_id: Some("T-301".to_string()),
1264 longest_cycle_time_secs: Some(25),
1265 idle_time_pct: 0.1,
1266 escalation_count: 0,
1267 message_count: 2,
1268 auto_merge_count: 0,
1269 manual_merge_count: 0,
1270 rework_count: 0,
1271 review_nudge_count: 0,
1272 review_escalation_count: 0,
1273 avg_review_stall_secs: None,
1274 max_review_stall_secs: None,
1275 max_review_stall_task: None,
1276 task_rework_counts: Vec::new(),
1277 };
1278
1279 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1280 let content = fs::read_to_string(path).unwrap();
1281
1282 assert!(content.contains("Several retries were needed"));
1283 assert!(content.contains("smaller tasks"));
1284 }
1285
1286 fn write_owned_task_file(
1287 project_root: &Path,
1288 task_id: u32,
1289 title: &str,
1290 status: &str,
1291 claimed_by: &str,
1292 ) {
1293 let board_dir = project_root
1294 .join(".batty")
1295 .join("team_config")
1296 .join("board");
1297 let tasks_dir = board_dir.join("tasks");
1298 fs::create_dir_all(&tasks_dir).unwrap();
1299 let slug = title.replace(' ', "-");
1300 let task_path = tasks_dir.join(format!("{task_id:03}-{slug}.md"));
1301 let content = format!(
1302 r#"---
1303id: {task_id}
1304title: "{title}"
1305status: {status}
1306claimed_by: {claimed_by}
1307---
1308
1309Task body.
1310"#
1311 );
1312 fs::write(task_path, content).unwrap();
1313 }
1314
1315 fn write_event_log(project_root: &Path, events: &[TeamEvent]) {
1316 let events_path = project_root
1317 .join(".batty")
1318 .join("team_config")
1319 .join("events.jsonl");
1320 fs::create_dir_all(events_path.parent().unwrap()).unwrap();
1321 let body = events
1322 .iter()
1323 .map(|event| serde_json::to_string(event).unwrap())
1324 .collect::<Vec<_>>()
1325 .join("\n");
1326 fs::write(events_path, format!("{body}\n")).unwrap();
1327 }
1328
1329 #[test]
1330 fn should_generate_retro_when_all_active_tasks_are_done() {
1331 let tmp = tempdir().unwrap();
1332 write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
1333 write_event_log(
1334 tmp.path(),
1335 &[
1336 at(TeamEvent::daemon_started(), 100),
1337 at(TeamEvent::task_assigned("eng-1", "45"), 110),
1338 at(TeamEvent::task_completed("eng-1", None), 150),
1339 at(TeamEvent::daemon_stopped(), 160),
1340 ],
1341 );
1342
1343 let stats = should_generate_retro(tmp.path(), false, 60)
1344 .unwrap()
1345 .unwrap();
1346 assert_eq!(stats.run_start, 100);
1347 assert_eq!(stats.run_end, 160);
1348 assert_eq!(stats.task_stats.len(), 1);
1349 assert_eq!(stats.task_stats[0].task_id, "45");
1350 }
1351
1352 #[test]
1353 fn should_not_generate_retro_when_task_is_not_done() {
1354 let tmp = tempdir().unwrap();
1355 write_owned_task_file(tmp.path(), 45, "retro-task", "in-progress", "eng-1");
1356 write_event_log(tmp.path(), &[at(TeamEvent::daemon_started(), 100)]);
1357
1358 let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1359 assert_eq!(stats, None);
1360 }
1361
1362 #[test]
1363 fn should_not_generate_retro_twice() {
1364 let tmp = tempdir().unwrap();
1365 write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
1366 write_event_log(
1367 tmp.path(),
1368 &[
1369 at(TeamEvent::daemon_started(), 100),
1370 at(TeamEvent::task_assigned("eng-1", "45"), 110),
1371 at(TeamEvent::task_completed("eng-1", None), 150),
1372 at(TeamEvent::daemon_stopped(), 160),
1373 ],
1374 );
1375
1376 let stats = should_generate_retro(tmp.path(), true, 60).unwrap();
1377 assert_eq!(stats, None);
1378 }
1379
1380 #[test]
1381 fn skip_retro_for_short_run() {
1382 let tmp = tempdir().unwrap();
1383 write_owned_task_file(tmp.path(), 50, "short-task", "done", "eng-1");
1384 write_event_log(
1385 tmp.path(),
1386 &[
1387 at(TeamEvent::daemon_started(), 100),
1388 at(TeamEvent::daemon_stopped(), 104),
1389 ],
1390 );
1391
1392 let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1394 assert_eq!(stats, None);
1395 }
1396
1397 #[test]
1398 fn generate_retro_for_long_run() {
1399 let tmp = tempdir().unwrap();
1400 write_owned_task_file(tmp.path(), 51, "long-task", "done", "eng-1");
1401 write_event_log(
1402 tmp.path(),
1403 &[
1404 at(TeamEvent::daemon_started(), 100),
1405 at(TeamEvent::task_assigned("eng-1", "51"), 110),
1406 at(TeamEvent::task_completed("eng-1", None), 200),
1407 at(TeamEvent::task_assigned("eng-1", "52"), 210),
1408 at(TeamEvent::task_completed("eng-1", None), 300),
1409 at(TeamEvent::task_assigned("eng-1", "53"), 310),
1410 at(TeamEvent::task_completed("eng-1", None), 380),
1411 at(TeamEvent::daemon_stopped(), 400),
1412 ],
1413 );
1414
1415 let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1417 assert!(stats.is_some());
1418 let stats = stats.unwrap();
1419 assert_eq!(stats.total_duration_secs, 300);
1420 }
1421
1422 #[test]
1423 fn skip_retro_for_short_run_with_completions() {
1424 let tmp = tempdir().unwrap();
1425 write_owned_task_file(tmp.path(), 55, "quick-task", "done", "eng-1");
1426 write_event_log(
1427 tmp.path(),
1428 &[
1429 at(TeamEvent::daemon_started(), 100),
1430 at(TeamEvent::task_assigned("eng-1", "55"), 105),
1431 at(TeamEvent::task_completed("eng-1", None), 115),
1432 at(TeamEvent::task_assigned("eng-1", "56"), 118),
1433 at(TeamEvent::task_completed("eng-1", None), 125),
1434 at(TeamEvent::daemon_stopped(), 130),
1435 ],
1436 );
1437
1438 let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1440 assert!(stats.is_some());
1441 let stats = stats.unwrap();
1442 assert_eq!(stats.total_duration_secs, 30);
1443 }
1444
1445 #[test]
1446 fn analyze_events_computes_review_stall_duration() {
1447 let events = vec![
1448 at(TeamEvent::daemon_started(), 100),
1449 at(
1450 TeamEvent::task_assigned("eng-1", "Task #10: fast task"),
1451 110,
1452 ),
1453 at(TeamEvent::task_completed("eng-1", None), 150),
1454 at(
1456 TeamEvent::task_auto_merged("eng-1", "Task #10: fast task", 0.9, 2, 10),
1457 180,
1458 ),
1459 at(
1460 TeamEvent::task_assigned("eng-2", "Task #20: slow task"),
1461 120,
1462 ),
1463 at(TeamEvent::task_completed("eng-2", None), 200),
1464 at(TeamEvent::task_manual_merged("Task #20: slow task"), 300),
1466 at(TeamEvent::daemon_stopped_with_reason("signal", 210), 310),
1467 ];
1468
1469 let stats = analyze_events(&events).unwrap();
1470
1471 assert_eq!(stats.auto_merge_count, 1);
1472 assert_eq!(stats.manual_merge_count, 1);
1473 assert_eq!(stats.avg_review_stall_secs, Some(65));
1475 assert_eq!(stats.max_review_stall_secs, Some(100));
1477 assert_eq!(stats.max_review_stall_task.as_deref(), Some("20"));
1478 }
1479
1480 #[test]
1481 fn analyze_events_no_stall_without_merges() {
1482 let events = vec![
1483 at(TeamEvent::daemon_started(), 100),
1484 at(TeamEvent::task_assigned("eng-1", "42"), 110),
1485 at(TeamEvent::task_completed("eng-1", None), 150),
1486 at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1487 ];
1488
1489 let stats = analyze_events(&events).unwrap();
1490
1491 assert_eq!(stats.avg_review_stall_secs, None);
1492 assert_eq!(stats.max_review_stall_secs, None);
1493 assert_eq!(stats.max_review_stall_task, None);
1494 }
1495
1496 #[test]
1497 fn analyze_events_tracks_per_task_rework() {
1498 let events = vec![
1499 at(TeamEvent::daemon_started(), 100),
1500 at(TeamEvent::task_assigned("eng-1", "Task #10: reworked"), 110),
1501 at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 120),
1502 at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 130),
1503 at(TeamEvent::task_assigned("eng-2", "Task #20: once"), 115),
1504 at(TeamEvent::task_reworked("eng-2", "Task #20: once"), 140),
1505 at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1506 ];
1507
1508 let stats = analyze_events(&events).unwrap();
1509
1510 assert_eq!(stats.rework_count, 3);
1511 assert_eq!(stats.task_rework_counts.len(), 2);
1513 assert_eq!(stats.task_rework_counts[0], ("10".to_string(), 2));
1514 assert_eq!(stats.task_rework_counts[1], ("20".to_string(), 1));
1515 }
1516
1517 #[test]
1518 fn analyze_events_empty_rework_list() {
1519 let events = vec![
1520 at(TeamEvent::daemon_started(), 100),
1521 at(TeamEvent::task_assigned("eng-1", "42"), 110),
1522 at(TeamEvent::task_completed("eng-1", None), 150),
1523 at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1524 ];
1525
1526 let stats = analyze_events(&events).unwrap();
1527
1528 assert!(stats.task_rework_counts.is_empty());
1529 }
1530
1531 #[test]
1532 fn render_review_pipeline_section_includes_stall_and_rework() {
1533 let tmp = tempdir().unwrap();
1534 let stats = RunStats {
1535 run_start: 100,
1536 run_end: 500,
1537 total_duration_secs: 400,
1538 task_stats: Vec::new(),
1539 average_cycle_time_secs: None,
1540 fastest_task_id: None,
1541 fastest_cycle_time_secs: None,
1542 longest_task_id: None,
1543 longest_cycle_time_secs: None,
1544 idle_time_pct: 0.0,
1545 escalation_count: 0,
1546 message_count: 0,
1547 auto_merge_count: 3,
1548 manual_merge_count: 1,
1549 rework_count: 2,
1550 review_nudge_count: 1,
1551 review_escalation_count: 0,
1552 avg_review_stall_secs: Some(90),
1553 max_review_stall_secs: Some(180),
1554 max_review_stall_task: Some("T-5".to_string()),
1555 task_rework_counts: vec![("T-5".to_string(), 2)],
1556 };
1557
1558 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1559 let content = fs::read_to_string(path).unwrap();
1560
1561 assert!(content.contains("## Review Pipeline"));
1562 assert!(content.contains("Auto-merged: 3"));
1563 assert!(content.contains("Manually merged: 1"));
1564 assert!(content.contains("Auto-merge rate: 75%"));
1565 assert!(content.contains("Avg review stall: 1m 30s"));
1566 assert!(content.contains("Max review stall: 3m 00s (T-5)"));
1567 assert!(content.contains("Rework cycles: 2"));
1568 assert!(content.contains("### Rework by Task"));
1569 assert!(content.contains("| T-5 | 2 |"));
1570 }
1571
1572 #[test]
1573 fn render_review_pipeline_no_stall_data() {
1574 let tmp = tempdir().unwrap();
1575 let stats = RunStats {
1576 run_start: 100,
1577 run_end: 300,
1578 total_duration_secs: 200,
1579 task_stats: Vec::new(),
1580 average_cycle_time_secs: None,
1581 fastest_task_id: None,
1582 fastest_cycle_time_secs: None,
1583 longest_task_id: None,
1584 longest_cycle_time_secs: None,
1585 idle_time_pct: 0.0,
1586 escalation_count: 0,
1587 message_count: 0,
1588 auto_merge_count: 2,
1589 manual_merge_count: 0,
1590 rework_count: 0,
1591 review_nudge_count: 0,
1592 review_escalation_count: 0,
1593 avg_review_stall_secs: None,
1594 max_review_stall_secs: None,
1595 max_review_stall_task: None,
1596 task_rework_counts: Vec::new(),
1597 };
1598
1599 let path = generate_retrospective(tmp.path(), &stats).unwrap();
1600 let content = fs::read_to_string(path).unwrap();
1601
1602 assert!(content.contains("## Review Pipeline"));
1603 assert!(content.contains("Avg review stall: -"));
1604 assert!(content.contains("Max review stall: -"));
1605 assert!(!content.contains("### Rework by Task"));
1606 }
1607
1608 #[test]
1611 fn task_reference_extracts_id_from_task_prefix() {
1612 assert_eq!(task_reference("Task #42: build feature"), "42");
1613 }
1614
1615 #[test]
1616 fn task_reference_returns_full_line_when_no_prefix() {
1617 assert_eq!(task_reference("build feature"), "build feature");
1618 }
1619
1620 #[test]
1621 fn task_reference_skips_blank_lines() {
1622 assert_eq!(task_reference("\n\n Task #99: test\nbody"), "99");
1623 }
1624
1625 #[test]
1626 fn task_reference_handles_whitespace_only_input() {
1627 assert_eq!(task_reference(" "), "");
1628 }
1629
1630 #[test]
1631 fn task_id_from_assignment_line_valid() {
1632 assert_eq!(
1633 task_id_from_assignment_line("Task #123: some task"),
1634 Some("123".to_string())
1635 );
1636 }
1637
1638 #[test]
1639 fn task_id_from_assignment_line_no_prefix() {
1640 assert_eq!(task_id_from_assignment_line("no prefix here"), None);
1641 }
1642
1643 #[test]
1644 fn task_id_from_assignment_line_empty_digits() {
1645 assert_eq!(task_id_from_assignment_line("Task #abc: letters"), None);
1646 }
1647
1648 #[test]
1649 fn cycle_time_metrics_no_completed_tasks() {
1650 let tasks = vec![sample_task("T-1", None, 1)];
1651 let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1652 assert_eq!(avg, None);
1653 assert_eq!(fastest, None);
1654 assert_eq!(fastest_time, None);
1655 assert_eq!(longest, None);
1656 assert_eq!(longest_time, None);
1657 }
1658
1659 #[test]
1660 fn cycle_time_metrics_single_completed_task() {
1661 let tasks = vec![sample_task("T-1", Some(60), 1)];
1662 let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1663 assert_eq!(avg, Some(60));
1664 assert_eq!(fastest, Some("T-1".to_string()));
1665 assert_eq!(fastest_time, Some(60));
1666 assert_eq!(longest, Some("T-1".to_string()));
1667 assert_eq!(longest_time, Some(60));
1668 }
1669
1670 #[test]
1671 fn cycle_time_metrics_multiple_tasks_picks_extremes() {
1672 let tasks = vec![
1673 sample_task("T-fast", Some(10), 1),
1674 sample_task("T-mid", Some(50), 1),
1675 sample_task("T-slow", Some(90), 1),
1676 sample_task("T-incomplete", None, 1),
1677 ];
1678 let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1679 assert_eq!(avg, Some(50)); assert_eq!(fastest, Some("T-fast".to_string()));
1681 assert_eq!(fastest_time, Some(10));
1682 assert_eq!(longest, Some("T-slow".to_string()));
1683 assert_eq!(longest_time, Some(90));
1684 }
1685
1686 #[test]
1687 fn format_duration_zero() {
1688 assert_eq!(format_duration(0), "0s");
1689 }
1690
1691 #[test]
1692 fn format_duration_exact_minute() {
1693 assert_eq!(format_duration(60), "1m 00s");
1694 }
1695
1696 #[test]
1697 fn format_duration_exact_hour() {
1698 assert_eq!(format_duration(3600), "1h 00m 00s");
1699 }
1700
1701 #[test]
1702 fn format_duration_large() {
1703 assert_eq!(format_duration(7322), "2h 02m 02s");
1704 }
1705
1706 #[test]
1707 fn render_task_cycle_rows_empty() {
1708 let rows = render_task_cycle_rows(&[]);
1709 assert!(rows.contains("No tasks recorded"));
1710 }
1711
1712 #[test]
1713 fn render_task_cycle_rows_completed_and_incomplete() {
1714 let tasks = vec![
1715 sample_task("T-1", Some(120), 1),
1716 sample_task("T-2", None, 2),
1717 ];
1718 let rows = render_task_cycle_rows(&tasks);
1719 assert!(rows.contains("| T-1 | eng-1 | completed | 2m 00s | 1 | no |"));
1720 assert!(rows.contains("| T-2 | eng-1 | incomplete | - | 2 | no |"));
1721 }
1722
1723 #[test]
1724 fn render_task_cycle_rows_escalated_task() {
1725 let tasks = vec![sample_task("T-esc", Some(200), 4)]; let rows = render_task_cycle_rows(&tasks);
1727 assert!(rows.contains("| T-esc | eng-1 | completed | 3m 20s | 4 | yes |"));
1728 }
1729
1730 #[test]
1731 fn render_bottlenecks_no_completed_tasks() {
1732 let tasks = vec![sample_task("T-1", None, 1)];
1733 let output = render_bottlenecks(&tasks);
1734 assert!(output.contains("no completed tasks recorded"));
1735 assert!(output.contains("no task needed multiple attempts"));
1736 }
1737
1738 #[test]
1739 fn render_bottlenecks_with_retries() {
1740 let tasks = vec![
1741 sample_task("T-1", Some(100), 1),
1742 sample_task("T-2", Some(200), 3),
1743 ];
1744 let output = render_bottlenecks(&tasks);
1745 assert!(output.contains("Longest task: `T-2`"));
1746 assert!(output.contains("Most retried: `T-2` retried 3 times"));
1747 }
1748
1749 #[test]
1750 fn render_bottlenecks_single_retry_shows_no_retries_message() {
1751 let tasks = vec![sample_task("T-1", Some(60), 1)];
1752 let output = render_bottlenecks(&tasks);
1753 assert!(output.contains("no task needed multiple attempts"));
1754 }
1755
1756 #[test]
1757 fn render_recommendations_low_idle_low_retries() {
1758 let stats = RunStats {
1759 run_start: 0,
1760 run_end: 100,
1761 total_duration_secs: 100,
1762 task_stats: vec![sample_task("T-1", Some(50), 1)],
1763 average_cycle_time_secs: Some(50),
1764 fastest_task_id: Some("T-1".to_string()),
1765 fastest_cycle_time_secs: Some(50),
1766 longest_task_id: Some("T-1".to_string()),
1767 longest_cycle_time_secs: Some(50),
1768 idle_time_pct: 0.1,
1769 escalation_count: 0,
1770 message_count: 1,
1771 auto_merge_count: 0,
1772 manual_merge_count: 0,
1773 rework_count: 0,
1774 review_nudge_count: 0,
1775 review_escalation_count: 0,
1776 avg_review_stall_secs: None,
1777 max_review_stall_secs: None,
1778 max_review_stall_task: None,
1779 task_rework_counts: Vec::new(),
1780 };
1781 let output = render_recommendations(&stats);
1782 assert!(output.contains("No major bottlenecks"));
1783 }
1784
1785 #[test]
1786 fn render_recommendations_both_high_idle_and_high_retries() {
1787 let stats = RunStats {
1788 run_start: 0,
1789 run_end: 100,
1790 total_duration_secs: 100,
1791 task_stats: vec![sample_task("T-1", Some(50), 5)],
1792 average_cycle_time_secs: Some(50),
1793 fastest_task_id: Some("T-1".to_string()),
1794 fastest_cycle_time_secs: Some(50),
1795 longest_task_id: Some("T-1".to_string()),
1796 longest_cycle_time_secs: Some(50),
1797 idle_time_pct: 0.8,
1798 escalation_count: 0,
1799 message_count: 1,
1800 auto_merge_count: 0,
1801 manual_merge_count: 0,
1802 rework_count: 0,
1803 review_nudge_count: 0,
1804 review_escalation_count: 0,
1805 avg_review_stall_secs: None,
1806 max_review_stall_secs: None,
1807 max_review_stall_task: None,
1808 task_rework_counts: Vec::new(),
1809 };
1810 let output = render_recommendations(&stats);
1811 assert!(output.contains("Idle time stayed high"));
1812 assert!(output.contains("Several retries were needed"));
1813 }
1814
1815 #[test]
1816 fn render_review_performance_empty_when_no_merges() {
1817 let stats = RunStats {
1818 run_start: 0,
1819 run_end: 100,
1820 total_duration_secs: 100,
1821 task_stats: Vec::new(),
1822 average_cycle_time_secs: None,
1823 fastest_task_id: None,
1824 fastest_cycle_time_secs: None,
1825 longest_task_id: None,
1826 longest_cycle_time_secs: None,
1827 idle_time_pct: 0.0,
1828 escalation_count: 0,
1829 message_count: 0,
1830 auto_merge_count: 0,
1831 manual_merge_count: 0,
1832 rework_count: 0,
1833 review_nudge_count: 0,
1834 review_escalation_count: 0,
1835 avg_review_stall_secs: None,
1836 max_review_stall_secs: None,
1837 max_review_stall_task: None,
1838 task_rework_counts: Vec::new(),
1839 };
1840 let section = render_review_performance(&stats);
1841 assert!(section.is_empty());
1842 }
1843
1844 #[test]
1845 fn render_review_performance_100_percent_auto_merge_rate() {
1846 let stats = RunStats {
1847 run_start: 0,
1848 run_end: 100,
1849 total_duration_secs: 100,
1850 task_stats: Vec::new(),
1851 average_cycle_time_secs: None,
1852 fastest_task_id: None,
1853 fastest_cycle_time_secs: None,
1854 longest_task_id: None,
1855 longest_cycle_time_secs: None,
1856 idle_time_pct: 0.0,
1857 escalation_count: 0,
1858 message_count: 0,
1859 auto_merge_count: 5,
1860 manual_merge_count: 0,
1861 rework_count: 0,
1862 review_nudge_count: 0,
1863 review_escalation_count: 0,
1864 avg_review_stall_secs: None,
1865 max_review_stall_secs: None,
1866 max_review_stall_task: None,
1867 task_rework_counts: Vec::new(),
1868 };
1869 let section = render_review_performance(&stats);
1870 assert!(section.contains("Auto-merge rate: 100%"));
1871 assert!(section.contains("Auto-merged: 5"));
1872 assert!(section.contains("Manually merged: 0"));
1873 }
1874
1875 #[test]
1876 fn analyze_events_multiple_tasks_different_engineers() {
1877 let events = vec![
1878 at(TeamEvent::daemon_started(), 100),
1879 at(TeamEvent::task_assigned("eng-1", "Task #10: task-a"), 110),
1880 at(TeamEvent::task_assigned("eng-2", "Task #20: task-b"), 115),
1881 at(TeamEvent::task_completed("eng-1", None), 160),
1882 at(TeamEvent::task_completed("eng-2", None), 200),
1883 at(TeamEvent::daemon_stopped_with_reason("signal", 110), 210),
1884 ];
1885
1886 let stats = analyze_events(&events).unwrap();
1887 assert_eq!(stats.task_stats.len(), 2);
1888
1889 let t10 = stats.task_stats.iter().find(|t| t.task_id == "10").unwrap();
1890 assert_eq!(t10.assigned_to, "eng-1");
1891 assert_eq!(t10.cycle_time_secs, Some(50));
1892
1893 let t20 = stats.task_stats.iter().find(|t| t.task_id == "20").unwrap();
1894 assert_eq!(t20.assigned_to, "eng-2");
1895 assert_eq!(t20.cycle_time_secs, Some(85));
1896 }
1897
1898 #[test]
1899 fn analyze_events_tracks_review_nudges_and_escalations() {
1900 let events = vec![
1901 at(TeamEvent::daemon_started(), 100),
1902 at(
1903 TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
1904 120,
1905 ),
1906 at(
1907 TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
1908 140,
1909 ),
1910 at(
1911 TeamEvent::review_escalated("Task #5: reviewed", "stale"),
1912 160,
1913 ),
1914 at(TeamEvent::daemon_stopped_with_reason("signal", 80), 180),
1915 ];
1916
1917 let stats = analyze_events(&events).unwrap();
1918 assert_eq!(stats.review_nudge_count, 2);
1919 assert_eq!(stats.review_escalation_count, 1);
1920 }
1921
1922 #[test]
1923 fn analyze_events_completion_without_assignment_is_ignored() {
1924 let events = vec![
1925 at(TeamEvent::daemon_started(), 100),
1926 at(TeamEvent::task_completed("eng-1", None), 150),
1928 at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1929 ];
1930
1931 let stats = analyze_events(&events).unwrap();
1932 assert!(stats.task_stats.is_empty());
1933 assert_eq!(stats.average_cycle_time_secs, None);
1934 }
1935
1936 #[test]
1937 fn analyze_events_escalation_without_prior_assignment_creates_task() {
1938 let events = vec![
1939 at(TeamEvent::daemon_started(), 100),
1940 at(
1941 TeamEvent::task_escalated("eng-1", "Task #99: escalated-only", None),
1942 120,
1943 ),
1944 at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1945 ];
1946
1947 let stats = analyze_events(&events).unwrap();
1948 assert_eq!(stats.escalation_count, 1);
1949 assert_eq!(stats.task_stats.len(), 1);
1950 assert!(stats.task_stats[0].was_escalated);
1951 assert_eq!(stats.task_stats[0].task_id, "Task #99: escalated-only");
1953 }
1954
1955 #[test]
1956 fn analyze_events_daemon_started_only() {
1957 let events = vec![at(TeamEvent::daemon_started(), 100)];
1958 let stats = analyze_events(&events).unwrap();
1959 assert_eq!(stats.run_start, 100);
1960 assert_eq!(stats.run_end, 100);
1961 assert_eq!(stats.total_duration_secs, 0);
1962 assert!(stats.task_stats.is_empty());
1963 }
1964
1965 #[test]
1966 fn analyze_events_load_snapshot_all_working() {
1967 let events = vec![
1968 at(TeamEvent::daemon_started(), 100),
1969 at(TeamEvent::load_snapshot(4, 4, true), 110),
1970 at(TeamEvent::load_snapshot(4, 4, true), 120),
1971 at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1972 ];
1973
1974 let stats = analyze_events(&events).unwrap();
1975 assert!((stats.idle_time_pct - 0.0).abs() < 1e-9);
1976 }
1977
1978 #[test]
1979 fn analyze_events_load_snapshot_all_idle() {
1980 let events = vec![
1981 at(TeamEvent::daemon_started(), 100),
1982 at(TeamEvent::load_snapshot(0, 4, true), 110),
1983 at(TeamEvent::load_snapshot(0, 4, true), 120),
1984 at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1985 ];
1986
1987 let stats = analyze_events(&events).unwrap();
1988 assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
1989 }
1990
1991 #[test]
1992 fn analyze_events_load_snapshot_zero_members() {
1993 let events = vec![
1994 at(TeamEvent::daemon_started(), 100),
1995 at(TeamEvent::load_snapshot(0, 0, true), 110),
1996 at(TeamEvent::daemon_stopped_with_reason("signal", 20), 120),
1997 ];
1998
1999 let stats = analyze_events(&events).unwrap();
2000 assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
2001 }
2002
2003 #[test]
2004 fn should_generate_retro_no_board_dir_returns_none() {
2005 let tmp = tempdir().unwrap();
2006 let result = should_generate_retro(tmp.path(), false, 60).unwrap();
2008 assert_eq!(result, None);
2009 }
2010
2011 #[test]
2012 fn should_generate_retro_empty_board_returns_none() {
2013 let tmp = tempdir().unwrap();
2014 let tasks_dir = tmp
2015 .path()
2016 .join(".batty")
2017 .join("team_config")
2018 .join("board")
2019 .join("tasks");
2020 fs::create_dir_all(&tasks_dir).unwrap();
2021 let result = should_generate_retro(tmp.path(), false, 60).unwrap();
2023 assert_eq!(result, None);
2024 }
2025
2026 use crate::team::telemetry_db;
2029
2030 fn populate_basic_run_db(conn: &Connection) {
2032 let events = vec![
2033 at(TeamEvent::daemon_started(), 100),
2034 at(TeamEvent::task_assigned("eng-1", "42"), 110),
2035 at(TeamEvent::message_routed("manager", "eng-1"), 115),
2036 at(TeamEvent::task_completed("eng-1", None), 150),
2037 at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
2038 ];
2039 for event in &events {
2040 telemetry_db::insert_event(conn, event).unwrap();
2041 }
2042 }
2043
2044 #[test]
2045 fn retro_with_telemetry_db() {
2046 let conn = telemetry_db::open_in_memory().unwrap();
2047 populate_basic_run_db(&conn);
2048
2049 let stats = analyze_from_db(&conn).unwrap();
2050
2051 assert_eq!(stats.run_start, 100);
2052 assert_eq!(stats.run_end, 160);
2053 assert_eq!(stats.total_duration_secs, 60);
2054 assert_eq!(stats.message_count, 1);
2055 assert_eq!(stats.escalation_count, 0);
2056 assert_eq!(stats.task_stats.len(), 1);
2057 assert_eq!(stats.task_stats[0].task_id, "42");
2058 assert_eq!(stats.task_stats[0].assigned_to, "eng-1");
2059 assert_eq!(stats.task_stats[0].cycle_time_secs, Some(40));
2060 assert_eq!(stats.average_cycle_time_secs, Some(40));
2061 assert_eq!(stats.fastest_task_id.as_deref(), Some("42"));
2062 assert_eq!(stats.longest_task_id.as_deref(), Some("42"));
2063 }
2064
2065 #[test]
2066 fn retro_from_db_matches_jsonl_analysis() {
2067 let events = vec![
2069 at(TeamEvent::daemon_started(), 100),
2070 at(TeamEvent::task_assigned("eng-1", "42"), 110),
2071 at(TeamEvent::message_routed("manager", "eng-1"), 115),
2072 at(TeamEvent::task_completed("eng-1", None), 150),
2073 at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
2074 ];
2075
2076 let jsonl_stats = analyze_events(&events).unwrap();
2077
2078 let conn = telemetry_db::open_in_memory().unwrap();
2079 for event in &events {
2080 telemetry_db::insert_event(&conn, event).unwrap();
2081 }
2082 let db_stats = analyze_from_db(&conn).unwrap();
2083
2084 assert_eq!(jsonl_stats.run_start, db_stats.run_start);
2085 assert_eq!(jsonl_stats.run_end, db_stats.run_end);
2086 assert_eq!(
2087 jsonl_stats.total_duration_secs,
2088 db_stats.total_duration_secs
2089 );
2090 assert_eq!(jsonl_stats.task_stats.len(), db_stats.task_stats.len());
2091 assert_eq!(
2092 jsonl_stats.average_cycle_time_secs,
2093 db_stats.average_cycle_time_secs
2094 );
2095 assert_eq!(jsonl_stats.fastest_task_id, db_stats.fastest_task_id);
2096 assert_eq!(jsonl_stats.longest_task_id, db_stats.longest_task_id);
2097 assert_eq!(jsonl_stats.escalation_count, db_stats.escalation_count);
2098 assert_eq!(jsonl_stats.message_count, db_stats.message_count);
2099 assert_eq!(jsonl_stats.idle_time_pct, db_stats.idle_time_pct);
2100 assert_eq!(jsonl_stats.auto_merge_count, db_stats.auto_merge_count);
2101 assert_eq!(jsonl_stats.manual_merge_count, db_stats.manual_merge_count);
2102 assert_eq!(jsonl_stats.rework_count, db_stats.rework_count);
2103 }
2104
2105 #[test]
2106 fn retro_without_db_falls_back() {
2107 let tmp = tempdir().unwrap();
2109 let events_dir = tmp.path().join(".batty").join("team_config");
2110 fs::create_dir_all(&events_dir).unwrap();
2111
2112 let events = vec![
2113 at(TeamEvent::daemon_started(), 100),
2114 at(TeamEvent::task_assigned("eng-1", "42"), 110),
2115 at(TeamEvent::task_completed("eng-1", None), 150),
2116 at(TeamEvent::daemon_stopped(), 160),
2117 ];
2118 write_event_log(tmp.path(), &events);
2119
2120 let stats = analyze_project(tmp.path()).unwrap().unwrap();
2122 assert_eq!(stats.run_start, 100);
2123 assert_eq!(stats.run_end, 160);
2124 assert_eq!(stats.task_stats.len(), 1);
2125 assert_eq!(stats.task_stats[0].task_id, "42");
2126 }
2127
2128 #[test]
2129 fn retro_report_format_unchanged() {
2130 let conn = telemetry_db::open_in_memory().unwrap();
2132 populate_basic_run_db(&conn);
2133
2134 let stats = analyze_from_db(&conn).unwrap();
2135 let report = render_retrospective(&stats);
2136
2137 assert!(report.contains("# Batty Retrospective"));
2138 assert!(report.contains("## Summary"));
2139 assert!(report.contains("## Task Cycle Times"));
2140 assert!(report.contains("## Bottlenecks"));
2141 assert!(report.contains("## Recommendations"));
2142 assert!(report.contains("- Tasks completed: 1"));
2143 assert!(report.contains("- Average cycle time: 40s"));
2144 assert!(report.contains("| 42 | eng-1 | completed | 40s | 1 | no |"));
2145 }
2146
2147 #[test]
2148 fn retro_from_db_empty_returns_none() {
2149 let conn = telemetry_db::open_in_memory().unwrap();
2150 assert!(analyze_from_db(&conn).is_none());
2151 }
2152
2153 #[test]
2154 fn retro_from_db_with_retries_and_escalations() {
2155 let conn = telemetry_db::open_in_memory().unwrap();
2156 let events = vec![
2157 at(TeamEvent::daemon_started(), 100),
2158 at(
2159 TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
2160 110,
2161 ),
2162 at(
2163 TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
2164 130,
2165 ),
2166 at(TeamEvent::task_escalated("eng-1", "42", None), 135),
2167 at(TeamEvent::task_completed("eng-1", None), 170),
2168 at(TeamEvent::daemon_stopped_with_reason("signal", 70), 180),
2169 ];
2170 for event in &events {
2171 telemetry_db::insert_event(&conn, event).unwrap();
2172 }
2173
2174 let stats = analyze_from_db(&conn).unwrap();
2175 assert_eq!(stats.task_stats.len(), 1);
2176 assert_eq!(stats.task_stats[0].task_id, "42");
2177 assert_eq!(stats.task_stats[0].retry_count, 2);
2178 assert!(stats.task_stats[0].was_escalated);
2179 assert_eq!(stats.task_stats[0].cycle_time_secs, Some(60));
2180 assert_eq!(stats.escalation_count, 1);
2181 }
2182
2183 #[test]
2184 fn retro_from_db_with_review_pipeline() {
2185 let conn = telemetry_db::open_in_memory().unwrap();
2186 let events = vec![
2187 at(TeamEvent::daemon_started(), 100),
2188 at(TeamEvent::task_assigned("eng-1", "Task #10: fast"), 110),
2189 at(TeamEvent::task_completed("eng-1", None), 150),
2190 at(
2191 TeamEvent::task_auto_merged("eng-1", "Task #10: fast", 0.9, 2, 10),
2192 180,
2193 ),
2194 at(TeamEvent::task_assigned("eng-2", "Task #20: slow"), 120),
2195 at(TeamEvent::task_completed("eng-2", None), 200),
2196 at(TeamEvent::task_manual_merged("Task #20: slow"), 300),
2197 at(TeamEvent::task_reworked("eng-1", "Task #10: fast"), 145),
2198 at(
2199 TeamEvent::review_nudge_sent("manager", "Task #10: fast"),
2200 155,
2201 ),
2202 at(TeamEvent::daemon_stopped_with_reason("signal", 210), 310),
2203 ];
2204 for event in &events {
2205 telemetry_db::insert_event(&conn, event).unwrap();
2206 }
2207
2208 let stats = analyze_from_db(&conn).unwrap();
2209 assert_eq!(stats.auto_merge_count, 1);
2210 assert_eq!(stats.manual_merge_count, 1);
2211 assert_eq!(stats.rework_count, 1);
2212 assert_eq!(stats.review_nudge_count, 1);
2213 assert_eq!(stats.avg_review_stall_secs, Some(65));
2215 assert_eq!(stats.max_review_stall_secs, Some(100));
2216 assert_eq!(stats.max_review_stall_task.as_deref(), Some("20"));
2217 assert_eq!(stats.task_rework_counts, vec![("10".to_string(), 1)]);
2218 }
2219
2220 #[test]
2221 fn retro_from_db_multiple_runs_uses_last() {
2222 let conn = telemetry_db::open_in_memory().unwrap();
2223 let events = vec![
2224 at(TeamEvent::daemon_started(), 100),
2226 at(TeamEvent::task_assigned("eng-1", "old-task"), 105),
2227 at(TeamEvent::daemon_stopped_with_reason("signal", 10), 110),
2228 at(TeamEvent::daemon_started(), 200),
2230 at(TeamEvent::task_assigned("eng-2", "Task #12: new-task"), 210),
2231 at(TeamEvent::task_completed("eng-2", None), 240),
2232 at(TeamEvent::daemon_stopped_with_reason("signal", 45), 245),
2233 ];
2234 for event in &events {
2235 telemetry_db::insert_event(&conn, event).unwrap();
2236 }
2237
2238 let stats = analyze_from_db(&conn).unwrap();
2239 assert_eq!(stats.run_start, 200);
2240 assert_eq!(stats.run_end, 245);
2241 assert_eq!(stats.task_stats.len(), 1);
2242 assert_eq!(stats.task_stats[0].task_id, "12");
2243 }
2244
2245 #[test]
2246 fn analyze_project_prefers_db_over_jsonl() {
2247 let tmp = tempdir().unwrap();
2248 let jsonl_events = vec![
2250 at(TeamEvent::daemon_started(), 100),
2251 at(TeamEvent::task_assigned("eng-1", "99"), 110),
2252 at(TeamEvent::task_completed("eng-1", None), 150),
2253 at(TeamEvent::daemon_stopped(), 160),
2254 ];
2255 write_event_log(tmp.path(), &jsonl_events);
2256
2257 let db_path = tmp.path().join(".batty");
2259 fs::create_dir_all(&db_path).unwrap();
2260 let conn = telemetry_db::open(tmp.path()).unwrap();
2261 let db_events = vec![
2262 at(TeamEvent::daemon_started(), 200),
2263 at(TeamEvent::task_assigned("eng-1", "42"), 210),
2264 at(TeamEvent::task_completed("eng-1", None), 250),
2265 at(TeamEvent::daemon_stopped(), 260),
2266 ];
2267 for event in &db_events {
2268 telemetry_db::insert_event(&conn, event).unwrap();
2269 }
2270 drop(conn);
2271
2272 let stats = analyze_project(tmp.path()).unwrap().unwrap();
2274 assert_eq!(stats.task_stats[0].task_id, "42");
2275 }
2276}