1use std::collections::HashSet;
2
3use chrono::{DateTime, Duration, Utc};
4use serde::Serialize;
5
6use crate::analysis::graph::{GraphMetrics, IssueGraph};
7use crate::model::Issue;
8
9const DEFAULT_ESTIMATED_MINUTES: i64 = 60;
10const I64_MAX_F64: f64 = 9_223_372_036_854_775_807.0;
11const I64_MIN_F64: f64 = -9_223_372_036_854_775_808.0;
12
13#[derive(Debug, Clone, Serialize)]
14pub struct ForecastItem {
15 pub id: String,
16 pub title: String,
17 pub status: String,
18 pub confidence: f64,
19 pub eta_minutes: i64,
20 pub estimated_days: f64,
21 pub eta_date: String,
22 pub eta_date_low: String,
23 pub eta_date_high: String,
24 pub velocity_minutes_per_day: f64,
25 pub agents: usize,
26 pub factors: Vec<String>,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct ForecastSummary {
31 pub generated_at: String,
32 pub count: usize,
33 pub avg_eta_minutes: i64,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct ForecastOutput {
38 pub summary: ForecastSummary,
39 pub forecasts: Vec<ForecastItem>,
40}
41
42#[derive(Debug, Clone)]
43pub struct EtaEstimate {
44 pub estimated_minutes: i64,
45 pub estimated_days: f64,
46 pub eta_date: String,
47 pub eta_date_low: String,
48 pub eta_date_high: String,
49 pub confidence: f64,
50 pub velocity_minutes_per_day: f64,
51 pub agents: usize,
52 pub factors: Vec<String>,
53}
54
55#[must_use]
56pub fn estimate_forecast(
57 issues: &[Issue],
58 graph: &IssueGraph,
59 metrics: &GraphMetrics,
60 issue_id_or_all: &str,
61 label_filter: Option<&str>,
62 agents: usize,
63) -> ForecastOutput {
64 let now = Utc::now();
65 let mut forecasts = Vec::<ForecastItem>::new();
66
67 let target_all = issue_id_or_all.eq_ignore_ascii_case("all");
68
69 for issue in issues {
70 if !issue.is_open_like() {
71 continue;
72 }
73 if !target_all && issue.id != issue_id_or_all {
74 continue;
75 }
76 if label_filter.is_some_and(|label| !has_label(&issue.labels, label)) {
77 continue;
78 }
79
80 let Some(eta) = estimate_eta_for_issue(issues, graph, metrics, &issue.id, agents, now)
81 else {
82 continue;
83 };
84
85 forecasts.push(ForecastItem {
86 id: issue.id.clone(),
87 title: issue.title.clone(),
88 status: issue.status.clone(),
89 confidence: eta.confidence,
90 eta_minutes: eta.estimated_minutes,
91 estimated_days: eta.estimated_days,
92 eta_date: eta.eta_date,
93 eta_date_low: eta.eta_date_low,
94 eta_date_high: eta.eta_date_high,
95 velocity_minutes_per_day: eta.velocity_minutes_per_day,
96 agents: eta.agents,
97 factors: eta.factors,
98 });
99 }
100
101 let avg_eta_minutes = if forecasts.is_empty() {
102 0
103 } else {
104 forecasts.iter().map(|item| item.eta_minutes).sum::<i64>()
105 / i64::try_from(forecasts.len()).unwrap_or(1)
106 };
107
108 ForecastOutput {
109 summary: ForecastSummary {
110 generated_at: now.to_rfc3339(),
111 count: forecasts.len(),
112 avg_eta_minutes,
113 },
114 forecasts,
115 }
116}
117
118#[must_use]
119pub fn estimate_eta_for_issue(
120 issues: &[Issue],
121 graph: &IssueGraph,
122 metrics: &GraphMetrics,
123 issue_id: &str,
124 agents: usize,
125 now: DateTime<Utc>,
126) -> Option<EtaEstimate> {
127 let mut active = HashSet::<String>::new();
128 estimate_eta_for_issue_inner(issues, graph, metrics, issue_id, agents, now, &mut active)
129}
130
131fn estimate_eta_for_issue_inner(
132 issues: &[Issue],
133 graph: &IssueGraph,
134 metrics: &GraphMetrics,
135 issue_id: &str,
136 agents: usize,
137 now: DateTime<Utc>,
138 active: &mut HashSet<String>,
139) -> Option<EtaEstimate> {
140 if !active.insert(issue_id.to_string()) {
141 return None;
142 }
143
144 let Some(issue) = issues.iter().find(|issue| issue.id == issue_id) else {
145 active.remove(issue_id);
146 return None;
147 };
148 let agents = agents.max(1);
149
150 let median_minutes = compute_median_estimated_minutes(issues);
151 let (complexity_minutes, mut factors) =
152 estimate_complexity_minutes(issue, metrics, median_minutes);
153 let (mut velocity_per_day, velocity_samples, velocity_factors) =
154 estimate_velocity_minutes_per_day(issues, issue, now, median_minutes);
155
156 if velocity_per_day <= 0.0 {
157 velocity_per_day = (median_minutes as f64) / 5.0;
158 if velocity_per_day <= 0.0 {
159 velocity_per_day = 60.0;
160 }
161 factors.extend(velocity_factors);
162 factors.push("velocity: no recent closures; using default".to_string());
163 } else {
164 factors.extend(velocity_factors);
165 }
166
167 let capacity_per_day = velocity_per_day * (agents as f64);
168 let mut estimated_days = if capacity_per_day > 0.0 {
169 (complexity_minutes as f64) / capacity_per_day
170 } else {
171 0.0
172 };
173 if estimated_days.is_sign_negative() {
174 estimated_days = 0.0;
175 }
176
177 let blocker_wait_days = graph
178 .open_blockers(issue_id)
179 .into_iter()
180 .filter_map(|blocker_id| {
181 estimate_eta_for_issue_inner(issues, graph, metrics, &blocker_id, agents, now, active)
182 })
183 .map(|eta| eta.estimated_days)
184 .fold(0.0_f64, f64::max);
185 if blocker_wait_days > 0.0 {
186 estimated_days += blocker_wait_days;
187 factors.push(format!(
188 "blocked: waits {:.1}d on dependencies",
189 blocker_wait_days
190 ));
191 }
192
193 let confidence = estimate_eta_confidence(issue, velocity_samples);
194 let delta_days = 0.5_f64.max(estimated_days * (1.0 - confidence) * 0.8);
195
196 let eta = now + duration_days(estimated_days);
197 let eta_low = now + duration_days((estimated_days - delta_days).max(0.0));
198 let eta_high = now + duration_days(estimated_days + delta_days);
199
200 factors.push(format!("agents: {agents}"));
201 if factors.len() > 8 {
202 factors.truncate(8);
203 }
204
205 let estimate = EtaEstimate {
206 estimated_minutes: complexity_minutes,
207 estimated_days,
208 eta_date: eta.to_rfc3339(),
209 eta_date_low: eta_low.to_rfc3339(),
210 eta_date_high: eta_high.to_rfc3339(),
211 confidence,
212 velocity_minutes_per_day: velocity_per_day,
213 agents,
214 factors,
215 };
216 active.remove(issue_id);
217 Some(estimate)
218}
219
220fn estimate_complexity_minutes(
221 issue: &Issue,
222 metrics: &GraphMetrics,
223 median_minutes: i64,
224) -> (i64, Vec<String>) {
225 let mut factors = Vec::<String>::new();
226
227 let explicit = issue.estimated_minutes.unwrap_or(0) > 0;
228 let mut base_minutes = if explicit {
229 i64::from(issue.estimated_minutes.unwrap_or(0))
230 } else {
231 median_minutes
232 };
233
234 let estimate_source = if explicit {
235 "explicit"
236 } else if base_minutes > 0 {
237 "median"
238 } else {
239 "default"
240 };
241 if base_minutes <= 0 {
242 base_minutes = DEFAULT_ESTIMATED_MINUTES;
243 }
244 factors.push(format!("estimate: {estimate_source} ({base_minutes}m)"));
245
246 let issue_type = issue.issue_type.trim().to_ascii_lowercase();
247 let type_weight = match issue_type.as_str() {
248 "chore" => 0.8,
249 "feature" => 1.3,
250 "epic" => 2.0,
251 _ => 1.0,
252 };
253 factors.push(format!("type: {issue_type}×{type_weight:.1}"));
254
255 let depth = metrics.critical_depth.get(&issue.id).copied().unwrap_or(0) as f64;
256 let depth_factor = 1.0 + (depth / 10.0).min(1.0);
257 factors.push(format!("depth: {depth:.0}×{depth_factor:.2}"));
258
259 let desc_runes = issue.description.chars().count();
260 let desc_factor = 1.0 + ((desc_runes as f64) / 2000.0).min(1.0);
261 if desc_runes > 0 {
262 factors.push(format!("desc: {desc_runes}r×{desc_factor:.2}"));
263 } else {
264 factors.push("desc: empty×1.00".to_string());
265 }
266
267 let derived =
268 truncate_f64_to_i64((base_minutes as f64) * type_weight * depth_factor * desc_factor)
269 .unwrap_or(base_minutes);
270 (derived.max(1), factors)
271}
272
273fn estimate_velocity_minutes_per_day(
274 issues: &[Issue],
275 issue: &Issue,
276 now: DateTime<Utc>,
277 median_minutes: i64,
278) -> (f64, usize, Vec<String>) {
279 let since = now - Duration::days(30);
280 if issue.labels.is_empty() {
281 let (velocity, samples) =
282 velocity_minutes_per_day_for_label(issues, None, since, median_minutes);
283 return (
284 velocity,
285 samples,
286 vec![format!("velocity: global ({samples} samples/30d)")],
287 );
288 }
289
290 let mut slowest_label = String::new();
293 let mut slowest_velocity = 0.0;
294 let mut slowest_samples = 0usize;
295
296 for label in &issue.labels {
297 let (velocity, samples) =
298 velocity_minutes_per_day_for_label(issues, Some(label), since, median_minutes);
299 if samples == 0 || velocity <= 0.0 {
300 continue;
301 }
302
303 if slowest_velocity == 0.0
304 || velocity < slowest_velocity
305 || ((velocity - slowest_velocity).abs() < f64::EPSILON
306 && label.to_ascii_lowercase() < slowest_label.to_ascii_lowercase())
307 {
308 slowest_label.clone_from(label);
309 slowest_velocity = velocity;
310 slowest_samples = samples;
311 }
312 }
313
314 if slowest_velocity > 0.0 {
315 return (
316 slowest_velocity,
317 slowest_samples,
318 vec![format!(
319 "velocity: label={slowest_label} ({slowest_velocity:.0} min/day, {slowest_samples} samples/30d)"
320 )],
321 );
322 }
323
324 let (velocity, samples) =
325 velocity_minutes_per_day_for_label(issues, None, since, median_minutes);
326 (
327 velocity,
328 samples,
329 vec![format!("velocity: global ({samples} samples/30d)")],
330 )
331}
332
333fn velocity_minutes_per_day_for_label(
334 issues: &[Issue],
335 label: Option<&str>,
336 since: DateTime<Utc>,
337 median_minutes: i64,
338) -> (f64, usize) {
339 let mut total_minutes = 0_i64;
340 let mut samples = 0usize;
341
342 for issue in issues {
343 if !issue.is_closed_like() {
344 continue;
345 }
346
347 let closed_at = issue.closed_at.or(issue.updated_at);
348 let Some(closed_at) = closed_at else {
349 continue;
350 };
351 if closed_at < since {
352 continue;
353 }
354
355 if label.is_some_and(|needle| !has_label(&issue.labels, needle)) {
356 continue;
357 }
358
359 let minutes = i64::from(issue.estimated_minutes.unwrap_or(0)).max(0);
360 total_minutes += if minutes > 0 {
361 minutes
362 } else if median_minutes > 0 {
363 median_minutes
364 } else {
365 DEFAULT_ESTIMATED_MINUTES
366 };
367 samples = samples.saturating_add(1);
368 }
369
370 if samples == 0 {
371 (0.0, 0)
372 } else {
373 (total_minutes as f64 / 30.0, samples)
374 }
375}
376
377fn has_label(labels: &[String], target: &str) -> bool {
378 let target = target.to_ascii_lowercase();
379 labels
380 .iter()
381 .any(|label| label.to_ascii_lowercase() == target)
382}
383
384fn estimate_eta_confidence(issue: &Issue, velocity_samples: usize) -> f64 {
385 let mut confidence = 0.25_f64;
386
387 if issue.estimated_minutes.unwrap_or(0) > 0 {
388 confidence += 0.25;
389 }
390
391 confidence += if velocity_samples >= 15 {
392 0.30
393 } else if velocity_samples >= 5 {
394 0.20
395 } else if velocity_samples >= 1 {
396 0.10
397 } else {
398 -0.05
399 };
400
401 if issue.labels.is_empty() {
402 confidence -= 0.05;
403 }
404
405 clamp(confidence, 0.10, 0.90)
406}
407
408fn compute_median_estimated_minutes(issues: &[Issue]) -> i64 {
409 let mut estimates = issues
410 .iter()
411 .filter_map(|issue| issue.estimated_minutes)
412 .map(i64::from)
413 .filter(|minutes| *minutes > 0)
414 .collect::<Vec<_>>();
415
416 if estimates.is_empty() {
417 return DEFAULT_ESTIMATED_MINUTES;
418 }
419
420 estimates.sort_unstable();
421 let mid = estimates.len() / 2;
422 if estimates.len() % 2 == 0 {
423 (estimates[mid - 1] + estimates[mid]) / 2
424 } else {
425 estimates[mid]
426 }
427}
428
429fn duration_days(days: f64) -> Duration {
430 if days <= 0.0 || !days.is_finite() {
431 return Duration::zero();
432 }
433
434 const NANOS_PER_DAY: f64 = 86_400.0 * 1_000_000_000.0;
435 let nanos = truncate_f64_to_i64(days * NANOS_PER_DAY)
436 .unwrap_or(i64::MAX)
437 .max(0);
438 Duration::nanoseconds(nanos)
439}
440
441fn clamp(value: f64, min: f64, max: f64) -> f64 {
442 value.max(min).min(max)
443}
444
445fn truncate_f64_to_i64(value: f64) -> Option<i64> {
446 if !value.is_finite() {
447 return None;
448 }
449
450 if value >= I64_MAX_F64 {
451 return Some(i64::MAX);
452 }
453 if value <= I64_MIN_F64 {
454 return Some(i64::MIN);
455 }
456
457 #[allow(clippy::cast_possible_truncation)]
458 Some(value.trunc() as i64)
459}
460
461#[cfg(test)]
462mod tests {
463 use chrono::Utc;
464
465 use crate::analysis::graph::IssueGraph;
466 use crate::model::Issue;
467
468 use super::{estimate_eta_for_issue, estimate_forecast, velocity_minutes_per_day_for_label};
469
470 #[test]
471 fn forecast_for_all_open_issues() {
472 let issues = vec![
473 Issue {
474 id: "A".to_string(),
475 title: "A".to_string(),
476 status: "open".to_string(),
477 issue_type: "task".to_string(),
478 estimated_minutes: Some(90),
479 ..Issue::default()
480 },
481 Issue {
482 id: "B".to_string(),
483 title: "B".to_string(),
484 status: "open".to_string(),
485 issue_type: "task".to_string(),
486 estimated_minutes: Some(30),
487 ..Issue::default()
488 },
489 Issue {
490 id: "C".to_string(),
491 title: "C".to_string(),
492 status: "closed".to_string(),
493 issue_type: "task".to_string(),
494 ..Issue::default()
495 },
496 ];
497
498 let graph = IssueGraph::build(&issues);
499 let metrics = graph.compute_metrics();
500 let output = estimate_forecast(&issues, &graph, &metrics, "all", None, 1);
501 assert_eq!(output.summary.count, 2);
502 assert_eq!(output.forecasts[0].id, "A");
503 assert_eq!(output.forecasts[1].id, "B");
504 assert!(output.forecasts[0].estimated_days >= 0.0);
505 }
506
507 #[test]
508 fn eta_includes_bounds_and_normalizes_agents() {
509 let issues = vec![Issue {
510 id: "A".to_string(),
511 title: "A".to_string(),
512 status: "open".to_string(),
513 issue_type: "task".to_string(),
514 ..Issue::default()
515 }];
516
517 let graph = IssueGraph::build(&issues);
518 let metrics = graph.compute_metrics();
519 let eta = estimate_eta_for_issue(&issues, &graph, &metrics, "A", 0, Utc::now())
520 .expect("eta should be computed");
521
522 assert_eq!(eta.agents, 1);
523 assert!(!eta.eta_date.is_empty());
524 assert!(!eta.eta_date_low.is_empty());
525 assert!(!eta.eta_date_high.is_empty());
526 }
527
528 #[test]
529 fn velocity_counts_tombstone_as_closed_like() {
530 let now = Utc::now();
531 let issues = vec![
532 Issue {
533 id: "A".to_string(),
534 title: "A".to_string(),
535 status: "closed".to_string(),
536 issue_type: "task".to_string(),
537 estimated_minutes: Some(120),
538 closed_at: Some(now - chrono::Duration::days(1)),
539 ..Issue::default()
540 },
541 Issue {
542 id: "B".to_string(),
543 title: "B".to_string(),
544 status: "tombstone".to_string(),
545 issue_type: "task".to_string(),
546 estimated_minutes: Some(60),
547 closed_at: Some(now - chrono::Duration::days(2)),
548 ..Issue::default()
549 },
550 ];
551
552 let (velocity, samples) =
553 velocity_minutes_per_day_for_label(&issues, None, now - chrono::Duration::days(30), 60);
554
555 assert_eq!(samples, 2);
556 assert!((velocity - 6.0).abs() < 0.001);
557 }
558
559 #[test]
562 fn median_odd_count() {
563 let issues = vec![
564 Issue {
565 estimated_minutes: Some(30),
566 ..Issue::default()
567 },
568 Issue {
569 estimated_minutes: Some(60),
570 ..Issue::default()
571 },
572 Issue {
573 estimated_minutes: Some(120),
574 ..Issue::default()
575 },
576 ];
577 assert_eq!(super::compute_median_estimated_minutes(&issues), 60);
578 }
579
580 #[test]
581 fn median_even_count() {
582 let issues = vec![
583 Issue {
584 estimated_minutes: Some(30),
585 ..Issue::default()
586 },
587 Issue {
588 estimated_minutes: Some(90),
589 ..Issue::default()
590 },
591 ];
592 assert_eq!(super::compute_median_estimated_minutes(&issues), 60);
594 }
595
596 #[test]
597 fn median_empty_returns_default() {
598 assert_eq!(
599 super::compute_median_estimated_minutes(&[]),
600 super::DEFAULT_ESTIMATED_MINUTES
601 );
602 }
603
604 #[test]
605 fn median_filters_zero_and_none() {
606 let issues = vec![
607 Issue {
608 estimated_minutes: Some(0),
609 ..Issue::default()
610 },
611 Issue {
612 estimated_minutes: None,
613 ..Issue::default()
614 },
615 Issue {
616 estimated_minutes: Some(120),
617 ..Issue::default()
618 },
619 ];
620 assert_eq!(super::compute_median_estimated_minutes(&issues), 120);
621 }
622
623 #[test]
626 fn complexity_uses_explicit_estimate() {
627 let graph = IssueGraph::build(&[]);
628 let metrics = graph.compute_metrics();
629 let issue = Issue {
630 id: "A".to_string(),
631 estimated_minutes: Some(120),
632 issue_type: "task".to_string(),
633 ..Issue::default()
634 };
635 let (minutes, factors) = super::estimate_complexity_minutes(&issue, &metrics, 60);
636 assert_eq!(minutes, 120);
639 assert!(factors.iter().any(|f| f.contains("explicit")));
640 }
641
642 #[test]
643 fn complexity_uses_median_fallback() {
644 let graph = IssueGraph::build(&[]);
645 let metrics = graph.compute_metrics();
646 let issue = Issue {
647 id: "A".to_string(),
648 issue_type: "task".to_string(),
649 ..Issue::default()
650 };
651 let (minutes, factors) = super::estimate_complexity_minutes(&issue, &metrics, 90);
652 assert_eq!(minutes, 90);
654 assert!(factors.iter().any(|f| f.contains("median")));
655 }
656
657 #[test]
658 fn complexity_type_weight_feature() {
659 let graph = IssueGraph::build(&[]);
660 let metrics = graph.compute_metrics();
661 let issue = Issue {
662 id: "A".to_string(),
663 estimated_minutes: Some(100),
664 issue_type: "feature".to_string(),
665 ..Issue::default()
666 };
667 let (minutes, _) = super::estimate_complexity_minutes(&issue, &metrics, 60);
668 assert_eq!(minutes, 130);
670 }
671
672 #[test]
673 fn complexity_type_weight_epic() {
674 let graph = IssueGraph::build(&[]);
675 let metrics = graph.compute_metrics();
676 let issue = Issue {
677 id: "A".to_string(),
678 estimated_minutes: Some(100),
679 issue_type: "epic".to_string(),
680 ..Issue::default()
681 };
682 let (minutes, _) = super::estimate_complexity_minutes(&issue, &metrics, 60);
683 assert_eq!(minutes, 200);
685 }
686
687 #[test]
688 fn complexity_description_scales_estimate() {
689 let graph = IssueGraph::build(&[]);
690 let metrics = graph.compute_metrics();
691 let long_desc = "x".repeat(2000);
692 let issue = Issue {
693 id: "A".to_string(),
694 estimated_minutes: Some(100),
695 issue_type: "task".to_string(),
696 description: long_desc,
697 ..Issue::default()
698 };
699 let (minutes, _) = super::estimate_complexity_minutes(&issue, &metrics, 60);
700 assert_eq!(minutes, 200);
702 }
703
704 #[test]
707 fn confidence_base_no_estimate_no_velocity() {
708 let issue = Issue::default();
709 let confidence = super::estimate_eta_confidence(&issue, 0);
710 assert!((confidence - 0.15).abs() < 0.01);
712 }
713
714 #[test]
715 fn confidence_with_explicit_estimate() {
716 let issue = Issue {
717 estimated_minutes: Some(60),
718 ..Issue::default()
719 };
720 let confidence = super::estimate_eta_confidence(&issue, 0);
721 assert!((confidence - 0.40).abs() < 0.01);
723 }
724
725 #[test]
726 fn confidence_high_velocity_samples() {
727 let issue = Issue {
728 estimated_minutes: Some(60),
729 labels: vec!["backend".to_string()],
730 ..Issue::default()
731 };
732 let confidence = super::estimate_eta_confidence(&issue, 20);
733 assert!((confidence - 0.80).abs() < 0.01);
735 }
736
737 #[test]
740 fn velocity_label_filter() {
741 let now = Utc::now();
742 let issues = vec![
743 Issue {
744 id: "A".to_string(),
745 status: "closed".to_string(),
746 labels: vec!["backend".to_string()],
747 estimated_minutes: Some(120),
748 closed_at: Some(now - chrono::Duration::days(5)),
749 ..Issue::default()
750 },
751 Issue {
752 id: "B".to_string(),
753 status: "closed".to_string(),
754 labels: vec!["frontend".to_string()],
755 estimated_minutes: Some(60),
756 closed_at: Some(now - chrono::Duration::days(3)),
757 ..Issue::default()
758 },
759 ];
760
761 let (vel_backend, samples_backend) = velocity_minutes_per_day_for_label(
762 &issues,
763 Some("backend"),
764 now - chrono::Duration::days(30),
765 60,
766 );
767 assert_eq!(samples_backend, 1);
768 assert!((vel_backend - 4.0).abs() < 0.01); let (vel_frontend, samples_frontend) = velocity_minutes_per_day_for_label(
771 &issues,
772 Some("frontend"),
773 now - chrono::Duration::days(30),
774 60,
775 );
776 assert_eq!(samples_frontend, 1);
777 assert!((vel_frontend - 2.0).abs() < 0.01); }
779
780 #[test]
781 fn velocity_ignores_old_closures() {
782 let now = Utc::now();
783 let issues = vec![Issue {
784 id: "A".to_string(),
785 status: "closed".to_string(),
786 estimated_minutes: Some(120),
787 closed_at: Some(now - chrono::Duration::days(60)),
788 ..Issue::default()
789 }];
790 let (velocity, samples) =
791 velocity_minutes_per_day_for_label(&issues, None, now - chrono::Duration::days(30), 60);
792 assert_eq!(samples, 0);
793 assert_eq!(velocity, 0.0);
794 }
795
796 #[test]
799 fn more_agents_reduces_eta() {
800 let issues = vec![Issue {
801 id: "A".to_string(),
802 title: "A".to_string(),
803 status: "open".to_string(),
804 issue_type: "task".to_string(),
805 estimated_minutes: Some(240),
806 ..Issue::default()
807 }];
808 let graph = IssueGraph::build(&issues);
809 let metrics = graph.compute_metrics();
810 let now = Utc::now();
811
812 let eta1 = estimate_eta_for_issue(&issues, &graph, &metrics, "A", 1, now).unwrap();
813 let eta3 = estimate_eta_for_issue(&issues, &graph, &metrics, "A", 3, now).unwrap();
814 assert!(
815 eta3.estimated_days < eta1.estimated_days || eta1.estimated_days == 0.0,
816 "3 agents should complete faster than 1"
817 );
818 }
819
820 #[test]
821 fn blocked_issue_eta_includes_blocker_wait_time() {
822 let issues = vec![
823 Issue {
824 id: "BLOCKER".to_string(),
825 title: "Blocker".to_string(),
826 status: "open".to_string(),
827 issue_type: "task".to_string(),
828 estimated_minutes: Some(240),
829 ..Issue::default()
830 },
831 Issue {
832 id: "BLOCKED".to_string(),
833 title: "Blocked".to_string(),
834 status: "blocked".to_string(),
835 issue_type: "task".to_string(),
836 estimated_minutes: Some(60),
837 dependencies: vec![crate::model::Dependency {
838 issue_id: "BLOCKED".to_string(),
839 depends_on_id: "BLOCKER".to_string(),
840 dep_type: "blocks".to_string(),
841 ..crate::model::Dependency::default()
842 }],
843 ..Issue::default()
844 },
845 ];
846 let graph = IssueGraph::build(&issues);
847 let metrics = graph.compute_metrics();
848 let now = Utc::now();
849
850 let blocker_eta =
851 estimate_eta_for_issue(&issues, &graph, &metrics, "BLOCKER", 1, now).unwrap();
852 let dependent_eta =
853 estimate_eta_for_issue(&issues, &graph, &metrics, "BLOCKED", 1, now).unwrap();
854
855 assert!(
856 dependent_eta.estimated_days > blocker_eta.estimated_days,
857 "blocked work should include at least the blocker wait plus its own work"
858 );
859 assert!(
860 dependent_eta
861 .factors
862 .iter()
863 .any(|factor| factor.contains("blocked: waits")),
864 "forecast factors should explain blocker wait time"
865 );
866 }
867
868 #[test]
871 fn forecast_respects_label_filter() {
872 let issues = vec![
873 Issue {
874 id: "A".to_string(),
875 title: "A".to_string(),
876 status: "open".to_string(),
877 issue_type: "task".to_string(),
878 labels: vec!["backend".to_string()],
879 ..Issue::default()
880 },
881 Issue {
882 id: "B".to_string(),
883 title: "B".to_string(),
884 status: "open".to_string(),
885 issue_type: "task".to_string(),
886 labels: vec!["frontend".to_string()],
887 ..Issue::default()
888 },
889 ];
890 let graph = IssueGraph::build(&issues);
891 let metrics = graph.compute_metrics();
892 let output = estimate_forecast(&issues, &graph, &metrics, "all", Some("backend"), 1);
893 assert_eq!(output.summary.count, 1);
894 assert_eq!(output.forecasts[0].id, "A");
895 }
896
897 #[test]
900 fn duration_days_handles_zero_and_negative() {
901 assert_eq!(super::duration_days(0.0), chrono::Duration::zero());
902 assert_eq!(super::duration_days(-5.0), chrono::Duration::zero());
903 }
904
905 #[test]
906 fn truncate_f64_to_i64_edge_cases() {
907 assert_eq!(super::truncate_f64_to_i64(f64::NAN), None);
908 assert_eq!(super::truncate_f64_to_i64(f64::INFINITY), None);
909 assert_eq!(super::truncate_f64_to_i64(42.9), Some(42));
910 assert_eq!(super::truncate_f64_to_i64(-3.7), Some(-3));
911 assert_eq!(super::truncate_f64_to_i64(1e19), Some(i64::MAX));
912 assert_eq!(super::truncate_f64_to_i64(-1e19), Some(i64::MIN));
913 }
914
915 #[test]
916 fn clamp_works_correctly() {
917 assert_eq!(super::clamp(0.5, 0.1, 0.9), 0.5);
918 assert_eq!(super::clamp(-0.5, 0.1, 0.9), 0.1);
919 assert_eq!(super::clamp(1.5, 0.1, 0.9), 0.9);
920 }
921
922 #[test]
923 fn forecast_single_issue_by_id() {
924 let issues = vec![
925 Issue {
926 id: "A".to_string(),
927 title: "A".to_string(),
928 status: "open".to_string(),
929 issue_type: "task".to_string(),
930 ..Issue::default()
931 },
932 Issue {
933 id: "B".to_string(),
934 title: "B".to_string(),
935 status: "open".to_string(),
936 issue_type: "task".to_string(),
937 ..Issue::default()
938 },
939 ];
940 let graph = IssueGraph::build(&issues);
941 let metrics = graph.compute_metrics();
942 let output = estimate_forecast(&issues, &graph, &metrics, "B", None, 1);
943 assert_eq!(output.summary.count, 1);
944 assert_eq!(output.forecasts[0].id, "B");
945 }
946}