use std::collections::{BTreeMap, BTreeSet, HashMap};
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::model::Issue;
use super::graph::{GraphMetrics, IssueGraph};
const DEFAULT_STALE_THRESHOLD_DAYS: i64 = 14;
const HEALTHY_THRESHOLD: i32 = 70;
const WARNING_THRESHOLD: i32 = 40;
const VELOCITY_WEIGHT: f64 = 0.25;
const FRESHNESS_WEIGHT: f64 = 0.25;
const FLOW_WEIGHT: f64 = 0.25;
const CRITICALITY_WEIGHT: f64 = 0.25;
#[derive(Debug, Clone, Serialize)]
pub struct VelocityMetrics {
pub closed_last_7_days: i32,
pub closed_last_30_days: i32,
pub avg_days_to_close: f64,
pub trend_direction: String,
pub trend_percent: f64,
pub velocity_score: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct FreshnessMetrics {
#[serde(skip_serializing_if = "Option::is_none")]
pub most_recent_update: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oldest_open_issue: Option<DateTime<Utc>>,
pub avg_days_since_update: f64,
pub stale_count: i32,
pub stale_threshold_days: i64,
pub freshness_score: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct FlowMetrics {
pub incoming_deps: i32,
pub outgoing_deps: i32,
pub incoming_labels: Vec<String>,
pub outgoing_labels: Vec<String>,
pub blocked_by_external: i32,
pub blocking_external: i32,
pub flow_score: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct CriticalityMetrics {
pub avg_pagerank: f64,
pub avg_betweenness: f64,
pub max_betweenness: f64,
pub critical_path_count: i32,
pub bottleneck_count: i32,
pub criticality_score: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct LabelHealth {
pub label: String,
pub issue_count: usize,
pub open_count: usize,
pub closed_count: usize,
pub blocked_count: usize,
pub health: i32,
pub health_level: String,
pub velocity: VelocityMetrics,
pub freshness: FreshnessMetrics,
pub flow: FlowMetrics,
pub criticality: CriticalityMetrics,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub issues: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LabelSummary {
pub label: String,
pub issue_count: usize,
pub open_count: usize,
pub health: i32,
pub health_level: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_issue: Option<String>,
pub needs_attention: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct LabelHealthResult {
pub total_labels: usize,
pub healthy_count: usize,
pub warning_count: usize,
pub critical_count: usize,
pub labels: Vec<LabelHealth>,
pub summaries: Vec<LabelSummary>,
pub attention_needed: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LabelDependency {
pub from_label: String,
pub to_label: String,
pub issue_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub issue_ids: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blocking_pairs: Vec<BlockingPair>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlockingPair {
pub blocker_id: String,
pub blocked_id: String,
pub blocker_label: String,
pub blocked_label: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrossLabelFlow {
pub labels: Vec<String>,
pub flow_matrix: Vec<Vec<i32>>,
pub dependencies: Vec<LabelDependency>,
pub bottleneck_labels: Vec<String>,
pub total_cross_label_deps: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct LabelAttentionScore {
pub label: String,
pub attention_score: f64,
pub normalized_score: f64,
pub rank: usize,
pub pagerank_sum: f64,
pub staleness_factor: f64,
pub block_impact: f64,
pub velocity_factor: f64,
pub open_count: usize,
pub blocked_count: usize,
pub stale_count: usize,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct LabelAttentionResult {
pub labels: Vec<LabelAttentionScore>,
pub total_labels: usize,
pub max_score: f64,
pub min_score: f64,
}
fn clamp_score(v: i32) -> i32 {
v.clamp(0, 100)
}
fn health_level(score: i32) -> &'static str {
if score >= HEALTHY_THRESHOLD {
"healthy"
} else if score >= WARNING_THRESHOLD {
"warning"
} else {
"critical"
}
}
fn compute_velocity(labeled_issues: &[&Issue], now: DateTime<Utc>) -> VelocityMetrics {
let week_ago = now - chrono::Duration::days(7);
let month_ago = now - chrono::Duration::days(30);
let prev_week_start = now - chrono::Duration::days(14);
let mut closed_7 = 0i32;
let mut closed_30 = 0i32;
let mut current_week = 0i32;
let mut prev_week = 0i32;
let mut total_close_days = 0.0f64;
let mut close_samples = 0i32;
for issue in labeled_issues {
if !issue.is_closed_like() {
continue;
}
let closed_at = issue.closed_at.or(issue.updated_at);
let Some(closed_at) = closed_at else {
continue;
};
if closed_at > week_ago {
closed_7 += 1;
current_week += 1;
}
if closed_at > month_ago {
closed_30 += 1;
}
if closed_at > prev_week_start && closed_at <= week_ago {
prev_week += 1;
}
if let Some(created) = issue.created_at {
let days = (closed_at - created).num_hours() as f64 / 24.0;
if days >= 0.0 {
total_close_days += days;
close_samples += 1;
}
}
}
let avg_days = if close_samples > 0 {
total_close_days / f64::from(close_samples)
} else {
0.0
};
let (trend_direction, trend_percent) = if prev_week > 0 {
let pct = (f64::from(current_week - prev_week) / f64::from(prev_week)) * 100.0;
let dir = if pct > 10.0 {
"improving"
} else if pct < -10.0 {
"declining"
} else {
"stable"
};
(dir, pct)
} else if current_week > 0 {
("improving", 100.0)
} else {
("stable", 0.0)
};
#[allow(clippy::cast_possible_truncation)]
let mut velocity_score = if closed_30 > 0 {
(f64::from(closed_30) * 10.0).min(100.0) as i32
} else {
0
};
if trend_direction == "improving" && velocity_score < 100 {
velocity_score = clamp_score(velocity_score + 10);
}
VelocityMetrics {
closed_last_7_days: closed_7,
closed_last_30_days: closed_30,
avg_days_to_close: avg_days,
trend_direction: trend_direction.to_string(),
trend_percent,
velocity_score: clamp_score(velocity_score),
}
}
fn compute_freshness(
labeled_issues: &[&Issue],
now: DateTime<Utc>,
stale_days: i64,
) -> FreshnessMetrics {
let threshold = stale_days as f64;
let mut most_recent: Option<DateTime<Utc>> = None;
let mut oldest_open: Option<DateTime<Utc>> = None;
let mut total_staleness = 0.0f64;
let mut count = 0i32;
let mut stale_count = 0i32;
for issue in labeled_issues {
if let Some(updated) = issue.updated_at {
if most_recent.is_none_or(|mr| updated > mr) {
most_recent = Some(updated);
}
let days = (now - updated).num_hours() as f64 / 24.0;
total_staleness += days;
count += 1;
if days >= threshold {
stale_count += 1;
}
}
if !issue.is_closed_like() {
if let Some(created) = issue.created_at {
if oldest_open.is_none_or(|oo| created < oo) {
oldest_open = Some(created);
}
}
}
}
let avg_staleness = if count > 0 {
total_staleness / f64::from(count)
} else {
0.0
};
#[allow(clippy::cast_possible_truncation)]
let freshness_score = (100.0 - (avg_staleness / (threshold * 2.0)) * 100.0).max(0.0) as i32;
FreshnessMetrics {
most_recent_update: most_recent,
oldest_open_issue: oldest_open,
avg_days_since_update: avg_staleness,
stale_count,
stale_threshold_days: stale_days,
freshness_score: clamp_score(freshness_score),
}
}
fn compute_flow(label: &str, labeled_issues: &[&Issue], all_issues: &[Issue]) -> FlowMetrics {
let canonical_target = canonical_label(label);
let issue_label_map: HashMap<&str, &[String]> = all_issues
.iter()
.map(|i| (i.id.as_str(), i.labels.as_slice()))
.collect();
let mut incoming_deps = 0i32;
let mut outgoing_deps = 0i32;
let mut incoming_labels = BTreeSet::new();
let mut outgoing_labels = BTreeSet::new();
for issue in labeled_issues {
for dep in &issue.dependencies {
if !dep.is_blocking() {
continue;
}
if let Some(blocker_labels) = issue_label_map.get(dep.depends_on_id.as_str()) {
for bl in *blocker_labels {
if !label_matches(bl, &canonical_target) {
incoming_deps += 1;
incoming_labels.insert(canonical_label(bl));
}
}
}
}
}
for issue in labeled_issues {
for blocked in all_issues {
for dep in &blocked.dependencies {
if dep.is_blocking() && dep.depends_on_id == issue.id {
for blocked_label in &blocked.labels {
if !label_matches(blocked_label, &canonical_target) {
outgoing_deps += 1;
outgoing_labels.insert(canonical_label(blocked_label));
}
}
}
}
}
}
let flow_score = clamp_score(100 - (incoming_deps * 5));
FlowMetrics {
incoming_deps,
outgoing_deps,
incoming_labels: incoming_labels.into_iter().collect(),
outgoing_labels: outgoing_labels.into_iter().collect(),
blocked_by_external: incoming_deps,
blocking_external: outgoing_deps,
flow_score,
}
}
fn compute_criticality(labeled_issues: &[&Issue], metrics: &GraphMetrics) -> CriticalityMetrics {
let max_pr = metrics.pagerank.values().copied().fold(0.0f64, f64::max);
let max_bw = metrics.betweenness.values().copied().fold(0.0f64, f64::max);
let mut pr_sum = 0.0f64;
let mut bw_sum = 0.0f64;
let mut max_bw_label = 0.0f64;
let mut crit_count = 0i32;
let mut bottleneck_count = 0i32;
for issue in labeled_issues {
let pr = metrics.pagerank.get(&issue.id).copied().unwrap_or(0.0);
let bw = metrics.betweenness.get(&issue.id).copied().unwrap_or(0.0);
pr_sum += pr;
bw_sum += bw;
if bw > max_bw_label {
max_bw_label = bw;
}
if metrics.critical_depth.get(&issue.id).copied().unwrap_or(0) > 0 {
crit_count += 1;
}
if bw > 0.0 {
bottleneck_count += 1;
}
}
let n = labeled_issues.len() as f64;
let avg_pr = if n > 0.0 { pr_sum / n } else { 0.0 };
let avg_bw = if n > 0.0 { bw_sum / n } else { 0.0 };
#[allow(clippy::cast_possible_truncation)]
let mut crit_score = 0i32;
if max_pr > 0.0 {
#[allow(clippy::cast_possible_truncation)]
{
crit_score += ((avg_pr / max_pr) * 50.0) as i32;
}
}
if max_bw > 0.0 {
#[allow(clippy::cast_possible_truncation)]
{
crit_score += ((max_bw_label / max_bw) * 50.0) as i32;
}
}
CriticalityMetrics {
avg_pagerank: avg_pr,
avg_betweenness: avg_bw,
max_betweenness: max_bw_label,
critical_path_count: crit_count,
bottleneck_count,
criticality_score: clamp_score(crit_score),
}
}
fn composite_health(velocity: i32, freshness: i32, flow: i32, criticality: i32) -> i32 {
let weighted = f64::from(velocity) * VELOCITY_WEIGHT
+ f64::from(freshness) * FRESHNESS_WEIGHT
+ f64::from(flow) * FLOW_WEIGHT
+ f64::from(criticality) * CRITICALITY_WEIGHT;
#[allow(clippy::cast_possible_truncation)]
let score = (weighted + 0.5) as i32;
clamp_score(score)
}
fn label_matches(candidate: &str, target: &str) -> bool {
candidate.eq_ignore_ascii_case(target)
}
fn canonical_label(label: &str) -> String {
label.to_ascii_lowercase()
}
fn compute_label_health(
label: &str,
all_issues: &[Issue],
metrics: &GraphMetrics,
now: DateTime<Utc>,
) -> LabelHealth {
let labeled: Vec<&Issue> = all_issues
.iter()
.filter(|i| i.labels.iter().any(|l| label_matches(l, label)))
.collect();
let issue_count = labeled.len();
if issue_count == 0 {
return LabelHealth {
label: label.to_string(),
issue_count: 0,
open_count: 0,
closed_count: 0,
blocked_count: 0,
health: 0,
health_level: "critical".to_string(),
velocity: VelocityMetrics {
closed_last_7_days: 0,
closed_last_30_days: 0,
avg_days_to_close: 0.0,
trend_direction: "stable".to_string(),
trend_percent: 0.0,
velocity_score: 0,
},
freshness: FreshnessMetrics {
most_recent_update: None,
oldest_open_issue: None,
avg_days_since_update: 0.0,
stale_count: 0,
stale_threshold_days: DEFAULT_STALE_THRESHOLD_DAYS,
freshness_score: 0,
},
flow: FlowMetrics {
incoming_deps: 0,
outgoing_deps: 0,
incoming_labels: vec![],
outgoing_labels: vec![],
blocked_by_external: 0,
blocking_external: 0,
flow_score: 100,
},
criticality: CriticalityMetrics {
avg_pagerank: 0.0,
avg_betweenness: 0.0,
max_betweenness: 0.0,
critical_path_count: 0,
bottleneck_count: 0,
criticality_score: 0,
},
issues: vec![],
};
}
let mut open_count = 0usize;
let mut closed_count = 0usize;
let mut blocked_count = 0usize;
let mut issue_ids = Vec::with_capacity(issue_count);
for issue in &labeled {
issue_ids.push(issue.id.clone());
let status = issue.normalized_status();
if issue.is_closed_like() {
closed_count += 1;
} else if status == "blocked" {
blocked_count += 1;
} else {
open_count += 1;
}
}
let velocity = compute_velocity(&labeled, now);
let freshness = compute_freshness(&labeled, now, DEFAULT_STALE_THRESHOLD_DAYS);
let flow = compute_flow(label, &labeled, all_issues);
let criticality = compute_criticality(&labeled, metrics);
let health = composite_health(
velocity.velocity_score,
freshness.freshness_score,
flow.flow_score,
criticality.criticality_score,
);
LabelHealth {
label: label.to_string(),
issue_count,
open_count,
closed_count,
blocked_count,
health,
health_level: health_level(health).to_string(),
velocity,
freshness,
flow,
criticality,
issues: issue_ids,
}
}
pub fn compute_single_label_health(
label: &str,
issues: &[Issue],
metrics: &GraphMetrics,
) -> LabelHealth {
compute_label_health(label, issues, metrics, Utc::now())
}
pub fn compute_all_label_health(
issues: &[Issue],
graph: &IssueGraph,
metrics: &GraphMetrics,
) -> LabelHealthResult {
let now = Utc::now();
let _ = graph;
let mut label_set = BTreeSet::new();
for issue in issues {
for label in &issue.labels {
if !label.is_empty() {
label_set.insert(canonical_label(label));
}
}
}
let mut result = LabelHealthResult {
total_labels: label_set.len(),
healthy_count: 0,
warning_count: 0,
critical_count: 0,
labels: Vec::with_capacity(label_set.len()),
summaries: Vec::with_capacity(label_set.len()),
attention_needed: vec![],
};
for label in &label_set {
let health = compute_label_health(label, issues, metrics, now);
let summary = LabelSummary {
label: label.clone(),
issue_count: health.issue_count,
open_count: health.open_count,
health: health.health,
health_level: health.health_level.clone(),
top_issue: health.issues.first().cloned(),
needs_attention: health.health < HEALTHY_THRESHOLD,
};
match health.health_level.as_str() {
"healthy" => result.healthy_count += 1,
"warning" => {
result.warning_count += 1;
result.attention_needed.push(label.clone());
}
"critical" => {
result.critical_count += 1;
result.attention_needed.push(label.clone());
}
_ => {}
}
result.labels.push(health);
result.summaries.push(summary);
}
result
.summaries
.sort_by(|a, b| b.health.cmp(&a.health).then_with(|| a.label.cmp(&b.label)));
result
}
pub fn compute_cross_label_flow(issues: &[Issue]) -> CrossLabelFlow {
let mut label_set = BTreeSet::new();
for issue in issues {
for label in &issue.labels {
if !label.is_empty() {
label_set.insert(canonical_label(label));
}
}
}
let label_list: Vec<String> = label_set.into_iter().collect();
let n = label_list.len();
let mut label_index: HashMap<&str, usize> = HashMap::with_capacity(n);
for (i, label) in label_list.iter().enumerate() {
label_index.insert(label.as_str(), i);
}
let mut matrix = vec![vec![0i32; n]; n];
let issue_map: HashMap<&str, &Issue> = issues.iter().map(|i| (i.id.as_str(), i)).collect();
let mut dep_map: BTreeMap<(String, String), LabelDependency> = BTreeMap::new();
let mut total_deps = 0usize;
for blocked in issues {
if blocked.is_closed_like() {
continue;
}
for dep in &blocked.dependencies {
if !dep.is_blocking() {
continue;
}
let Some(blocker) = issue_map.get(dep.depends_on_id.as_str()) else {
continue;
};
if blocker.is_closed_like() {
continue;
}
for from_label in &blocker.labels {
for to_label in &blocked.labels {
let canonical_from = canonical_label(from_label);
let canonical_to = canonical_label(to_label);
if canonical_from.is_empty()
|| canonical_to.is_empty()
|| canonical_from == canonical_to
{
continue;
}
let Some(&i_from) = label_index.get(canonical_from.as_str()) else {
continue;
};
let Some(&i_to) = label_index.get(canonical_to.as_str()) else {
continue;
};
matrix[i_from][i_to] += 1;
total_deps += 1;
let key = (canonical_from.clone(), canonical_to.clone());
let entry = dep_map.entry(key).or_insert_with_key(|k| LabelDependency {
from_label: k.0.clone(),
to_label: k.1.clone(),
issue_count: 0,
issue_ids: vec![],
blocking_pairs: vec![],
});
entry.issue_count += 1;
entry.issue_ids.push(blocked.id.clone());
entry.blocking_pairs.push(BlockingPair {
blocker_id: blocker.id.clone(),
blocked_id: blocked.id.clone(),
blocker_label: canonical_from,
blocked_label: canonical_to,
});
}
}
}
}
let dependencies: Vec<LabelDependency> = dep_map.into_values().collect();
let mut out_counts: Vec<(usize, &str)> = Vec::with_capacity(n);
let mut max_out = 0i32;
for (i, row) in matrix.iter().enumerate() {
let sum: i32 = row.iter().sum();
out_counts.push((i, &label_list[i]));
if sum > max_out {
max_out = sum;
}
}
let mut bottleneck_labels: Vec<String> = Vec::new();
if max_out > 0 {
for (i, _) in &out_counts {
let sum: i32 = matrix[*i].iter().sum();
if sum == max_out {
bottleneck_labels.push(label_list[*i].clone());
}
}
}
bottleneck_labels.sort();
CrossLabelFlow {
labels: label_list,
flow_matrix: matrix,
dependencies,
bottleneck_labels,
total_cross_label_deps: total_deps,
}
}
pub fn compute_label_attention(
issues: &[Issue],
metrics: &GraphMetrics,
limit: usize,
) -> LabelAttentionResult {
let now = Utc::now();
let mut label_set = BTreeSet::new();
for issue in issues {
for label in &issue.labels {
if !label.is_empty() {
label_set.insert(canonical_label(label));
}
}
}
if label_set.is_empty() {
return LabelAttentionResult {
labels: vec![],
total_labels: 0,
max_score: 0.0,
min_score: 0.0,
};
}
let mut scores: Vec<LabelAttentionScore> = Vec::with_capacity(label_set.len());
for label in &label_set {
let labeled: Vec<&Issue> = issues
.iter()
.filter(|i| i.labels.iter().any(|l| label_matches(l, label)))
.collect();
let mut open_count = 0usize;
let mut blocked_count = 0usize;
let mut stale_count = 0usize;
let mut pr_sum = 0.0f64;
for issue in &labeled {
if issue.is_closed_like() {
continue;
}
open_count += 1;
let status = issue.normalized_status();
if status == "blocked" {
blocked_count += 1;
}
pr_sum += metrics.pagerank.get(&issue.id).copied().unwrap_or(0.0);
if let Some(updated) = issue.updated_at {
let days: f64 = (now - updated).num_hours() as f64 / 24.0;
if days >= DEFAULT_STALE_THRESHOLD_DAYS as f64 {
stale_count += 1;
}
}
}
let staleness_factor = if open_count > 0 {
1.0 + (stale_count as f64 / open_count as f64)
} else {
1.0
};
let mut block_impact = 0.0f64;
for issue in &labeled {
if issue.is_closed_like() {
continue;
}
for other in issues {
for dep in &other.dependencies {
if dep.is_blocking() && dep.depends_on_id == issue.id {
if other
.labels
.iter()
.any(|candidate| !label_matches(candidate, label))
|| !other
.labels
.iter()
.any(|candidate| label_matches(candidate, label))
{
block_impact += 1.0;
}
}
}
}
}
let block_factor = (1.0 + block_impact).max(1.0);
let velocity = compute_velocity(&labeled, now);
let velocity_factor = (1.0 + f64::from(velocity.closed_last_30_days)).max(1.0);
let attention = (pr_sum * staleness_factor * block_factor) / velocity_factor;
let reason = if stale_count > 0 && blocked_count > 0 {
format!("{stale_count} stale + {blocked_count} blocked issues need attention")
} else if stale_count > 0 {
format!("{stale_count} stale issue(s) need attention")
} else if blocked_count > 0 {
format!("{blocked_count} blocked issue(s)")
} else if open_count > 0 {
format!("{open_count} open issue(s)")
} else {
"no open issues".to_string()
};
scores.push(LabelAttentionScore {
label: label.clone(),
attention_score: attention,
normalized_score: 0.0, rank: 0, pagerank_sum: pr_sum,
staleness_factor,
block_impact,
velocity_factor,
open_count,
blocked_count,
stale_count,
reason,
});
}
scores.sort_by(|a, b| {
b.attention_score
.total_cmp(&a.attention_score)
.then_with(|| a.label.cmp(&b.label))
});
let max_score = scores.first().map_or(0.0, |s| s.attention_score);
let min_score = scores.last().map_or(0.0, |s| s.attention_score);
let range = max_score - min_score;
for (i, score) in scores.iter_mut().enumerate() {
score.rank = i + 1;
score.normalized_score = if range > 0.0 {
(score.attention_score - min_score) / range
} else if max_score > 0.0 {
1.0
} else {
0.0
};
}
let total_labels = scores.len();
if limit > 0 && scores.len() > limit {
scores.truncate(limit);
}
LabelAttentionResult {
labels: scores,
total_labels,
max_score,
min_score,
}
}
pub fn compute_label_subgraph(issues: &[Issue], label: &str) -> Vec<Issue> {
if label.is_empty() || issues.is_empty() {
return Vec::new();
}
let issue_map: HashMap<&str, &Issue> = issues.iter().map(|i| (i.id.as_str(), i)).collect();
let mut reverse_deps = HashMap::<&str, Vec<&str>>::new();
for issue in issues {
for dep in &issue.dependencies {
if issue_map.contains_key(dep.depends_on_id.as_str()) {
reverse_deps
.entry(dep.depends_on_id.as_str())
.or_default()
.push(issue.id.as_str());
}
}
}
let mut included: BTreeSet<&str> = BTreeSet::new();
let mut frontier = Vec::<&str>::new();
for issue in issues {
if issue.labels.iter().any(|l| l.eq_ignore_ascii_case(label)) {
included.insert(&issue.id);
frontier.push(issue.id.as_str());
}
}
if included.is_empty() {
return Vec::new();
}
while let Some(issue_id) = frontier.pop() {
if let Some(issue) = issue_map.get(issue_id) {
for dep in &issue.dependencies {
let dep_id = dep.depends_on_id.as_str();
if issue_map.contains_key(dep_id) && included.insert(dep_id) {
frontier.push(dep_id);
}
}
}
if let Some(dependents) = reverse_deps.get(issue_id) {
for dependent_id in dependents {
if included.insert(dependent_id) {
frontier.push(dependent_id);
}
}
}
}
issues
.iter()
.filter(|i| included.contains(i.id.as_str()))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use crate::model::{Dependency, Issue, ts};
use super::*;
fn make_issue(id: &str, labels: &[&str], status: &str) -> Issue {
Issue {
id: id.to_string(),
title: format!("Issue {id}"),
status: status.to_string(),
issue_type: "task".to_string(),
priority: 2,
labels: labels.iter().map(|s| (*s).to_string()).collect(),
created_at: ts("2026-01-01T00:00:00Z"),
updated_at: ts("2026-02-15T00:00:00Z"),
..Issue::default()
}
}
fn make_issue_with_dep(id: &str, labels: &[&str], status: &str, depends_on: &str) -> Issue {
let mut issue = make_issue(id, labels, status);
issue.dependencies.push(Dependency {
issue_id: id.to_string(),
depends_on_id: depends_on.to_string(),
dep_type: "blocks".to_string(),
..Dependency::default()
});
issue
}
#[test]
fn label_health_empty_issues() {
let issues: Vec<Issue> = vec![];
let graph = super::super::graph::IssueGraph::build(&issues);
let metrics = graph.compute_metrics();
let result = compute_all_label_health(&issues, &graph, &metrics);
assert_eq!(result.total_labels, 0);
assert!(result.labels.is_empty());
}
#[test]
fn label_health_single_label() {
let issues = vec![
make_issue("A", &["backend"], "open"),
make_issue("B", &["backend"], "closed"),
];
let graph = super::super::graph::IssueGraph::build(&issues);
let metrics = graph.compute_metrics();
let result = compute_all_label_health(&issues, &graph, &metrics);
assert_eq!(result.total_labels, 1);
assert_eq!(result.labels.len(), 1);
assert_eq!(result.labels[0].label, "backend");
assert_eq!(result.labels[0].open_count, 1);
assert_eq!(result.labels[0].closed_count, 1);
}
#[test]
fn label_health_levels_correct() {
assert_eq!(health_level(80), "healthy");
assert_eq!(health_level(70), "healthy");
assert_eq!(health_level(69), "warning");
assert_eq!(health_level(40), "warning");
assert_eq!(health_level(39), "critical");
assert_eq!(health_level(0), "critical");
}
#[test]
fn cross_label_flow_empty() {
let issues: Vec<Issue> = vec![];
let flow = compute_cross_label_flow(&issues);
assert!(flow.labels.is_empty());
assert_eq!(flow.total_cross_label_deps, 0);
}
#[test]
fn cross_label_flow_with_deps() {
let issues = vec![
make_issue("A", &["backend"], "open"),
make_issue_with_dep("B", &["frontend"], "open", "A"),
];
let flow = compute_cross_label_flow(&issues);
assert_eq!(flow.labels.len(), 2);
assert!(flow.total_cross_label_deps > 0);
assert!(!flow.dependencies.is_empty());
let dep = &flow.dependencies[0];
assert_eq!(dep.from_label, "backend");
assert_eq!(dep.to_label, "frontend");
}
#[test]
fn cross_label_flow_no_self_deps() {
let issues = vec![
make_issue("A", &["backend"], "open"),
make_issue_with_dep("B", &["backend"], "open", "A"),
];
let flow = compute_cross_label_flow(&issues);
assert_eq!(flow.total_cross_label_deps, 0);
}
#[test]
fn cross_label_flow_merges_case_variants() {
let issues = vec![
make_issue("A", &["Backend"], "open"),
make_issue_with_dep("B", &["FRONTEND"], "open", "A"),
make_issue_with_dep("C", &["frontend"], "open", "A"),
];
let flow = compute_cross_label_flow(&issues);
assert_eq!(
flow.labels,
vec!["backend".to_string(), "frontend".to_string()]
);
assert_eq!(flow.total_cross_label_deps, 2);
assert_eq!(flow.dependencies.len(), 1);
assert_eq!(flow.dependencies[0].from_label, "backend");
assert_eq!(flow.dependencies[0].to_label, "frontend");
assert_eq!(flow.dependencies[0].issue_count, 2);
}
#[test]
fn attention_empty_issues() {
let issues: Vec<Issue> = vec![];
let graph = super::super::graph::IssueGraph::build(&issues);
let metrics = graph.compute_metrics();
let result = compute_label_attention(&issues, &metrics, 0);
assert_eq!(result.total_labels, 0);
assert!(result.labels.is_empty());
}
#[test]
fn attention_ranking_order() {
let issues = vec![
make_issue("A", &["critical"], "open"),
make_issue("B", &["critical"], "blocked"),
make_issue("C", &["stable"], "closed"),
];
let graph = super::super::graph::IssueGraph::build(&issues);
let metrics = graph.compute_metrics();
let result = compute_label_attention(&issues, &metrics, 0);
assert_eq!(result.total_labels, 2);
for score in &result.labels {
assert!(score.rank >= 1);
}
}
#[test]
fn attention_respects_limit() {
let issues = vec![
make_issue("A", &["alpha"], "open"),
make_issue("B", &["beta"], "open"),
make_issue("C", &["gamma"], "open"),
];
let graph = super::super::graph::IssueGraph::build(&issues);
let metrics = graph.compute_metrics();
let result = compute_label_attention(&issues, &metrics, 2);
assert_eq!(result.labels.len(), 2);
assert_eq!(result.total_labels, 3);
}
#[test]
fn attention_block_impact_matches_labels_case_insensitively() {
let blocker = make_issue("A", &["Backend"], "open");
let blocked_same_label = make_issue_with_dep("B", &["backend"], "open", "A");
let blocked_other_label = make_issue_with_dep("C", &["frontend"], "open", "A");
let issues = vec![blocker, blocked_same_label, blocked_other_label];
let graph = super::super::graph::IssueGraph::build(&issues);
let metrics = graph.compute_metrics();
let result = compute_label_attention(&issues, &metrics, 0);
let backend = result
.labels
.iter()
.find(|score| score.label == "backend")
.expect("backend score should exist");
assert_eq!(backend.block_impact, 1.0);
}
#[test]
fn attention_merges_case_variants() {
let issues = vec![
make_issue("A", &["Backend"], "open"),
make_issue("B", &["backend"], "blocked"),
];
let graph = super::super::graph::IssueGraph::build(&issues);
let metrics = graph.compute_metrics();
let result = compute_label_attention(&issues, &metrics, 0);
assert_eq!(result.total_labels, 1);
assert_eq!(result.labels.len(), 1);
assert_eq!(result.labels[0].label, "backend");
assert_eq!(result.labels[0].open_count, 2);
}
#[test]
fn all_label_health_merges_case_variants() {
let issues = vec![
make_issue("A", &["Backend"], "open"),
make_issue("B", &["backend"], "blocked"),
];
let graph = super::super::graph::IssueGraph::build(&issues);
let metrics = graph.compute_metrics();
let result = compute_all_label_health(&issues, &graph, &metrics);
assert_eq!(result.total_labels, 1);
assert_eq!(result.labels.len(), 1);
assert_eq!(result.labels[0].label, "backend");
assert_eq!(result.labels[0].issue_count, 2);
}
#[test]
fn velocity_counts_recent_closures() {
let now = chrono::Utc::now();
let closed_3_days_ago = now - chrono::Duration::days(3);
let created = now - chrono::Duration::days(10);
let mut i1 = make_issue("A", &["backend"], "closed");
i1.closed_at = Some(closed_3_days_ago);
i1.created_at = Some(created);
i1.updated_at = Some(closed_3_days_ago);
let vel = compute_velocity(&[&i1], now);
assert_eq!(vel.closed_last_7_days, 1);
assert_eq!(vel.closed_last_30_days, 1);
assert!(vel.avg_days_to_close > 0.0);
}
#[test]
fn velocity_zero_when_no_closures() {
let now = chrono::Utc::now();
let i1 = make_issue("A", &["backend"], "open");
let vel = compute_velocity(&[&i1], now);
assert_eq!(vel.closed_last_7_days, 0);
assert_eq!(vel.closed_last_30_days, 0);
assert_eq!(vel.velocity_score, 0);
}
#[test]
fn velocity_trend_improving_when_current_higher() {
let now = chrono::Utc::now();
let mut recent = make_issue("A", &["x"], "closed");
recent.closed_at = Some(now - chrono::Duration::days(2));
recent.created_at = Some(now - chrono::Duration::days(5));
recent.updated_at = Some(now - chrono::Duration::days(2));
let mut older = make_issue("B", &["x"], "closed");
let last_week = now - chrono::Duration::days(10);
older.closed_at = Some(last_week);
older.created_at = Some(now - chrono::Duration::days(20));
older.updated_at = Some(last_week);
let vel = compute_velocity(&[&recent, &older], now);
assert_eq!(vel.closed_last_7_days, 1);
assert_eq!(vel.closed_last_30_days, 2);
}
#[test]
fn freshness_tracks_most_recent_and_oldest_open() {
let now = chrono::Utc::now();
let recent = now - chrono::Duration::days(1);
let old = now - chrono::Duration::days(30);
let mut i1 = make_issue("A", &["x"], "open");
i1.updated_at = Some(recent);
i1.created_at = Some(old);
let mut i2 = make_issue("B", &["x"], "open");
i2.updated_at = Some(old);
i2.created_at = Some(old);
let fresh = compute_freshness(&[&i1, &i2], now, DEFAULT_STALE_THRESHOLD_DAYS);
assert_eq!(fresh.most_recent_update, Some(recent));
assert_eq!(fresh.oldest_open_issue, Some(old));
assert!(fresh.avg_days_since_update > 0.0);
}
#[test]
fn freshness_stale_count() {
let now = chrono::Utc::now();
let stale = now - chrono::Duration::days(20); let fresh = now - chrono::Duration::days(5);
let mut i1 = make_issue("A", &["x"], "open");
i1.updated_at = Some(stale);
let mut i2 = make_issue("B", &["x"], "open");
i2.updated_at = Some(fresh);
let result = compute_freshness(&[&i1, &i2], now, DEFAULT_STALE_THRESHOLD_DAYS);
assert_eq!(result.stale_count, 1);
}
#[test]
fn freshness_high_score_for_fresh_issues() {
let now = chrono::Utc::now();
let very_recent = now - chrono::Duration::hours(12);
let mut i1 = make_issue("A", &["x"], "open");
i1.updated_at = Some(very_recent);
let result = compute_freshness(&[&i1], now, DEFAULT_STALE_THRESHOLD_DAYS);
assert!(
result.freshness_score >= 90,
"very fresh issue should score high"
);
assert_eq!(result.stale_count, 0);
}
#[test]
fn freshness_empty_issues() {
let now = chrono::Utc::now();
let result = compute_freshness(&[], now, DEFAULT_STALE_THRESHOLD_DAYS);
assert_eq!(result.avg_days_since_update, 0.0);
assert!(result.most_recent_update.is_none());
assert!(result.oldest_open_issue.is_none());
}
#[test]
fn flow_counts_cross_label_deps() {
let i1 = make_issue("A", &["backend"], "open");
let i2 = make_issue_with_dep("B", &["frontend"], "open", "A");
let flow = compute_flow("frontend", &[&i2], &[i1.clone(), i2.clone()]);
assert!(flow.incoming_deps > 0);
assert!(flow.incoming_labels.contains(&"backend".to_string()));
}
#[test]
fn flow_no_deps_scores_100() {
let i1 = make_issue("A", &["backend"], "open");
let flow = compute_flow("backend", &[&i1], &[i1.clone()]);
assert_eq!(flow.incoming_deps, 0);
assert_eq!(flow.outgoing_deps, 0);
assert_eq!(flow.flow_score, 100);
}
#[test]
fn flow_counts_outgoing_cross_label_deps() {
let source_issue = make_issue("A", &["backend"], "open");
let dependent_issue = make_issue_with_dep("B", &["frontend"], "open", "A");
let flow = compute_flow(
"backend",
&[&source_issue],
&[source_issue.clone(), dependent_issue],
);
assert_eq!(flow.outgoing_deps, 1);
assert_eq!(flow.blocking_external, 1);
assert!(flow.outgoing_labels.contains(&"frontend".to_string()));
}
#[test]
fn criticality_zero_with_no_graph() {
let graph = super::super::graph::IssueGraph::build(&[]);
let metrics = graph.compute_metrics();
let i1 = make_issue("A", &["x"], "open");
let crit = compute_criticality(&[&i1], &metrics);
assert_eq!(crit.avg_pagerank, 0.0);
assert_eq!(crit.avg_betweenness, 0.0);
assert_eq!(crit.criticality_score, 0);
}
#[test]
fn criticality_nonzero_with_dependencies() {
let i1 = make_issue("A", &["x"], "open");
let i2 = make_issue_with_dep("B", &["x"], "open", "A");
let i3 = make_issue_with_dep("C", &["x"], "open", "A");
let all = vec![i1, i2, i3];
let graph = super::super::graph::IssueGraph::build(&all);
let metrics = graph.compute_metrics();
let labeled: Vec<&Issue> = all.iter().collect();
let crit = compute_criticality(&labeled, &metrics);
assert!(crit.avg_pagerank > 0.0);
}
#[test]
fn composite_health_equal_weights() {
assert_eq!(composite_health(80, 80, 80, 80), 80);
}
#[test]
fn composite_health_clamped_to_0_100() {
assert_eq!(composite_health(0, 0, 0, 0), 0);
assert_eq!(composite_health(100, 100, 100, 100), 100);
}
#[test]
fn composite_health_mixed() {
assert_eq!(composite_health(100, 0, 50, 50), 50);
}
#[test]
fn clamp_score_boundaries() {
assert_eq!(clamp_score(-10), 0);
assert_eq!(clamp_score(0), 0);
assert_eq!(clamp_score(50), 50);
assert_eq!(clamp_score(100), 100);
assert_eq!(clamp_score(150), 100);
}
#[test]
fn single_label_health_integrates_all_metrics() {
let now = chrono::Utc::now();
let recent = now - chrono::Duration::days(2);
let mut i1 = make_issue("A", &["backend"], "open");
i1.updated_at = Some(recent);
i1.created_at = Some(recent);
let mut i2 = make_issue("B", &["backend"], "closed");
i2.closed_at = Some(recent);
i2.updated_at = Some(recent);
i2.created_at = Some(now - chrono::Duration::days(10));
let graph = super::super::graph::IssueGraph::build(&[i1.clone(), i2.clone()]);
let metrics = graph.compute_metrics();
let health = compute_single_label_health("backend", &[i1, i2], &metrics);
assert_eq!(health.label, "backend");
assert_eq!(health.issue_count, 2);
assert_eq!(health.open_count, 1);
assert_eq!(health.closed_count, 1);
assert!(health.health >= 0 && health.health <= 100);
assert!(!health.health_level.is_empty());
assert_eq!(health.velocity.closed_last_7_days, 1);
}
#[test]
fn label_health_no_matching_issues() {
let i1 = make_issue("A", &["backend"], "open");
let graph = super::super::graph::IssueGraph::build(&[i1.clone()]);
let metrics = graph.compute_metrics();
let health = compute_single_label_health("nonexistent", &[i1], &metrics);
assert_eq!(health.issue_count, 0);
assert_eq!(health.health, 0);
assert_eq!(health.health_level, "critical");
}
#[test]
fn single_label_health_matches_case_insensitively() {
let issue = make_issue("A", &["Backend"], "open");
let graph = super::super::graph::IssueGraph::build(&[issue.clone()]);
let metrics = graph.compute_metrics();
let health = compute_single_label_health("backend", &[issue], &metrics);
assert_eq!(health.issue_count, 1);
assert_eq!(health.label, "backend");
}
#[test]
fn label_subgraph_includes_core_and_deps() {
let i1 = make_issue("A", &["backend"], "open");
let i2 = make_issue_with_dep("B", &["backend"], "open", "A");
let i3 = make_issue_with_dep("C", &["frontend"], "open", "A");
let i4 = make_issue("D", &["frontend"], "open");
let subgraph = compute_label_subgraph(&[i1, i2, i3, i4], "backend");
assert!(subgraph.iter().any(|i| i.id == "A"));
assert!(subgraph.iter().any(|i| i.id == "B"));
assert!(subgraph.iter().any(|i| i.id == "C"));
assert!(!subgraph.iter().any(|i| i.id == "D"));
}
#[test]
fn label_subgraph_empty_label_returns_empty() {
let i1 = make_issue("A", &["backend"], "open");
let subgraph = compute_label_subgraph(&[i1.clone()], "");
assert!(subgraph.is_empty());
}
#[test]
fn label_subgraph_no_matching_label_returns_empty() {
let i1 = make_issue("A", &["backend"], "open");
let subgraph = compute_label_subgraph(&[i1], "nonexistent");
assert!(subgraph.is_empty());
}
#[test]
fn label_subgraph_walks_transitive_connected_component() {
let i1 = make_issue("A", &["backend"], "open");
let i2 = make_issue_with_dep("B", &["frontend"], "open", "A");
let i3 = make_issue_with_dep("C", &["ops"], "open", "B");
let i4 = make_issue_with_dep("D", &["qa"], "open", "C");
let i5 = make_issue("E", &["frontend"], "open");
let subgraph = compute_label_subgraph(&[i1, i2, i3, i4, i5], "backend");
let ids = subgraph
.iter()
.map(|issue| issue.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["A", "B", "C", "D"]);
}
#[test]
fn cross_label_flow_multi_label_issue() {
let i1 = make_issue("A", &["backend", "api"], "open");
let i2 = make_issue_with_dep("B", &["frontend"], "open", "A");
let flow = compute_cross_label_flow(&[i1, i2]);
assert!(flow.total_cross_label_deps >= 2);
}
}