Skip to main content

bvr/analysis/
alerts.rs

1use chrono::{DateTime, Utc};
2use serde::Serialize;
3
4use crate::analysis::graph::{GraphMetrics, IssueGraph};
5use crate::model::Issue;
6
7const SECS_PER_DAY: u32 = 86_400;
8const STALE_WARNING_DAYS: f64 = 14.0;
9const STALE_CRITICAL_DAYS: f64 = 30.0;
10const IN_PROGRESS_STALE_MULTIPLIER: f64 = 0.5;
11const BLOCKING_CASCADE_INFO_THRESHOLD: usize = 3;
12const BLOCKING_CASCADE_WARNING_THRESHOLD: usize = 5;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum AlertSeverity {
17    Critical,
18    Warning,
19    Info,
20}
21
22impl AlertSeverity {
23    #[must_use]
24    pub const fn as_str(self) -> &'static str {
25        match self {
26            Self::Critical => "critical",
27            Self::Warning => "warning",
28            Self::Info => "info",
29        }
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34#[serde(rename_all = "snake_case")]
35pub enum AlertType {
36    NewCycle,
37    StaleIssue,
38    BlockingCascade,
39}
40
41impl AlertType {
42    #[must_use]
43    pub const fn as_str(self) -> &'static str {
44        match self {
45            Self::NewCycle => "new_cycle",
46            Self::StaleIssue => "stale_issue",
47            Self::BlockingCascade => "blocking_cascade",
48        }
49    }
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct Alert {
54    #[serde(rename = "type")]
55    pub alert_type: AlertType,
56    pub severity: AlertSeverity,
57    pub message: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub baseline_value: Option<f64>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub current_value: Option<f64>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub delta: Option<f64>,
64    #[serde(skip_serializing_if = "Vec::is_empty")]
65    pub details: Vec<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub issue_id: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub label: Option<String>,
70    pub detected_at: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub unblocks_count: Option<usize>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub downstream_priority_sum: Option<i32>,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct AlertSummary {
79    pub total: usize,
80    pub critical: usize,
81    pub warning: usize,
82    pub info: usize,
83}
84
85#[derive(Debug, Clone, Serialize)]
86pub struct RobotAlertsOutput {
87    #[serde(flatten)]
88    pub envelope: crate::robot::RobotEnvelope,
89    pub alerts: Vec<Alert>,
90    pub summary: AlertSummary,
91    pub usage_hints: Vec<String>,
92}
93
94/// Tunable thresholds for alert generation.
95#[derive(Debug, Clone)]
96pub struct AlertThresholds {
97    pub stale_warning_days: f64,
98    pub stale_critical_days: f64,
99    pub in_progress_stale_multiplier: f64,
100    pub blocking_cascade_info: usize,
101    pub blocking_cascade_warning: usize,
102}
103
104impl Default for AlertThresholds {
105    fn default() -> Self {
106        Self {
107            stale_warning_days: STALE_WARNING_DAYS,
108            stale_critical_days: STALE_CRITICAL_DAYS,
109            in_progress_stale_multiplier: IN_PROGRESS_STALE_MULTIPLIER,
110            blocking_cascade_info: BLOCKING_CASCADE_INFO_THRESHOLD,
111            blocking_cascade_warning: BLOCKING_CASCADE_WARNING_THRESHOLD,
112        }
113    }
114}
115
116#[derive(Debug, Clone, Default)]
117pub struct AlertOptions {
118    pub severity: Option<String>,
119    pub alert_type: Option<String>,
120    pub alert_label: Option<String>,
121    pub thresholds: AlertThresholds,
122}
123
124#[must_use]
125pub fn generate_robot_alerts_output(
126    issues: &[Issue],
127    graph: &IssueGraph,
128    metrics: &GraphMetrics,
129    options: &AlertOptions,
130) -> RobotAlertsOutput {
131    let now = Utc::now();
132
133    let mut alerts = Vec::<Alert>::new();
134    detect_new_cycles(metrics, now, &mut alerts);
135    detect_stale_issues(issues, now, &options.thresholds, &mut alerts);
136    detect_blocking_cascades(issues, graph, now, &options.thresholds, &mut alerts);
137
138    alerts.retain(|alert| matches_alert_filters(alert, options));
139    let summary = summarize_alerts(&alerts);
140
141    RobotAlertsOutput {
142        envelope: crate::robot::envelope(issues),
143        alerts,
144        summary,
145        usage_hints: vec![
146            "--severity=warning --alert-type=stale_issue   # stale warnings only".to_string(),
147            "--alert-type=blocking_cascade                 # high-unblock opportunities"
148                .to_string(),
149            "jq '.alerts | map(.issue_id)'                # list impacted issues".to_string(),
150        ],
151    }
152}
153
154fn detect_new_cycles(metrics: &GraphMetrics, now: DateTime<Utc>, alerts: &mut Vec<Alert>) {
155    if metrics.cycles.is_empty() {
156        return;
157    }
158
159    let details = metrics
160        .cycles
161        .iter()
162        .map(|cycle| cycle.join(" → "))
163        .collect::<Vec<_>>();
164
165    alerts.push(Alert {
166        alert_type: AlertType::NewCycle,
167        severity: AlertSeverity::Critical,
168        message: format!("{} new cycle(s) detected", metrics.cycles.len()),
169        baseline_value: Some(0.0),
170        current_value: Some(metrics.cycles.len() as f64),
171        delta: Some(metrics.cycles.len() as f64),
172        details,
173        issue_id: None,
174        label: None,
175        detected_at: now.to_rfc3339(),
176        unblocks_count: None,
177        downstream_priority_sum: None,
178    });
179}
180
181fn detect_stale_issues(
182    issues: &[Issue],
183    now: DateTime<Utc>,
184    thresholds: &AlertThresholds,
185    alerts: &mut Vec<Alert>,
186) {
187    for issue in issues {
188        let status = issue.normalized_status();
189        if status == "closed" || status == "tombstone" {
190            continue;
191        }
192
193        let Some(last_active) = issue.updated_at.or(issue.created_at) else {
194            continue;
195        };
196
197        let mut warning_days = thresholds.stale_warning_days;
198        let mut critical_days = thresholds.stale_critical_days;
199        if status == "in_progress" {
200            warning_days *= thresholds.in_progress_stale_multiplier;
201            critical_days *= thresholds.in_progress_stale_multiplier;
202        }
203
204        let inactivity = now.signed_duration_since(last_active);
205        if inactivity.num_seconds() < 0 {
206            continue;
207        }
208        let days = inactivity.num_seconds() as f64 / f64::from(SECS_PER_DAY);
209
210        let severity = if days >= critical_days {
211            Some(AlertSeverity::Critical)
212        } else if days >= warning_days {
213            Some(AlertSeverity::Warning)
214        } else {
215            None
216        };
217
218        let Some(severity) = severity else {
219            continue;
220        };
221
222        alerts.push(Alert {
223            alert_type: AlertType::StaleIssue,
224            severity,
225            message: format!("Issue {} inactive for {:.0} days", issue.id, days),
226            baseline_value: None,
227            current_value: None,
228            delta: None,
229            details: vec![
230                format!("status={}", issue.status),
231                format!("last_update={}", last_active.to_rfc3339()),
232            ],
233            issue_id: Some(issue.id.clone()),
234            label: None,
235            detected_at: now.to_rfc3339(),
236            unblocks_count: None,
237            downstream_priority_sum: None,
238        });
239    }
240}
241
242fn detect_blocking_cascades(
243    issues: &[Issue],
244    graph: &IssueGraph,
245    now: DateTime<Utc>,
246    thresholds: &AlertThresholds,
247    alerts: &mut Vec<Alert>,
248) {
249    for issue_id in graph.actionable_ids() {
250        let unblocks = compute_unblocks(graph, &issue_id);
251        let unblocks_count = unblocks.len();
252        if unblocks_count < thresholds.blocking_cascade_info {
253            continue;
254        }
255
256        let severity = if unblocks_count >= thresholds.blocking_cascade_warning {
257            AlertSeverity::Warning
258        } else {
259            AlertSeverity::Info
260        };
261
262        let downstream_priority_sum = unblocks
263            .iter()
264            .filter_map(|id| issues.iter().find(|issue| issue.id == *id))
265            .map(|issue| issue.priority)
266            .sum::<i32>();
267
268        alerts.push(Alert {
269            alert_type: AlertType::BlockingCascade,
270            severity,
271            message: format!("Completing {issue_id} unblocks {unblocks_count} downstream item(s)"),
272            baseline_value: None,
273            current_value: None,
274            delta: None,
275            details: unblocks,
276            issue_id: Some(issue_id),
277            label: None,
278            detected_at: now.to_rfc3339(),
279            unblocks_count: Some(unblocks_count),
280            downstream_priority_sum: Some(downstream_priority_sum),
281        });
282    }
283}
284
285fn compute_unblocks(graph: &IssueGraph, issue_id: &str) -> Vec<String> {
286    let mut unblocks = Vec::<String>::new();
287    for dependent_id in graph.dependents(issue_id) {
288        let Some(dependent_issue) = graph.issue(&dependent_id) else {
289            continue;
290        };
291        if dependent_issue.is_closed_like() {
292            continue;
293        }
294
295        let still_blocked = graph.blockers(&dependent_id).into_iter().any(|blocker| {
296            blocker != issue_id && graph.issue(&blocker).is_some_and(Issue::is_open_like)
297        });
298
299        if !still_blocked {
300            unblocks.push(dependent_id);
301        }
302    }
303
304    unblocks.sort();
305    unblocks
306}
307
308fn matches_alert_filters(alert: &Alert, options: &AlertOptions) -> bool {
309    if options
310        .severity
311        .as_deref()
312        .is_some_and(|severity| !alert.severity.as_str().eq_ignore_ascii_case(severity))
313    {
314        return false;
315    }
316
317    if options
318        .alert_type
319        .as_deref()
320        .is_some_and(|alert_type| !alert.alert_type.as_str().eq_ignore_ascii_case(alert_type))
321    {
322        return false;
323    }
324
325    if let Some(raw_label) = options.alert_label.as_deref() {
326        let needle = raw_label.to_ascii_lowercase();
327        let found_in_details = alert
328            .details
329            .iter()
330            .any(|detail| detail.to_ascii_lowercase().contains(&needle));
331
332        if found_in_details {
333            return true;
334        }
335
336        let found_in_label = alert
337            .label
338            .as_ref()
339            .is_some_and(|label| label.to_ascii_lowercase().contains(&needle));
340        if !found_in_label {
341            return false;
342        }
343    }
344
345    true
346}
347
348fn summarize_alerts(alerts: &[Alert]) -> AlertSummary {
349    let mut summary = AlertSummary {
350        total: alerts.len(),
351        critical: 0,
352        warning: 0,
353        info: 0,
354    };
355
356    for alert in alerts {
357        match alert.severity {
358            AlertSeverity::Critical => summary.critical = summary.critical.saturating_add(1),
359            AlertSeverity::Warning => summary.warning = summary.warning.saturating_add(1),
360            AlertSeverity::Info => summary.info = summary.info.saturating_add(1),
361        }
362    }
363
364    summary
365}
366
367#[cfg(test)]
368mod tests {
369    use chrono::Duration;
370
371    use super::{AlertOptions, AlertSeverity, AlertType, generate_robot_alerts_output};
372    use crate::analysis::graph::IssueGraph;
373    use crate::model::{Dependency, Issue};
374
375    fn issue(id: &str, status: &str) -> Issue {
376        Issue {
377            id: id.to_string(),
378            title: id.to_string(),
379            description: String::new(),
380            design: String::new(),
381            acceptance_criteria: String::new(),
382            notes: String::new(),
383            status: status.to_string(),
384            priority: 2,
385            issue_type: "task".to_string(),
386            assignee: String::new(),
387            estimated_minutes: None,
388            created_at: None,
389            updated_at: None,
390            due_date: None,
391            closed_at: None,
392            labels: Vec::new(),
393            comments: Vec::new(),
394            dependencies: Vec::new(),
395            source_repo: String::new(),
396            workspace_prefix: None,
397            content_hash: None,
398            external_ref: None,
399        }
400    }
401
402    #[test]
403    fn robot_alerts_include_cycle_stale_and_cascade() {
404        let now = chrono::Utc::now();
405        let stale_at = now - Duration::days(20);
406        let fresh_at = now - Duration::days(1);
407
408        let mut root = issue("ROOT", "open");
409        root.updated_at = Some(fresh_at);
410        root.created_at = Some(fresh_at);
411
412        let mut d1 = issue("D1", "open");
413        d1.updated_at = Some(fresh_at);
414        d1.created_at = Some(fresh_at);
415        d1.dependencies.push(Dependency {
416            issue_id: "D1".to_string(),
417            depends_on_id: "ROOT".to_string(),
418            dep_type: "blocks".to_string(),
419            ..Dependency::default()
420        });
421
422        let mut d2 = issue("D2", "open");
423        d2.updated_at = Some(fresh_at);
424        d2.created_at = Some(fresh_at);
425        d2.dependencies.push(Dependency {
426            issue_id: "D2".to_string(),
427            depends_on_id: "ROOT".to_string(),
428            dep_type: "blocks".to_string(),
429            ..Dependency::default()
430        });
431
432        let mut d3 = issue("D3", "open");
433        d3.updated_at = Some(fresh_at);
434        d3.created_at = Some(fresh_at);
435        d3.dependencies.push(Dependency {
436            issue_id: "D3".to_string(),
437            depends_on_id: "ROOT".to_string(),
438            dep_type: "blocks".to_string(),
439            ..Dependency::default()
440        });
441
442        let mut stale = issue("STALE", "open");
443        stale.updated_at = Some(stale_at);
444        stale.created_at = Some(stale_at);
445
446        let mut tombstone = issue("TOMBSTONE", "tombstone");
447        tombstone.updated_at = Some(stale_at);
448        tombstone.created_at = Some(stale_at);
449
450        let mut cycle_a = issue("cycle-a", "open");
451        cycle_a.dependencies.push(Dependency {
452            issue_id: "cycle-a".to_string(),
453            depends_on_id: "cycle-b".to_string(),
454            dep_type: "blocks".to_string(),
455            ..Dependency::default()
456        });
457
458        let mut cycle_b = issue("cycle-b", "open");
459        cycle_b.dependencies.push(Dependency {
460            issue_id: "cycle-b".to_string(),
461            depends_on_id: "cycle-a".to_string(),
462            dep_type: "blocks".to_string(),
463            ..Dependency::default()
464        });
465
466        let issues = vec![root, d1, d2, d3, stale, tombstone, cycle_a, cycle_b];
467        let graph = IssueGraph::build(&issues);
468        let metrics = graph.compute_metrics();
469
470        let output =
471            generate_robot_alerts_output(&issues, &graph, &metrics, &AlertOptions::default());
472        assert_eq!(output.summary.total, output.alerts.len());
473        assert!(output.alerts.iter().any(|alert| {
474            alert.alert_type == AlertType::StaleIssue
475                && alert.severity == AlertSeverity::Warning
476                && alert.issue_id.as_deref() == Some("STALE")
477        }));
478        assert!(!output.alerts.iter().any(|alert| {
479            alert.alert_type == AlertType::StaleIssue
480                && alert.issue_id.as_deref() == Some("TOMBSTONE")
481        }));
482        assert!(output.alerts.iter().any(|alert| {
483            alert.alert_type == AlertType::BlockingCascade
484                && alert.issue_id.as_deref() == Some("ROOT")
485        }));
486        assert!(
487            output
488                .alerts
489                .iter()
490                .any(|alert| alert.alert_type == AlertType::NewCycle)
491        );
492    }
493
494    #[test]
495    fn robot_alert_filters_are_applied() {
496        let now = chrono::Utc::now();
497        let stale_at = now - Duration::days(20);
498        let fresh_at = now - Duration::days(1);
499
500        let mut root = issue("ROOT", "open");
501        root.updated_at = Some(fresh_at);
502        root.created_at = Some(fresh_at);
503
504        let mut d1 = issue("D1", "open");
505        d1.updated_at = Some(fresh_at);
506        d1.created_at = Some(fresh_at);
507        d1.dependencies.push(Dependency {
508            issue_id: "D1".to_string(),
509            depends_on_id: "ROOT".to_string(),
510            dep_type: "blocks".to_string(),
511            ..Dependency::default()
512        });
513
514        let mut d2 = issue("D2", "open");
515        d2.updated_at = Some(fresh_at);
516        d2.created_at = Some(fresh_at);
517        d2.dependencies.push(Dependency {
518            issue_id: "D2".to_string(),
519            depends_on_id: "ROOT".to_string(),
520            dep_type: "blocks".to_string(),
521            ..Dependency::default()
522        });
523
524        let mut d3 = issue("D3", "open");
525        d3.updated_at = Some(fresh_at);
526        d3.created_at = Some(fresh_at);
527        d3.dependencies.push(Dependency {
528            issue_id: "D3".to_string(),
529            depends_on_id: "ROOT".to_string(),
530            dep_type: "blocks".to_string(),
531            ..Dependency::default()
532        });
533
534        let mut stale = issue("STALE", "open");
535        stale.updated_at = Some(stale_at);
536        stale.created_at = Some(stale_at);
537
538        let issues = vec![root, d1, d2, d3, stale];
539        let graph = IssueGraph::build(&issues);
540        let metrics = graph.compute_metrics();
541
542        let warning_only = generate_robot_alerts_output(
543            &issues,
544            &graph,
545            &metrics,
546            &AlertOptions {
547                severity: Some("warning".to_string()),
548                alert_type: None,
549                alert_label: None,
550                ..AlertOptions::default()
551            },
552        );
553        assert!(
554            warning_only
555                .alerts
556                .iter()
557                .all(|alert| alert.severity == AlertSeverity::Warning)
558        );
559
560        let stale_only = generate_robot_alerts_output(
561            &issues,
562            &graph,
563            &metrics,
564            &AlertOptions {
565                severity: None,
566                alert_type: Some("stale_issue".to_string()),
567                alert_label: None,
568                ..AlertOptions::default()
569            },
570        );
571        assert!(!stale_only.alerts.is_empty());
572        assert!(
573            stale_only
574                .alerts
575                .iter()
576                .all(|alert| alert.alert_type == AlertType::StaleIssue)
577        );
578
579        let detail_filter = generate_robot_alerts_output(
580            &issues,
581            &graph,
582            &metrics,
583            &AlertOptions {
584                severity: None,
585                alert_type: Some("blocking_cascade".to_string()),
586                alert_label: Some("d1".to_string()),
587                ..AlertOptions::default()
588            },
589        );
590        assert_eq!(detail_filter.alerts.len(), 1);
591        assert_eq!(detail_filter.alerts[0].issue_id.as_deref(), Some("ROOT"));
592
593        let case_insensitive = generate_robot_alerts_output(
594            &issues,
595            &graph,
596            &metrics,
597            &AlertOptions {
598                severity: Some("WaRnInG".to_string()),
599                alert_type: Some("StAlE_IsSuE".to_string()),
600                alert_label: None,
601                ..AlertOptions::default()
602            },
603        );
604        assert!(!case_insensitive.alerts.is_empty());
605        assert!(
606            case_insensitive
607                .alerts
608                .iter()
609                .all(|alert| alert.severity == AlertSeverity::Warning
610                    && alert.alert_type == AlertType::StaleIssue)
611        );
612    }
613
614    // ── detect_stale_issues ─────────────────────────────────────────
615
616    #[test]
617    fn stale_warning_at_14_days() {
618        let now = chrono::Utc::now();
619        let at_15_days = now - Duration::days(15);
620        let mut alerts = Vec::new();
621        let mut i = issue("A", "open");
622        i.updated_at = Some(at_15_days);
623        super::detect_stale_issues(&[i], now, &super::AlertThresholds::default(), &mut alerts);
624        assert_eq!(alerts.len(), 1);
625        assert_eq!(alerts[0].severity, AlertSeverity::Warning);
626    }
627
628    #[test]
629    fn stale_critical_at_30_days() {
630        let now = chrono::Utc::now();
631        let at_31_days = now - Duration::days(31);
632        let mut alerts = Vec::new();
633        let mut i = issue("A", "open");
634        i.updated_at = Some(at_31_days);
635        super::detect_stale_issues(&[i], now, &super::AlertThresholds::default(), &mut alerts);
636        assert_eq!(alerts.len(), 1);
637        assert_eq!(alerts[0].severity, AlertSeverity::Critical);
638    }
639
640    #[test]
641    fn stale_not_triggered_for_fresh_issue() {
642        let now = chrono::Utc::now();
643        let fresh = now - Duration::days(5);
644        let mut alerts = Vec::new();
645        let mut i = issue("A", "open");
646        i.updated_at = Some(fresh);
647        super::detect_stale_issues(&[i], now, &super::AlertThresholds::default(), &mut alerts);
648        assert!(alerts.is_empty());
649    }
650
651    #[test]
652    fn stale_skips_closed_and_tombstone() {
653        let now = chrono::Utc::now();
654        let old = now - Duration::days(60);
655        let mut alerts = Vec::new();
656        let mut closed = issue("A", "closed");
657        closed.updated_at = Some(old);
658        let mut tomb = issue("B", "tombstone");
659        tomb.updated_at = Some(old);
660        super::detect_stale_issues(
661            &[closed, tomb],
662            now,
663            &super::AlertThresholds::default(),
664            &mut alerts,
665        );
666        assert!(alerts.is_empty());
667    }
668
669    #[test]
670    fn in_progress_stale_multiplier_halves_thresholds() {
671        let now = chrono::Utc::now();
672        // in_progress: 14 * 0.5 = 7 day warning threshold, so 8 days triggers warning
673        let at_8_days = now - Duration::days(8);
674        let mut alerts = Vec::new();
675        let mut i = issue("A", "in_progress");
676        i.updated_at = Some(at_8_days);
677        super::detect_stale_issues(&[i], now, &super::AlertThresholds::default(), &mut alerts);
678        assert_eq!(alerts.len(), 1);
679        assert_eq!(alerts[0].severity, AlertSeverity::Warning);
680        assert!(alerts[0].message.contains("A"));
681    }
682
683    #[test]
684    fn in_progress_critical_at_half_threshold() {
685        let now = chrono::Utc::now();
686        // 30 * 0.5 = 15 days critical threshold for in_progress
687        let at_16_days = now - Duration::days(16);
688        let mut alerts = Vec::new();
689        let mut i = issue("A", "in_progress");
690        i.updated_at = Some(at_16_days);
691        super::detect_stale_issues(&[i], now, &super::AlertThresholds::default(), &mut alerts);
692        assert_eq!(alerts.len(), 1);
693        assert_eq!(alerts[0].severity, AlertSeverity::Critical);
694    }
695
696    #[test]
697    fn stale_falls_back_to_created_at() {
698        let now = chrono::Utc::now();
699        let old = now - Duration::days(20);
700        let mut alerts = Vec::new();
701        let mut i = issue("A", "open");
702        i.updated_at = None;
703        i.created_at = Some(old);
704        super::detect_stale_issues(&[i], now, &super::AlertThresholds::default(), &mut alerts);
705        assert_eq!(alerts.len(), 1);
706        assert_eq!(alerts[0].issue_id.as_deref(), Some("A"));
707    }
708
709    #[test]
710    fn stale_skips_no_timestamps() {
711        let now = chrono::Utc::now();
712        let mut alerts = Vec::new();
713        let i = issue("A", "open");
714        super::detect_stale_issues(&[i], now, &super::AlertThresholds::default(), &mut alerts);
715        assert!(alerts.is_empty());
716    }
717
718    // ── detect_new_cycles ───────────────────────────────────────────
719
720    #[test]
721    fn cycle_alert_severity_is_critical() {
722        let graph = IssueGraph::build(&[]);
723        let mut metrics = graph.compute_metrics();
724        metrics.cycles.push(vec!["A".to_string(), "B".to_string()]);
725
726        let now = chrono::Utc::now();
727        let mut alerts = Vec::new();
728        super::detect_new_cycles(&metrics, now, &mut alerts);
729        assert_eq!(alerts.len(), 1);
730        assert_eq!(alerts[0].severity, AlertSeverity::Critical);
731        assert_eq!(alerts[0].alert_type, AlertType::NewCycle);
732        assert_eq!(alerts[0].current_value, Some(1.0));
733    }
734
735    #[test]
736    fn cycle_alert_empty_cycles() {
737        let graph = IssueGraph::build(&[]);
738        let metrics = graph.compute_metrics();
739        let mut alerts = Vec::new();
740        super::detect_new_cycles(&metrics, chrono::Utc::now(), &mut alerts);
741        assert!(alerts.is_empty());
742    }
743
744    // ── detect_blocking_cascades ────────────────────────────────────
745
746    #[test]
747    fn cascade_info_at_3_dependents() {
748        let now = chrono::Utc::now();
749        let fresh = now - Duration::days(1);
750
751        let mut root = issue("ROOT", "open");
752        root.updated_at = Some(fresh);
753
754        let mut deps = Vec::new();
755        for i in 1..=3 {
756            let mut d = issue(&format!("D{i}"), "open");
757            d.updated_at = Some(fresh);
758            d.dependencies.push(Dependency {
759                issue_id: d.id.clone(),
760                depends_on_id: "ROOT".to_string(),
761                dep_type: "blocks".to_string(),
762                ..Dependency::default()
763            });
764            deps.push(d);
765        }
766
767        let mut issues = vec![root];
768        issues.extend(deps);
769        let graph = IssueGraph::build(&issues);
770
771        let mut alerts = Vec::new();
772        super::detect_blocking_cascades(
773            &issues,
774            &graph,
775            now,
776            &super::AlertThresholds::default(),
777            &mut alerts,
778        );
779        assert!(!alerts.is_empty());
780        let cascade = alerts
781            .iter()
782            .find(|a| a.alert_type == AlertType::BlockingCascade)
783            .unwrap();
784        assert_eq!(cascade.severity, AlertSeverity::Info);
785        assert_eq!(cascade.unblocks_count, Some(3));
786    }
787
788    #[test]
789    fn cascade_warning_at_5_dependents() {
790        let now = chrono::Utc::now();
791        let fresh = now - Duration::days(1);
792
793        let mut root = issue("ROOT", "open");
794        root.updated_at = Some(fresh);
795
796        let mut deps = Vec::new();
797        for i in 1..=5 {
798            let mut d = issue(&format!("D{i}"), "open");
799            d.updated_at = Some(fresh);
800            d.dependencies.push(Dependency {
801                issue_id: d.id.clone(),
802                depends_on_id: "ROOT".to_string(),
803                dep_type: "blocks".to_string(),
804                ..Dependency::default()
805            });
806            deps.push(d);
807        }
808
809        let mut issues = vec![root];
810        issues.extend(deps);
811        let graph = IssueGraph::build(&issues);
812
813        let mut alerts = Vec::new();
814        super::detect_blocking_cascades(
815            &issues,
816            &graph,
817            now,
818            &super::AlertThresholds::default(),
819            &mut alerts,
820        );
821        let cascade = alerts
822            .iter()
823            .find(|a| a.alert_type == AlertType::BlockingCascade)
824            .unwrap();
825        assert_eq!(cascade.severity, AlertSeverity::Warning);
826        assert_eq!(cascade.unblocks_count, Some(5));
827    }
828
829    #[test]
830    fn cascade_not_triggered_below_threshold() {
831        let now = chrono::Utc::now();
832        let fresh = now - Duration::days(1);
833
834        let mut root = issue("ROOT", "open");
835        root.updated_at = Some(fresh);
836
837        let mut d1 = issue("D1", "open");
838        d1.updated_at = Some(fresh);
839        d1.dependencies.push(Dependency {
840            issue_id: "D1".to_string(),
841            depends_on_id: "ROOT".to_string(),
842            dep_type: "blocks".to_string(),
843            ..Dependency::default()
844        });
845
846        let issues = vec![root, d1];
847        let graph = IssueGraph::build(&issues);
848
849        let mut alerts = Vec::new();
850        super::detect_blocking_cascades(
851            &issues,
852            &graph,
853            now,
854            &super::AlertThresholds::default(),
855            &mut alerts,
856        );
857        assert!(alerts.is_empty(), "1 dependent < threshold of 3");
858    }
859
860    #[test]
861    fn cascade_downstream_priority_sum() {
862        let now = chrono::Utc::now();
863        let fresh = now - Duration::days(1);
864
865        let mut root = issue("ROOT", "open");
866        root.updated_at = Some(fresh);
867
868        let mut deps = Vec::new();
869        for i in 1..=3 {
870            let mut d = issue(&format!("D{i}"), "open");
871            d.updated_at = Some(fresh);
872            d.priority = i as i32; // priorities 1, 2, 3
873            d.dependencies.push(Dependency {
874                issue_id: d.id.clone(),
875                depends_on_id: "ROOT".to_string(),
876                dep_type: "blocks".to_string(),
877                ..Dependency::default()
878            });
879            deps.push(d);
880        }
881
882        let mut issues = vec![root];
883        issues.extend(deps);
884        let graph = IssueGraph::build(&issues);
885
886        let mut alerts = Vec::new();
887        super::detect_blocking_cascades(
888            &issues,
889            &graph,
890            now,
891            &super::AlertThresholds::default(),
892            &mut alerts,
893        );
894        let cascade = alerts
895            .iter()
896            .find(|a| a.alert_type == AlertType::BlockingCascade)
897            .unwrap();
898        assert_eq!(cascade.downstream_priority_sum, Some(6)); // 1+2+3
899    }
900
901    // ── summarize_alerts ────────────────────────────────────────────
902
903    #[test]
904    fn summarize_counts_by_severity() {
905        let now_str = chrono::Utc::now().to_rfc3339();
906        let mk = |severity, alert_type| super::Alert {
907            alert_type,
908            severity,
909            message: String::new(),
910            baseline_value: None,
911            current_value: None,
912            delta: None,
913            details: Vec::new(),
914            issue_id: None,
915            label: None,
916            detected_at: now_str.clone(),
917            unblocks_count: None,
918            downstream_priority_sum: None,
919        };
920
921        let alerts = vec![
922            mk(AlertSeverity::Critical, AlertType::NewCycle),
923            mk(AlertSeverity::Warning, AlertType::StaleIssue),
924            mk(AlertSeverity::Warning, AlertType::StaleIssue),
925            mk(AlertSeverity::Info, AlertType::BlockingCascade),
926        ];
927        let summary = super::summarize_alerts(&alerts);
928        assert_eq!(summary.total, 4);
929        assert_eq!(summary.critical, 1);
930        assert_eq!(summary.warning, 2);
931        assert_eq!(summary.info, 1);
932    }
933
934    #[test]
935    fn summarize_empty() {
936        let summary = super::summarize_alerts(&[]);
937        assert_eq!(summary.total, 0);
938        assert_eq!(summary.critical, 0);
939    }
940
941    // ── matches_alert_filters ───────────────────────────────────────
942
943    #[test]
944    fn filter_no_options_matches_all() {
945        let alert = super::Alert {
946            alert_type: AlertType::StaleIssue,
947            severity: AlertSeverity::Warning,
948            message: String::new(),
949            baseline_value: None,
950            current_value: None,
951            delta: None,
952            details: Vec::new(),
953            issue_id: None,
954            label: None,
955            detected_at: String::new(),
956            unblocks_count: None,
957            downstream_priority_sum: None,
958        };
959        assert!(super::matches_alert_filters(
960            &alert,
961            &AlertOptions::default()
962        ));
963    }
964
965    #[test]
966    fn filter_label_in_details() {
967        let alert = super::Alert {
968            alert_type: AlertType::BlockingCascade,
969            severity: AlertSeverity::Info,
970            message: String::new(),
971            baseline_value: None,
972            current_value: None,
973            delta: None,
974            details: vec!["D1".to_string(), "D2".to_string()],
975            issue_id: None,
976            label: None,
977            detected_at: String::new(),
978            unblocks_count: None,
979            downstream_priority_sum: None,
980        };
981        let opts = AlertOptions {
982            alert_label: Some("d1".to_string()),
983            ..AlertOptions::default()
984        };
985        assert!(super::matches_alert_filters(&alert, &opts));
986
987        let no_match = AlertOptions {
988            alert_label: Some("xyz".to_string()),
989            ..AlertOptions::default()
990        };
991        assert!(!super::matches_alert_filters(&alert, &no_match));
992    }
993
994    #[test]
995    fn filter_label_field_match() {
996        let alert = super::Alert {
997            alert_type: AlertType::StaleIssue,
998            severity: AlertSeverity::Warning,
999            message: String::new(),
1000            baseline_value: None,
1001            current_value: None,
1002            delta: None,
1003            details: Vec::new(),
1004            issue_id: None,
1005            label: Some("Backend".to_string()),
1006            detected_at: String::new(),
1007            unblocks_count: None,
1008            downstream_priority_sum: None,
1009        };
1010        let opts = AlertOptions {
1011            alert_label: Some("backend".to_string()),
1012            ..AlertOptions::default()
1013        };
1014        assert!(super::matches_alert_filters(&alert, &opts));
1015    }
1016}