1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use chrono::{Datelike, Duration, Utc, Weekday};
4use serde::Serialize;
5
6use crate::analysis::graph::{GraphMetrics, IssueGraph};
7use crate::model::Issue;
8
9const W_PAGERANK: f64 = 0.22;
29const W_BETWEENNESS: f64 = 0.20;
30const W_BLOCKER_RATIO: f64 = 0.13;
31const W_STALENESS: f64 = 0.05;
32const W_PRIORITY_BOOST: f64 = 0.10;
33const W_TIME_TO_IMPACT: f64 = 0.10;
34const W_URGENCY: f64 = 0.10;
35const W_RISK: f64 = 0.10;
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub enum WeightPreset {
45 #[default]
47 Default,
48 GraphHeavy,
50 PriorityFirst,
52 QuickWins,
54 RiskAverse,
56}
57
58impl WeightPreset {
59 #[must_use]
62 pub fn adjustments(self) -> HashMap<String, f64> {
63 match self {
64 Self::Default => HashMap::new(),
65 Self::GraphHeavy => [
66 ("PageRank", 1.5),
67 ("Betweenness", 1.5),
68 ("TimeToImpact", 1.3),
69 ("PriorityBoost", 0.6),
70 ("Urgency", 0.6),
71 ]
72 .into_iter()
73 .map(|(k, v)| (k.to_string(), v))
74 .collect(),
75 Self::PriorityFirst => [
76 ("PriorityBoost", 2.0),
77 ("Urgency", 1.5),
78 ("PageRank", 0.7),
79 ("Betweenness", 0.7),
80 ]
81 .into_iter()
82 .map(|(k, v)| (k.to_string(), v))
83 .collect(),
84 Self::QuickWins => [
85 ("BlockerRatio", 1.5),
86 ("TimeToImpact", 1.5),
87 ("Risk", 0.6),
88 ("Staleness", 0.5),
89 ]
90 .into_iter()
91 .map(|(k, v)| (k.to_string(), v))
92 .collect(),
93 Self::RiskAverse => [
94 ("Risk", 1.8),
95 ("Betweenness", 1.3),
96 ("BlockerRatio", 1.3),
97 ("PageRank", 0.8),
98 ]
99 .into_iter()
100 .map(|(k, v)| (k.to_string(), v))
101 .collect(),
102 }
103 }
104
105 pub const ALL: &[&str] = &[
107 "default",
108 "graph-heavy",
109 "priority-first",
110 "quick-wins",
111 "risk-averse",
112 ];
113
114 #[must_use]
116 pub fn from_name(name: &str) -> Option<Self> {
117 match name {
118 "default" => Some(Self::Default),
119 "graph-heavy" => Some(Self::GraphHeavy),
120 "priority-first" => Some(Self::PriorityFirst),
121 "quick-wins" => Some(Self::QuickWins),
122 "risk-averse" => Some(Self::RiskAverse),
123 _ => None,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Serialize)]
130pub struct ScoreComponent {
131 pub name: &'static str,
132 pub weight: f64,
133 pub raw: f64,
134 pub normalized: f64,
135 pub weighted: f64,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub explanation: Option<String>,
138}
139
140#[derive(Debug, Clone, Serialize)]
142pub struct ImpactScore {
143 pub issue_id: String,
144 pub score: f64,
145 pub breakdown: Vec<ScoreComponent>,
146}
147
148struct ScoringContext {
150 max_pagerank: f64,
151 max_betweenness: f64,
152 max_blocks: f64,
153 total_open: usize,
154 now_ts: i64,
155}
156
157impl ScoringContext {
158 fn from_metrics(metrics: &GraphMetrics, total_open: usize) -> Self {
159 Self {
160 max_pagerank: metrics
161 .pagerank
162 .values()
163 .copied()
164 .fold(0.0_f64, f64::max)
165 .max(1e-9),
166 max_betweenness: metrics
167 .betweenness
168 .values()
169 .copied()
170 .fold(0.0_f64, f64::max)
171 .max(1e-9),
172 max_blocks: metrics
173 .blocks_count
174 .values()
175 .copied()
176 .max()
177 .unwrap_or(1)
178 .max(1) as f64,
179 total_open,
180 now_ts: Utc::now().timestamp(),
181 }
182 }
183}
184
185struct TriageLookupCache<'a> {
186 issue_by_id: HashMap<&'a str, &'a Issue>,
187 open_blocker_count_by_issue: HashMap<&'a str, usize>,
188 issues_in_cycles: HashSet<&'a str>,
189}
190
191impl<'a> TriageLookupCache<'a> {
192 fn new(issues: &'a [Issue], graph: &IssueGraph, metrics: &'a GraphMetrics) -> Self {
193 let issue_by_id = issues
194 .iter()
195 .map(|issue| (issue.id.as_str(), issue))
196 .collect();
197 let open_blocker_count_by_issue = issues
198 .iter()
199 .map(|issue| (issue.id.as_str(), graph.open_blockers(&issue.id).len()))
200 .collect();
201 let issues_in_cycles = metrics
202 .cycles
203 .iter()
204 .flat_map(|cycle| cycle.iter().map(String::as_str))
205 .collect();
206
207 Self {
208 issue_by_id,
209 open_blocker_count_by_issue,
210 issues_in_cycles,
211 }
212 }
213}
214
215fn compute_impact_score(
217 issue: &Issue,
218 metrics: &GraphMetrics,
219 ctx: &ScoringContext,
220 lookups: &TriageLookupCache<'_>,
221 weight_adjustments: &HashMap<String, f64>,
222) -> ImpactScore {
223 let pr_raw = metrics.pagerank.get(&issue.id).copied().unwrap_or_default();
225 let pr_norm = pr_raw / ctx.max_pagerank;
226
227 let bt_raw = metrics
229 .betweenness
230 .get(&issue.id)
231 .copied()
232 .unwrap_or_default();
233 let bt_norm = bt_raw / ctx.max_betweenness;
234
235 let blocks = metrics
237 .blocks_count
238 .get(&issue.id)
239 .copied()
240 .unwrap_or_default();
241 let blocker_ratio_raw = blocks as f64;
242 let blocker_ratio_norm = if ctx.total_open > 1 {
243 blocker_ratio_raw / ctx.max_blocks
244 } else {
245 0.0
246 };
247
248 let updated_ts = issue
250 .updated_at
251 .map(|dt| dt.timestamp())
252 .unwrap_or(ctx.now_ts);
253 const SECS_PER_DAY: f64 = 86_400.0;
254 let days_stale = ((ctx.now_ts - updated_ts) as f64 / SECS_PER_DAY).max(0.0);
255 let staleness_norm = 1.0 - (days_stale / 90.0).min(1.0);
257
258 let priority_norm = issue.priority_normalized();
260
261 let depth = metrics.critical_depth.get(&issue.id).copied().unwrap_or(0);
263 let max_depth = metrics
264 .critical_depth
265 .values()
266 .copied()
267 .max()
268 .unwrap_or(1)
269 .max(1);
270 let tti_raw = depth as f64;
272 let tti_norm = tti_raw / max_depth as f64;
273 let tti_explanation = if depth > 0 {
274 Some(format!(
275 "critical depth {depth}/{max_depth} — completing this unlocks a chain of {blocks} issue(s)"
276 ))
277 } else {
278 None
279 };
280
281 let (urgency_norm, urgency_explanation) = match issue.normalized_status().as_str() {
283 "in_progress" => (1.0, Some("actively being worked on".to_string())),
284 "open" => (0.8, None),
285 "review" => (0.7, Some("awaiting review".to_string())),
286 "blocked" => (0.5, Some("blocked — resolve blockers first".to_string())),
287 "deferred" | "pinned" => (0.3, Some("deferred/pinned — lower urgency".to_string())),
288 _ => (0.6, None),
289 };
290
291 let open_blockers = lookups
293 .open_blocker_count_by_issue
294 .get(issue.id.as_str())
295 .copied()
296 .unwrap_or_default();
297 let is_in_cycle = lookups.issues_in_cycles.contains(issue.id.as_str());
298 let is_articulation = metrics.articulation_points.contains(issue.id.as_str());
299 let mut risk_signals = Vec::new();
300 let mut risk_raw = 0.0_f64;
301 if open_blockers > 0 {
302 risk_raw += 0.3;
303 risk_signals.push(format!("{open_blockers} open blocker(s)"));
304 }
305 if is_in_cycle {
306 risk_raw += 0.4;
307 risk_signals.push("part of a dependency cycle".to_string());
308 }
309 if is_articulation {
310 risk_raw += 0.3;
311 risk_signals.push("articulation point (removing breaks graph)".to_string());
312 }
313 let risk_norm = risk_raw.min(1.0);
314 let risk_benefit = 1.0 - risk_norm;
316 let risk_explanation = if risk_signals.is_empty() {
317 None
318 } else {
319 Some(risk_signals.join("; "))
320 };
321
322 let adj = |name: &str| -> f64 {
324 weight_adjustments
325 .get(name)
326 .copied()
327 .unwrap_or(1.0)
328 .clamp(0.5, 2.0)
329 };
330
331 let w_pr = W_PAGERANK * adj("PageRank");
333 let w_bt = W_BETWEENNESS * adj("Betweenness");
334 let weight_blocker_ratio = W_BLOCKER_RATIO * adj("BlockerRatio");
335 let weight_staleness = W_STALENESS * adj("Staleness");
336 let weight_priority_boost = W_PRIORITY_BOOST * adj("PriorityBoost");
337 let w_tti = W_TIME_TO_IMPACT * adj("TimeToImpact");
338 let w_urg = W_URGENCY * adj("Urgency");
339 let w_risk = W_RISK * adj("Risk");
340
341 let w_sum = w_pr
343 + w_bt
344 + weight_blocker_ratio
345 + weight_staleness
346 + weight_priority_boost
347 + w_tti
348 + w_urg
349 + w_risk;
350 let norm = if w_sum > 0.0 { 1.0 / w_sum } else { 1.0 };
351
352 let components = vec![
353 ScoreComponent {
354 name: "PageRank",
355 weight: w_pr * norm,
356 raw: pr_raw,
357 normalized: pr_norm,
358 weighted: w_pr * norm * pr_norm,
359 explanation: None,
360 },
361 ScoreComponent {
362 name: "Betweenness",
363 weight: w_bt * norm,
364 raw: bt_raw,
365 normalized: bt_norm,
366 weighted: w_bt * norm * bt_norm,
367 explanation: None,
368 },
369 ScoreComponent {
370 name: "BlockerRatio",
371 weight: weight_blocker_ratio * norm,
372 raw: blocker_ratio_raw,
373 normalized: blocker_ratio_norm,
374 weighted: weight_blocker_ratio * norm * blocker_ratio_norm,
375 explanation: if blocks > 0 {
376 Some(format!("blocks {blocks} issue(s)"))
377 } else {
378 None
379 },
380 },
381 ScoreComponent {
382 name: "Staleness",
383 weight: weight_staleness * norm,
384 raw: days_stale,
385 normalized: staleness_norm,
386 weighted: weight_staleness * norm * staleness_norm,
387 explanation: if days_stale > 30.0 {
388 Some(format!("stale: {days_stale:.0} days since last update"))
389 } else {
390 None
391 },
392 },
393 ScoreComponent {
394 name: "PriorityBoost",
395 weight: weight_priority_boost * norm,
396 raw: issue.priority as f64,
397 normalized: priority_norm,
398 weighted: weight_priority_boost * norm * priority_norm,
399 explanation: None,
400 },
401 ScoreComponent {
402 name: "TimeToImpact",
403 weight: w_tti * norm,
404 raw: tti_raw,
405 normalized: tti_norm,
406 weighted: w_tti * norm * tti_norm,
407 explanation: tti_explanation,
408 },
409 ScoreComponent {
410 name: "Urgency",
411 weight: w_urg * norm,
412 raw: urgency_norm, normalized: urgency_norm,
414 weighted: w_urg * norm * urgency_norm,
415 explanation: urgency_explanation,
416 },
417 ScoreComponent {
418 name: "Risk",
419 weight: w_risk * norm,
420 raw: risk_norm,
421 normalized: risk_benefit,
422 weighted: w_risk * norm * risk_benefit,
423 explanation: risk_explanation,
424 },
425 ];
426
427 let score: f64 = components.iter().map(|c| c.weighted).sum();
428
429 ImpactScore {
430 issue_id: issue.id.clone(),
431 score: score.clamp(0.0, 1.0),
432 breakdown: components,
433 }
434}
435
436#[derive(Debug, Clone, Serialize)]
441pub struct QuickPick {
442 pub id: String,
443 pub title: String,
444 pub score: f64,
445 pub reasons: Vec<String>,
446 pub unblocks: usize,
447}
448
449#[derive(Debug, Clone, Serialize)]
450pub struct Recommendation {
451 pub id: String,
452 pub title: String,
453 #[serde(rename = "type")]
454 pub issue_type: String,
455 pub status: String,
456 pub priority: i32,
457 pub labels: Vec<String>,
458 pub score: f64,
459 pub impact_score: f64,
460 pub confidence: f64,
461 pub action: String,
462 pub reasons: Vec<String>,
463 pub unblocks: usize,
464 #[serde(skip_serializing_if = "Vec::is_empty")]
465 pub unblocks_ids: Vec<String>,
466 #[serde(skip_serializing_if = "Vec::is_empty")]
467 pub blocked_by: Vec<String>,
468 pub assignee: String,
469 pub claim_command: String,
470 pub show_command: String,
471 #[serde(skip_serializing_if = "Option::is_none")]
472 pub breakdown: Option<Vec<ScoreComponent>>,
473}
474
475#[derive(Debug, Clone, Serialize)]
476pub struct BlockerToClear {
477 pub id: String,
478 pub title: String,
479 pub status: String,
480 pub unblocks: usize,
481}
482
483#[derive(Debug, Clone, Serialize)]
484pub struct RecommendationsByTrack {
485 pub track_id: String,
486 pub top_pick: Option<Recommendation>,
487 pub item_ids: Vec<String>,
488}
489
490#[derive(Debug, Clone, Serialize)]
491pub struct RecommendationsByLabel {
492 pub label: String,
493 pub top_pick: Option<Recommendation>,
494 pub item_ids: Vec<String>,
495}
496
497#[derive(Debug, Clone, Serialize)]
498pub struct QuickRef {
499 pub total_open: usize,
500 pub total_actionable: usize,
501 pub open_count: usize,
502 pub actionable_count: usize,
503 pub blocked_count: usize,
504 pub in_progress_count: usize,
505 pub top_picks: Vec<QuickPick>,
506}
507
508#[derive(Debug, Clone, Serialize)]
509pub struct ProjectHealthCounts {
510 pub total: usize,
511 pub open: usize,
512 pub closed: usize,
513 pub actionable: usize,
514 pub blocked: usize,
515 pub by_status: BTreeMap<String, usize>,
516 pub by_priority: BTreeMap<i32, usize>,
517 pub by_type: BTreeMap<String, usize>,
518}
519
520#[derive(Debug, Clone, Serialize)]
521pub struct ProjectHealthGraph {
522 pub node_count: usize,
523 pub edge_count: usize,
524 pub density: f64,
525 pub has_cycles: bool,
526 pub cycle_count: usize,
527 pub phase2_ready: bool,
528}
529
530#[derive(Debug, Clone, Serialize)]
531pub struct WeeklyClosureCount {
532 pub week_start: chrono::DateTime<Utc>,
533 pub closed: usize,
534}
535
536#[derive(Debug, Clone, Serialize)]
537pub struct ProjectHealthVelocity {
538 pub closed_last_7_days: usize,
539 pub closed_last_30_days: usize,
540 pub avg_days_to_close: f64,
541 pub weekly: Vec<WeeklyClosureCount>,
542}
543
544#[derive(Debug, Clone, Serialize)]
545pub struct ProjectHealth {
546 pub counts: ProjectHealthCounts,
547 pub graph: ProjectHealthGraph,
548 pub velocity: ProjectHealthVelocity,
549}
550
551#[derive(Debug, Clone, Serialize)]
552pub struct TriageResult {
553 pub meta: TriageMeta,
554 pub quick_ref: QuickRef,
555 pub recommendations: Vec<Recommendation>,
556 pub quick_wins: Vec<Recommendation>,
557 pub blockers_to_clear: Vec<BlockerToClear>,
558 pub recommendations_by_track: Vec<RecommendationsByTrack>,
559 pub recommendations_by_label: Vec<RecommendationsByLabel>,
560 pub project_health: ProjectHealth,
561 pub commands: TriageCommands,
562}
563
564#[derive(Debug, Clone, Serialize)]
565pub struct TriageMeta {
566 pub version: &'static str,
567 pub generated_at: String,
568 pub phase2_ready: bool,
569 pub issue_count: usize,
570 pub compute_time_ms: u64,
571}
572
573#[derive(Debug, Clone, Serialize)]
574pub struct TriageCommands {
575 #[serde(skip_serializing_if = "Option::is_none")]
576 pub claim_top: Option<String>,
577 #[serde(skip_serializing_if = "Option::is_none")]
578 pub show_top: Option<String>,
579 pub list_ready: String,
580 pub list_blocked: String,
581 pub refresh_triage: String,
582}
583
584#[derive(Debug, Clone, Serialize)]
586pub struct TriageScoringOptions {
587 pub base_score_weight: f64,
589 pub unblock_boost_weight: f64,
591 pub quick_win_weight: f64,
593 pub unblock_threshold: usize,
595 pub quick_win_max_depth: usize,
597 pub enable_label_health: bool,
599 pub enable_claim_penalty: bool,
601 pub enable_attention_score: bool,
603 #[serde(skip_serializing_if = "Option::is_none")]
605 pub claimed_by_agent: Option<String>,
606 #[serde(skip_serializing_if = "HashMap::is_empty")]
609 pub weight_adjustments: HashMap<String, f64>,
610}
611
612impl Default for TriageScoringOptions {
613 fn default() -> Self {
614 Self {
615 base_score_weight: 0.70,
616 unblock_boost_weight: 0.15,
617 quick_win_weight: 0.15,
618 unblock_threshold: 5,
619 quick_win_max_depth: 2,
620 enable_label_health: false,
621 enable_claim_penalty: false,
622 enable_attention_score: false,
623 claimed_by_agent: None,
624 weight_adjustments: HashMap::new(),
625 }
626 }
627}
628
629#[derive(Debug, Clone, Default)]
630pub struct TriageOptions {
631 pub group_by_track: bool,
632 pub group_by_label: bool,
633 pub max_recommendations: usize,
634 pub scoring: TriageScoringOptions,
635}
636
637#[derive(Debug, Clone)]
638pub struct TriageComputation {
639 pub result: TriageResult,
640 pub score_by_id: HashMap<String, f64>,
641}
642
643pub fn compute_triage(
644 issues: &[Issue],
645 graph: &IssueGraph,
646 metrics: &GraphMetrics,
647 options: &TriageOptions,
648) -> TriageComputation {
649 let max_recommendations = if options.max_recommendations == 0 {
650 50
651 } else {
652 options.max_recommendations
653 };
654
655 let actionable: HashSet<String> = graph.actionable_ids().into_iter().collect();
656 let total_open = issues.iter().filter(|issue| issue.is_open_like()).count();
657
658 let ctx = ScoringContext::from_metrics(metrics, total_open);
659 let lookups = TriageLookupCache::new(issues, graph, metrics);
660
661 let mut recommendations = Vec::<Recommendation>::new();
662 let mut score_by_id = HashMap::<String, f64>::new();
663
664 let scoring = &options.scoring;
665
666 for issue in issues.iter().filter(|issue| actionable.contains(&issue.id)) {
667 let impact =
668 compute_impact_score(issue, metrics, &ctx, &lookups, &scoring.weight_adjustments);
669 let base_score = impact.score;
670
671 let unblocks = metrics
672 .blocks_count
673 .get(&issue.id)
674 .copied()
675 .unwrap_or_default();
676
677 let unblock_boost = if scoring.unblock_threshold > 0 && unblocks > 0 {
679 (unblocks as f64 / scoring.unblock_threshold as f64).min(1.0)
680 } else {
681 0.0
682 };
683
684 let depth = metrics.critical_depth.get(&issue.id).copied().unwrap_or(0);
686 let quick_win_bonus = if depth <= scoring.quick_win_max_depth
687 && issue.priority <= 2
688 && issue.estimated_minutes.is_none_or(|m| m <= 120)
689 {
690 1.0
691 } else {
692 0.0
693 };
694
695 let score = (scoring.base_score_weight * base_score
697 + scoring.unblock_boost_weight * unblock_boost
698 + scoring.quick_win_weight * quick_win_bonus)
699 .clamp(0.0, 1.0);
700
701 let mut reasons = Vec::<String>::new();
703 let pr_norm = impact.breakdown[0].normalized;
704 if pr_norm > 0.6 {
705 reasons.push("high graph centrality".to_string());
706 }
707 if unblocks > 0 {
708 reasons.push(format!("unblocks {unblocks} issues"));
709 }
710 if issue.priority <= 2 {
711 reasons.push("high declared priority".to_string());
712 }
713 if unblock_boost > 0.5 {
714 reasons.push(format!("unblock boost {:.0}%", unblock_boost * 100.0));
715 }
716 if quick_win_bonus > 0.0 {
717 reasons.push("quick win candidate".to_string());
718 }
719 if reasons.is_empty() {
720 reasons.push("ready to execute now".to_string());
721 }
722
723 score_by_id.insert(issue.id.clone(), score);
724
725 let open_blocker_ids: Vec<String> = graph.open_blockers(&issue.id).into_iter().collect();
726 let unblocks_ids: Vec<String> = graph
727 .dependents(&issue.id)
728 .into_iter()
729 .filter(|dep_id| graph.issue(dep_id).is_some_and(Issue::is_open_like))
730 .collect();
731
732 let action = if !open_blocker_ids.is_empty() {
733 format!(
734 "Work on {} first to unblock this",
735 open_blocker_ids.first().unwrap_or(&String::new())
736 )
737 } else if issue.normalized_status() == "in_progress" {
738 "Continue work on this issue".to_string()
739 } else {
740 "Start work on this issue".to_string()
741 };
742
743 recommendations.push(Recommendation {
744 id: issue.id.clone(),
745 title: issue.title.clone(),
746 issue_type: issue.issue_type.clone(),
747 status: issue.status.clone(),
748 priority: issue.priority,
749 labels: issue.labels.clone(),
750 score,
751 impact_score: base_score,
752 confidence: (0.5 + 0.5 * score).clamp(0.0, 1.0),
753 action,
754 reasons,
755 unblocks,
756 unblocks_ids,
757 blocked_by: open_blocker_ids,
758 assignee: issue.assignee.clone(),
759 claim_command: format!("br update {} --status=in_progress", issue.id),
760 show_command: format!("br show {}", issue.id),
761 breakdown: Some(impact.breakdown),
762 });
763 }
764
765 recommendations.sort_by(|left, right| {
766 right
767 .score
768 .total_cmp(&left.score)
769 .then_with(|| left.id.cmp(&right.id))
770 });
771 recommendations.truncate(max_recommendations);
772
773 let top_picks: Vec<QuickPick> = recommendations
776 .iter()
777 .filter(|rec| {
778 lookups
779 .issue_by_id
780 .get(rec.id.as_str())
781 .is_none_or(|issue| issue.normalized_status() != "in_progress")
782 })
783 .take(3)
784 .map(|rec| QuickPick {
785 id: rec.id.clone(),
786 title: rec.title.clone(),
787 score: rec.score,
788 reasons: rec.reasons.clone(),
789 unblocks: rec.unblocks,
790 })
791 .collect();
792
793 let quick_wins = recommendations
794 .iter()
795 .filter(|rec| {
796 lookups
797 .issue_by_id
798 .get(rec.id.as_str())
799 .is_some_and(|issue| {
800 issue.estimated_minutes.is_some_and(|mins| mins <= 120)
801 || (issue.priority <= 2 && rec.unblocks > 0)
802 })
803 })
804 .take(10)
805 .cloned()
806 .collect();
807
808 let blockers_to_clear = compute_blockers_to_clear(issues, metrics, &actionable, &lookups);
809
810 let recommendations_by_track = if options.group_by_track {
811 compute_recommendations_by_track(graph, &recommendations)
812 } else {
813 Vec::new()
814 };
815
816 let recommendations_by_label = if options.group_by_label {
817 compute_recommendations_by_label(&recommendations)
818 } else {
819 Vec::new()
820 };
821
822 let blocked_count = total_open.saturating_sub(actionable.len());
823 let in_progress_count = issues
824 .iter()
825 .filter(|issue| issue.is_open_like() && issue.normalized_status() == "in_progress")
826 .count();
827
828 let claim_top = top_picks
829 .first()
830 .map(|pick| format!("CI=1 br update {} --status in_progress --json", pick.id));
831 let show_top = top_picks
832 .first()
833 .map(|pick| format!("CI=1 br show {} --json", pick.id));
834
835 let result = TriageResult {
836 meta: TriageMeta {
837 version: env!("CARGO_PKG_VERSION"),
838 generated_at: chrono::Utc::now().to_rfc3339(),
839 phase2_ready: true,
840 issue_count: issues.len(),
841 compute_time_ms: 0,
842 },
843 quick_ref: QuickRef {
844 total_open,
845 total_actionable: actionable.len(),
846 open_count: total_open,
847 actionable_count: actionable.len(),
848 blocked_count,
849 in_progress_count,
850 top_picks,
851 },
852 recommendations,
853 quick_wins,
854 blockers_to_clear,
855 recommendations_by_track,
856 recommendations_by_label,
857 project_health: compute_project_health(
858 issues,
859 graph,
860 metrics,
861 total_open,
862 actionable.len(),
863 ),
864 commands: TriageCommands {
865 claim_top,
866 show_top,
867 list_ready: "CI=1 br ready --json".to_string(),
868 list_blocked: "CI=1 br blocked --json".to_string(),
869 refresh_triage: "bvr --robot-triage".to_string(),
870 },
871 };
872
873 TriageComputation {
874 result,
875 score_by_id,
876 }
877}
878
879fn compute_project_health(
880 issues: &[Issue],
881 graph: &IssueGraph,
882 metrics: &GraphMetrics,
883 total_open: usize,
884 actionable_count: usize,
885) -> ProjectHealth {
886 let mut by_status = BTreeMap::<String, usize>::new();
887 let mut by_priority = BTreeMap::<i32, usize>::new();
888 let mut by_type = BTreeMap::<String, usize>::new();
889 let mut closed_count = 0usize;
890 let mut in_progress_count = 0usize;
891 let mut closed_last_7_days = 0usize;
892 let mut closed_last_30_days = 0usize;
893 let mut total_close_days = 0.0_f64;
894 let mut close_samples = 0usize;
895 let now = Utc::now();
896 let week_starts = recent_week_starts(now, 8);
897 let mut weekly = week_starts
898 .iter()
899 .copied()
900 .map(|week_start| WeeklyClosureCount {
901 week_start,
902 closed: 0,
903 })
904 .collect::<Vec<_>>();
905
906 for issue in issues {
907 *by_status.entry(issue.normalized_status()).or_default() += 1;
908 *by_priority.entry(issue.priority).or_default() += 1;
909 *by_type.entry(issue.issue_type.clone()).or_default() += 1;
910
911 if issue.is_open_like() && issue.normalized_status() == "in_progress" {
912 in_progress_count = in_progress_count.saturating_add(1);
913 }
914
915 if !issue.is_closed_like() {
916 continue;
917 }
918
919 closed_count = closed_count.saturating_add(1);
920
921 let Some(closed_at) = issue.closed_at.or(issue.updated_at) else {
922 continue;
923 };
924
925 if closed_at >= now - Duration::days(7) {
926 closed_last_7_days = closed_last_7_days.saturating_add(1);
927 }
928 if closed_at >= now - Duration::days(30) {
929 closed_last_30_days = closed_last_30_days.saturating_add(1);
930 }
931 if let Some(created_at) = issue.created_at {
932 total_close_days += (closed_at - created_at).num_hours() as f64 / 24.0;
933 close_samples = close_samples.saturating_add(1);
934 }
935
936 for bucket in &mut weekly {
937 let week_end = bucket.week_start + Duration::days(7);
938 if closed_at >= bucket.week_start && closed_at < week_end {
939 bucket.closed = bucket.closed.saturating_add(1);
940 break;
941 }
942 }
943 }
944
945 let blocked_count = total_open.saturating_sub(actionable_count);
946 let node_count = graph.node_count();
947 let edge_count = graph.edge_count();
948 let density = if node_count <= 1 {
949 0.0
950 } else {
951 edge_count as f64 / (node_count * (node_count - 1)) as f64
952 };
953
954 ProjectHealth {
955 counts: ProjectHealthCounts {
956 total: issues.len(),
957 open: total_open,
958 closed: closed_count,
959 actionable: actionable_count,
960 blocked: blocked_count,
961 by_status,
962 by_priority,
963 by_type,
964 },
965 graph: ProjectHealthGraph {
966 node_count,
967 edge_count,
968 density,
969 has_cycles: !metrics.cycles.is_empty(),
970 cycle_count: metrics.cycles.len(),
971 phase2_ready: triage_phase2_ready(metrics),
972 },
973 velocity: ProjectHealthVelocity {
974 closed_last_7_days,
975 closed_last_30_days,
976 avg_days_to_close: if close_samples == 0 {
977 0.0
978 } else {
979 total_close_days / close_samples as f64
980 },
981 weekly,
982 },
983 }
984}
985
986fn triage_phase2_ready(metrics: &GraphMetrics) -> bool {
987 !metrics
988 .skipped_metrics
989 .iter()
990 .any(|metric| metric.metric == "Betweenness")
991}
992
993fn recent_week_starts(now: chrono::DateTime<Utc>, count: usize) -> Vec<chrono::DateTime<Utc>> {
994 let weekday_offset = match now.weekday() {
995 Weekday::Mon => 0,
996 Weekday::Tue => 1,
997 Weekday::Wed => 2,
998 Weekday::Thu => 3,
999 Weekday::Fri => 4,
1000 Weekday::Sat => 5,
1001 Weekday::Sun => 6,
1002 };
1003 let start_of_week = now.date_naive().and_hms_opt(0, 0, 0).unwrap_or_default()
1004 - Duration::days(i64::from(weekday_offset));
1005
1006 (0..count)
1007 .map(|index| {
1008 chrono::DateTime::<Utc>::from_naive_utc_and_offset(
1009 start_of_week - Duration::days((index * 7) as i64),
1010 Utc,
1011 )
1012 })
1013 .collect()
1014}
1015
1016fn compute_blockers_to_clear(
1017 issues: &[Issue],
1018 metrics: &GraphMetrics,
1019 actionable: &HashSet<String>,
1020 lookups: &TriageLookupCache<'_>,
1021) -> Vec<BlockerToClear> {
1022 let mut blockers = Vec::<BlockerToClear>::new();
1023
1024 for issue in issues
1025 .iter()
1026 .filter(|issue| issue.is_open_like() && !actionable.contains(&issue.id))
1027 {
1028 let unblocks = metrics
1029 .blocks_count
1030 .get(&issue.id)
1031 .copied()
1032 .unwrap_or_default();
1033 if unblocks == 0 {
1034 continue;
1035 }
1036 if lookups
1037 .open_blocker_count_by_issue
1038 .get(issue.id.as_str())
1039 .copied()
1040 .unwrap_or_default()
1041 == 0
1042 {
1043 continue;
1044 }
1045
1046 blockers.push(BlockerToClear {
1047 id: issue.id.clone(),
1048 title: issue.title.clone(),
1049 status: issue.status.clone(),
1050 unblocks,
1051 });
1052 }
1053
1054 blockers.sort_by(|left, right| {
1055 right
1056 .unblocks
1057 .cmp(&left.unblocks)
1058 .then_with(|| left.id.cmp(&right.id))
1059 });
1060 blockers.truncate(15);
1061 blockers
1062}
1063
1064fn compute_recommendations_by_track(
1065 graph: &IssueGraph,
1066 recommendations: &[Recommendation],
1067) -> Vec<RecommendationsByTrack> {
1068 let component_lookup = graph.connected_open_components();
1069 let rec_by_id: HashMap<&str, &Recommendation> = recommendations
1070 .iter()
1071 .map(|rec| (rec.id.as_str(), rec))
1072 .collect();
1073
1074 let mut by_track = Vec::<RecommendationsByTrack>::new();
1075
1076 for (index, component) in component_lookup.iter().enumerate() {
1077 let mut items: Vec<&Recommendation> = component
1078 .iter()
1079 .filter_map(|id| rec_by_id.get(id.as_str()).copied())
1080 .collect();
1081
1082 items.sort_by(|left, right| {
1083 right
1084 .score
1085 .total_cmp(&left.score)
1086 .then_with(|| left.id.cmp(&right.id))
1087 });
1088
1089 if items.is_empty() {
1090 continue;
1091 }
1092
1093 by_track.push(RecommendationsByTrack {
1094 track_id: format!("track-{}", index + 1),
1095 top_pick: items.first().map(|item| (*item).clone()),
1096 item_ids: items.into_iter().map(|item| item.id.clone()).collect(),
1097 });
1098 }
1099
1100 by_track
1101}
1102
1103fn compute_recommendations_by_label(
1104 recommendations: &[Recommendation],
1105) -> Vec<RecommendationsByLabel> {
1106 let mut groups: BTreeMap<String, Vec<Recommendation>> = BTreeMap::new();
1107
1108 for rec in recommendations {
1109 for label in &rec.labels {
1110 groups.entry(label.clone()).or_default().push(rec.clone());
1111 }
1112 }
1113
1114 let mut out = Vec::<RecommendationsByLabel>::new();
1115 for (label, mut recs) in groups {
1116 recs.sort_by(|left, right| {
1117 right
1118 .score
1119 .total_cmp(&left.score)
1120 .then_with(|| left.id.cmp(&right.id))
1121 });
1122
1123 out.push(RecommendationsByLabel {
1124 label,
1125 top_pick: recs.first().cloned(),
1126 item_ids: recs.into_iter().map(|rec| rec.id).collect(),
1127 });
1128 }
1129
1130 out
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use std::collections::HashMap;
1136
1137 use crate::analysis::graph::{GraphMetrics, IssueGraph};
1138 use crate::model::Issue;
1139
1140 use super::{TriageOptions, TriageScoringOptions, WeightPreset, compute_triage};
1141
1142 fn test_lookups<'a>(
1143 issues: &'a [Issue],
1144 graph: &IssueGraph,
1145 metrics: &'a GraphMetrics,
1146 ) -> super::TriageLookupCache<'a> {
1147 super::TriageLookupCache::new(issues, graph, metrics)
1148 }
1149
1150 #[test]
1151 fn triage_produces_recommendations() {
1152 let issues = vec![
1153 Issue {
1154 id: "A".to_string(),
1155 title: "Root".to_string(),
1156 status: "open".to_string(),
1157 issue_type: "task".to_string(),
1158 priority: 1,
1159 ..Issue::default()
1160 },
1161 Issue {
1162 id: "B".to_string(),
1163 title: "Depends on A".to_string(),
1164 status: "blocked".to_string(),
1165 issue_type: "task".to_string(),
1166 priority: 2,
1167 dependencies: vec![crate::model::Dependency {
1168 depends_on_id: "A".to_string(),
1169 dep_type: "blocks".to_string(),
1170 ..crate::model::Dependency::default()
1171 }],
1172 ..Issue::default()
1173 },
1174 ];
1175
1176 let graph = IssueGraph::build(&issues);
1177 let metrics = graph.compute_metrics();
1178
1179 let triage = compute_triage(
1180 &issues,
1181 &graph,
1182 &metrics,
1183 &TriageOptions {
1184 group_by_track: true,
1185 group_by_label: true,
1186 max_recommendations: 10,
1187 ..TriageOptions::default()
1188 },
1189 );
1190
1191 assert_eq!(triage.result.quick_ref.total_open, 2);
1192 assert_eq!(triage.result.quick_ref.total_actionable, 1);
1193 assert_eq!(triage.result.quick_ref.open_count, 2);
1194 assert_eq!(triage.result.quick_ref.actionable_count, 1);
1195 assert_eq!(triage.result.quick_ref.blocked_count, 1);
1196 assert_eq!(triage.result.quick_ref.in_progress_count, 0);
1197 assert_eq!(triage.result.recommendations.len(), 1);
1198 assert_eq!(triage.result.recommendations[0].id, "A");
1199 assert_eq!(triage.result.project_health.counts.total, 2);
1200 assert_eq!(triage.result.project_health.counts.open, 2);
1201 assert_eq!(triage.result.project_health.counts.blocked, 1);
1202 assert_eq!(triage.result.project_health.graph.node_count, 2);
1203 assert_eq!(triage.result.project_health.graph.edge_count, 1);
1204 assert!(triage.result.project_health.graph.phase2_ready);
1205 }
1206
1207 #[test]
1208 fn triage_empty_issues_produces_zero_recommendations() {
1209 let issues: Vec<Issue> = vec![];
1210 let graph = IssueGraph::build(&issues);
1211 let metrics = graph.compute_metrics();
1212 let triage = compute_triage(
1213 &issues,
1214 &graph,
1215 &metrics,
1216 &TriageOptions {
1217 group_by_track: false,
1218 group_by_label: false,
1219 max_recommendations: 10,
1220 ..TriageOptions::default()
1221 },
1222 );
1223 assert_eq!(triage.result.quick_ref.total_open, 0);
1224 assert_eq!(triage.result.quick_ref.total_actionable, 0);
1225 assert_eq!(triage.result.quick_ref.open_count, 0);
1226 assert_eq!(triage.result.quick_ref.actionable_count, 0);
1227 assert_eq!(triage.result.quick_ref.blocked_count, 0);
1228 assert_eq!(triage.result.quick_ref.in_progress_count, 0);
1229 assert!(triage.result.recommendations.is_empty());
1230 assert!(triage.result.blockers_to_clear.is_empty());
1231 assert_eq!(triage.result.project_health.counts.total, 0);
1232 assert_eq!(triage.result.project_health.velocity.weekly.len(), 8);
1233 }
1234
1235 #[test]
1236 fn triage_all_closed_produces_no_actionable() {
1237 let issues = vec![
1238 Issue {
1239 id: "A".to_string(),
1240 title: "Done".to_string(),
1241 status: "closed".to_string(),
1242 issue_type: "task".to_string(),
1243 ..Issue::default()
1244 },
1245 Issue {
1246 id: "B".to_string(),
1247 title: "Also done".to_string(),
1248 status: "tombstone".to_string(),
1249 issue_type: "task".to_string(),
1250 ..Issue::default()
1251 },
1252 ];
1253 let graph = IssueGraph::build(&issues);
1254 let metrics = graph.compute_metrics();
1255 let triage = compute_triage(
1256 &issues,
1257 &graph,
1258 &metrics,
1259 &TriageOptions {
1260 group_by_track: false,
1261 group_by_label: false,
1262 max_recommendations: 10,
1263 ..TriageOptions::default()
1264 },
1265 );
1266 assert_eq!(triage.result.quick_ref.total_open, 0);
1267 assert_eq!(triage.result.quick_ref.open_count, 0);
1268 assert!(triage.result.recommendations.is_empty());
1269 }
1270
1271 #[test]
1272 fn triage_includes_actionable_in_progress_items() {
1273 let issues = vec![
1274 Issue {
1275 id: "A".to_string(),
1276 title: "Already claimed blocker".to_string(),
1277 status: "in_progress".to_string(),
1278 issue_type: "task".to_string(),
1279 priority: 1,
1280 ..Issue::default()
1281 },
1282 Issue {
1283 id: "B".to_string(),
1284 title: "Blocked by A".to_string(),
1285 status: "blocked".to_string(),
1286 issue_type: "task".to_string(),
1287 priority: 2,
1288 dependencies: vec![crate::model::Dependency {
1289 depends_on_id: "A".to_string(),
1290 dep_type: "blocks".to_string(),
1291 ..crate::model::Dependency::default()
1292 }],
1293 ..Issue::default()
1294 },
1295 ];
1296 let graph = IssueGraph::build(&issues);
1297 let metrics = graph.compute_metrics();
1298 let triage = compute_triage(
1299 &issues,
1300 &graph,
1301 &metrics,
1302 &TriageOptions {
1303 max_recommendations: 10,
1304 ..TriageOptions::default()
1305 },
1306 );
1307
1308 assert_eq!(triage.result.recommendations.len(), 1);
1309 assert_eq!(triage.result.recommendations[0].id, "A");
1310 assert_eq!(triage.result.recommendations[0].status, "in_progress");
1311 assert!(
1314 triage.result.quick_ref.top_picks.is_empty(),
1315 "top_picks should exclude in_progress items"
1316 );
1317 }
1318
1319 #[test]
1320 fn triage_max_recommendations_limits_output() {
1321 let issues: Vec<Issue> = (0..20)
1322 .map(|i| Issue {
1323 id: format!("X-{i}"),
1324 title: format!("Issue {i}"),
1325 status: "open".to_string(),
1326 issue_type: "task".to_string(),
1327 priority: 1,
1328 ..Issue::default()
1329 })
1330 .collect();
1331 let graph = IssueGraph::build(&issues);
1332 let metrics = graph.compute_metrics();
1333 let triage = compute_triage(
1334 &issues,
1335 &graph,
1336 &metrics,
1337 &TriageOptions {
1338 group_by_track: false,
1339 group_by_label: false,
1340 max_recommendations: 5,
1341 ..TriageOptions::default()
1342 },
1343 );
1344 assert!(triage.result.recommendations.len() <= 5);
1345 }
1346
1347 #[test]
1348 fn triage_scores_are_sorted_descending() {
1349 let issues: Vec<Issue> = (0..5)
1350 .map(|i| Issue {
1351 id: format!("P-{i}"),
1352 title: format!("Task {i}"),
1353 status: "open".to_string(),
1354 issue_type: "task".to_string(),
1355 priority: i + 1,
1356 ..Issue::default()
1357 })
1358 .collect();
1359 let graph = IssueGraph::build(&issues);
1360 let metrics = graph.compute_metrics();
1361 let triage = compute_triage(
1362 &issues,
1363 &graph,
1364 &metrics,
1365 &TriageOptions {
1366 group_by_track: false,
1367 group_by_label: false,
1368 max_recommendations: 10,
1369 ..TriageOptions::default()
1370 },
1371 );
1372 let scores: Vec<f64> = triage
1373 .result
1374 .recommendations
1375 .iter()
1376 .map(|r| r.score)
1377 .collect();
1378 for w in scores.windows(2) {
1379 assert!(
1380 w[0] >= w[1],
1381 "scores should be descending: {} >= {}",
1382 w[0],
1383 w[1]
1384 );
1385 }
1386 }
1387
1388 #[test]
1389 fn triage_blockers_to_clear_identifies_chained_blockers() {
1390 let issues = vec![
1393 Issue {
1394 id: "A".to_string(),
1395 title: "Root".to_string(),
1396 status: "open".to_string(),
1397 issue_type: "task".to_string(),
1398 priority: 1,
1399 ..Issue::default()
1400 },
1401 Issue {
1402 id: "B".to_string(),
1403 title: "Middle".to_string(),
1404 status: "open".to_string(),
1405 issue_type: "task".to_string(),
1406 priority: 2,
1407 dependencies: vec![crate::model::Dependency {
1408 depends_on_id: "A".to_string(),
1409 dep_type: "blocks".to_string(),
1410 ..crate::model::Dependency::default()
1411 }],
1412 ..Issue::default()
1413 },
1414 Issue {
1415 id: "C".to_string(),
1416 title: "Leaf 1".to_string(),
1417 status: "open".to_string(),
1418 issue_type: "task".to_string(),
1419 priority: 3,
1420 dependencies: vec![crate::model::Dependency {
1421 depends_on_id: "B".to_string(),
1422 dep_type: "blocks".to_string(),
1423 ..crate::model::Dependency::default()
1424 }],
1425 ..Issue::default()
1426 },
1427 Issue {
1428 id: "D".to_string(),
1429 title: "Leaf 2".to_string(),
1430 status: "open".to_string(),
1431 issue_type: "task".to_string(),
1432 priority: 3,
1433 dependencies: vec![crate::model::Dependency {
1434 depends_on_id: "B".to_string(),
1435 dep_type: "blocks".to_string(),
1436 ..crate::model::Dependency::default()
1437 }],
1438 ..Issue::default()
1439 },
1440 ];
1441 let graph = IssueGraph::build(&issues);
1442 let metrics = graph.compute_metrics();
1443 let triage = compute_triage(
1444 &issues,
1445 &graph,
1446 &metrics,
1447 &TriageOptions {
1448 group_by_track: false,
1449 group_by_label: false,
1450 max_recommendations: 10,
1451 ..TriageOptions::default()
1452 },
1453 );
1454 let b_blocker = triage.result.blockers_to_clear.iter().find(|b| b.id == "B");
1456 assert!(
1457 b_blocker.is_some(),
1458 "B should be in blockers_to_clear (got {:?})",
1459 triage.result.blockers_to_clear
1460 );
1461 assert!(
1462 b_blocker.unwrap().unblocks >= 2,
1463 "B should unblock 2+ issues"
1464 );
1465 }
1466
1467 #[test]
1468 fn triage_group_by_label_produces_label_groups() {
1469 let issues = vec![
1470 Issue {
1471 id: "A".to_string(),
1472 title: "UI fix".to_string(),
1473 status: "open".to_string(),
1474 issue_type: "task".to_string(),
1475 labels: vec!["ui".to_string()],
1476 ..Issue::default()
1477 },
1478 Issue {
1479 id: "B".to_string(),
1480 title: "API fix".to_string(),
1481 status: "open".to_string(),
1482 issue_type: "task".to_string(),
1483 labels: vec!["api".to_string()],
1484 ..Issue::default()
1485 },
1486 ];
1487 let graph = IssueGraph::build(&issues);
1488 let metrics = graph.compute_metrics();
1489 let triage = compute_triage(
1490 &issues,
1491 &graph,
1492 &metrics,
1493 &TriageOptions {
1494 group_by_track: false,
1495 group_by_label: true,
1496 max_recommendations: 10,
1497 ..TriageOptions::default()
1498 },
1499 );
1500 assert!(
1501 !triage.result.recommendations_by_label.is_empty(),
1502 "should group by label"
1503 );
1504 }
1505
1506 #[test]
1507 fn triage_omits_top_commands_when_no_actionable_items_exist() {
1508 let issues = vec![Issue {
1509 id: "A".to_string(),
1510 title: "Closed".to_string(),
1511 status: "closed".to_string(),
1512 issue_type: "task".to_string(),
1513 ..Issue::default()
1514 }];
1515 let graph = IssueGraph::build(&issues);
1516 let metrics = graph.compute_metrics();
1517 let triage = compute_triage(&issues, &graph, &metrics, &TriageOptions::default());
1518
1519 assert!(triage.result.commands.claim_top.is_none());
1520 assert!(triage.result.commands.show_top.is_none());
1521 assert_eq!(triage.result.commands.list_ready, "CI=1 br ready --json");
1522 }
1523
1524 #[test]
1527 fn impact_score_has_8_components() {
1528 let issues = vec![Issue {
1529 id: "A".to_string(),
1530 title: "Root".to_string(),
1531 status: "open".to_string(),
1532 issue_type: "task".to_string(),
1533 priority: 1,
1534 ..Issue::default()
1535 }];
1536 let graph = IssueGraph::build(&issues);
1537 let metrics = graph.compute_metrics();
1538 let ctx = super::ScoringContext::from_metrics(&metrics, 1);
1539 let lookups = test_lookups(&issues, &graph, &metrics);
1540 let score =
1541 super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
1542
1543 assert_eq!(score.breakdown.len(), 8);
1544 assert_eq!(score.breakdown[0].name, "PageRank");
1545 assert_eq!(score.breakdown[1].name, "Betweenness");
1546 assert_eq!(score.breakdown[2].name, "BlockerRatio");
1547 assert_eq!(score.breakdown[3].name, "Staleness");
1548 assert_eq!(score.breakdown[4].name, "PriorityBoost");
1549 assert_eq!(score.breakdown[5].name, "TimeToImpact");
1550 assert_eq!(score.breakdown[6].name, "Urgency");
1551 assert_eq!(score.breakdown[7].name, "Risk");
1552 }
1553
1554 #[test]
1555 fn impact_score_weights_sum_to_one() {
1556 let total: f64 = [
1557 super::W_PAGERANK,
1558 super::W_BETWEENNESS,
1559 super::W_BLOCKER_RATIO,
1560 super::W_STALENESS,
1561 super::W_PRIORITY_BOOST,
1562 super::W_TIME_TO_IMPACT,
1563 super::W_URGENCY,
1564 super::W_RISK,
1565 ]
1566 .iter()
1567 .sum();
1568 assert!(
1569 (total - 1.0).abs() < 1e-9,
1570 "weights should sum to 1.0, got {total}"
1571 );
1572 }
1573
1574 #[test]
1575 fn impact_score_bounded_zero_one() {
1576 let issues = vec![
1577 Issue {
1578 id: "A".to_string(),
1579 title: "Root".to_string(),
1580 status: "open".to_string(),
1581 issue_type: "task".to_string(),
1582 priority: 1,
1583 ..Issue::default()
1584 },
1585 Issue {
1586 id: "B".to_string(),
1587 title: "Leaf".to_string(),
1588 status: "open".to_string(),
1589 issue_type: "task".to_string(),
1590 priority: 5,
1591 dependencies: vec![crate::model::Dependency {
1592 depends_on_id: "A".to_string(),
1593 dep_type: "blocks".to_string(),
1594 ..crate::model::Dependency::default()
1595 }],
1596 ..Issue::default()
1597 },
1598 ];
1599 let graph = IssueGraph::build(&issues);
1600 let metrics = graph.compute_metrics();
1601 let ctx = super::ScoringContext::from_metrics(&metrics, 2);
1602 let lookups = test_lookups(&issues, &graph, &metrics);
1603
1604 for issue in &issues {
1605 let score =
1606 super::compute_impact_score(issue, &metrics, &ctx, &lookups, &HashMap::new());
1607 assert!(
1608 score.score >= 0.0 && score.score <= 1.0,
1609 "score for {} should be in [0,1], got {}",
1610 issue.id,
1611 score.score
1612 );
1613 for comp in &score.breakdown {
1614 assert!(
1615 comp.normalized >= 0.0 && comp.normalized <= 1.0,
1616 "normalized {} for {} should be in [0,1], got {}",
1617 comp.name,
1618 issue.id,
1619 comp.normalized
1620 );
1621 }
1622 }
1623 }
1624
1625 #[test]
1626 fn impact_score_empty_graph() {
1627 let issues: Vec<Issue> = Vec::new();
1628 let graph = IssueGraph::build(&issues);
1629 let metrics = graph.compute_metrics();
1630 let triage = compute_triage(&issues, &graph, &metrics, &TriageOptions::default());
1631 assert!(triage.result.recommendations.is_empty());
1632 }
1633
1634 #[test]
1635 fn impact_score_single_node() {
1636 let issues = vec![Issue {
1637 id: "A".to_string(),
1638 title: "Only".to_string(),
1639 status: "open".to_string(),
1640 issue_type: "task".to_string(),
1641 priority: 2,
1642 ..Issue::default()
1643 }];
1644 let graph = IssueGraph::build(&issues);
1645 let metrics = graph.compute_metrics();
1646 let ctx = super::ScoringContext::from_metrics(&metrics, 1);
1647 let lookups = test_lookups(&issues, &graph, &metrics);
1648 let score =
1649 super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
1650
1651 assert!(score.score > 0.0);
1653 assert_eq!(score.breakdown.len(), 8);
1654 }
1655
1656 #[test]
1657 fn impact_score_blocker_scores_higher_than_leaf() {
1658 let issues = vec![
1659 Issue {
1660 id: "A".to_string(),
1661 title: "Root blocker".to_string(),
1662 status: "open".to_string(),
1663 issue_type: "task".to_string(),
1664 priority: 1,
1665 ..Issue::default()
1666 },
1667 Issue {
1668 id: "B".to_string(),
1669 title: "Blocked leaf".to_string(),
1670 status: "open".to_string(),
1671 issue_type: "task".to_string(),
1672 priority: 4,
1673 dependencies: vec![crate::model::Dependency {
1674 depends_on_id: "A".to_string(),
1675 dep_type: "blocks".to_string(),
1676 ..crate::model::Dependency::default()
1677 }],
1678 ..Issue::default()
1679 },
1680 ];
1681 let graph = IssueGraph::build(&issues);
1682 let metrics = graph.compute_metrics();
1683 let ctx = super::ScoringContext::from_metrics(&metrics, 2);
1684 let lookups = test_lookups(&issues, &graph, &metrics);
1685
1686 let score_a =
1687 super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
1688 let score_b =
1689 super::compute_impact_score(&issues[1], &metrics, &ctx, &lookups, &HashMap::new());
1690
1691 assert!(
1692 score_a.score > score_b.score,
1693 "root blocker ({}) should score higher than blocked leaf ({})",
1694 score_a.score,
1695 score_b.score
1696 );
1697 }
1698
1699 #[test]
1700 fn impact_score_breakdown_included_in_recommendations() {
1701 let issues = vec![Issue {
1702 id: "A".to_string(),
1703 title: "Task".to_string(),
1704 status: "open".to_string(),
1705 issue_type: "task".to_string(),
1706 priority: 2,
1707 ..Issue::default()
1708 }];
1709 let graph = IssueGraph::build(&issues);
1710 let metrics = graph.compute_metrics();
1711 let triage = compute_triage(
1712 &issues,
1713 &graph,
1714 &metrics,
1715 &TriageOptions {
1716 max_recommendations: 10,
1717 ..TriageOptions::default()
1718 },
1719 );
1720
1721 assert_eq!(triage.result.recommendations.len(), 1);
1722 let rec = &triage.result.recommendations[0];
1723 assert!(rec.breakdown.is_some(), "breakdown should be present");
1724 let bd = rec.breakdown.as_ref().unwrap();
1725 assert_eq!(bd.len(), 8);
1726 }
1727
1728 #[test]
1729 fn impact_score_risk_penalizes_cyclic_issues() {
1730 let issues = vec![
1731 Issue {
1732 id: "A".to_string(),
1733 title: "In cycle".to_string(),
1734 status: "open".to_string(),
1735 issue_type: "task".to_string(),
1736 priority: 2,
1737 dependencies: vec![crate::model::Dependency {
1738 depends_on_id: "B".to_string(),
1739 dep_type: "blocks".to_string(),
1740 ..crate::model::Dependency::default()
1741 }],
1742 ..Issue::default()
1743 },
1744 Issue {
1745 id: "B".to_string(),
1746 title: "In cycle".to_string(),
1747 status: "open".to_string(),
1748 issue_type: "task".to_string(),
1749 priority: 2,
1750 dependencies: vec![crate::model::Dependency {
1751 depends_on_id: "A".to_string(),
1752 dep_type: "blocks".to_string(),
1753 ..crate::model::Dependency::default()
1754 }],
1755 ..Issue::default()
1756 },
1757 ];
1758 let graph = IssueGraph::build(&issues);
1759 let metrics = graph.compute_metrics();
1760 let ctx = super::ScoringContext::from_metrics(&metrics, 2);
1761 let lookups = test_lookups(&issues, &graph, &metrics);
1762 let score =
1763 super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
1764
1765 let risk = &score.breakdown[7];
1767 assert_eq!(risk.name, "Risk");
1768 assert!(
1769 risk.raw > 0.0,
1770 "risk raw should be > 0 for cyclic issue, got {}",
1771 risk.raw
1772 );
1773 assert!(risk.explanation.is_some());
1774 }
1775
1776 #[test]
1777 fn impact_score_large_graph_correctness() {
1778 let mut issues: Vec<Issue> = (0..100)
1779 .map(|i| Issue {
1780 id: format!("N-{i}"),
1781 title: format!("Node {i}"),
1782 status: "open".to_string(),
1783 issue_type: "task".to_string(),
1784 priority: (i % 4) + 1,
1785 ..Issue::default()
1786 })
1787 .collect();
1788 for i in 1..20 {
1790 issues[i].dependencies.push(crate::model::Dependency {
1791 issue_id: format!("N-{i}"),
1792 depends_on_id: format!("N-{}", i - 1),
1793 dep_type: "blocks".to_string(),
1794 ..crate::model::Dependency::default()
1795 });
1796 }
1797
1798 let graph = IssueGraph::build(&issues);
1799 let metrics = graph.compute_metrics();
1800 let ctx = super::ScoringContext::from_metrics(&metrics, issues.len());
1801 let lookups = test_lookups(&issues, &graph, &metrics);
1802
1803 for issue in &issues {
1804 let score =
1805 super::compute_impact_score(issue, &metrics, &ctx, &lookups, &HashMap::new());
1806 assert!(
1807 score.score >= 0.0 && score.score <= 1.0,
1808 "score for {} out of range: {}",
1809 issue.id,
1810 score.score
1811 );
1812 assert_eq!(score.breakdown.len(), 8);
1813 let sum: f64 = score.breakdown.iter().map(|c| c.weighted).sum();
1815 assert!(
1816 (sum - score.score).abs() < 1e-9,
1817 "breakdown sum ({sum}) != score ({}) for {}",
1818 score.score,
1819 issue.id,
1820 );
1821 }
1822 }
1823
1824 #[test]
1829 fn scoring_options_defaults_match_go() {
1830 let opts = TriageScoringOptions::default();
1831 assert!((opts.base_score_weight - 0.70).abs() < 1e-9);
1832 assert!((opts.unblock_boost_weight - 0.15).abs() < 1e-9);
1833 assert!((opts.quick_win_weight - 0.15).abs() < 1e-9);
1834 assert_eq!(opts.unblock_threshold, 5);
1835 assert_eq!(opts.quick_win_max_depth, 2);
1836 assert!(!opts.enable_label_health);
1837 assert!(!opts.enable_claim_penalty);
1838 assert!(!opts.enable_attention_score);
1839 assert!(opts.claimed_by_agent.is_none());
1840 let sum = opts.base_score_weight + opts.unblock_boost_weight + opts.quick_win_weight;
1842 assert!(
1843 (sum - 1.0).abs() < 1e-9,
1844 "weights should sum to 1.0, got {sum}"
1845 );
1846 }
1847
1848 #[test]
1849 fn scoring_options_serializes_to_json() {
1850 let opts = TriageScoringOptions::default();
1851 let json = serde_json::to_value(&opts).unwrap();
1852 assert_eq!(json["base_score_weight"], 0.7);
1853 assert_eq!(json["unblock_boost_weight"], 0.15);
1854 assert_eq!(json["quick_win_weight"], 0.15);
1855 assert_eq!(json["unblock_threshold"], 5);
1856 assert_eq!(json["quick_win_max_depth"], 2);
1857 assert_eq!(json["enable_label_health"], false);
1858 assert!(json.get("claimed_by_agent").is_none());
1860 }
1861
1862 #[test]
1863 fn scoring_options_unblock_boost_increases_score() {
1864 let mut issues = vec![
1867 Issue {
1868 id: "A".to_string(),
1869 title: "Blocker".to_string(),
1870 status: "open".to_string(),
1871 issue_type: "task".to_string(),
1872 priority: 2,
1873 ..Issue::default()
1874 },
1875 Issue {
1876 id: "B".to_string(),
1877 title: "Leaf".to_string(),
1878 status: "open".to_string(),
1879 issue_type: "task".to_string(),
1880 priority: 2,
1881 ..Issue::default()
1882 },
1883 ];
1884
1885 for i in 0..6 {
1887 issues.push(Issue {
1888 id: format!("D{i}"),
1889 title: format!("Dep {i}"),
1890 status: "blocked".to_string(),
1891 issue_type: "task".to_string(),
1892 dependencies: vec![crate::model::Dependency {
1893 depends_on_id: "A".to_string(),
1894 dep_type: "blocks".to_string(),
1895 ..crate::model::Dependency::default()
1896 }],
1897 ..Issue::default()
1898 });
1899 }
1900
1901 let graph = IssueGraph::build(&issues);
1902 let metrics = graph.compute_metrics();
1903 let triage = compute_triage(
1904 &issues,
1905 &graph,
1906 &metrics,
1907 &TriageOptions {
1908 max_recommendations: 10,
1909 ..TriageOptions::default()
1910 },
1911 );
1912
1913 let rec_a = triage.result.recommendations.iter().find(|r| r.id == "A");
1914 let rec_b = triage.result.recommendations.iter().find(|r| r.id == "B");
1915 assert!(rec_a.is_some(), "A should be recommended");
1916 assert!(rec_b.is_some(), "B should be recommended");
1917 assert!(
1918 rec_a.unwrap().score > rec_b.unwrap().score,
1919 "A (blocker with unblock boost) should score higher than B"
1920 );
1921 }
1922
1923 #[test]
1924 fn scoring_options_custom_weights_change_ranking() {
1925 let issues = vec![
1926 Issue {
1927 id: "A".to_string(),
1928 title: "High priority quick win".to_string(),
1929 status: "open".to_string(),
1930 issue_type: "task".to_string(),
1931 priority: 1,
1932 estimated_minutes: Some(60),
1933 ..Issue::default()
1934 },
1935 Issue {
1936 id: "B".to_string(),
1937 title: "Low priority".to_string(),
1938 status: "open".to_string(),
1939 issue_type: "task".to_string(),
1940 priority: 4,
1941 ..Issue::default()
1942 },
1943 ];
1944
1945 let graph = IssueGraph::build(&issues);
1946 let metrics = graph.compute_metrics();
1947
1948 let triage_qw = compute_triage(
1950 &issues,
1951 &graph,
1952 &metrics,
1953 &TriageOptions {
1954 max_recommendations: 10,
1955 scoring: TriageScoringOptions {
1956 base_score_weight: 0.2,
1957 unblock_boost_weight: 0.0,
1958 quick_win_weight: 0.8,
1959 ..TriageScoringOptions::default()
1960 },
1961 ..TriageOptions::default()
1962 },
1963 );
1964
1965 let a_score = triage_qw
1966 .result
1967 .recommendations
1968 .iter()
1969 .find(|r| r.id == "A")
1970 .map(|r| r.score)
1971 .unwrap_or(0.0);
1972 let b_score = triage_qw
1973 .result
1974 .recommendations
1975 .iter()
1976 .find(|r| r.id == "B")
1977 .map(|r| r.score)
1978 .unwrap_or(0.0);
1979
1980 assert!(
1981 a_score > b_score,
1982 "A (quick win) should score much higher than B with heavy quick-win weight"
1983 );
1984 }
1985
1986 #[test]
1987 fn scoring_options_empty_graph_no_panic() {
1988 let issues: Vec<Issue> = vec![];
1989 let graph = IssueGraph::build(&issues);
1990 let metrics = graph.compute_metrics();
1991 let triage = compute_triage(
1992 &issues,
1993 &graph,
1994 &metrics,
1995 &TriageOptions {
1996 scoring: TriageScoringOptions {
1997 base_score_weight: 0.5,
1998 unblock_boost_weight: 0.3,
1999 quick_win_weight: 0.2,
2000 ..TriageScoringOptions::default()
2001 },
2002 ..TriageOptions::default()
2003 },
2004 );
2005 assert!(triage.result.recommendations.is_empty());
2006 }
2007
2008 #[test]
2009 fn scoring_options_single_node() {
2010 let issues = vec![Issue {
2011 id: "A".to_string(),
2012 title: "Solo".to_string(),
2013 status: "open".to_string(),
2014 issue_type: "task".to_string(),
2015 priority: 1,
2016 ..Issue::default()
2017 }];
2018
2019 let graph = IssueGraph::build(&issues);
2020 let metrics = graph.compute_metrics();
2021 let triage = compute_triage(
2022 &issues,
2023 &graph,
2024 &metrics,
2025 &TriageOptions {
2026 max_recommendations: 10,
2027 ..TriageOptions::default()
2028 },
2029 );
2030
2031 assert_eq!(triage.result.recommendations.len(), 1);
2032 let rec = &triage.result.recommendations[0];
2033 assert!(rec.score >= 0.0 && rec.score <= 1.0);
2034 assert!(rec.impact_score >= 0.0 && rec.impact_score <= 1.0);
2036 }
2037
2038 #[test]
2039 fn feedback_weight_adjustments_shift_scores() {
2040 let issues = vec![
2041 Issue {
2042 id: "A".to_string(),
2043 title: "High centrality".to_string(),
2044 status: "open".to_string(),
2045 issue_type: "task".to_string(),
2046 priority: 1,
2047 ..Issue::default()
2048 },
2049 Issue {
2050 id: "B".to_string(),
2051 title: "Depends on A".to_string(),
2052 status: "blocked".to_string(),
2053 issue_type: "task".to_string(),
2054 priority: 2,
2055 dependencies: vec![crate::model::Dependency {
2056 issue_id: "B".to_string(),
2057 depends_on_id: "A".to_string(),
2058 dep_type: "blocks".to_string(),
2059 ..crate::model::Dependency::default()
2060 }],
2061 ..Issue::default()
2062 },
2063 ];
2064
2065 let graph = IssueGraph::build(&issues);
2066 let metrics = graph.compute_metrics();
2067 let ctx = super::ScoringContext::from_metrics(&metrics, 2);
2068 let lookups = test_lookups(&issues, &graph, &metrics);
2069
2070 let baseline =
2072 super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
2073
2074 let mut adjustments = HashMap::new();
2076 adjustments.insert("PageRank".to_string(), 2.0);
2077 let boosted =
2078 super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &adjustments);
2079
2080 let baseline_pr_weight = baseline.breakdown[0].weight;
2082 let boosted_pr_weight = boosted.breakdown[0].weight;
2083 assert!(
2084 boosted_pr_weight > baseline_pr_weight,
2085 "boosted PageRank weight {boosted_pr_weight} should exceed baseline {baseline_pr_weight}"
2086 );
2087
2088 assert!(boosted.score >= 0.0 && boosted.score <= 1.0);
2090 }
2091
2092 #[test]
2093 fn feedback_empty_adjustments_match_baseline() {
2094 let issues = vec![Issue {
2095 id: "A".to_string(),
2096 title: "Solo".to_string(),
2097 status: "open".to_string(),
2098 issue_type: "task".to_string(),
2099 priority: 1,
2100 ..Issue::default()
2101 }];
2102 let graph = IssueGraph::build(&issues);
2103 let metrics = graph.compute_metrics();
2104 let ctx = super::ScoringContext::from_metrics(&metrics, 1);
2105 let lookups = test_lookups(&issues, &graph, &metrics);
2106
2107 let no_adj =
2108 super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &HashMap::new());
2109 let empty_map: HashMap<String, f64> = HashMap::new();
2110 let with_empty =
2111 super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &empty_map);
2112
2113 assert!(
2114 (no_adj.score - with_empty.score).abs() < 1e-12,
2115 "empty adjustments should produce identical scores"
2116 );
2117 }
2118
2119 #[test]
2120 fn feedback_adjustments_renormalize_weights() {
2121 let issues = vec![Issue {
2122 id: "A".to_string(),
2123 title: "Test".to_string(),
2124 status: "open".to_string(),
2125 issue_type: "task".to_string(),
2126 priority: 2,
2127 ..Issue::default()
2128 }];
2129 let graph = IssueGraph::build(&issues);
2130 let metrics = graph.compute_metrics();
2131 let ctx = super::ScoringContext::from_metrics(&metrics, 1);
2132 let lookups = test_lookups(&issues, &graph, &metrics);
2133
2134 let mut adjustments = HashMap::new();
2136 for name in &[
2137 "PageRank",
2138 "Betweenness",
2139 "BlockerRatio",
2140 "Staleness",
2141 "PriorityBoost",
2142 "TimeToImpact",
2143 "Urgency",
2144 "Risk",
2145 ] {
2146 adjustments.insert(name.to_string(), 2.0);
2147 }
2148 let score = super::compute_impact_score(&issues[0], &metrics, &ctx, &lookups, &adjustments);
2149
2150 let weight_sum: f64 = score.breakdown.iter().map(|c| c.weight).sum();
2151 assert!(
2152 (weight_sum - 1.0).abs() < 1e-9,
2153 "adjusted weights should sum to 1.0, got {weight_sum}"
2154 );
2155 }
2156
2157 #[test]
2158 fn weight_preset_from_name_roundtrips() {
2159 for name in WeightPreset::ALL {
2160 assert!(
2161 WeightPreset::from_name(name).is_some(),
2162 "preset {name} should parse"
2163 );
2164 }
2165 assert!(WeightPreset::from_name("nonexistent").is_none());
2166 }
2167
2168 #[test]
2169 fn weight_preset_default_is_empty() {
2170 assert!(WeightPreset::Default.adjustments().is_empty());
2171 }
2172
2173 #[test]
2174 fn weight_preset_adjustments_are_within_clamp_range() {
2175 for name in WeightPreset::ALL {
2178 let preset = WeightPreset::from_name(name).unwrap();
2179 for (component, value) in preset.adjustments() {
2180 assert!(
2181 (0.5..=2.0).contains(&value),
2182 "preset {name}: {component} adjustment {value} is outside 0.5–2.0 clamp range"
2183 );
2184 }
2185 }
2186 }
2187
2188 #[test]
2189 fn weight_preset_graph_heavy_boosts_structural_signals() {
2190 let adj = WeightPreset::GraphHeavy.adjustments();
2191 assert!(adj["PageRank"] > 1.0);
2192 assert!(adj["Betweenness"] > 1.0);
2193 assert!(adj["PriorityBoost"] < 1.0);
2194 }
2195}