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#[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 #[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 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 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 #[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 #[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; 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)); }
900
901 #[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 #[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}