1use std::collections::{BTreeMap, BTreeSet};
2
3use chrono::Utc;
4use serde::Serialize;
5use serde_json::json;
6
7use crate::analysis::graph::GraphMetrics;
8use crate::model::Issue;
9
10const DEFAULT_MAX_SUGGESTIONS: usize = 50;
11const DUPLICATE_JACCARD_THRESHOLD: f64 = 0.7;
12const DUPLICATE_MIN_KEYWORDS: usize = 2;
13const DUPLICATE_MAX_SUGGESTIONS: usize = 20;
14const DEPENDENCY_MIN_KEYWORD_OVERLAP: usize = 2;
15const DEPENDENCY_MIN_CONFIDENCE: f64 = 0.5;
16const DEPENDENCY_MAX_SUGGESTIONS: usize = 20;
17const LABEL_MIN_CONFIDENCE: f64 = 0.5;
18const LABEL_MAX_PER_ISSUE: usize = 3;
19const LABEL_MAX_TOTAL: usize = 30;
20const CYCLE_MAX: usize = 10;
21const HIGH_CONFIDENCE_THRESHOLD: f64 = 0.7;
22const LOW_CONFIDENCE_THRESHOLD: f64 = 0.4;
23const STALE_DAYS_THRESHOLD: i64 = 90;
24const STALE_PAGERANK_PERCENTILE: f64 = 0.25;
25const STALE_MAX_SUGGESTIONS: usize = 20;
26
27const STOP_WORDS: &[&str] = &[
28 "the", "and", "for", "with", "this", "that", "from", "are", "was", "were", "been", "have",
29 "has", "had", "does", "did", "will", "would", "could", "should", "may", "might", "can", "not",
30 "all", "any", "some", "each", "when", "where", "what", "which", "how", "why", "who", "its",
31 "also", "just", "only", "more", "than", "then", "now", "here", "there", "these", "those",
32 "such", "into", "over", "after", "before", "being", "other", "about", "like", "very", "most",
33 "make", "use",
34];
35
36const BUILTIN_LABEL_MAPPINGS: &[(&str, &[&str])] = &[
37 ("database", &["database", "db"]),
38 ("migration", &["database", "migration"]),
39 ("api", &["api"]),
40 ("endpoint", &["api"]),
41 ("rest", &["api"]),
42 ("graphql", &["api", "graphql"]),
43 ("auth", &["auth", "security"]),
44 ("login", &["auth"]),
45 ("password", &["auth", "security"]),
46 ("security", &["security"]),
47 ("test", &["testing"]),
48 ("tests", &["testing"]),
49 ("unittest", &["testing"]),
50 ("integration", &["testing", "integration"]),
51 ("ui", &["ui", "frontend"]),
52 ("frontend", &["frontend"]),
53 ("backend", &["backend"]),
54 ("server", &["backend"]),
55 ("cli", &["cli"]),
56 ("command", &["cli"]),
57 ("config", &["config"]),
58 ("settings", &["config"]),
59 ("performance", &["performance"]),
60 ("slow", &["performance"]),
61 ("fast", &["performance"]),
62 ("memory", &["performance"]),
63 ("cache", &["performance", "cache"]),
64 ("docs", &["documentation"]),
65 ("readme", &["documentation"]),
66 ("refactor", &["refactoring"]),
67 ("cleanup", &["refactoring", "maintenance"]),
68 ("dependency", &["dependencies"]),
69 ("deps", &["dependencies"]),
70 ("bug", &["bug"]),
71 ("fix", &["bug"]),
72 ("broken", &["bug"]),
73 ("crash", &["bug"]),
74 ("error", &["bug"]),
75 ("feature", &["feature"]),
76 ("enhance", &["enhancement"]),
77 ("improve", &["enhancement"]),
78 ("urgent", &["urgent", "priority"]),
79 ("hotfix", &["urgent", "bug"]),
80];
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum SuggestionType {
85 MissingDependency,
86 PotentialDuplicate,
87 LabelSuggestion,
88 CycleWarning,
89 StaleCleanup,
90}
91
92impl SuggestionType {
93 #[must_use]
94 pub const fn as_str(self) -> &'static str {
95 match self {
96 Self::MissingDependency => "missing_dependency",
97 Self::PotentialDuplicate => "potential_duplicate",
98 Self::LabelSuggestion => "label_suggestion",
99 Self::CycleWarning => "cycle_warning",
100 Self::StaleCleanup => "stale_cleanup",
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum SuggestionConfidenceLevel {
107 Low,
108 Medium,
109 High,
110}
111
112impl SuggestionConfidenceLevel {
113 #[must_use]
114 pub const fn as_str(self) -> &'static str {
115 match self {
116 Self::Low => "low",
117 Self::Medium => "medium",
118 Self::High => "high",
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
124pub struct SuggestOptions {
125 pub min_confidence: f64,
126 pub max_suggestions: usize,
127 pub filter_type: Option<SuggestionType>,
128 pub filter_bead: Option<String>,
129}
130
131impl Default for SuggestOptions {
132 fn default() -> Self {
133 Self {
134 min_confidence: 0.0,
135 max_suggestions: DEFAULT_MAX_SUGGESTIONS,
136 filter_type: None,
137 filter_bead: None,
138 }
139 }
140}
141
142#[derive(Debug, Clone, Serialize)]
143pub struct Suggestion {
144 #[serde(rename = "type")]
145 pub suggestion_type: SuggestionType,
146 pub target_bead: String,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub related_bead: Option<String>,
149 pub summary: String,
150 pub reason: String,
151 pub confidence: f64,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub action_command: Option<String>,
154 pub generated_at: String,
155 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
156 pub metadata: BTreeMap<String, serde_json::Value>,
157}
158
159#[derive(Debug, Clone, Serialize)]
160pub struct SuggestionSet {
161 pub suggestions: Vec<Suggestion>,
162 pub generated_at: String,
163 pub data_hash: String,
164 pub stats: SuggestionStats,
165}
166
167#[derive(Debug, Clone, Serialize)]
168pub struct SuggestionStats {
169 pub total: usize,
170 pub by_type: BTreeMap<String, usize>,
171 pub by_confidence: BTreeMap<String, usize>,
172 pub high_confidence_count: usize,
173 pub actionable_count: usize,
174}
175
176#[derive(Debug, Clone, Serialize)]
177pub struct SuggestFilter {
178 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
179 pub filter_type: Option<String>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub min_confidence: Option<f64>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub bead_id: Option<String>,
184}
185
186#[derive(Debug, Clone, Serialize)]
187pub struct RobotSuggestOutput {
188 #[serde(flatten)]
189 pub envelope: crate::robot::RobotEnvelope,
190 pub filters: SuggestFilter,
191 pub suggestions: SuggestionSet,
192 pub usage_hints: Vec<String>,
193}
194
195#[must_use]
196pub fn generate_robot_suggest_output(
197 issues: &[Issue],
198 metrics: &GraphMetrics,
199 options: &SuggestOptions,
200) -> RobotSuggestOutput {
201 let env = crate::robot::envelope(issues);
202 let mut suggestions = detect_potential_duplicates(issues, &env.generated_at);
203 suggestions.extend(detect_missing_dependencies(issues, &env.generated_at));
204 suggestions.extend(detect_label_suggestions(issues, &env.generated_at));
205 suggestions.extend(detect_cycle_warnings(metrics, &env.generated_at));
206 suggestions.extend(detect_stale_cleanup(issues, metrics, &env.generated_at));
207 suggestions.retain(|suggestion| matches_filters(suggestion, options));
208 sort_suggestions(&mut suggestions);
209 if options.max_suggestions > 0 && suggestions.len() > options.max_suggestions {
210 suggestions.truncate(options.max_suggestions);
211 }
212
213 let suggestion_set = SuggestionSet {
214 stats: compute_stats(&suggestions),
215 suggestions,
216 generated_at: env.generated_at.clone(),
217 data_hash: env.data_hash.clone(),
218 };
219
220 let active_bead_filter = options
221 .filter_bead
222 .as_ref()
223 .map(|value| value.trim().to_string())
224 .filter(|value| !value.is_empty());
225
226 let filters = SuggestFilter {
227 filter_type: options.filter_type.map(|value| value.as_str().to_string()),
228 min_confidence: if options.min_confidence > 0.0 {
229 Some(options.min_confidence)
230 } else {
231 None
232 },
233 bead_id: active_bead_filter,
234 };
235
236 RobotSuggestOutput {
237 envelope: env,
238 filters,
239 suggestions: suggestion_set,
240 usage_hints: vec![
241 "jq '.suggestions.suggestions[:5]' - Top 5 suggestions by confidence".to_string(),
242 "jq '.suggestions.suggestions[] | select(.type==\"potential_duplicate\")' - Filter duplicates".to_string(),
243 "jq '.suggestions.suggestions[] | select(.confidence >= 0.8)' - High-confidence only".to_string(),
244 "jq '.suggestions.stats.by_type' - Count by suggestion type".to_string(),
245 "jq '.suggestions.suggestions[].action_command' - All action commands".to_string(),
246 "--suggest-type=dependency - Filter to dependency suggestions".to_string(),
247 "--suggest-confidence=0.7 - Minimum confidence threshold".to_string(),
248 "--suggest-bead=<id> - Suggestions for specific bead".to_string(),
249 ],
250 }
251}
252
253fn detect_potential_duplicates(issues: &[Issue], generated_at: &str) -> Vec<Suggestion> {
254 if issues.len() < 2 {
255 return Vec::new();
256 }
257
258 let keyword_sets = issues
259 .iter()
260 .map(|issue| extract_keywords(&issue.title, &issue.description))
261 .collect::<Vec<_>>();
262
263 let mut suggestions = Vec::<Suggestion>::new();
264 for left_index in 0..issues.len() {
265 if keyword_sets[left_index].len() < DUPLICATE_MIN_KEYWORDS {
266 continue;
267 }
268
269 for right_index in (left_index + 1)..issues.len() {
270 if keyword_sets[right_index].len() < DUPLICATE_MIN_KEYWORDS {
271 continue;
272 }
273
274 let left_issue = &issues[left_index];
275 let right_issue = &issues[right_index];
276 if left_issue.normalized_status() == "tombstone"
277 || right_issue.normalized_status() == "tombstone"
278 {
279 continue;
280 }
281
282 if left_issue.is_closed_like() != right_issue.is_closed_like() {
283 continue;
284 }
285
286 let common = intersect_keywords(&keyword_sets[left_index], &keyword_sets[right_index]);
287 let union_count = keyword_sets[left_index]
288 .len()
289 .saturating_add(keyword_sets[right_index].len())
290 .saturating_sub(common.len());
291 if union_count == 0 {
292 continue;
293 }
294
295 let similarity = ratio(common.len(), union_count);
296 if similarity < DUPLICATE_JACCARD_THRESHOLD {
297 continue;
298 }
299
300 let mut suggestion = base_suggestion(
301 generated_at,
302 SuggestionType::PotentialDuplicate,
303 left_issue.id.clone(),
304 format!("Potential duplicate of {}", right_issue.id),
305 format!(
306 "{:.0}% keyword similarity; common: {}",
307 similarity * 100.0,
308 common
309 .iter()
310 .take(5)
311 .cloned()
312 .collect::<Vec<_>>()
313 .join(", ")
314 ),
315 similarity,
316 );
317 suggestion.related_bead = Some(right_issue.id.clone());
318 if left_issue.is_open_like() && right_issue.is_open_like() {
319 suggestion.action_command = Some(format!(
320 "br dep add {} {} --type=related",
321 left_issue.id, right_issue.id
322 ));
323 }
324 suggestion
325 .metadata
326 .insert("method".to_string(), json!("jaccard"));
327 suggestion
328 .metadata
329 .insert("common_keywords".to_string(), json!(common));
330 suggestions.push(suggestion);
331 }
332 }
333
334 sort_suggestions(&mut suggestions);
335 if suggestions.len() > DUPLICATE_MAX_SUGGESTIONS {
336 suggestions.truncate(DUPLICATE_MAX_SUGGESTIONS);
337 }
338 suggestions
339}
340
341fn detect_missing_dependencies(issues: &[Issue], generated_at: &str) -> Vec<Suggestion> {
342 if issues.len() < 2 {
343 return Vec::new();
344 }
345
346 let keyword_sets = issues
347 .iter()
348 .map(|issue| extract_keywords(&issue.title, &issue.description))
349 .collect::<Vec<_>>();
350
351 let label_sets = issues
352 .iter()
353 .map(|issue| {
354 issue
355 .labels
356 .iter()
357 .map(|value| value.to_ascii_lowercase())
358 .collect::<BTreeSet<_>>()
359 })
360 .collect::<Vec<_>>();
361
362 let mut suggestions = Vec::<Suggestion>::new();
363 for left_index in 0..issues.len() {
364 for right_index in (left_index + 1)..issues.len() {
365 let left_issue = &issues[left_index];
366 let right_issue = &issues[right_index];
367 if left_issue.is_closed_like() || right_issue.is_closed_like() {
368 continue;
369 }
370 if has_dependency_between(left_issue, right_issue) {
371 continue;
372 }
373
374 let shared_keywords =
375 intersect_keywords(&keyword_sets[left_index], &keyword_sets[right_index]);
376 if shared_keywords.len() < DEPENDENCY_MIN_KEYWORD_OVERLAP {
377 continue;
378 }
379
380 let shared_labels = label_sets[left_index]
381 .intersection(&label_sets[right_index])
382 .cloned()
383 .collect::<Vec<_>>();
384
385 let mut confidence = (usize_to_f64(shared_keywords.len()) * 0.1).min(0.5);
386 if mentions_issue_id(left_issue, right_issue)
387 || mentions_issue_id(right_issue, left_issue)
388 {
389 confidence += 0.3;
390 }
391 if title_keyword_overlap(right_issue, &keyword_sets[left_index]) {
392 confidence += 0.15;
393 }
394 confidence += usize_to_f64(shared_labels.len()) * 0.1;
395 confidence = confidence.min(0.95);
396 if confidence < DEPENDENCY_MIN_CONFIDENCE {
397 continue;
398 }
399
400 let (from_issue, to_issue) = dependency_direction(left_issue, right_issue);
401 let mut suggestion = base_suggestion(
402 generated_at,
403 SuggestionType::MissingDependency,
404 from_issue.id.clone(),
405 format!("May depend on {}", to_issue.id),
406 format!(
407 "{} shared keywords{}",
408 shared_keywords.len(),
409 if shared_labels.is_empty() {
410 String::new()
411 } else {
412 format!(", {} shared labels", shared_labels.len())
413 }
414 ),
415 confidence,
416 );
417 suggestion.related_bead = Some(to_issue.id.clone());
418 suggestion.action_command =
419 Some(format!("br dep add {} {}", from_issue.id, to_issue.id));
420 suggestion
421 .metadata
422 .insert("shared_keywords".to_string(), json!(shared_keywords));
423 if !shared_labels.is_empty() {
424 suggestion
425 .metadata
426 .insert("shared_labels".to_string(), json!(shared_labels));
427 }
428 suggestions.push(suggestion);
429 }
430 }
431
432 sort_suggestions(&mut suggestions);
433 if suggestions.len() > DEPENDENCY_MAX_SUGGESTIONS {
434 suggestions.truncate(DEPENDENCY_MAX_SUGGESTIONS);
435 }
436 suggestions
437}
438
439fn detect_label_suggestions(issues: &[Issue], generated_at: &str) -> Vec<Suggestion> {
440 if issues.is_empty() {
441 return Vec::new();
442 }
443
444 let canonical_labels = canonical_project_labels(issues);
445 let all_labels = issues
446 .iter()
447 .flat_map(|issue| issue.labels.iter().map(|label| label.to_ascii_lowercase()))
448 .collect::<BTreeSet<_>>();
449 if all_labels.is_empty() {
450 return Vec::new();
451 }
452
453 let learned_mappings = learn_label_mappings(issues);
454 let mut matches = Vec::<Suggestion>::new();
455
456 for issue in issues {
457 if issue.is_closed_like() {
458 continue;
459 }
460
461 let existing_labels = issue
462 .labels
463 .iter()
464 .map(|label| label.to_ascii_lowercase())
465 .collect::<BTreeSet<_>>();
466 let keywords = extract_keywords(&issue.title, &issue.description);
467
468 let mut label_scores = BTreeMap::<String, f64>::new();
469 let mut label_reasons = BTreeMap::<String, BTreeSet<String>>::new();
470
471 for keyword in &keywords {
472 for &(mapping_keyword, labels) in BUILTIN_LABEL_MAPPINGS {
473 if mapping_keyword != keyword {
474 continue;
475 }
476
477 for &label in labels {
478 let candidate = label.to_string();
479 if existing_labels.contains(&candidate) || !all_labels.contains(&candidate) {
480 continue;
481 }
482 *label_scores.entry(candidate.clone()).or_insert(0.0) += 0.3;
483 label_reasons
484 .entry(candidate)
485 .or_default()
486 .insert(keyword.clone());
487 }
488 }
489
490 if let Some(label_counts) = learned_mappings.get(keyword) {
491 for (label, count) in label_counts {
492 if existing_labels.contains(label) || !all_labels.contains(label) {
493 continue;
494 }
495 let learned_bonus = (0.1 + (usize_to_f64(*count) * 0.05)).min(0.4);
496 *label_scores.entry(label.clone()).or_insert(0.0) += learned_bonus;
497 label_reasons
498 .entry(label.clone())
499 .or_default()
500 .insert(keyword.clone());
501 }
502 }
503 }
504
505 let mut candidates = label_scores.into_iter().collect::<Vec<(String, f64)>>();
506 candidates.sort_by(|left, right| {
507 right
508 .1
509 .total_cmp(&left.1)
510 .then_with(|| left.0.cmp(&right.0))
511 });
512
513 for (index, (label, score)) in candidates.into_iter().enumerate() {
514 if index >= LABEL_MAX_PER_ISSUE || score < LABEL_MIN_CONFIDENCE {
515 continue;
516 }
517
518 let display_label = canonical_labels
519 .get(&label)
520 .cloned()
521 .unwrap_or(label.clone());
522
523 let matched_keywords = label_reasons
524 .get(&label)
525 .map(|values| values.iter().cloned().collect::<Vec<_>>())
526 .unwrap_or_default();
527 let reason = format!("keywords: {}", matched_keywords.join(", "));
528
529 let mut suggestion = base_suggestion(
530 generated_at,
531 SuggestionType::LabelSuggestion,
532 issue.id.clone(),
533 format!("Consider adding label '{display_label}'"),
534 reason,
535 score.min(0.95),
536 );
537 suggestion.action_command = Some(format!(
538 "br update {} --add-label={display_label}",
539 issue.id
540 ));
541 suggestion
542 .metadata
543 .insert("suggested_label".to_string(), json!(display_label));
544 suggestion
545 .metadata
546 .insert("matched_keywords".to_string(), json!(matched_keywords));
547 matches.push(suggestion);
548 }
549 }
550
551 sort_suggestions(&mut matches);
552 if matches.len() > LABEL_MAX_TOTAL {
553 matches.truncate(LABEL_MAX_TOTAL);
554 }
555 matches
556}
557
558fn detect_cycle_warnings(metrics: &GraphMetrics, generated_at: &str) -> Vec<Suggestion> {
559 let mut suggestions = Vec::<Suggestion>::new();
560
561 for cycle in metrics.cycles.iter().take(CYCLE_MAX) {
562 if cycle.is_empty() {
563 continue;
564 }
565
566 let cycle_length = cycle.len();
567 let distance_from_shortest = cycle_length.saturating_sub(2);
568 let confidence = (1.0 - (usize_to_f64(distance_from_shortest) * 0.1)).clamp(0.5, 1.0);
569
570 let summary = if cycle_length == 1 {
571 format!("Self-loop: {} depends on itself", cycle[0])
572 } else if cycle_length == 2 {
573 format!("Direct cycle between {} and {}", cycle[0], cycle[1])
574 } else {
575 format!("Dependency cycle of {cycle_length} issues")
576 };
577
578 let mut cycle_path = cycle.clone();
579 if cycle_length > 1 {
580 cycle_path.push(cycle[0].clone());
581 }
582
583 let mut suggestion = base_suggestion(
584 generated_at,
585 SuggestionType::CycleWarning,
586 cycle[0].clone(),
587 summary,
588 format!("Cycle path: {}", cycle_path.join(" -> ")),
589 confidence,
590 );
591 if cycle_length >= 2 {
592 let last = cycle[cycle_length - 1].clone();
593 let first = cycle[0].clone();
594 suggestion.action_command = Some(format!("br dep remove {last} {first}"));
595 suggestion.related_bead = Some(cycle[1].clone());
596 }
597 suggestion
598 .metadata
599 .insert("cycle_length".to_string(), json!(cycle_length));
600 suggestion
601 .metadata
602 .insert("cycle_path".to_string(), json!(cycle));
603 suggestions.push(suggestion);
604 }
605
606 suggestions
607}
608
609fn detect_stale_cleanup(
610 issues: &[Issue],
611 metrics: &GraphMetrics,
612 generated_at: &str,
613) -> Vec<Suggestion> {
614 let now = Utc::now();
615
616 let mut open_pageranks: Vec<f64> = issues
618 .iter()
619 .filter(|i| i.is_open_like())
620 .map(|i| metrics.pagerank.get(&i.id).copied().unwrap_or(0.0))
621 .collect();
622 open_pageranks.sort_by(f64::total_cmp);
623 let pagerank_threshold = if open_pageranks.is_empty() {
624 0.0
625 } else {
626 let idx = ((open_pageranks.len() - 1) as f64 * STALE_PAGERANK_PERCENTILE).floor() as usize;
627 open_pageranks[idx]
628 };
629
630 let mut suggestions = Vec::new();
631
632 for issue in issues.iter().filter(|i| i.is_open_like()) {
633 let updated = issue.updated_at;
634 let days_stale = match updated {
635 Some(ts) => (now - ts).num_days(),
636 None => {
637 match issue.created_at {
639 Some(ts) => (now - ts).num_days(),
640 None => continue,
641 }
642 }
643 };
644
645 if days_stale < STALE_DAYS_THRESHOLD {
646 continue;
647 }
648
649 let pagerank = metrics.pagerank.get(&issue.id).copied().unwrap_or(0.0);
650 if pagerank > pagerank_threshold {
651 continue;
652 }
653
654 let age_factor = ((days_stale as f64 - STALE_DAYS_THRESHOLD as f64) / 180.0).min(1.0);
656 let confidence = (0.4 + 0.3 * age_factor).clamp(0.0, 1.0);
657
658 let mut suggestion = base_suggestion(
659 generated_at,
660 SuggestionType::StaleCleanup,
661 issue.id.clone(),
662 format!(
663 "{} has been stale for {} days with low graph impact",
664 issue.id, days_stale
665 ),
666 format!(
667 "Last updated {} days ago, PageRank {:.4} (below threshold {:.4})",
668 days_stale, pagerank, pagerank_threshold
669 ),
670 confidence,
671 );
672 suggestion.action_command =
673 Some(format!("br close {} --reason \"stale cleanup\"", issue.id));
674 suggestion
675 .metadata
676 .insert("days_stale".to_string(), json!(days_stale));
677 suggestion
678 .metadata
679 .insert("pagerank".to_string(), json!(pagerank));
680 suggestions.push(suggestion);
681 }
682
683 suggestions.sort_by(|a, b| {
684 b.confidence
685 .total_cmp(&a.confidence)
686 .then_with(|| a.target_bead.cmp(&b.target_bead))
687 });
688 suggestions.truncate(STALE_MAX_SUGGESTIONS);
689 suggestions
690}
691
692fn base_suggestion(
693 generated_at: &str,
694 suggestion_type: SuggestionType,
695 target_bead: String,
696 summary: String,
697 reason: String,
698 confidence: f64,
699) -> Suggestion {
700 Suggestion {
701 suggestion_type,
702 target_bead,
703 related_bead: None,
704 summary,
705 reason,
706 confidence,
707 action_command: None,
708 generated_at: generated_at.to_string(),
709 metadata: BTreeMap::new(),
710 }
711}
712
713fn dependency_direction<'a>(left: &'a Issue, right: &'a Issue) -> (&'a Issue, &'a Issue) {
714 let left_created = left.created_at;
715 let right_created = right.created_at;
716
717 let priority_cmp = left.priority.cmp(&right.priority);
718 let time_cmp = match (left_created, right_created) {
719 (Some(l), Some(r)) => l.cmp(&r),
720 (Some(_), None) => std::cmp::Ordering::Less,
721 (None, Some(_)) => std::cmp::Ordering::Greater,
722 (None, None) => std::cmp::Ordering::Equal,
723 };
724
725 let left_is_blocker = match priority_cmp {
726 std::cmp::Ordering::Less => true,
727 std::cmp::Ordering::Greater => false,
728 std::cmp::Ordering::Equal => match time_cmp {
729 std::cmp::Ordering::Less => true,
730 std::cmp::Ordering::Greater => false,
731 std::cmp::Ordering::Equal => left.id < right.id,
732 },
733 };
734
735 if left_is_blocker {
736 (right, left)
737 } else {
738 (left, right)
739 }
740}
741
742fn learn_label_mappings(issues: &[Issue]) -> BTreeMap<String, BTreeMap<String, usize>> {
743 let mut mappings = BTreeMap::<String, BTreeMap<String, usize>>::new();
744
745 for issue in issues {
746 if issue.labels.is_empty() {
747 continue;
748 }
749
750 let keywords = extract_keywords(&issue.title, &issue.description);
751 for keyword in keywords {
752 let label_counts = mappings.entry(keyword).or_default();
753 for label in &issue.labels {
754 let label = label.to_ascii_lowercase();
755 *label_counts.entry(label).or_insert(0) += 1;
756 }
757 }
758 }
759
760 mappings
761}
762
763fn canonical_project_labels(issues: &[Issue]) -> BTreeMap<String, String> {
764 let mut variants = BTreeMap::<String, BTreeMap<String, usize>>::new();
765
766 for issue in issues {
767 for label in &issue.labels {
768 let normalized = label.to_ascii_lowercase();
769 *variants
770 .entry(normalized)
771 .or_default()
772 .entry(label.clone())
773 .or_insert(0) += 1;
774 }
775 }
776
777 variants
778 .into_iter()
779 .filter_map(|(normalized, counts)| {
780 counts
781 .into_iter()
782 .max_by(|left, right| left.1.cmp(&right.1).then_with(|| right.0.cmp(&left.0)))
783 .map(|(canonical, _)| (normalized, canonical))
784 })
785 .collect()
786}
787
788fn has_dependency_between(left: &Issue, right: &Issue) -> bool {
789 left.dependencies
790 .iter()
791 .any(|dep| dep.depends_on_id == right.id)
792 || right
793 .dependencies
794 .iter()
795 .any(|dep| dep.depends_on_id == left.id)
796}
797
798fn mentions_issue_id(primary: &Issue, other: &Issue) -> bool {
799 let primary_text = primary.description.to_ascii_lowercase();
800 let other_id = other.id.to_ascii_lowercase();
801 !other_id.is_empty() && primary_text.contains(&other_id)
802}
803
804fn title_keyword_overlap(other: &Issue, primary_keywords: &[String]) -> bool {
805 let other_title = other.title.to_ascii_lowercase();
806 primary_keywords
807 .iter()
808 .any(|keyword| keyword.len() >= 5 && other_title.contains(keyword))
809}
810
811fn matches_filters(suggestion: &Suggestion, options: &SuggestOptions) -> bool {
812 if options.min_confidence > 0.0 && suggestion.confidence < options.min_confidence {
813 return false;
814 }
815
816 if options
817 .filter_type
818 .is_some_and(|filter_type| suggestion.suggestion_type != filter_type)
819 {
820 return false;
821 }
822
823 let bead_filter = options
824 .filter_bead
825 .as_ref()
826 .map(|value| value.trim())
827 .filter(|value| !value.is_empty());
828 if let Some(bead_id) = bead_filter
829 && suggestion.target_bead != bead_id
830 && suggestion.related_bead.as_deref() != Some(bead_id)
831 {
832 return false;
833 }
834
835 true
836}
837
838fn sort_suggestions(suggestions: &mut [Suggestion]) {
839 suggestions.sort_by(|left, right| {
840 right
841 .confidence
842 .total_cmp(&left.confidence)
843 .then_with(|| {
844 left.suggestion_type
845 .as_str()
846 .cmp(right.suggestion_type.as_str())
847 })
848 .then_with(|| left.target_bead.cmp(&right.target_bead))
849 .then_with(|| left.related_bead.cmp(&right.related_bead))
850 });
851}
852
853fn compute_stats(suggestions: &[Suggestion]) -> SuggestionStats {
854 let mut by_type = BTreeMap::<String, usize>::new();
855 let mut by_confidence = BTreeMap::<String, usize>::new();
856 let mut high_confidence_count = 0usize;
857 let mut actionable_count = 0usize;
858
859 for suggestion in suggestions {
860 *by_type
861 .entry(suggestion.suggestion_type.as_str().to_string())
862 .or_insert(0) += 1;
863
864 let level = confidence_level(suggestion.confidence);
865 *by_confidence.entry(level.as_str().to_string()).or_insert(0) += 1;
866 if suggestion.confidence >= HIGH_CONFIDENCE_THRESHOLD {
867 high_confidence_count += 1;
868 }
869 if suggestion.action_command.is_some() {
870 actionable_count += 1;
871 }
872 }
873
874 SuggestionStats {
875 total: suggestions.len(),
876 by_type,
877 by_confidence,
878 high_confidence_count,
879 actionable_count,
880 }
881}
882
883fn confidence_level(confidence: f64) -> SuggestionConfidenceLevel {
884 if confidence < LOW_CONFIDENCE_THRESHOLD {
885 return SuggestionConfidenceLevel::Low;
886 }
887 if confidence >= HIGH_CONFIDENCE_THRESHOLD {
888 return SuggestionConfidenceLevel::High;
889 }
890 SuggestionConfidenceLevel::Medium
891}
892
893fn extract_keywords(title: &str, description: &str) -> Vec<String> {
894 let normalized = normalize_text(&format!("{title} {description}"));
895 let mut keywords = BTreeSet::<String>::new();
896
897 for word in normalized.split_whitespace() {
898 if word.len() < 3 || STOP_WORDS.contains(&word) {
899 continue;
900 }
901 keywords.insert(word.to_string());
902 }
903
904 keywords.into_iter().collect()
905}
906
907fn normalize_text(input: &str) -> String {
908 input
909 .chars()
910 .map(|ch| {
911 if ch.is_ascii_alphanumeric() || ch.is_ascii_whitespace() {
912 ch.to_ascii_lowercase()
913 } else {
914 ' '
915 }
916 })
917 .collect()
918}
919
920fn intersect_keywords(left: &[String], right: &[String]) -> Vec<String> {
921 let right_set = right.iter().cloned().collect::<BTreeSet<_>>();
922 left.iter()
923 .filter(|word| right_set.contains(*word))
924 .cloned()
925 .collect::<BTreeSet<_>>()
926 .into_iter()
927 .collect()
928}
929
930fn ratio(numerator: usize, denominator: usize) -> f64 {
931 if denominator == 0 {
932 return 0.0;
933 }
934 usize_to_f64(numerator) / usize_to_f64(denominator)
935}
936
937fn usize_to_f64(value: usize) -> f64 {
938 value as f64
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944
945 fn make_suggestion(
946 suggestion_type: SuggestionType,
947 confidence: f64,
948 target: &str,
949 ) -> Suggestion {
950 Suggestion {
951 suggestion_type,
952 target_bead: target.to_string(),
953 related_bead: None,
954 summary: String::new(),
955 reason: String::new(),
956 confidence,
957 action_command: None,
958 generated_at: String::new(),
959 metadata: BTreeMap::new(),
960 }
961 }
962
963 #[test]
964 fn sort_by_confidence_descending() {
965 let mut suggestions = vec![
966 make_suggestion(SuggestionType::MissingDependency, 0.5, "bd-a"),
967 make_suggestion(SuggestionType::MissingDependency, 0.9, "bd-b"),
968 make_suggestion(SuggestionType::MissingDependency, 0.7, "bd-c"),
969 ];
970 sort_suggestions(&mut suggestions);
971 assert_eq!(suggestions[0].target_bead, "bd-b"); assert_eq!(suggestions[1].target_bead, "bd-c"); assert_eq!(suggestions[2].target_bead, "bd-a"); }
975
976 #[test]
977 fn sort_tiebreak_by_type_alphabetical() {
978 let mut suggestions = vec![
980 make_suggestion(SuggestionType::PotentialDuplicate, 0.8, "bd-a"), make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-b"), make_suggestion(SuggestionType::MissingDependency, 0.8, "bd-c"), make_suggestion(SuggestionType::LabelSuggestion, 0.8, "bd-d"), ];
985 sort_suggestions(&mut suggestions);
986 assert_eq!(suggestions[0].suggestion_type.as_str(), "cycle_warning");
988 assert_eq!(suggestions[1].suggestion_type.as_str(), "label_suggestion");
989 assert_eq!(
990 suggestions[2].suggestion_type.as_str(),
991 "missing_dependency"
992 );
993 assert_eq!(
994 suggestions[3].suggestion_type.as_str(),
995 "potential_duplicate"
996 );
997 }
998
999 #[test]
1000 fn sort_tiebreak_by_target_bead() {
1001 let mut suggestions = vec![
1003 make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-z"),
1004 make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-a"),
1005 make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-m"),
1006 ];
1007 sort_suggestions(&mut suggestions);
1008 assert_eq!(suggestions[0].target_bead, "bd-a");
1009 assert_eq!(suggestions[1].target_bead, "bd-m");
1010 assert_eq!(suggestions[2].target_bead, "bd-z");
1011 }
1012
1013 fn make_issue_with_dates(id: &str, status: &str, updated_days_ago: i64) -> Issue {
1014 let updated = Utc::now() - chrono::Duration::days(updated_days_ago);
1015 Issue {
1016 id: id.to_string(),
1017 title: format!("Issue {id}"),
1018 status: status.to_string(),
1019 issue_type: "task".to_string(),
1020 priority: 3,
1021 updated_at: Some(updated),
1022 created_at: Some(updated),
1023 ..Issue::default()
1024 }
1025 }
1026
1027 fn make_issue_with_dep(id: &str, title: &str, status: &str, depends_on: &str) -> Issue {
1028 let mut issue = make_issue_with_dates(id, status, 30);
1029 issue.title = title.to_string();
1030 issue.dependencies.push(crate::model::Dependency {
1031 issue_id: id.to_string(),
1032 depends_on_id: depends_on.to_string(),
1033 dep_type: "blocks".to_string(),
1034 ..crate::model::Dependency::default()
1035 });
1036 issue
1037 }
1038
1039 #[test]
1040 fn stale_cleanup_detects_old_low_impact_issues() {
1041 use crate::analysis::graph::IssueGraph;
1042
1043 let issues = vec![
1044 make_issue_with_dates("A", "open", 120),
1045 make_issue_with_dates("B", "open", 10),
1046 ];
1047 let graph = IssueGraph::build(&issues);
1048 let metrics = graph.compute_metrics();
1049 let now = Utc::now().to_rfc3339();
1050
1051 let results = detect_stale_cleanup(&issues, &metrics, &now);
1052 assert!(
1054 results.iter().any(|s| s.target_bead == "A"),
1055 "should detect stale issue A"
1056 );
1057 assert!(
1058 !results.iter().any(|s| s.target_bead == "B"),
1059 "should not flag fresh issue B"
1060 );
1061 }
1062
1063 #[test]
1064 fn stale_cleanup_skips_closed_issues() {
1065 use crate::analysis::graph::IssueGraph;
1066
1067 let issues = vec![make_issue_with_dates("A", "closed", 200)];
1068 let graph = IssueGraph::build(&issues);
1069 let metrics = graph.compute_metrics();
1070 let now = Utc::now().to_rfc3339();
1071
1072 let results = detect_stale_cleanup(&issues, &metrics, &now);
1073 assert!(results.is_empty(), "closed issues should not be flagged");
1074 }
1075
1076 #[test]
1077 fn stale_cleanup_has_action_command() {
1078 use crate::analysis::graph::IssueGraph;
1079
1080 let issues = vec![make_issue_with_dates("X", "open", 100)];
1081 let graph = IssueGraph::build(&issues);
1082 let metrics = graph.compute_metrics();
1083 let now = Utc::now().to_rfc3339();
1084
1085 let results = detect_stale_cleanup(&issues, &metrics, &now);
1086 assert!(!results.is_empty());
1087 assert_eq!(
1088 results[0].action_command.as_deref(),
1089 Some("br close X --reason \"stale cleanup\"")
1090 );
1091 assert_eq!(results[0].suggestion_type, SuggestionType::StaleCleanup);
1092 }
1093
1094 #[test]
1095 fn stale_cleanup_empty_issues() {
1096 use crate::analysis::graph::IssueGraph;
1097
1098 let issues: Vec<Issue> = vec![];
1099 let graph = IssueGraph::build(&issues);
1100 let metrics = graph.compute_metrics();
1101 let now = Utc::now().to_rfc3339();
1102
1103 let results = detect_stale_cleanup(&issues, &metrics, &now);
1104 assert!(results.is_empty());
1105 }
1106
1107 #[test]
1108 fn stale_cleanup_does_not_flag_high_impact_issue_at_small_n() {
1109 use crate::analysis::graph::IssueGraph;
1110
1111 let mut blocker = make_issue_with_dates("A", "open", 120);
1112 let blocked_one = make_issue_with_dep("B", "frontend follow-up", "open", "A");
1113 let blocked_two = make_issue_with_dep("C", "ops follow-up", "open", "A");
1114 blocker.title = "Core blocker".to_string();
1115
1116 let issues = vec![blocker, blocked_one, blocked_two];
1117 let graph = IssueGraph::build(&issues);
1118 let metrics = graph.compute_metrics();
1119 let now = Utc::now().to_rfc3339();
1120
1121 let results = detect_stale_cleanup(&issues, &metrics, &now);
1122 assert!(
1123 !results
1124 .iter()
1125 .any(|suggestion| suggestion.target_bead == "A"),
1126 "the highest-impact stale issue should not be classified as low-impact"
1127 );
1128 }
1129
1130 fn make_issue(id: &str, title: &str, description: &str, status: &str) -> Issue {
1133 Issue {
1134 id: id.to_string(),
1135 title: title.to_string(),
1136 description: description.to_string(),
1137 status: status.to_string(),
1138 issue_type: "task".to_string(),
1139 priority: 2,
1140 ..Issue::default()
1141 }
1142 }
1143
1144 #[test]
1145 fn duplicates_detected_for_high_keyword_overlap() {
1146 let issues = vec![
1149 make_issue(
1150 "bd-1",
1151 "database migration schema upgrade rollback",
1152 "database migration schema upgrade rollback procedure",
1153 "open",
1154 ),
1155 make_issue(
1156 "bd-2",
1157 "database migration schema upgrade rollback",
1158 "database migration schema upgrade rollback implementation",
1159 "open",
1160 ),
1161 ];
1162 let now = Utc::now().to_rfc3339();
1163 let results = detect_potential_duplicates(&issues, &now);
1164 assert!(
1165 !results.is_empty(),
1166 "highly similar issues should be flagged as duplicates"
1167 );
1168 assert_eq!(
1169 results[0].suggestion_type,
1170 SuggestionType::PotentialDuplicate
1171 );
1172 assert!(results[0].related_bead.is_some());
1173 assert!(results[0].action_command.is_some());
1174 assert_eq!(
1175 results[0].metadata.get("method").and_then(|v| v.as_str()),
1176 Some("jaccard")
1177 );
1178 }
1179
1180 #[test]
1181 fn duplicates_not_detected_for_dissimilar_issues() {
1182 let issues = vec![
1183 make_issue(
1184 "bd-1",
1185 "Database migration system",
1186 "Handle schema changes",
1187 "open",
1188 ),
1189 make_issue(
1190 "bd-2",
1191 "Frontend styling improvements",
1192 "Update CSS grid layout for responsive design",
1193 "open",
1194 ),
1195 ];
1196 let now = Utc::now().to_rfc3339();
1197 let results = detect_potential_duplicates(&issues, &now);
1198 assert!(
1199 results.is_empty(),
1200 "dissimilar issues should not be flagged"
1201 );
1202 }
1203
1204 #[test]
1205 fn duplicates_skip_tombstone_issues() {
1206 let issues = vec![
1207 make_issue(
1208 "bd-1",
1209 "Database migration system upgrade",
1210 "Schema migration for database upgrades",
1211 "tombstone",
1212 ),
1213 make_issue(
1214 "bd-2",
1215 "Database migration system upgrade needed",
1216 "Schema migration for database upgrade process",
1217 "open",
1218 ),
1219 ];
1220 let now = Utc::now().to_rfc3339();
1221 let results = detect_potential_duplicates(&issues, &now);
1222 assert!(results.is_empty(), "tombstone issues should be skipped");
1223 }
1224
1225 #[test]
1226 fn duplicates_skip_mixed_open_closed_status() {
1227 let issues = vec![
1228 make_issue(
1229 "bd-1",
1230 "Database migration system upgrade",
1231 "Schema migration for database upgrades",
1232 "open",
1233 ),
1234 make_issue(
1235 "bd-2",
1236 "Database migration system upgrade needed",
1237 "Schema migration for database upgrade process",
1238 "closed",
1239 ),
1240 ];
1241 let now = Utc::now().to_rfc3339();
1242 let results = detect_potential_duplicates(&issues, &now);
1243 assert!(
1244 results.is_empty(),
1245 "one open + one closed should not be flagged"
1246 );
1247 }
1248
1249 #[test]
1250 fn duplicates_single_issue_returns_empty() {
1251 let issues = vec![make_issue(
1252 "bd-1",
1253 "Something important",
1254 "Details here",
1255 "open",
1256 )];
1257 let now = Utc::now().to_rfc3339();
1258 let results = detect_potential_duplicates(&issues, &now);
1259 assert!(results.is_empty());
1260 }
1261
1262 #[test]
1263 fn duplicates_empty_issues_returns_empty() {
1264 let results = detect_potential_duplicates(&[], &Utc::now().to_rfc3339());
1265 assert!(results.is_empty());
1266 }
1267
1268 #[test]
1269 fn duplicates_both_closed_still_detected() {
1270 let issues = vec![
1271 make_issue(
1272 "bd-1",
1273 "Database migration system upgrade",
1274 "Schema migration for database upgrades process",
1275 "closed",
1276 ),
1277 make_issue(
1278 "bd-2",
1279 "Database migration system upgrade needed",
1280 "Schema migration for database upgrade process",
1281 "closed",
1282 ),
1283 ];
1284 let now = Utc::now().to_rfc3339();
1285 let results = detect_potential_duplicates(&issues, &now);
1286 assert!(
1288 !results.is_empty(),
1289 "two closed issues with high overlap should still be flagged"
1290 );
1291 }
1292
1293 #[test]
1296 fn missing_deps_detected_for_keyword_overlap() {
1297 let issues = vec![
1298 make_issue(
1299 "bd-1",
1300 "Implement authentication service",
1301 "Build the authentication backend service layer",
1302 "open",
1303 ),
1304 make_issue(
1305 "bd-2",
1306 "Authentication integration testing",
1307 "Test the authentication service integration bd-1",
1308 "open",
1309 ),
1310 ];
1311 let now = Utc::now().to_rfc3339();
1312 let results = detect_missing_dependencies(&issues, &now);
1313 assert!(
1314 !results.is_empty(),
1315 "issues with shared keywords + ID mention should suggest dependency"
1316 );
1317 assert_eq!(
1318 results[0].suggestion_type,
1319 SuggestionType::MissingDependency
1320 );
1321 assert!(results[0].related_bead.is_some());
1322 assert!(
1323 results[0]
1324 .action_command
1325 .as_deref()
1326 .unwrap()
1327 .starts_with("br dep add")
1328 );
1329 }
1330
1331 #[test]
1332 fn missing_deps_skip_closed_issues() {
1333 let issues = vec![
1334 make_issue(
1335 "bd-1",
1336 "Authentication service implementation",
1337 "Build authentication backend service",
1338 "closed",
1339 ),
1340 make_issue(
1341 "bd-2",
1342 "Authentication integration testing",
1343 "Test authentication service integration",
1344 "open",
1345 ),
1346 ];
1347 let now = Utc::now().to_rfc3339();
1348 let results = detect_missing_dependencies(&issues, &now);
1349 assert!(
1350 results.is_empty(),
1351 "closed issues should not get dep suggestions"
1352 );
1353 }
1354
1355 #[test]
1356 fn missing_deps_skip_existing_dependency() {
1357 use crate::model::Dependency;
1358
1359 let mut issue1 = make_issue(
1360 "bd-1",
1361 "Authentication service implementation",
1362 "Build authentication backend service",
1363 "open",
1364 );
1365 issue1.dependencies.push(Dependency {
1366 depends_on_id: "bd-2".to_string(),
1367 ..Dependency::default()
1368 });
1369 let issue2 = make_issue(
1370 "bd-2",
1371 "Authentication service testing",
1372 "Test authentication backend service",
1373 "open",
1374 );
1375 let now = Utc::now().to_rfc3339();
1376 let results = detect_missing_dependencies(&[issue1, issue2], &now);
1377 assert!(
1378 results.is_empty(),
1379 "issues with existing dep should not be suggested"
1380 );
1381 }
1382
1383 #[test]
1384 fn missing_deps_shared_labels_boost_confidence() {
1385 let mut issue1 = make_issue(
1386 "bd-1",
1387 "Authentication service implementation",
1388 "Build authentication backend service layer",
1389 "open",
1390 );
1391 issue1.labels = vec!["backend".to_string(), "auth".to_string()];
1392
1393 let mut issue2 = make_issue(
1394 "bd-2",
1395 "Authentication endpoint testing",
1396 "Test authentication backend endpoints layer",
1397 "open",
1398 );
1399 issue2.labels = vec!["backend".to_string(), "auth".to_string()];
1400
1401 let now = Utc::now().to_rfc3339();
1402 let results = detect_missing_dependencies(&[issue1, issue2], &now);
1403 if !results.is_empty() {
1404 assert!(
1406 results[0].metadata.get("shared_labels").is_some(),
1407 "shared labels should be in metadata"
1408 );
1409 }
1410 }
1411
1412 #[test]
1413 fn missing_deps_empty_and_single_return_empty() {
1414 let now = Utc::now().to_rfc3339();
1415 assert!(detect_missing_dependencies(&[], &now).is_empty());
1416 assert!(
1417 detect_missing_dependencies(
1418 &[make_issue("bd-1", "Something", "Details", "open")],
1419 &now
1420 )
1421 .is_empty()
1422 );
1423 }
1424
1425 #[test]
1428 fn label_suggestion_from_builtin_mapping() {
1429 let mut labeled = make_issue(
1431 "bd-1",
1432 "Old database work",
1433 "Previous db migration",
1434 "closed",
1435 );
1436 labeled.labels = vec!["database".to_string()];
1437
1438 let unlabeled = make_issue(
1439 "bd-2",
1440 "New database migration needed",
1441 "Handle the database schema changes",
1442 "open",
1443 );
1444
1445 let now = Utc::now().to_rfc3339();
1446 let results = detect_label_suggestions(&[labeled, unlabeled], &now);
1447 assert!(
1448 !results.is_empty(),
1449 "should suggest 'database' label for issue with database keyword"
1450 );
1451 assert_eq!(results[0].suggestion_type, SuggestionType::LabelSuggestion);
1452 assert_eq!(results[0].target_bead, "bd-2");
1453 let suggested = results[0]
1454 .metadata
1455 .get("suggested_label")
1456 .and_then(|v| v.as_str());
1457 assert_eq!(suggested, Some("database"));
1458 }
1459
1460 #[test]
1461 fn label_suggestion_skips_already_labeled() {
1462 let mut issue1 = make_issue(
1463 "bd-1",
1464 "Database migration work",
1465 "Previous effort",
1466 "closed",
1467 );
1468 issue1.labels = vec!["database".to_string()];
1469
1470 let mut issue2 = make_issue(
1471 "bd-2",
1472 "New database migration",
1473 "Database schema changes",
1474 "open",
1475 );
1476 issue2.labels = vec!["database".to_string()]; let now = Utc::now().to_rfc3339();
1479 let results = detect_label_suggestions(&[issue1, issue2], &now);
1480 let db_suggestions: Vec<_> = results
1482 .iter()
1483 .filter(|s| {
1484 s.target_bead == "bd-2"
1485 && s.metadata.get("suggested_label").and_then(|v| v.as_str())
1486 == Some("database")
1487 })
1488 .collect();
1489 assert!(
1490 db_suggestions.is_empty(),
1491 "should not suggest label the issue already has"
1492 );
1493 }
1494
1495 #[test]
1496 fn label_suggestion_skips_closed_issues() {
1497 let mut issue1 = make_issue("bd-1", "Database work", "DB migration", "open");
1498 issue1.labels = vec!["database".to_string()];
1499
1500 let issue2 = make_issue(
1501 "bd-2",
1502 "Database migration",
1503 "Database schema changes",
1504 "closed",
1505 );
1506
1507 let now = Utc::now().to_rfc3339();
1508 let results = detect_label_suggestions(&[issue1, issue2], &now);
1509 let closed_suggestions: Vec<_> =
1510 results.iter().filter(|s| s.target_bead == "bd-2").collect();
1511 assert!(
1512 closed_suggestions.is_empty(),
1513 "closed issues should not get label suggestions"
1514 );
1515 }
1516
1517 #[test]
1518 fn label_suggestion_preserves_canonical_project_label_casing() {
1519 let mut labeled = make_issue(
1520 "bd-1",
1521 "Backend auth work",
1522 "Previous backend login change",
1523 "closed",
1524 );
1525 labeled.labels = vec!["Backend".to_string()];
1526
1527 let unlabeled = make_issue(
1528 "bd-2",
1529 "Backend login endpoint",
1530 "Fix backend auth flow",
1531 "open",
1532 );
1533
1534 let now = Utc::now().to_rfc3339();
1535 let results = detect_label_suggestions(&[labeled, unlabeled], &now);
1536 let suggestion = results
1537 .iter()
1538 .find(|item| item.target_bead == "bd-2")
1539 .expect("expected a backend label suggestion");
1540 assert_eq!(
1541 suggestion
1542 .metadata
1543 .get("suggested_label")
1544 .and_then(|value| value.as_str()),
1545 Some("Backend")
1546 );
1547 assert_eq!(
1548 suggestion.action_command.as_deref(),
1549 Some("br update bd-2 --add-label=Backend")
1550 );
1551 assert!(suggestion.summary.contains("'Backend'"));
1552 }
1553
1554 #[test]
1555 fn label_suggestion_empty_labels_returns_empty() {
1556 let issues = vec![
1558 make_issue("bd-1", "Database migration", "Schema changes", "open"),
1559 make_issue(
1560 "bd-2",
1561 "Another database task",
1562 "More database work",
1563 "open",
1564 ),
1565 ];
1566 let now = Utc::now().to_rfc3339();
1567 let results = detect_label_suggestions(&issues, &now);
1568 assert!(
1569 results.is_empty(),
1570 "no labels in project means no suggestions"
1571 );
1572 }
1573
1574 #[test]
1575 fn label_suggestion_empty_issues() {
1576 let results = detect_label_suggestions(&[], &Utc::now().to_rfc3339());
1577 assert!(results.is_empty());
1578 }
1579
1580 #[test]
1581 fn label_suggestion_learned_mapping() {
1582 let mut i1 = make_issue(
1587 "bd-1",
1588 "webhook handler retry payload",
1589 "Process webhook retry payload events",
1590 "closed",
1591 );
1592 i1.labels = vec!["integration".to_string()];
1593 let mut i2 = make_issue(
1594 "bd-2",
1595 "webhook handler retry payload",
1596 "Retry failed webhook payload handler",
1597 "closed",
1598 );
1599 i2.labels = vec!["integration".to_string()];
1600 let i3 = make_issue(
1601 "bd-3",
1602 "webhook handler retry payload",
1603 "Validate incoming webhook handler retry payload",
1604 "open",
1605 );
1606
1607 let now = Utc::now().to_rfc3339();
1608 let results = detect_label_suggestions(&[i1, i2, i3], &now);
1609 let integration_suggestions: Vec<_> = results
1610 .iter()
1611 .filter(|s| {
1612 s.target_bead == "bd-3"
1613 && s.metadata.get("suggested_label").and_then(|v| v.as_str())
1614 == Some("integration")
1615 })
1616 .collect();
1617 assert!(
1618 !integration_suggestions.is_empty(),
1619 "learned mapping from multiple shared keywords should suggest label"
1620 );
1621 }
1622
1623 #[test]
1624 fn canonical_project_labels_prefers_most_common_variant() {
1625 let mut issue1 = make_issue("bd-1", "One", "Desc", "open");
1626 issue1.labels = vec!["Backend".to_string()];
1627 let mut issue2 = make_issue("bd-2", "Two", "Desc", "open");
1628 issue2.labels = vec!["backend".to_string()];
1629 let mut issue3 = make_issue("bd-3", "Three", "Desc", "open");
1630 issue3.labels = vec!["Backend".to_string()];
1631
1632 let labels = canonical_project_labels(&[issue1, issue2, issue3]);
1633 assert_eq!(labels.get("backend").map(String::as_str), Some("Backend"));
1634 }
1635
1636 fn empty_metrics() -> GraphMetrics {
1639 use crate::analysis::graph::IssueGraph;
1640 let graph = IssueGraph::build(&[]);
1641 graph.compute_metrics()
1642 }
1643
1644 #[test]
1645 fn cycle_warning_self_loop() {
1646 let mut metrics = empty_metrics();
1647 metrics.cycles.push(vec!["bd-1".to_string()]);
1648
1649 let now = Utc::now().to_rfc3339();
1650 let results = detect_cycle_warnings(&metrics, &now);
1651 assert_eq!(results.len(), 1);
1652 assert_eq!(results[0].suggestion_type, SuggestionType::CycleWarning);
1653 assert!(results[0].summary.contains("Self-loop"));
1654 assert_eq!(results[0].target_bead, "bd-1");
1655 assert!(results[0].action_command.is_none());
1657 }
1658
1659 #[test]
1660 fn cycle_warning_two_node_cycle() {
1661 let mut metrics = empty_metrics();
1662 metrics
1663 .cycles
1664 .push(vec!["bd-1".to_string(), "bd-2".to_string()]);
1665
1666 let now = Utc::now().to_rfc3339();
1667 let results = detect_cycle_warnings(&metrics, &now);
1668 assert_eq!(results.len(), 1);
1669 assert!(results[0].summary.contains("Direct cycle"));
1670 assert!(results[0].summary.contains("bd-1"));
1671 assert!(results[0].summary.contains("bd-2"));
1672 assert_eq!(results[0].related_bead.as_deref(), Some("bd-2"));
1673 assert_eq!(
1675 results[0].action_command.as_deref(),
1676 Some("br dep remove bd-2 bd-1")
1677 );
1678 assert_eq!(
1679 results[0]
1680 .metadata
1681 .get("cycle_length")
1682 .and_then(|v| v.as_u64()),
1683 Some(2)
1684 );
1685 }
1686
1687 #[test]
1688 fn cycle_warning_large_cycle() {
1689 let mut metrics = empty_metrics();
1690 metrics.cycles.push(vec![
1691 "bd-1".to_string(),
1692 "bd-2".to_string(),
1693 "bd-3".to_string(),
1694 "bd-4".to_string(),
1695 ]);
1696
1697 let now = Utc::now().to_rfc3339();
1698 let results = detect_cycle_warnings(&metrics, &now);
1699 assert_eq!(results.len(), 1);
1700 assert!(results[0].summary.contains("4 issues"));
1701 assert!(results[0].confidence < 1.0);
1703 assert!(results[0].confidence >= 0.5);
1704 assert!(
1706 results[0]
1707 .reason
1708 .contains("bd-1 -> bd-2 -> bd-3 -> bd-4 -> bd-1")
1709 );
1710 }
1711
1712 #[test]
1713 fn cycle_warning_empty_cycles() {
1714 let metrics = empty_metrics();
1715 let results = detect_cycle_warnings(&metrics, &Utc::now().to_rfc3339());
1716 assert!(results.is_empty());
1717 }
1718
1719 #[test]
1720 fn cycle_warning_skips_empty_cycle_vec() {
1721 let mut metrics = empty_metrics();
1722 metrics.cycles.push(vec![]); metrics
1724 .cycles
1725 .push(vec!["bd-1".to_string(), "bd-2".to_string()]);
1726
1727 let now = Utc::now().to_rfc3339();
1728 let results = detect_cycle_warnings(&metrics, &now);
1729 assert_eq!(results.len(), 1, "empty cycle should be skipped");
1730 assert!(results[0].summary.contains("Direct cycle"));
1731 }
1732
1733 #[test]
1734 fn cycle_warning_capped_at_max() {
1735 let mut metrics = empty_metrics();
1736 for i in 0..15 {
1737 metrics
1738 .cycles
1739 .push(vec![format!("bd-{i}a"), format!("bd-{i}b")]);
1740 }
1741
1742 let now = Utc::now().to_rfc3339();
1743 let results = detect_cycle_warnings(&metrics, &now);
1744 assert_eq!(
1745 results.len(),
1746 CYCLE_MAX,
1747 "should cap at CYCLE_MAX={CYCLE_MAX}"
1748 );
1749 }
1750
1751 #[test]
1754 fn extract_keywords_filters_stop_words_and_short() {
1755 let keywords = extract_keywords("The quick fix", "and the bug was not here");
1756 assert!(!keywords.contains(&"the".to_string()));
1758 assert!(!keywords.contains(&"and".to_string()));
1759 assert!(!keywords.contains(&"was".to_string()));
1760 assert!(keywords.contains(&"quick".to_string()));
1761 assert!(keywords.contains(&"fix".to_string()));
1762 assert!(keywords.contains(&"bug".to_string()));
1763 }
1764
1765 #[test]
1766 fn extract_keywords_normalizes_case_and_punctuation() {
1767 let keywords = extract_keywords("Database-Migration", "Schema_Upgrade!");
1768 assert!(keywords.contains(&"database".to_string()));
1769 assert!(keywords.contains(&"migration".to_string()));
1770 assert!(keywords.contains(&"schema".to_string()));
1771 assert!(keywords.contains(&"upgrade".to_string()));
1772 }
1773
1774 #[test]
1775 fn intersect_keywords_returns_common() {
1776 let left = vec![
1777 "database".to_string(),
1778 "migration".to_string(),
1779 "schema".to_string(),
1780 ];
1781 let right = vec![
1782 "migration".to_string(),
1783 "testing".to_string(),
1784 "schema".to_string(),
1785 ];
1786 let common = intersect_keywords(&left, &right);
1787 assert!(common.contains(&"migration".to_string()));
1788 assert!(common.contains(&"schema".to_string()));
1789 assert!(!common.contains(&"database".to_string()));
1790 assert!(!common.contains(&"testing".to_string()));
1791 }
1792
1793 #[test]
1794 fn ratio_handles_zero_denominator() {
1795 assert_eq!(ratio(5, 0), 0.0);
1796 assert!((ratio(3, 4) - 0.75).abs() < 0.001);
1797 }
1798
1799 #[test]
1802 fn compute_stats_counts_correctly() {
1803 let suggestions = vec![
1804 make_suggestion(SuggestionType::PotentialDuplicate, 0.9, "bd-1"),
1805 make_suggestion(SuggestionType::CycleWarning, 0.3, "bd-2"),
1806 make_suggestion(SuggestionType::MissingDependency, 0.6, "bd-3"),
1807 ];
1808 let stats = compute_stats(&suggestions);
1809 assert_eq!(stats.total, 3);
1810 assert_eq!(stats.by_type.get("potential_duplicate"), Some(&1));
1811 assert_eq!(stats.by_type.get("cycle_warning"), Some(&1));
1812 assert_eq!(stats.by_type.get("missing_dependency"), Some(&1));
1813 assert_eq!(stats.high_confidence_count, 1); assert_eq!(stats.by_confidence.get("high"), Some(&1));
1815 assert_eq!(stats.by_confidence.get("medium"), Some(&1)); assert_eq!(stats.by_confidence.get("low"), Some(&1)); }
1818
1819 #[test]
1820 fn compute_stats_empty() {
1821 let stats = compute_stats(&[]);
1822 assert_eq!(stats.total, 0);
1823 assert!(stats.by_type.is_empty());
1824 assert_eq!(stats.high_confidence_count, 0);
1825 assert_eq!(stats.actionable_count, 0);
1826 }
1827
1828 #[test]
1831 fn matches_filters_by_min_confidence() {
1832 let suggestion = make_suggestion(SuggestionType::CycleWarning, 0.5, "bd-1");
1833 let mut options = SuggestOptions::default();
1834 options.min_confidence = 0.3;
1835 assert!(matches_filters(&suggestion, &options));
1836 options.min_confidence = 0.8;
1837 assert!(!matches_filters(&suggestion, &options));
1838 }
1839
1840 #[test]
1841 fn matches_filters_by_type() {
1842 let suggestion = make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-1");
1843 let mut options = SuggestOptions::default();
1844 options.filter_type = Some(SuggestionType::CycleWarning);
1845 assert!(matches_filters(&suggestion, &options));
1846 options.filter_type = Some(SuggestionType::PotentialDuplicate);
1847 assert!(!matches_filters(&suggestion, &options));
1848 }
1849
1850 #[test]
1851 fn matches_filters_by_bead_id() {
1852 let mut suggestion = make_suggestion(SuggestionType::MissingDependency, 0.7, "bd-1");
1853 suggestion.related_bead = Some("bd-2".to_string());
1854 let mut options = SuggestOptions::default();
1855
1856 options.filter_bead = Some("bd-1".to_string());
1857 assert!(matches_filters(&suggestion, &options), "target match");
1858
1859 options.filter_bead = Some("bd-2".to_string());
1860 assert!(matches_filters(&suggestion, &options), "related match");
1861
1862 options.filter_bead = Some("bd-99".to_string());
1863 assert!(!matches_filters(&suggestion, &options), "no match");
1864 }
1865
1866 #[test]
1869 fn confidence_level_boundaries() {
1870 assert_eq!(confidence_level(0.0).as_str(), "low");
1871 assert_eq!(confidence_level(0.39).as_str(), "low");
1872 assert_eq!(confidence_level(0.4).as_str(), "medium");
1873 assert_eq!(confidence_level(0.69).as_str(), "medium");
1874 assert_eq!(confidence_level(0.7).as_str(), "high");
1875 assert_eq!(confidence_level(1.0).as_str(), "high");
1876 }
1877}