Skip to main content

bvr/analysis/
label_intel.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use chrono::{DateTime, Utc};
4use serde::Serialize;
5
6use crate::model::Issue;
7
8use super::graph::{GraphMetrics, IssueGraph};
9
10// ============================================================================
11// Constants
12// ============================================================================
13
14const DEFAULT_STALE_THRESHOLD_DAYS: i64 = 14;
15const HEALTHY_THRESHOLD: i32 = 70;
16const WARNING_THRESHOLD: i32 = 40;
17const VELOCITY_WEIGHT: f64 = 0.25;
18const FRESHNESS_WEIGHT: f64 = 0.25;
19const FLOW_WEIGHT: f64 = 0.25;
20const CRITICALITY_WEIGHT: f64 = 0.25;
21
22// ============================================================================
23// Label Health Types
24// ============================================================================
25
26#[derive(Debug, Clone, Serialize)]
27pub struct VelocityMetrics {
28    pub closed_last_7_days: i32,
29    pub closed_last_30_days: i32,
30    pub avg_days_to_close: f64,
31    pub trend_direction: String,
32    pub trend_percent: f64,
33    pub velocity_score: i32,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct FreshnessMetrics {
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub most_recent_update: Option<DateTime<Utc>>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub oldest_open_issue: Option<DateTime<Utc>>,
42    pub avg_days_since_update: f64,
43    pub stale_count: i32,
44    pub stale_threshold_days: i64,
45    pub freshness_score: i32,
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct FlowMetrics {
50    pub incoming_deps: i32,
51    pub outgoing_deps: i32,
52    pub incoming_labels: Vec<String>,
53    pub outgoing_labels: Vec<String>,
54    pub blocked_by_external: i32,
55    pub blocking_external: i32,
56    pub flow_score: i32,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct CriticalityMetrics {
61    pub avg_pagerank: f64,
62    pub avg_betweenness: f64,
63    pub max_betweenness: f64,
64    pub critical_path_count: i32,
65    pub bottleneck_count: i32,
66    pub criticality_score: i32,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct LabelHealth {
71    pub label: String,
72    pub issue_count: usize,
73    pub open_count: usize,
74    pub closed_count: usize,
75    pub blocked_count: usize,
76    pub health: i32,
77    pub health_level: String,
78    pub velocity: VelocityMetrics,
79    pub freshness: FreshnessMetrics,
80    pub flow: FlowMetrics,
81    pub criticality: CriticalityMetrics,
82    #[serde(skip_serializing_if = "Vec::is_empty")]
83    pub issues: Vec<String>,
84}
85
86#[derive(Debug, Clone, Serialize)]
87pub struct LabelSummary {
88    pub label: String,
89    pub issue_count: usize,
90    pub open_count: usize,
91    pub health: i32,
92    pub health_level: String,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub top_issue: Option<String>,
95    pub needs_attention: bool,
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct LabelHealthResult {
100    pub total_labels: usize,
101    pub healthy_count: usize,
102    pub warning_count: usize,
103    pub critical_count: usize,
104    pub labels: Vec<LabelHealth>,
105    pub summaries: Vec<LabelSummary>,
106    pub attention_needed: Vec<String>,
107}
108
109// ============================================================================
110// Cross-Label Flow Types
111// ============================================================================
112
113#[derive(Debug, Clone, Serialize)]
114pub struct LabelDependency {
115    pub from_label: String,
116    pub to_label: String,
117    pub issue_count: usize,
118    #[serde(skip_serializing_if = "Vec::is_empty")]
119    pub issue_ids: Vec<String>,
120    #[serde(skip_serializing_if = "Vec::is_empty")]
121    pub blocking_pairs: Vec<BlockingPair>,
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct BlockingPair {
126    pub blocker_id: String,
127    pub blocked_id: String,
128    pub blocker_label: String,
129    pub blocked_label: String,
130}
131
132#[derive(Debug, Clone, Serialize)]
133pub struct CrossLabelFlow {
134    pub labels: Vec<String>,
135    pub flow_matrix: Vec<Vec<i32>>,
136    pub dependencies: Vec<LabelDependency>,
137    pub bottleneck_labels: Vec<String>,
138    pub total_cross_label_deps: usize,
139}
140
141// ============================================================================
142// Attention Score Types
143// ============================================================================
144
145#[derive(Debug, Clone, Serialize)]
146pub struct LabelAttentionScore {
147    pub label: String,
148    pub attention_score: f64,
149    pub normalized_score: f64,
150    pub rank: usize,
151    pub pagerank_sum: f64,
152    pub staleness_factor: f64,
153    pub block_impact: f64,
154    pub velocity_factor: f64,
155    pub open_count: usize,
156    pub blocked_count: usize,
157    pub stale_count: usize,
158    pub reason: String,
159}
160
161#[derive(Debug, Clone, Serialize)]
162pub struct LabelAttentionResult {
163    pub labels: Vec<LabelAttentionScore>,
164    pub total_labels: usize,
165    pub max_score: f64,
166    pub min_score: f64,
167}
168
169// ============================================================================
170// Computation Functions
171// ============================================================================
172
173fn clamp_score(v: i32) -> i32 {
174    v.clamp(0, 100)
175}
176
177fn health_level(score: i32) -> &'static str {
178    if score >= HEALTHY_THRESHOLD {
179        "healthy"
180    } else if score >= WARNING_THRESHOLD {
181        "warning"
182    } else {
183        "critical"
184    }
185}
186
187fn compute_velocity(labeled_issues: &[&Issue], now: DateTime<Utc>) -> VelocityMetrics {
188    let week_ago = now - chrono::Duration::days(7);
189    let month_ago = now - chrono::Duration::days(30);
190    let prev_week_start = now - chrono::Duration::days(14);
191
192    let mut closed_7 = 0i32;
193    let mut closed_30 = 0i32;
194    let mut current_week = 0i32;
195    let mut prev_week = 0i32;
196    let mut total_close_days = 0.0f64;
197    let mut close_samples = 0i32;
198
199    for issue in labeled_issues {
200        if !issue.is_closed_like() {
201            continue;
202        }
203        let closed_at = issue.closed_at.or(issue.updated_at);
204        let Some(closed_at) = closed_at else {
205            continue;
206        };
207
208        if closed_at > week_ago {
209            closed_7 += 1;
210            current_week += 1;
211        }
212        if closed_at > month_ago {
213            closed_30 += 1;
214        }
215        if closed_at > prev_week_start && closed_at <= week_ago {
216            prev_week += 1;
217        }
218
219        if let Some(created) = issue.created_at {
220            let days = (closed_at - created).num_hours() as f64 / 24.0;
221            if days >= 0.0 {
222                total_close_days += days;
223                close_samples += 1;
224            }
225        }
226    }
227
228    let avg_days = if close_samples > 0 {
229        total_close_days / f64::from(close_samples)
230    } else {
231        0.0
232    };
233
234    let (trend_direction, trend_percent) = if prev_week > 0 {
235        let pct = (f64::from(current_week - prev_week) / f64::from(prev_week)) * 100.0;
236        let dir = if pct > 10.0 {
237            "improving"
238        } else if pct < -10.0 {
239            "declining"
240        } else {
241            "stable"
242        };
243        (dir, pct)
244    } else if current_week > 0 {
245        ("improving", 100.0)
246    } else {
247        ("stable", 0.0)
248    };
249
250    #[allow(clippy::cast_possible_truncation)]
251    let mut velocity_score = if closed_30 > 0 {
252        (f64::from(closed_30) * 10.0).min(100.0) as i32
253    } else {
254        0
255    };
256
257    if trend_direction == "improving" && velocity_score < 100 {
258        velocity_score = clamp_score(velocity_score + 10);
259    }
260
261    VelocityMetrics {
262        closed_last_7_days: closed_7,
263        closed_last_30_days: closed_30,
264        avg_days_to_close: avg_days,
265        trend_direction: trend_direction.to_string(),
266        trend_percent,
267        velocity_score: clamp_score(velocity_score),
268    }
269}
270
271fn compute_freshness(
272    labeled_issues: &[&Issue],
273    now: DateTime<Utc>,
274    stale_days: i64,
275) -> FreshnessMetrics {
276    let threshold = stale_days as f64;
277    let mut most_recent: Option<DateTime<Utc>> = None;
278    let mut oldest_open: Option<DateTime<Utc>> = None;
279    let mut total_staleness = 0.0f64;
280    let mut count = 0i32;
281    let mut stale_count = 0i32;
282
283    for issue in labeled_issues {
284        if let Some(updated) = issue.updated_at {
285            if most_recent.is_none_or(|mr| updated > mr) {
286                most_recent = Some(updated);
287            }
288            let days = (now - updated).num_hours() as f64 / 24.0;
289            total_staleness += days;
290            count += 1;
291            if days >= threshold {
292                stale_count += 1;
293            }
294        }
295
296        if !issue.is_closed_like() {
297            if let Some(created) = issue.created_at {
298                if oldest_open.is_none_or(|oo| created < oo) {
299                    oldest_open = Some(created);
300                }
301            }
302        }
303    }
304
305    let avg_staleness = if count > 0 {
306        total_staleness / f64::from(count)
307    } else {
308        0.0
309    };
310
311    #[allow(clippy::cast_possible_truncation)]
312    let freshness_score = (100.0 - (avg_staleness / (threshold * 2.0)) * 100.0).max(0.0) as i32;
313
314    FreshnessMetrics {
315        most_recent_update: most_recent,
316        oldest_open_issue: oldest_open,
317        avg_days_since_update: avg_staleness,
318        stale_count,
319        stale_threshold_days: stale_days,
320        freshness_score: clamp_score(freshness_score),
321    }
322}
323
324fn compute_flow(label: &str, labeled_issues: &[&Issue], all_issues: &[Issue]) -> FlowMetrics {
325    let canonical_target = canonical_label(label);
326    let issue_label_map: HashMap<&str, &[String]> = all_issues
327        .iter()
328        .map(|i| (i.id.as_str(), i.labels.as_slice()))
329        .collect();
330
331    let mut incoming_deps = 0i32;
332    let mut outgoing_deps = 0i32;
333    let mut incoming_labels = BTreeSet::new();
334    let mut outgoing_labels = BTreeSet::new();
335
336    for issue in labeled_issues {
337        for dep in &issue.dependencies {
338            if !dep.is_blocking() {
339                continue;
340            }
341            // incoming: other label blocks this label
342            if let Some(blocker_labels) = issue_label_map.get(dep.depends_on_id.as_str()) {
343                for bl in *blocker_labels {
344                    if !label_matches(bl, &canonical_target) {
345                        incoming_deps += 1;
346                        incoming_labels.insert(canonical_label(bl));
347                    }
348                }
349            }
350        }
351    }
352
353    for issue in labeled_issues {
354        // outgoing: this label's issues block issues owned by other labels
355        for blocked in all_issues {
356            for dep in &blocked.dependencies {
357                if dep.is_blocking() && dep.depends_on_id == issue.id {
358                    for blocked_label in &blocked.labels {
359                        if !label_matches(blocked_label, &canonical_target) {
360                            outgoing_deps += 1;
361                            outgoing_labels.insert(canonical_label(blocked_label));
362                        }
363                    }
364                }
365            }
366        }
367    }
368
369    let flow_score = clamp_score(100 - (incoming_deps * 5));
370
371    FlowMetrics {
372        incoming_deps,
373        outgoing_deps,
374        incoming_labels: incoming_labels.into_iter().collect(),
375        outgoing_labels: outgoing_labels.into_iter().collect(),
376        blocked_by_external: incoming_deps,
377        blocking_external: outgoing_deps,
378        flow_score,
379    }
380}
381
382fn compute_criticality(labeled_issues: &[&Issue], metrics: &GraphMetrics) -> CriticalityMetrics {
383    let max_pr = metrics.pagerank.values().copied().fold(0.0f64, f64::max);
384    let max_bw = metrics.betweenness.values().copied().fold(0.0f64, f64::max);
385
386    let mut pr_sum = 0.0f64;
387    let mut bw_sum = 0.0f64;
388    let mut max_bw_label = 0.0f64;
389    let mut crit_count = 0i32;
390    let mut bottleneck_count = 0i32;
391
392    for issue in labeled_issues {
393        let pr = metrics.pagerank.get(&issue.id).copied().unwrap_or(0.0);
394        let bw = metrics.betweenness.get(&issue.id).copied().unwrap_or(0.0);
395        pr_sum += pr;
396        bw_sum += bw;
397        if bw > max_bw_label {
398            max_bw_label = bw;
399        }
400        if metrics.critical_depth.get(&issue.id).copied().unwrap_or(0) > 0 {
401            crit_count += 1;
402        }
403        if bw > 0.0 {
404            bottleneck_count += 1;
405        }
406    }
407
408    let n = labeled_issues.len() as f64;
409    let avg_pr = if n > 0.0 { pr_sum / n } else { 0.0 };
410    let avg_bw = if n > 0.0 { bw_sum / n } else { 0.0 };
411
412    #[allow(clippy::cast_possible_truncation)]
413    let mut crit_score = 0i32;
414    if max_pr > 0.0 {
415        #[allow(clippy::cast_possible_truncation)]
416        {
417            crit_score += ((avg_pr / max_pr) * 50.0) as i32;
418        }
419    }
420    if max_bw > 0.0 {
421        #[allow(clippy::cast_possible_truncation)]
422        {
423            crit_score += ((max_bw_label / max_bw) * 50.0) as i32;
424        }
425    }
426
427    CriticalityMetrics {
428        avg_pagerank: avg_pr,
429        avg_betweenness: avg_bw,
430        max_betweenness: max_bw_label,
431        critical_path_count: crit_count,
432        bottleneck_count,
433        criticality_score: clamp_score(crit_score),
434    }
435}
436
437fn composite_health(velocity: i32, freshness: i32, flow: i32, criticality: i32) -> i32 {
438    let weighted = f64::from(velocity) * VELOCITY_WEIGHT
439        + f64::from(freshness) * FRESHNESS_WEIGHT
440        + f64::from(flow) * FLOW_WEIGHT
441        + f64::from(criticality) * CRITICALITY_WEIGHT;
442    #[allow(clippy::cast_possible_truncation)]
443    let score = (weighted + 0.5) as i32;
444    clamp_score(score)
445}
446
447fn label_matches(candidate: &str, target: &str) -> bool {
448    candidate.eq_ignore_ascii_case(target)
449}
450
451fn canonical_label(label: &str) -> String {
452    label.to_ascii_lowercase()
453}
454
455fn compute_label_health(
456    label: &str,
457    all_issues: &[Issue],
458    metrics: &GraphMetrics,
459    now: DateTime<Utc>,
460) -> LabelHealth {
461    let labeled: Vec<&Issue> = all_issues
462        .iter()
463        .filter(|i| i.labels.iter().any(|l| label_matches(l, label)))
464        .collect();
465
466    let issue_count = labeled.len();
467    if issue_count == 0 {
468        return LabelHealth {
469            label: label.to_string(),
470            issue_count: 0,
471            open_count: 0,
472            closed_count: 0,
473            blocked_count: 0,
474            health: 0,
475            health_level: "critical".to_string(),
476            velocity: VelocityMetrics {
477                closed_last_7_days: 0,
478                closed_last_30_days: 0,
479                avg_days_to_close: 0.0,
480                trend_direction: "stable".to_string(),
481                trend_percent: 0.0,
482                velocity_score: 0,
483            },
484            freshness: FreshnessMetrics {
485                most_recent_update: None,
486                oldest_open_issue: None,
487                avg_days_since_update: 0.0,
488                stale_count: 0,
489                stale_threshold_days: DEFAULT_STALE_THRESHOLD_DAYS,
490                freshness_score: 0,
491            },
492            flow: FlowMetrics {
493                incoming_deps: 0,
494                outgoing_deps: 0,
495                incoming_labels: vec![],
496                outgoing_labels: vec![],
497                blocked_by_external: 0,
498                blocking_external: 0,
499                flow_score: 100,
500            },
501            criticality: CriticalityMetrics {
502                avg_pagerank: 0.0,
503                avg_betweenness: 0.0,
504                max_betweenness: 0.0,
505                critical_path_count: 0,
506                bottleneck_count: 0,
507                criticality_score: 0,
508            },
509            issues: vec![],
510        };
511    }
512
513    let mut open_count = 0usize;
514    let mut closed_count = 0usize;
515    let mut blocked_count = 0usize;
516    let mut issue_ids = Vec::with_capacity(issue_count);
517
518    for issue in &labeled {
519        issue_ids.push(issue.id.clone());
520        let status = issue.normalized_status();
521        if issue.is_closed_like() {
522            closed_count += 1;
523        } else if status == "blocked" {
524            blocked_count += 1;
525        } else {
526            open_count += 1;
527        }
528    }
529
530    let velocity = compute_velocity(&labeled, now);
531    let freshness = compute_freshness(&labeled, now, DEFAULT_STALE_THRESHOLD_DAYS);
532    let flow = compute_flow(label, &labeled, all_issues);
533    let criticality = compute_criticality(&labeled, metrics);
534
535    let health = composite_health(
536        velocity.velocity_score,
537        freshness.freshness_score,
538        flow.flow_score,
539        criticality.criticality_score,
540    );
541
542    LabelHealth {
543        label: label.to_string(),
544        issue_count,
545        open_count,
546        closed_count,
547        blocked_count,
548        health,
549        health_level: health_level(health).to_string(),
550        velocity,
551        freshness,
552        flow,
553        criticality,
554        issues: issue_ids,
555    }
556}
557
558/// Compute health for a single label.
559pub fn compute_single_label_health(
560    label: &str,
561    issues: &[Issue],
562    metrics: &GraphMetrics,
563) -> LabelHealth {
564    compute_label_health(label, issues, metrics, Utc::now())
565}
566
567/// Compute health for all labels in the issue set.
568pub fn compute_all_label_health(
569    issues: &[Issue],
570    graph: &IssueGraph,
571    metrics: &GraphMetrics,
572) -> LabelHealthResult {
573    let now = Utc::now();
574    let _ = graph; // graph is available for future use
575
576    // Extract unique labels sorted
577    let mut label_set = BTreeSet::new();
578    for issue in issues {
579        for label in &issue.labels {
580            if !label.is_empty() {
581                label_set.insert(canonical_label(label));
582            }
583        }
584    }
585
586    let mut result = LabelHealthResult {
587        total_labels: label_set.len(),
588        healthy_count: 0,
589        warning_count: 0,
590        critical_count: 0,
591        labels: Vec::with_capacity(label_set.len()),
592        summaries: Vec::with_capacity(label_set.len()),
593        attention_needed: vec![],
594    };
595
596    for label in &label_set {
597        let health = compute_label_health(label, issues, metrics, now);
598
599        let summary = LabelSummary {
600            label: label.clone(),
601            issue_count: health.issue_count,
602            open_count: health.open_count,
603            health: health.health,
604            health_level: health.health_level.clone(),
605            top_issue: health.issues.first().cloned(),
606            needs_attention: health.health < HEALTHY_THRESHOLD,
607        };
608
609        match health.health_level.as_str() {
610            "healthy" => result.healthy_count += 1,
611            "warning" => {
612                result.warning_count += 1;
613                result.attention_needed.push(label.clone());
614            }
615            "critical" => {
616                result.critical_count += 1;
617                result.attention_needed.push(label.clone());
618            }
619            _ => {}
620        }
621
622        result.labels.push(health);
623        result.summaries.push(summary);
624    }
625
626    // Sort summaries by health descending, then label ascending
627    result
628        .summaries
629        .sort_by(|a, b| b.health.cmp(&a.health).then_with(|| a.label.cmp(&b.label)));
630
631    result
632}
633
634/// Compute cross-label dependency flow analysis.
635pub fn compute_cross_label_flow(issues: &[Issue]) -> CrossLabelFlow {
636    // Extract unique labels sorted
637    let mut label_set = BTreeSet::new();
638    for issue in issues {
639        for label in &issue.labels {
640            if !label.is_empty() {
641                label_set.insert(canonical_label(label));
642            }
643        }
644    }
645    let label_list: Vec<String> = label_set.into_iter().collect();
646    let n = label_list.len();
647
648    let mut label_index: HashMap<&str, usize> = HashMap::with_capacity(n);
649    for (i, label) in label_list.iter().enumerate() {
650        label_index.insert(label.as_str(), i);
651    }
652
653    let mut matrix = vec![vec![0i32; n]; n];
654    let issue_map: HashMap<&str, &Issue> = issues.iter().map(|i| (i.id.as_str(), i)).collect();
655
656    // Track dependencies between label pairs
657    let mut dep_map: BTreeMap<(String, String), LabelDependency> = BTreeMap::new();
658    let mut total_deps = 0usize;
659
660    for blocked in issues {
661        if blocked.is_closed_like() {
662            continue;
663        }
664        for dep in &blocked.dependencies {
665            if !dep.is_blocking() {
666                continue;
667            }
668            let Some(blocker) = issue_map.get(dep.depends_on_id.as_str()) else {
669                continue;
670            };
671            if blocker.is_closed_like() {
672                continue;
673            }
674
675            for from_label in &blocker.labels {
676                for to_label in &blocked.labels {
677                    let canonical_from = canonical_label(from_label);
678                    let canonical_to = canonical_label(to_label);
679                    if canonical_from.is_empty()
680                        || canonical_to.is_empty()
681                        || canonical_from == canonical_to
682                    {
683                        continue;
684                    }
685                    let Some(&i_from) = label_index.get(canonical_from.as_str()) else {
686                        continue;
687                    };
688                    let Some(&i_to) = label_index.get(canonical_to.as_str()) else {
689                        continue;
690                    };
691                    matrix[i_from][i_to] += 1;
692                    total_deps += 1;
693
694                    let key = (canonical_from.clone(), canonical_to.clone());
695                    let entry = dep_map.entry(key).or_insert_with_key(|k| LabelDependency {
696                        from_label: k.0.clone(),
697                        to_label: k.1.clone(),
698                        issue_count: 0,
699                        issue_ids: vec![],
700                        blocking_pairs: vec![],
701                    });
702                    entry.issue_count += 1;
703                    entry.issue_ids.push(blocked.id.clone());
704                    entry.blocking_pairs.push(BlockingPair {
705                        blocker_id: blocker.id.clone(),
706                        blocked_id: blocked.id.clone(),
707                        blocker_label: canonical_from,
708                        blocked_label: canonical_to,
709                    });
710                }
711            }
712        }
713    }
714
715    let dependencies: Vec<LabelDependency> = dep_map.into_values().collect();
716
717    // Bottleneck labels: highest outgoing dependencies
718    let mut out_counts: Vec<(usize, &str)> = Vec::with_capacity(n);
719    let mut max_out = 0i32;
720    for (i, row) in matrix.iter().enumerate() {
721        let sum: i32 = row.iter().sum();
722        out_counts.push((i, &label_list[i]));
723        if sum > max_out {
724            max_out = sum;
725        }
726    }
727
728    let mut bottleneck_labels: Vec<String> = Vec::new();
729    if max_out > 0 {
730        for (i, _) in &out_counts {
731            let sum: i32 = matrix[*i].iter().sum();
732            if sum == max_out {
733                bottleneck_labels.push(label_list[*i].clone());
734            }
735        }
736    }
737    bottleneck_labels.sort();
738
739    CrossLabelFlow {
740        labels: label_list,
741        flow_matrix: matrix,
742        dependencies,
743        bottleneck_labels,
744        total_cross_label_deps: total_deps,
745    }
746}
747
748/// Compute attention scores for all labels.
749/// Formula: `attention = (pagerank_sum * staleness_factor * block_impact) / velocity`
750/// Higher score = needs more attention.
751pub fn compute_label_attention(
752    issues: &[Issue],
753    metrics: &GraphMetrics,
754    limit: usize,
755) -> LabelAttentionResult {
756    let now = Utc::now();
757
758    // Extract unique labels
759    let mut label_set = BTreeSet::new();
760    for issue in issues {
761        for label in &issue.labels {
762            if !label.is_empty() {
763                label_set.insert(canonical_label(label));
764            }
765        }
766    }
767
768    if label_set.is_empty() {
769        return LabelAttentionResult {
770            labels: vec![],
771            total_labels: 0,
772            max_score: 0.0,
773            min_score: 0.0,
774        };
775    }
776
777    let mut scores: Vec<LabelAttentionScore> = Vec::with_capacity(label_set.len());
778
779    for label in &label_set {
780        let labeled: Vec<&Issue> = issues
781            .iter()
782            .filter(|i| i.labels.iter().any(|l| label_matches(l, label)))
783            .collect();
784
785        let mut open_count = 0usize;
786        let mut blocked_count = 0usize;
787        let mut stale_count = 0usize;
788        let mut pr_sum = 0.0f64;
789
790        for issue in &labeled {
791            if issue.is_closed_like() {
792                continue;
793            }
794            open_count += 1;
795
796            let status = issue.normalized_status();
797            if status == "blocked" {
798                blocked_count += 1;
799            }
800
801            pr_sum += metrics.pagerank.get(&issue.id).copied().unwrap_or(0.0);
802
803            // Check staleness
804            if let Some(updated) = issue.updated_at {
805                let days: f64 = (now - updated).num_hours() as f64 / 24.0;
806                if days >= DEFAULT_STALE_THRESHOLD_DAYS as f64 {
807                    stale_count += 1;
808                }
809            }
810        }
811
812        // staleness_factor: 1 + (stale_count / open_count)
813        let staleness_factor = if open_count > 0 {
814            1.0 + (stale_count as f64 / open_count as f64)
815        } else {
816            1.0
817        };
818
819        // block_impact: count of issues blocked by this label's issues
820        let mut block_impact = 0.0f64;
821        for issue in &labeled {
822            if issue.is_closed_like() {
823                continue;
824            }
825            // Count how many other issues depend on this issue
826            for other in issues {
827                for dep in &other.dependencies {
828                    if dep.is_blocking() && dep.depends_on_id == issue.id {
829                        // Check if the blocked issue has different labels
830                        if other
831                            .labels
832                            .iter()
833                            .any(|candidate| !label_matches(candidate, label))
834                            || !other
835                                .labels
836                                .iter()
837                                .any(|candidate| label_matches(candidate, label))
838                        {
839                            block_impact += 1.0;
840                        }
841                    }
842                }
843            }
844        }
845        // Ensure at least 1.0 to avoid zeroing out
846        let block_factor = (1.0 + block_impact).max(1.0);
847
848        // velocity_factor: based on recent closures
849        let velocity = compute_velocity(&labeled, now);
850        let velocity_factor = (1.0 + f64::from(velocity.closed_last_30_days)).max(1.0);
851
852        // attention = (pagerank_sum * staleness_factor * block_factor) / velocity_factor
853        let attention = (pr_sum * staleness_factor * block_factor) / velocity_factor;
854
855        // Build reason string
856        let reason = if stale_count > 0 && blocked_count > 0 {
857            format!("{stale_count} stale + {blocked_count} blocked issues need attention")
858        } else if stale_count > 0 {
859            format!("{stale_count} stale issue(s) need attention")
860        } else if blocked_count > 0 {
861            format!("{blocked_count} blocked issue(s)")
862        } else if open_count > 0 {
863            format!("{open_count} open issue(s)")
864        } else {
865            "no open issues".to_string()
866        };
867
868        scores.push(LabelAttentionScore {
869            label: label.clone(),
870            attention_score: attention,
871            normalized_score: 0.0, // set after normalization
872            rank: 0,               // set after sorting
873            pagerank_sum: pr_sum,
874            staleness_factor,
875            block_impact,
876            velocity_factor,
877            open_count,
878            blocked_count,
879            stale_count,
880            reason,
881        });
882    }
883
884    // Sort by attention score descending, then label ascending for ties
885    scores.sort_by(|a, b| {
886        b.attention_score
887            .total_cmp(&a.attention_score)
888            .then_with(|| a.label.cmp(&b.label))
889    });
890
891    // Normalize and rank
892    let max_score = scores.first().map_or(0.0, |s| s.attention_score);
893    let min_score = scores.last().map_or(0.0, |s| s.attention_score);
894    let range = max_score - min_score;
895
896    for (i, score) in scores.iter_mut().enumerate() {
897        score.rank = i + 1;
898        score.normalized_score = if range > 0.0 {
899            (score.attention_score - min_score) / range
900        } else if max_score > 0.0 {
901            1.0
902        } else {
903            0.0
904        };
905    }
906
907    let total_labels = scores.len();
908
909    // Apply limit
910    if limit > 0 && scores.len() > limit {
911        scores.truncate(limit);
912    }
913
914    LabelAttentionResult {
915        labels: scores,
916        total_labels,
917        max_score,
918        min_score,
919    }
920}
921
922// ============================================================================
923// Label subgraph extraction (Go parity: ComputeLabelSubgraph)
924// ============================================================================
925
926/// Extract a subgraph of issues scoped to a label.
927///
928/// Returns every issue in the weakly connected component reachable from any
929/// issue tagged with `label`.
930///
931/// Starting from the label-matching "core" issues, we walk both dependency
932/// directions transitively:
933/// 1. Upstream to the issues they depend on
934/// 2. Downstream to issues that depend on them
935///
936/// This matches the Go `ComputeLabelSubgraph` behavior used by `--label`
937/// to scope analysis to the label's connected component rather than a
938/// one-hop neighborhood.
939pub fn compute_label_subgraph(issues: &[Issue], label: &str) -> Vec<Issue> {
940    if label.is_empty() || issues.is_empty() {
941        return Vec::new();
942    }
943
944    let issue_map: HashMap<&str, &Issue> = issues.iter().map(|i| (i.id.as_str(), i)).collect();
945    let mut reverse_deps = HashMap::<&str, Vec<&str>>::new();
946    for issue in issues {
947        for dep in &issue.dependencies {
948            if issue_map.contains_key(dep.depends_on_id.as_str()) {
949                reverse_deps
950                    .entry(dep.depends_on_id.as_str())
951                    .or_default()
952                    .push(issue.id.as_str());
953            }
954        }
955    }
956
957    // Step 1: Find core issues (those with the target label)
958    let mut included: BTreeSet<&str> = BTreeSet::new();
959    let mut frontier = Vec::<&str>::new();
960    for issue in issues {
961        if issue.labels.iter().any(|l| l.eq_ignore_ascii_case(label)) {
962            included.insert(&issue.id);
963            frontier.push(issue.id.as_str());
964        }
965    }
966
967    if included.is_empty() {
968        return Vec::new();
969    }
970
971    // Step 2: Walk the full weakly connected component around the core set.
972    while let Some(issue_id) = frontier.pop() {
973        if let Some(issue) = issue_map.get(issue_id) {
974            for dep in &issue.dependencies {
975                let dep_id = dep.depends_on_id.as_str();
976                if issue_map.contains_key(dep_id) && included.insert(dep_id) {
977                    frontier.push(dep_id);
978                }
979            }
980        }
981
982        if let Some(dependents) = reverse_deps.get(issue_id) {
983            for dependent_id in dependents {
984                if included.insert(dependent_id) {
985                    frontier.push(dependent_id);
986                }
987            }
988        }
989    }
990
991    // Step 3: Collect the subgraph issues preserving original order
992    issues
993        .iter()
994        .filter(|i| included.contains(i.id.as_str()))
995        .cloned()
996        .collect()
997}
998
999// ============================================================================
1000// Tests
1001// ============================================================================
1002
1003#[cfg(test)]
1004mod tests {
1005    use crate::model::{Dependency, Issue, ts};
1006
1007    use super::*;
1008
1009    fn make_issue(id: &str, labels: &[&str], status: &str) -> Issue {
1010        Issue {
1011            id: id.to_string(),
1012            title: format!("Issue {id}"),
1013            status: status.to_string(),
1014            issue_type: "task".to_string(),
1015            priority: 2,
1016            labels: labels.iter().map(|s| (*s).to_string()).collect(),
1017            created_at: ts("2026-01-01T00:00:00Z"),
1018            updated_at: ts("2026-02-15T00:00:00Z"),
1019            ..Issue::default()
1020        }
1021    }
1022
1023    fn make_issue_with_dep(id: &str, labels: &[&str], status: &str, depends_on: &str) -> Issue {
1024        let mut issue = make_issue(id, labels, status);
1025        issue.dependencies.push(Dependency {
1026            issue_id: id.to_string(),
1027            depends_on_id: depends_on.to_string(),
1028            dep_type: "blocks".to_string(),
1029            ..Dependency::default()
1030        });
1031        issue
1032    }
1033
1034    #[test]
1035    fn label_health_empty_issues() {
1036        let issues: Vec<Issue> = vec![];
1037        let graph = super::super::graph::IssueGraph::build(&issues);
1038        let metrics = graph.compute_metrics();
1039        let result = compute_all_label_health(&issues, &graph, &metrics);
1040        assert_eq!(result.total_labels, 0);
1041        assert!(result.labels.is_empty());
1042    }
1043
1044    #[test]
1045    fn label_health_single_label() {
1046        let issues = vec![
1047            make_issue("A", &["backend"], "open"),
1048            make_issue("B", &["backend"], "closed"),
1049        ];
1050        let graph = super::super::graph::IssueGraph::build(&issues);
1051        let metrics = graph.compute_metrics();
1052        let result = compute_all_label_health(&issues, &graph, &metrics);
1053
1054        assert_eq!(result.total_labels, 1);
1055        assert_eq!(result.labels.len(), 1);
1056        assert_eq!(result.labels[0].label, "backend");
1057        assert_eq!(result.labels[0].open_count, 1);
1058        assert_eq!(result.labels[0].closed_count, 1);
1059    }
1060
1061    #[test]
1062    fn label_health_levels_correct() {
1063        // health_level should be set based on score thresholds
1064        assert_eq!(health_level(80), "healthy");
1065        assert_eq!(health_level(70), "healthy");
1066        assert_eq!(health_level(69), "warning");
1067        assert_eq!(health_level(40), "warning");
1068        assert_eq!(health_level(39), "critical");
1069        assert_eq!(health_level(0), "critical");
1070    }
1071
1072    #[test]
1073    fn cross_label_flow_empty() {
1074        let issues: Vec<Issue> = vec![];
1075        let flow = compute_cross_label_flow(&issues);
1076        assert!(flow.labels.is_empty());
1077        assert_eq!(flow.total_cross_label_deps, 0);
1078    }
1079
1080    #[test]
1081    fn cross_label_flow_with_deps() {
1082        let issues = vec![
1083            make_issue("A", &["backend"], "open"),
1084            make_issue_with_dep("B", &["frontend"], "open", "A"),
1085        ];
1086        let flow = compute_cross_label_flow(&issues);
1087
1088        assert_eq!(flow.labels.len(), 2);
1089        assert!(flow.total_cross_label_deps > 0);
1090        assert!(!flow.dependencies.is_empty());
1091        // backend blocks frontend
1092        let dep = &flow.dependencies[0];
1093        assert_eq!(dep.from_label, "backend");
1094        assert_eq!(dep.to_label, "frontend");
1095    }
1096
1097    #[test]
1098    fn cross_label_flow_no_self_deps() {
1099        let issues = vec![
1100            make_issue("A", &["backend"], "open"),
1101            make_issue_with_dep("B", &["backend"], "open", "A"),
1102        ];
1103        let flow = compute_cross_label_flow(&issues);
1104        // Both have same label, so no cross-label deps
1105        assert_eq!(flow.total_cross_label_deps, 0);
1106    }
1107
1108    #[test]
1109    fn cross_label_flow_merges_case_variants() {
1110        let issues = vec![
1111            make_issue("A", &["Backend"], "open"),
1112            make_issue_with_dep("B", &["FRONTEND"], "open", "A"),
1113            make_issue_with_dep("C", &["frontend"], "open", "A"),
1114        ];
1115        let flow = compute_cross_label_flow(&issues);
1116
1117        assert_eq!(
1118            flow.labels,
1119            vec!["backend".to_string(), "frontend".to_string()]
1120        );
1121        assert_eq!(flow.total_cross_label_deps, 2);
1122        assert_eq!(flow.dependencies.len(), 1);
1123        assert_eq!(flow.dependencies[0].from_label, "backend");
1124        assert_eq!(flow.dependencies[0].to_label, "frontend");
1125        assert_eq!(flow.dependencies[0].issue_count, 2);
1126    }
1127
1128    #[test]
1129    fn attention_empty_issues() {
1130        let issues: Vec<Issue> = vec![];
1131        let graph = super::super::graph::IssueGraph::build(&issues);
1132        let metrics = graph.compute_metrics();
1133        let result = compute_label_attention(&issues, &metrics, 0);
1134        assert_eq!(result.total_labels, 0);
1135        assert!(result.labels.is_empty());
1136    }
1137
1138    #[test]
1139    fn attention_ranking_order() {
1140        let issues = vec![
1141            make_issue("A", &["critical"], "open"),
1142            make_issue("B", &["critical"], "blocked"),
1143            make_issue("C", &["stable"], "closed"),
1144        ];
1145        let graph = super::super::graph::IssueGraph::build(&issues);
1146        let metrics = graph.compute_metrics();
1147        let result = compute_label_attention(&issues, &metrics, 0);
1148
1149        assert_eq!(result.total_labels, 2);
1150        // All scores should have rank >= 1
1151        for score in &result.labels {
1152            assert!(score.rank >= 1);
1153        }
1154    }
1155
1156    #[test]
1157    fn attention_respects_limit() {
1158        let issues = vec![
1159            make_issue("A", &["alpha"], "open"),
1160            make_issue("B", &["beta"], "open"),
1161            make_issue("C", &["gamma"], "open"),
1162        ];
1163        let graph = super::super::graph::IssueGraph::build(&issues);
1164        let metrics = graph.compute_metrics();
1165        let result = compute_label_attention(&issues, &metrics, 2);
1166        assert_eq!(result.labels.len(), 2);
1167        assert_eq!(result.total_labels, 3);
1168    }
1169
1170    #[test]
1171    fn attention_block_impact_matches_labels_case_insensitively() {
1172        let blocker = make_issue("A", &["Backend"], "open");
1173        let blocked_same_label = make_issue_with_dep("B", &["backend"], "open", "A");
1174        let blocked_other_label = make_issue_with_dep("C", &["frontend"], "open", "A");
1175
1176        let issues = vec![blocker, blocked_same_label, blocked_other_label];
1177        let graph = super::super::graph::IssueGraph::build(&issues);
1178        let metrics = graph.compute_metrics();
1179        let result = compute_label_attention(&issues, &metrics, 0);
1180
1181        let backend = result
1182            .labels
1183            .iter()
1184            .find(|score| score.label == "backend")
1185            .expect("backend score should exist");
1186
1187        assert_eq!(backend.block_impact, 1.0);
1188    }
1189
1190    #[test]
1191    fn attention_merges_case_variants() {
1192        let issues = vec![
1193            make_issue("A", &["Backend"], "open"),
1194            make_issue("B", &["backend"], "blocked"),
1195        ];
1196        let graph = super::super::graph::IssueGraph::build(&issues);
1197        let metrics = graph.compute_metrics();
1198        let result = compute_label_attention(&issues, &metrics, 0);
1199
1200        assert_eq!(result.total_labels, 1);
1201        assert_eq!(result.labels.len(), 1);
1202        assert_eq!(result.labels[0].label, "backend");
1203        assert_eq!(result.labels[0].open_count, 2);
1204    }
1205
1206    #[test]
1207    fn all_label_health_merges_case_variants() {
1208        let issues = vec![
1209            make_issue("A", &["Backend"], "open"),
1210            make_issue("B", &["backend"], "blocked"),
1211        ];
1212        let graph = super::super::graph::IssueGraph::build(&issues);
1213        let metrics = graph.compute_metrics();
1214        let result = compute_all_label_health(&issues, &graph, &metrics);
1215
1216        assert_eq!(result.total_labels, 1);
1217        assert_eq!(result.labels.len(), 1);
1218        assert_eq!(result.labels[0].label, "backend");
1219        assert_eq!(result.labels[0].issue_count, 2);
1220    }
1221
1222    // ── compute_velocity ────────────────────────────────────────────
1223
1224    #[test]
1225    fn velocity_counts_recent_closures() {
1226        let now = chrono::Utc::now();
1227        let closed_3_days_ago = now - chrono::Duration::days(3);
1228        let created = now - chrono::Duration::days(10);
1229
1230        let mut i1 = make_issue("A", &["backend"], "closed");
1231        i1.closed_at = Some(closed_3_days_ago);
1232        i1.created_at = Some(created);
1233        i1.updated_at = Some(closed_3_days_ago);
1234
1235        let vel = compute_velocity(&[&i1], now);
1236        assert_eq!(vel.closed_last_7_days, 1);
1237        assert_eq!(vel.closed_last_30_days, 1);
1238        assert!(vel.avg_days_to_close > 0.0);
1239    }
1240
1241    #[test]
1242    fn velocity_zero_when_no_closures() {
1243        let now = chrono::Utc::now();
1244        let i1 = make_issue("A", &["backend"], "open");
1245        let vel = compute_velocity(&[&i1], now);
1246        assert_eq!(vel.closed_last_7_days, 0);
1247        assert_eq!(vel.closed_last_30_days, 0);
1248        assert_eq!(vel.velocity_score, 0);
1249    }
1250
1251    #[test]
1252    fn velocity_trend_improving_when_current_higher() {
1253        let now = chrono::Utc::now();
1254        // Issue closed this week
1255        let mut recent = make_issue("A", &["x"], "closed");
1256        recent.closed_at = Some(now - chrono::Duration::days(2));
1257        recent.created_at = Some(now - chrono::Duration::days(5));
1258        recent.updated_at = Some(now - chrono::Duration::days(2));
1259
1260        // Issue closed last week (but not this week)
1261        let mut older = make_issue("B", &["x"], "closed");
1262        let last_week = now - chrono::Duration::days(10);
1263        older.closed_at = Some(last_week);
1264        older.created_at = Some(now - chrono::Duration::days(20));
1265        older.updated_at = Some(last_week);
1266
1267        let vel = compute_velocity(&[&recent, &older], now);
1268        assert_eq!(vel.closed_last_7_days, 1);
1269        assert_eq!(vel.closed_last_30_days, 2);
1270    }
1271
1272    // ── compute_freshness ───────────────────────────────────────────
1273
1274    #[test]
1275    fn freshness_tracks_most_recent_and_oldest_open() {
1276        let now = chrono::Utc::now();
1277        let recent = now - chrono::Duration::days(1);
1278        let old = now - chrono::Duration::days(30);
1279
1280        let mut i1 = make_issue("A", &["x"], "open");
1281        i1.updated_at = Some(recent);
1282        i1.created_at = Some(old);
1283
1284        let mut i2 = make_issue("B", &["x"], "open");
1285        i2.updated_at = Some(old);
1286        i2.created_at = Some(old);
1287
1288        let fresh = compute_freshness(&[&i1, &i2], now, DEFAULT_STALE_THRESHOLD_DAYS);
1289        assert_eq!(fresh.most_recent_update, Some(recent));
1290        assert_eq!(fresh.oldest_open_issue, Some(old));
1291        assert!(fresh.avg_days_since_update > 0.0);
1292    }
1293
1294    #[test]
1295    fn freshness_stale_count() {
1296        let now = chrono::Utc::now();
1297        let stale = now - chrono::Duration::days(20); // > 14 day threshold
1298        let fresh = now - chrono::Duration::days(5); // < 14 day threshold
1299
1300        let mut i1 = make_issue("A", &["x"], "open");
1301        i1.updated_at = Some(stale);
1302        let mut i2 = make_issue("B", &["x"], "open");
1303        i2.updated_at = Some(fresh);
1304
1305        let result = compute_freshness(&[&i1, &i2], now, DEFAULT_STALE_THRESHOLD_DAYS);
1306        assert_eq!(result.stale_count, 1);
1307    }
1308
1309    #[test]
1310    fn freshness_high_score_for_fresh_issues() {
1311        let now = chrono::Utc::now();
1312        let very_recent = now - chrono::Duration::hours(12);
1313
1314        let mut i1 = make_issue("A", &["x"], "open");
1315        i1.updated_at = Some(very_recent);
1316
1317        let result = compute_freshness(&[&i1], now, DEFAULT_STALE_THRESHOLD_DAYS);
1318        assert!(
1319            result.freshness_score >= 90,
1320            "very fresh issue should score high"
1321        );
1322        assert_eq!(result.stale_count, 0);
1323    }
1324
1325    #[test]
1326    fn freshness_empty_issues() {
1327        let now = chrono::Utc::now();
1328        let result = compute_freshness(&[], now, DEFAULT_STALE_THRESHOLD_DAYS);
1329        assert_eq!(result.avg_days_since_update, 0.0);
1330        assert!(result.most_recent_update.is_none());
1331        assert!(result.oldest_open_issue.is_none());
1332    }
1333
1334    // ── compute_flow ────────────────────────────────────────────────
1335
1336    #[test]
1337    fn flow_counts_cross_label_deps() {
1338        let i1 = make_issue("A", &["backend"], "open");
1339        let i2 = make_issue_with_dep("B", &["frontend"], "open", "A");
1340
1341        // compute_flow for "frontend" — B depends on A (backend)
1342        let flow = compute_flow("frontend", &[&i2], &[i1.clone(), i2.clone()]);
1343        assert!(flow.incoming_deps > 0);
1344        assert!(flow.incoming_labels.contains(&"backend".to_string()));
1345    }
1346
1347    #[test]
1348    fn flow_no_deps_scores_100() {
1349        let i1 = make_issue("A", &["backend"], "open");
1350        let flow = compute_flow("backend", &[&i1], &[i1.clone()]);
1351        assert_eq!(flow.incoming_deps, 0);
1352        assert_eq!(flow.outgoing_deps, 0);
1353        assert_eq!(flow.flow_score, 100);
1354    }
1355
1356    #[test]
1357    fn flow_counts_outgoing_cross_label_deps() {
1358        let source_issue = make_issue("A", &["backend"], "open");
1359        let dependent_issue = make_issue_with_dep("B", &["frontend"], "open", "A");
1360
1361        let flow = compute_flow(
1362            "backend",
1363            &[&source_issue],
1364            &[source_issue.clone(), dependent_issue],
1365        );
1366        assert_eq!(flow.outgoing_deps, 1);
1367        assert_eq!(flow.blocking_external, 1);
1368        assert!(flow.outgoing_labels.contains(&"frontend".to_string()));
1369    }
1370
1371    // ── compute_criticality ─────────────────────────────────────────
1372
1373    #[test]
1374    fn criticality_zero_with_no_graph() {
1375        let graph = super::super::graph::IssueGraph::build(&[]);
1376        let metrics = graph.compute_metrics();
1377        let i1 = make_issue("A", &["x"], "open");
1378        let crit = compute_criticality(&[&i1], &metrics);
1379        assert_eq!(crit.avg_pagerank, 0.0);
1380        assert_eq!(crit.avg_betweenness, 0.0);
1381        assert_eq!(crit.criticality_score, 0);
1382    }
1383
1384    #[test]
1385    fn criticality_nonzero_with_dependencies() {
1386        let i1 = make_issue("A", &["x"], "open");
1387        let i2 = make_issue_with_dep("B", &["x"], "open", "A");
1388        let i3 = make_issue_with_dep("C", &["x"], "open", "A");
1389
1390        let all = vec![i1, i2, i3];
1391        let graph = super::super::graph::IssueGraph::build(&all);
1392        let metrics = graph.compute_metrics();
1393
1394        let labeled: Vec<&Issue> = all.iter().collect();
1395        let crit = compute_criticality(&labeled, &metrics);
1396        assert!(crit.avg_pagerank > 0.0);
1397    }
1398
1399    // ── composite_health ────────────────────────────────────────────
1400
1401    #[test]
1402    fn composite_health_equal_weights() {
1403        // All scores 80 → composite = 80
1404        assert_eq!(composite_health(80, 80, 80, 80), 80);
1405    }
1406
1407    #[test]
1408    fn composite_health_clamped_to_0_100() {
1409        assert_eq!(composite_health(0, 0, 0, 0), 0);
1410        assert_eq!(composite_health(100, 100, 100, 100), 100);
1411    }
1412
1413    #[test]
1414    fn composite_health_mixed() {
1415        // 100*0.25 + 0*0.25 + 50*0.25 + 50*0.25 = 25 + 0 + 12.5 + 12.5 = 50
1416        assert_eq!(composite_health(100, 0, 50, 50), 50);
1417    }
1418
1419    // ── clamp_score ─────────────────────────────────────────────────
1420
1421    #[test]
1422    fn clamp_score_boundaries() {
1423        assert_eq!(clamp_score(-10), 0);
1424        assert_eq!(clamp_score(0), 0);
1425        assert_eq!(clamp_score(50), 50);
1426        assert_eq!(clamp_score(100), 100);
1427        assert_eq!(clamp_score(150), 100);
1428    }
1429
1430    // ── single label health ─────────────────────────────────────────
1431
1432    #[test]
1433    fn single_label_health_integrates_all_metrics() {
1434        let now = chrono::Utc::now();
1435        let recent = now - chrono::Duration::days(2);
1436
1437        let mut i1 = make_issue("A", &["backend"], "open");
1438        i1.updated_at = Some(recent);
1439        i1.created_at = Some(recent);
1440
1441        let mut i2 = make_issue("B", &["backend"], "closed");
1442        i2.closed_at = Some(recent);
1443        i2.updated_at = Some(recent);
1444        i2.created_at = Some(now - chrono::Duration::days(10));
1445
1446        let graph = super::super::graph::IssueGraph::build(&[i1.clone(), i2.clone()]);
1447        let metrics = graph.compute_metrics();
1448        let health = compute_single_label_health("backend", &[i1, i2], &metrics);
1449
1450        assert_eq!(health.label, "backend");
1451        assert_eq!(health.issue_count, 2);
1452        assert_eq!(health.open_count, 1);
1453        assert_eq!(health.closed_count, 1);
1454        assert!(health.health >= 0 && health.health <= 100);
1455        assert!(!health.health_level.is_empty());
1456        // Velocity should reflect the recent closure
1457        assert_eq!(health.velocity.closed_last_7_days, 1);
1458    }
1459
1460    #[test]
1461    fn label_health_no_matching_issues() {
1462        let i1 = make_issue("A", &["backend"], "open");
1463        let graph = super::super::graph::IssueGraph::build(&[i1.clone()]);
1464        let metrics = graph.compute_metrics();
1465        let health = compute_single_label_health("nonexistent", &[i1], &metrics);
1466        assert_eq!(health.issue_count, 0);
1467        assert_eq!(health.health, 0);
1468        assert_eq!(health.health_level, "critical");
1469    }
1470
1471    #[test]
1472    fn single_label_health_matches_case_insensitively() {
1473        let issue = make_issue("A", &["Backend"], "open");
1474        let graph = super::super::graph::IssueGraph::build(&[issue.clone()]);
1475        let metrics = graph.compute_metrics();
1476
1477        let health = compute_single_label_health("backend", &[issue], &metrics);
1478
1479        assert_eq!(health.issue_count, 1);
1480        assert_eq!(health.label, "backend");
1481    }
1482
1483    // ── cross_label_flow multi-label ────────────────────────────────
1484
1485    #[test]
1486    fn label_subgraph_includes_core_and_deps() {
1487        let i1 = make_issue("A", &["backend"], "open");
1488        let i2 = make_issue_with_dep("B", &["backend"], "open", "A");
1489        let i3 = make_issue_with_dep("C", &["frontend"], "open", "A");
1490        let i4 = make_issue("D", &["frontend"], "open");
1491
1492        let subgraph = compute_label_subgraph(&[i1, i2, i3, i4], "backend");
1493        // Core: A, B (have "backend" label)
1494        // Deps: C depends on A, so C is included as a dependency issue
1495        // D has no connection to backend → excluded
1496        assert!(subgraph.iter().any(|i| i.id == "A"));
1497        assert!(subgraph.iter().any(|i| i.id == "B"));
1498        assert!(subgraph.iter().any(|i| i.id == "C"));
1499        assert!(!subgraph.iter().any(|i| i.id == "D"));
1500    }
1501
1502    #[test]
1503    fn label_subgraph_empty_label_returns_empty() {
1504        let i1 = make_issue("A", &["backend"], "open");
1505        let subgraph = compute_label_subgraph(&[i1.clone()], "");
1506        assert!(subgraph.is_empty());
1507    }
1508
1509    #[test]
1510    fn label_subgraph_no_matching_label_returns_empty() {
1511        let i1 = make_issue("A", &["backend"], "open");
1512        let subgraph = compute_label_subgraph(&[i1], "nonexistent");
1513        assert!(subgraph.is_empty());
1514    }
1515
1516    #[test]
1517    fn label_subgraph_walks_transitive_connected_component() {
1518        let i1 = make_issue("A", &["backend"], "open");
1519        let i2 = make_issue_with_dep("B", &["frontend"], "open", "A");
1520        let i3 = make_issue_with_dep("C", &["ops"], "open", "B");
1521        let i4 = make_issue_with_dep("D", &["qa"], "open", "C");
1522        let i5 = make_issue("E", &["frontend"], "open");
1523
1524        let subgraph = compute_label_subgraph(&[i1, i2, i3, i4, i5], "backend");
1525        let ids = subgraph
1526            .iter()
1527            .map(|issue| issue.id.as_str())
1528            .collect::<Vec<_>>();
1529
1530        assert_eq!(ids, vec!["A", "B", "C", "D"]);
1531    }
1532
1533    #[test]
1534    fn cross_label_flow_multi_label_issue() {
1535        let i1 = make_issue("A", &["backend", "api"], "open");
1536        let i2 = make_issue_with_dep("B", &["frontend"], "open", "A");
1537        let flow = compute_cross_label_flow(&[i1, i2]);
1538        // frontend depends on backend+api → at least 2 cross-label deps
1539        assert!(flow.total_cross_label_deps >= 2);
1540    }
1541}