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
10const 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#[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#[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#[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
169fn 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 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 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
558pub 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
567pub fn compute_all_label_health(
569 issues: &[Issue],
570 graph: &IssueGraph,
571 metrics: &GraphMetrics,
572) -> LabelHealthResult {
573 let now = Utc::now();
574 let _ = graph; 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 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
634pub fn compute_cross_label_flow(issues: &[Issue]) -> CrossLabelFlow {
636 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 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 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
748pub fn compute_label_attention(
752 issues: &[Issue],
753 metrics: &GraphMetrics,
754 limit: usize,
755) -> LabelAttentionResult {
756 let now = Utc::now();
757
758 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 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 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 let mut block_impact = 0.0f64;
821 for issue in &labeled {
822 if issue.is_closed_like() {
823 continue;
824 }
825 for other in issues {
827 for dep in &other.dependencies {
828 if dep.is_blocking() && dep.depends_on_id == issue.id {
829 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 let block_factor = (1.0 + block_impact).max(1.0);
847
848 let velocity = compute_velocity(&labeled, now);
850 let velocity_factor = (1.0 + f64::from(velocity.closed_last_30_days)).max(1.0);
851
852 let attention = (pr_sum * staleness_factor * block_factor) / velocity_factor;
854
855 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, rank: 0, 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 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 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 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
922pub 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 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 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 issues
993 .iter()
994 .filter(|i| included.contains(i.id.as_str()))
995 .cloned()
996 .collect()
997}
998
999#[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 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 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 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 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 #[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 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 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 #[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); let fresh = now - chrono::Duration::days(5); 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 #[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 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 #[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 #[test]
1402 fn composite_health_equal_weights() {
1403 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 assert_eq!(composite_health(100, 0, 50, 50), 50);
1417 }
1418
1419 #[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 #[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 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 #[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 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 assert!(flow.total_cross_label_deps >= 2);
1540 }
1541}