Skip to main content

bvr/analysis/
recipe.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use super::triage::Recommendation;
8use crate::model::Issue;
9
10// ---------------------------------------------------------------------------
11// Recipe Types
12// ---------------------------------------------------------------------------
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Recipe {
16    pub name: String,
17    pub description: String,
18    #[serde(default)]
19    pub filters: FilterConfig,
20    #[serde(default)]
21    pub sort: SortConfig,
22    #[serde(default)]
23    pub max_items: usize,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct FilterConfig {
28    #[serde(default)]
29    pub status: Vec<String>,
30    #[serde(default)]
31    pub min_priority: Option<i32>,
32    #[serde(default)]
33    pub max_priority: Option<i32>,
34    #[serde(default)]
35    pub labels: Vec<String>,
36    #[serde(default)]
37    pub actionable: Option<bool>,
38    #[serde(default)]
39    pub has_blockers: Option<bool>,
40    #[serde(default)]
41    pub title_contains: Option<String>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct SortConfig {
46    #[serde(default = "default_sort_field")]
47    pub field: String,
48    #[serde(default = "default_sort_direction")]
49    pub direction: String,
50}
51
52fn default_sort_field() -> String {
53    "priority".to_string()
54}
55
56fn default_sort_direction() -> String {
57    "asc".to_string()
58}
59
60fn open_like_status_filters() -> Vec<String> {
61    vec![
62        "open".to_string(),
63        "in_progress".to_string(),
64        "blocked".to_string(),
65        "deferred".to_string(),
66        "pinned".to_string(),
67        "hooked".to_string(),
68        "review".to_string(),
69    ]
70}
71
72// ---------------------------------------------------------------------------
73// Built-in Recipes
74// ---------------------------------------------------------------------------
75
76pub fn builtin_recipes() -> Vec<Recipe> {
77    vec![
78        Recipe {
79            name: "default".to_string(),
80            description: "All open issues sorted by priority".to_string(),
81            filters: FilterConfig {
82                status: open_like_status_filters(),
83                ..Default::default()
84            },
85            sort: SortConfig {
86                field: "priority".into(),
87                direction: "asc".into(),
88            },
89            max_items: 0,
90        },
91        Recipe {
92            name: "actionable".to_string(),
93            description: "Issues ready to work on (no open blockers)".to_string(),
94            filters: FilterConfig {
95                actionable: Some(true),
96                ..Default::default()
97            },
98            sort: SortConfig {
99                field: "priority".into(),
100                direction: "asc".into(),
101            },
102            max_items: 20,
103        },
104        Recipe {
105            name: "blocked".to_string(),
106            description: "Issues waiting on dependencies".to_string(),
107            filters: FilterConfig {
108                has_blockers: Some(true),
109                ..Default::default()
110            },
111            sort: SortConfig {
112                field: "priority".into(),
113                direction: "asc".into(),
114            },
115            max_items: 0,
116        },
117        Recipe {
118            name: "high-impact".to_string(),
119            description: "Issues with highest graph centrality".to_string(),
120            filters: FilterConfig {
121                status: open_like_status_filters(),
122                ..Default::default()
123            },
124            sort: SortConfig {
125                field: "pagerank".into(),
126                direction: "desc".into(),
127            },
128            max_items: 15,
129        },
130        Recipe {
131            name: "triage".to_string(),
132            description: "Sorted by computed triage score".to_string(),
133            filters: FilterConfig::default(),
134            sort: SortConfig {
135                field: "triage".into(),
136                direction: "desc".into(),
137            },
138            max_items: 20,
139        },
140        Recipe {
141            name: "quick-wins".to_string(),
142            description: "Easy low-priority actionable items".to_string(),
143            filters: FilterConfig {
144                actionable: Some(true),
145                min_priority: Some(2),
146                ..Default::default()
147            },
148            sort: SortConfig {
149                field: "priority".into(),
150                direction: "desc".into(),
151            },
152            max_items: 10,
153        },
154        Recipe {
155            name: "stale".to_string(),
156            description: "Issues not updated in 30+ days".to_string(),
157            filters: FilterConfig {
158                status: open_like_status_filters(),
159                ..Default::default()
160            },
161            sort: SortConfig {
162                field: "updated".into(),
163                direction: "asc".into(),
164            },
165            max_items: 20,
166        },
167    ]
168}
169
170// ---------------------------------------------------------------------------
171// Recipe Application
172// ---------------------------------------------------------------------------
173
174/// Apply a recipe's filters to a list of recommendations.
175pub fn apply_recipe(
176    recipe: &Recipe,
177    recommendations: &[Recommendation],
178    issues: &[Issue],
179    actionable_ids: &[String],
180    pagerank: &HashMap<String, f64>,
181) -> Vec<Recommendation> {
182    let issue_map: HashMap<&str, &Issue> = issues.iter().map(|i| (i.id.as_str(), i)).collect();
183
184    let mut filtered: Vec<Recommendation> = recommendations
185        .iter()
186        .filter(|rec| {
187            let issue = issue_map.get(rec.id.as_str());
188
189            // Status filter
190            if !recipe.filters.status.is_empty() {
191                let status = issue.map_or("unknown", |i| i.status.as_str());
192                let normalized = status.to_ascii_lowercase();
193                if !recipe
194                    .filters
195                    .status
196                    .iter()
197                    .any(|s| s.trim().eq_ignore_ascii_case(&normalized))
198                {
199                    return false;
200                }
201            }
202
203            // Priority filter
204            if let Some(min_p) = recipe.filters.min_priority {
205                let priority = issue.map_or(99, |i| i.priority);
206                if priority < min_p {
207                    return false;
208                }
209            }
210            if let Some(max_p) = recipe.filters.max_priority {
211                let priority = issue.map_or(99, |i| i.priority);
212                if priority > max_p {
213                    return false;
214                }
215            }
216
217            // Label filter
218            if !recipe.filters.labels.is_empty() {
219                let labels = issue.map_or(&[] as &[String], |i| &i.labels);
220                if !recipe
221                    .filters
222                    .labels
223                    .iter()
224                    .any(|l| labels.iter().any(|il| il.eq_ignore_ascii_case(l)))
225                {
226                    return false;
227                }
228            }
229
230            // Actionable filter
231            if recipe.filters.actionable == Some(true) && !actionable_ids.contains(&rec.id) {
232                return false;
233            }
234
235            // Has blockers filter: when has_blockers=true, reject items that are
236            // actionable (no blockers). Non-actionable items (with blockers) pass through.
237            if recipe.filters.has_blockers == Some(true) && actionable_ids.contains(&rec.id) {
238                return false;
239            }
240
241            // Title contains filter
242            if let Some(ref needle) = recipe.filters.title_contains {
243                let title = issue.map_or("", |i| i.title.as_str());
244                if !title
245                    .to_ascii_lowercase()
246                    .contains(&needle.to_ascii_lowercase())
247                {
248                    return false;
249                }
250            }
251
252            true
253        })
254        .cloned()
255        .collect();
256
257    // Sort
258    match recipe.sort.field.as_str() {
259        "priority" => {
260            filtered.sort_by(|a, b| {
261                let pa = issue_map.get(a.id.as_str()).map_or(99, |i| i.priority);
262                let pb = issue_map.get(b.id.as_str()).map_or(99, |i| i.priority);
263                if recipe.sort.direction == "desc" {
264                    pb.cmp(&pa).then_with(|| a.id.cmp(&b.id))
265                } else {
266                    pa.cmp(&pb).then_with(|| a.id.cmp(&b.id))
267                }
268            });
269        }
270        "triage" | "score" => {
271            filtered.sort_by(|a, b| {
272                if recipe.sort.direction == "asc" {
273                    a.score.total_cmp(&b.score).then_with(|| a.id.cmp(&b.id))
274                } else {
275                    b.score.total_cmp(&a.score).then_with(|| a.id.cmp(&b.id))
276                }
277            });
278        }
279        "pagerank" => {
280            filtered.sort_by(|a, b| {
281                let pa = pagerank.get(&a.id).copied().unwrap_or(0.0);
282                let pb = pagerank.get(&b.id).copied().unwrap_or(0.0);
283                if recipe.sort.direction == "asc" {
284                    pa.total_cmp(&pb).then_with(|| a.id.cmp(&b.id))
285                } else {
286                    pb.total_cmp(&pa).then_with(|| a.id.cmp(&b.id))
287                }
288            });
289        }
290        _ => {
291            // Default: sort by ID
292            filtered.sort_by(|a, b| a.id.cmp(&b.id));
293        }
294    }
295
296    if recipe.max_items > 0 {
297        filtered.truncate(recipe.max_items);
298    }
299
300    filtered
301}
302
303// ---------------------------------------------------------------------------
304// Robot Output
305// ---------------------------------------------------------------------------
306
307#[derive(Debug, Clone, Serialize)]
308pub struct RecipeSummary {
309    pub name: String,
310    pub description: String,
311    pub source: String,
312}
313
314#[derive(Debug, Serialize)]
315pub struct RobotRecipesOutput {
316    #[serde(flatten)]
317    pub envelope: crate::robot::RobotEnvelope,
318    pub recipes: Vec<RecipeSummary>,
319}
320
321pub fn list_recipes() -> Vec<RecipeSummary> {
322    let mut recipes: Vec<RecipeSummary> = builtin_recipes()
323        .into_iter()
324        .map(|r| RecipeSummary {
325            name: r.name,
326            description: r.description,
327            source: "builtin".to_string(),
328        })
329        .collect();
330    recipes.sort_by(|a, b| a.name.cmp(&b.name));
331    recipes
332}
333
334pub fn find_recipe(name: &str) -> Option<Recipe> {
335    builtin_recipes().into_iter().find(|r| r.name == name)
336}
337
338// ---------------------------------------------------------------------------
339// Script Emission
340// ---------------------------------------------------------------------------
341
342#[derive(Debug, Clone, Copy)]
343pub enum ScriptFormat {
344    Bash,
345    Fish,
346    Zsh,
347}
348
349impl ScriptFormat {
350    pub fn from_str_or_default(s: &str) -> Self {
351        match s.to_ascii_lowercase().as_str() {
352            "fish" => Self::Fish,
353            "zsh" => Self::Zsh,
354            _ => Self::Bash,
355        }
356    }
357
358    pub const fn shebang(self) -> &'static str {
359        match self {
360            Self::Bash => "#!/usr/bin/env bash",
361            Self::Fish => "#!/usr/bin/env fish",
362            Self::Zsh => "#!/usr/bin/env zsh",
363        }
364    }
365}
366
367/// Generate a shell script for the top N recommendations.
368pub fn emit_script(
369    recommendations: &[Recommendation],
370    limit: usize,
371    format: ScriptFormat,
372    generated_at: &str,
373    data_hash: &str,
374) -> String {
375    let items: &[Recommendation] = if limit == 0 {
376        &[]
377    } else if recommendations.len() > limit {
378        &recommendations[..limit]
379    } else {
380        recommendations
381    };
382
383    let mut lines = Vec::new();
384
385    lines.push(format.shebang().to_string());
386    if matches!(format, ScriptFormat::Bash | ScriptFormat::Zsh) {
387        lines.push("set -euo pipefail".to_string());
388    }
389    lines.push(String::new());
390    lines.push(format!(
391        "# Generated by bvr --emit-script at {generated_at}"
392    ));
393    lines.push(format!("# Data hash: {data_hash}"));
394    lines.push(format!(
395        "# Top {} recommendations from {} total",
396        items.len(),
397        recommendations.len()
398    ));
399    lines.push(String::new());
400
401    for (i, rec) in items.iter().enumerate() {
402        let rank = i + 1;
403        lines.push(format!("# {rank}. {} (score: {:.3})", rec.title, rec.score));
404        if !rec.reasons.is_empty() {
405            lines.push(format!("#    Reason: {}", rec.reasons.join("; ")));
406        }
407        lines.push(format!("br show {}", rec.id));
408        lines.push(format!(
409            "# To claim: br update {} --status=in_progress",
410            rec.id
411        ));
412        lines.push(String::new());
413    }
414
415    if let Some(top) = items.first() {
416        lines.push("# === Quick Actions ===".to_string());
417        lines.push("# To claim the top pick:".to_string());
418        lines.push(format!("# br update {} --status=in_progress", top.id));
419    }
420
421    lines.join("\n")
422}
423
424// ---------------------------------------------------------------------------
425// Feedback Tuning
426// ---------------------------------------------------------------------------
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct FeedbackEvent {
430    pub issue_id: String,
431    pub action: String,
432    pub score: f64,
433    pub timestamp: String,
434    #[serde(default)]
435    pub by: String,
436    #[serde(default)]
437    pub reason: String,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct WeightAdjustment {
442    pub name: String,
443    pub adjustment: f64,
444    pub samples: usize,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, Default)]
448pub struct FeedbackData {
449    #[serde(default = "default_version")]
450    pub version: String,
451    #[serde(default)]
452    pub events: Vec<FeedbackEvent>,
453    #[serde(default)]
454    pub adjustments: Vec<WeightAdjustment>,
455}
456
457fn default_version() -> String {
458    env!("CARGO_PKG_VERSION").to_string()
459}
460
461#[derive(Debug, Clone, Serialize)]
462pub struct FeedbackStats {
463    pub total_accepted: usize,
464    pub total_ignored: usize,
465    pub avg_accept_score: f64,
466    pub avg_ignore_score: f64,
467    pub adjustments: Vec<WeightAdjustment>,
468}
469
470#[derive(Debug, Serialize)]
471pub struct RobotFeedbackOutput {
472    #[serde(flatten)]
473    pub envelope: crate::robot::RobotEnvelope,
474    pub stats: FeedbackStats,
475}
476
477impl FeedbackData {
478    pub fn load(project_dir: &Path) -> Self {
479        let path = project_dir.join(".bv").join("feedback.json");
480        fs::read_to_string(&path).map_or_else(
481            |_| Self::default(),
482            |content| serde_json::from_str(&content).unwrap_or_default(),
483        )
484    }
485
486    pub fn save(&self, project_dir: &Path) -> Result<(), String> {
487        let dir = project_dir.join(".bv");
488        fs::create_dir_all(&dir).map_err(|e| format!("failed to create .bv dir: {e}"))?;
489        let path = dir.join("feedback.json");
490        let json = serde_json::to_string_pretty(self)
491            .map_err(|e| format!("failed to serialize feedback: {e}"))?;
492        fs::write(&path, json).map_err(|e| format!("failed to write feedback: {e}"))?;
493        Ok(())
494    }
495
496    pub fn record_accept(&mut self, issue_id: &str, score: f64, by: &str, reason: &str) {
497        self.events.push(FeedbackEvent {
498            issue_id: issue_id.to_string(),
499            action: "accept".to_string(),
500            score,
501            timestamp: chrono_now(),
502            by: by.to_string(),
503            reason: reason.to_string(),
504        });
505        self.update_adjustments();
506    }
507
508    pub fn record_ignore(&mut self, issue_id: &str, score: f64, by: &str, reason: &str) {
509        self.events.push(FeedbackEvent {
510            issue_id: issue_id.to_string(),
511            action: "ignore".to_string(),
512            score,
513            timestamp: chrono_now(),
514            by: by.to_string(),
515            reason: reason.to_string(),
516        });
517        self.update_adjustments();
518    }
519
520    pub fn reset(&mut self) {
521        self.events.clear();
522        self.adjustments.clear();
523    }
524
525    pub fn stats(&self) -> FeedbackStats {
526        let accepted: Vec<&FeedbackEvent> = self
527            .events
528            .iter()
529            .filter(|e| e.action == "accept")
530            .collect();
531        let ignored: Vec<&FeedbackEvent> = self
532            .events
533            .iter()
534            .filter(|e| e.action == "ignore")
535            .collect();
536
537        let avg_accept = if accepted.is_empty() {
538            0.0
539        } else {
540            accepted.iter().map(|e| e.score).sum::<f64>() / accepted.len() as f64
541        };
542        let avg_ignore = if ignored.is_empty() {
543            0.0
544        } else {
545            ignored.iter().map(|e| e.score).sum::<f64>() / ignored.len() as f64
546        };
547
548        FeedbackStats {
549            total_accepted: accepted.len(),
550            total_ignored: ignored.len(),
551            avg_accept_score: avg_accept,
552            avg_ignore_score: avg_ignore,
553            adjustments: self.adjustments.clone(),
554        }
555    }
556
557    /// Returns the weight adjustments as a map suitable for TriageScoringOptions.
558    /// Maps component name (e.g. "PageRank") to a multiplier (0.5–2.0).
559    /// Returns an empty map if no feedback has been recorded.
560    #[must_use]
561    pub fn weight_adjustment_map(&self) -> std::collections::HashMap<String, f64> {
562        self.adjustments
563            .iter()
564            .map(|adj| (adj.name.clone(), adj.adjustment))
565            .collect()
566    }
567
568    fn update_adjustments(&mut self) {
569        // Simple exponential smoothing on accept/ignore ratio
570        let weight_names = [
571            "PageRank",
572            "Betweenness",
573            "BlockerRatio",
574            "Staleness",
575            "PriorityBoost",
576            "TimeToImpact",
577            "Urgency",
578            "Risk",
579        ];
580
581        let accepted = self.events.iter().filter(|e| e.action == "accept").count();
582        let ignored = self.events.iter().filter(|e| e.action == "ignore").count();
583        let total = accepted + ignored;
584
585        if total == 0 {
586            return;
587        }
588
589        let accept_ratio = accepted as f64 / total as f64;
590        // Adjust weights based on accept ratio
591        // If mostly accepted (>0.7), slightly boost all weights
592        // If mostly ignored (<0.3), slightly reduce
593        let adjustment = 1.0 + (accept_ratio - 0.5) * 0.2; // Range: 0.9-1.1
594        let clamped = adjustment.clamp(0.5, 2.0);
595
596        self.adjustments = weight_names
597            .iter()
598            .map(|name| WeightAdjustment {
599                name: name.to_string(),
600                adjustment: clamped,
601                samples: total,
602            })
603            .collect();
604    }
605}
606
607fn chrono_now() -> String {
608    chrono::Utc::now().to_rfc3339()
609}
610
611// ---------------------------------------------------------------------------
612// Tests
613// ---------------------------------------------------------------------------
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::analysis::triage::Recommendation;
619    use crate::model::Issue;
620
621    fn make_rec(id: &str, title: &str, score: f64) -> Recommendation {
622        Recommendation {
623            id: id.to_string(),
624            title: title.to_string(),
625            issue_type: "task".to_string(),
626            status: "open".to_string(),
627            priority: 2,
628            labels: Vec::new(),
629            score,
630            impact_score: score,
631            confidence: 0.8,
632            action: "Start work on this issue".to_string(),
633            reasons: vec!["test".to_string()],
634            unblocks: 0,
635            unblocks_ids: Vec::new(),
636            blocked_by: Vec::new(),
637            assignee: String::new(),
638            claim_command: String::new(),
639            show_command: String::new(),
640            breakdown: None,
641        }
642    }
643
644    fn make_issue(id: &str, status: &str) -> Issue {
645        Issue {
646            id: id.to_string(),
647            title: id.to_string(),
648            status: status.to_string(),
649            issue_type: "task".to_string(),
650            ..Issue::default()
651        }
652    }
653
654    #[test]
655    fn builtin_recipes_exist() {
656        let recipes = builtin_recipes();
657        assert!(recipes.len() >= 5);
658        assert!(recipes.iter().any(|r| r.name == "default"));
659        assert!(recipes.iter().any(|r| r.name == "actionable"));
660        assert!(recipes.iter().any(|r| r.name == "triage"));
661    }
662
663    #[test]
664    fn list_recipes_sorted() {
665        let list = list_recipes();
666        for i in 1..list.len() {
667            assert!(list[i - 1].name <= list[i].name);
668        }
669    }
670
671    #[test]
672    fn find_recipe_works() {
673        assert!(find_recipe("default").is_some());
674        assert!(find_recipe("nonexistent").is_none());
675    }
676
677    #[test]
678    fn default_recipe_includes_all_open_like_statuses() {
679        let recipe = find_recipe("default").expect("default recipe");
680        for status in ["review", "deferred", "pinned", "hooked"] {
681            assert!(
682                recipe.filters.status.iter().any(|s| s == status),
683                "default recipe should include open-like status {status}"
684            );
685        }
686    }
687
688    #[test]
689    fn apply_recipe_status_filter_is_case_insensitive() {
690        let recs = vec![make_rec("A", "A", 0.9)];
691        let issues = vec![make_issue("A", "review")];
692        let actionable = Vec::new();
693        let pagerank = HashMap::new();
694
695        let recipe = Recipe {
696            name: "custom".to_string(),
697            description: "status filter".to_string(),
698            filters: FilterConfig {
699                status: vec!["ReViEw".to_string()],
700                ..Default::default()
701            },
702            sort: SortConfig::default(),
703            max_items: 0,
704        };
705
706        let result = apply_recipe(&recipe, &recs, &issues, &actionable, &pagerank);
707        assert_eq!(result.len(), 1);
708    }
709
710    #[test]
711    fn apply_recipe_max_items() {
712        let recs = vec![
713            make_rec("A", "A", 0.9),
714            make_rec("B", "B", 0.8),
715            make_rec("C", "C", 0.7),
716        ];
717        let issues = Vec::new();
718        let actionable = Vec::new();
719        let pagerank = HashMap::new();
720
721        let mut recipe = find_recipe("default").unwrap();
722        recipe.max_items = 2;
723        recipe.filters.status.clear(); // Remove status filter for test
724
725        let result = apply_recipe(&recipe, &recs, &issues, &actionable, &pagerank);
726        assert!(result.len() <= 2);
727    }
728
729    #[test]
730    fn emit_script_bash() {
731        let recs = vec![make_rec("A", "Fix auth", 0.9)];
732        let script = emit_script(&recs, 5, ScriptFormat::Bash, "2025-01-01T00:00:00Z", "abc");
733
734        assert!(script.starts_with("#!/usr/bin/env bash"));
735        assert!(script.contains("br show A"));
736        assert!(script.contains("Fix auth"));
737    }
738
739    #[test]
740    fn emit_script_respects_limit() {
741        let recs = vec![
742            make_rec("A", "A", 0.9),
743            make_rec("B", "B", 0.8),
744            make_rec("C", "C", 0.7),
745        ];
746        let script = emit_script(&recs, 1, ScriptFormat::Bash, "2025-01-01T00:00:00Z", "abc");
747
748        assert!(script.contains("br show A"));
749        assert!(!script.contains("br show B"));
750    }
751
752    #[test]
753    fn emit_script_zero_limit_includes_no_recommendations() {
754        let recs = vec![make_rec("A", "A", 0.9), make_rec("B", "B", 0.8)];
755        let script = emit_script(&recs, 0, ScriptFormat::Bash, "2025-01-01T00:00:00Z", "abc");
756
757        assert!(script.contains("# Top 0 recommendations from 2 total"));
758        assert!(!script.contains("br show A"));
759        assert!(!script.contains("br show B"));
760    }
761
762    #[test]
763    fn feedback_record_and_stats() {
764        let mut feedback = FeedbackData::default();
765        feedback.record_accept("A", 0.9, "user", "good pick");
766        feedback.record_ignore("B", 0.3, "user", "not relevant");
767
768        let stats = feedback.stats();
769        assert_eq!(stats.total_accepted, 1);
770        assert_eq!(stats.total_ignored, 1);
771        assert!(stats.avg_accept_score > 0.0);
772    }
773
774    #[test]
775    fn feedback_reset() {
776        let mut feedback = FeedbackData::default();
777        feedback.record_accept("A", 0.9, "user", "good");
778        assert!(!feedback.events.is_empty());
779
780        feedback.reset();
781        assert!(feedback.events.is_empty());
782        assert!(feedback.adjustments.is_empty());
783    }
784
785    #[test]
786    fn feedback_serialization_roundtrip() {
787        let mut feedback = FeedbackData::default();
788        feedback.record_accept("A", 0.9, "user", "test");
789
790        let json = serde_json::to_string(&feedback).unwrap();
791        let restored: FeedbackData = serde_json::from_str(&json).unwrap();
792
793        assert_eq!(restored.events.len(), 1);
794        assert_eq!(restored.events[0].issue_id, "A");
795    }
796}