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#[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
72pub 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
170pub 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 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 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 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 if recipe.filters.actionable == Some(true) && !actionable_ids.contains(&rec.id) {
232 return false;
233 }
234
235 if recipe.filters.has_blockers == Some(true) && actionable_ids.contains(&rec.id) {
238 return false;
239 }
240
241 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 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 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#[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#[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
367pub 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#[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 #[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 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 let adjustment = 1.0 + (accept_ratio - 0.5) * 0.2; 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#[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(); 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}