1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
17#[serde(deny_unknown_fields)]
18pub struct LabelEffect {
19 pub name: String,
20 pub effect: String,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
41#[serde(deny_unknown_fields)]
42pub struct ScoringConfig {
43 #[serde(default)]
45 pub base_score: Option<f64>,
46
47 #[serde(default)]
50 pub age: Option<String>,
51
52 #[serde(default)]
56 pub approvals: Option<String>,
57
58 #[serde(default)]
60 pub size: Option<SizeConfig>,
61
62 #[serde(default)]
65 pub labels: Option<Vec<LabelEffect>>,
66
67 #[serde(default)]
70 pub previously_reviewed: Option<String>,
71
72 #[serde(default)]
75 pub draft: Option<String>,
76}
77
78impl Default for ScoringConfig {
79 fn default() -> Self {
80 Self {
81 base_score: Some(100.0),
82 age: Some("+1 per 1h".to_string()),
83 approvals: Some("+10 per 1".to_string()),
84 size: Some(SizeConfig {
85 exclude: None,
86 buckets: Some(vec![
87 SizeBucket {
88 range: "<100".to_string(),
89 effect: "x5".to_string(),
90 },
91 SizeBucket {
92 range: "100-500".to_string(),
93 effect: "x1".to_string(),
94 },
95 SizeBucket {
96 range: ">500".to_string(),
97 effect: "x0.5".to_string(),
98 },
99 ]),
100 }),
101 labels: None,
102 previously_reviewed: None,
103 draft: None,
104 }
105 }
106}
107
108pub fn merge_scoring_configs(
113 global: &ScoringConfig,
114 query: Option<&ScoringConfig>,
115) -> ScoringConfig {
116 let Some(query) = query else {
117 return global.clone();
118 };
119
120 ScoringConfig {
121 base_score: query.base_score.or(global.base_score),
122 age: query.age.clone().or_else(|| global.age.clone()),
123 approvals: query.approvals.clone().or_else(|| global.approvals.clone()),
124 size: merge_size_configs(global.size.as_ref(), query.size.as_ref()),
125 labels: merge_label_configs(global.labels.as_ref(), query.labels.as_ref()),
126 previously_reviewed: query
127 .previously_reviewed
128 .clone()
129 .or_else(|| global.previously_reviewed.clone()),
130 draft: query.draft.clone().or_else(|| global.draft.clone()),
131 }
132}
133
134fn merge_size_configs(
141 global: Option<&SizeConfig>,
142 query: Option<&SizeConfig>,
143) -> Option<SizeConfig> {
144 match (query, global) {
145 (Some(q), Some(g)) => Some(SizeConfig {
146 exclude: q.exclude.clone().or_else(|| g.exclude.clone()),
147 buckets: q.buckets.clone().or_else(|| g.buckets.clone()),
148 }),
149 (Some(q), None) => Some(q.clone()),
150 (None, g) => g.cloned(),
151 }
152}
153
154fn merge_label_configs(
158 global: Option<&Vec<LabelEffect>>,
159 query: Option<&Vec<LabelEffect>>,
160) -> Option<Vec<LabelEffect>> {
161 match (query, global) {
162 (None, g) => g.cloned(),
163 (Some(q), None) => Some(q.clone()),
164 (Some(q), Some(g)) => {
165 let mut merged: HashMap<String, LabelEffect> = HashMap::new();
166 for label in g {
168 merged.insert(label.name.to_lowercase(), label.clone());
169 }
170 for label in q {
172 merged.insert(label.name.to_lowercase(), label.clone());
173 }
174 Some(merged.into_values().collect())
175 }
176 }
177}
178
179#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
183#[serde(deny_unknown_fields)]
184pub struct SizeConfig {
185 #[serde(default)]
188 pub exclude: Option<Vec<String>>,
189
190 #[serde(default)]
192 pub buckets: Option<Vec<SizeBucket>>,
193}
194
195#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
200#[serde(deny_unknown_fields)]
201pub struct SizeBucket {
202 pub range: String,
204
205 pub effect: String,
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn test_default_scoring_config() {
215 let config = ScoringConfig::default();
216
217 assert_eq!(config.base_score, Some(100.0));
218 assert_eq!(config.age, Some("+1 per 1h".to_string()));
219 assert_eq!(config.approvals, Some("+10 per 1".to_string()));
220 assert!(config.size.is_some());
221 assert!(config.labels.is_none());
222 assert!(config.previously_reviewed.is_none());
223 }
224
225 #[test]
226 fn test_scoring_config_serde_roundtrip() {
227 let config = ScoringConfig::default();
228 let yaml = serde_saphyr::to_string(&config).unwrap();
229 let parsed: ScoringConfig = serde_saphyr::from_str(&yaml).unwrap();
230 assert_eq!(config, parsed);
231 }
232
233 #[test]
234 fn test_partial_scoring_config_parse() {
235 let yaml = r#"
236base_score: 200
237age: "+5 per 1h"
238"#;
239 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
240 assert_eq!(config.base_score, Some(200.0));
241 assert_eq!(config.age, Some("+5 per 1h".to_string()));
242 assert!(config.approvals.is_none());
243 assert!(config.size.is_none());
244 assert!(config.labels.is_none());
245 assert!(config.previously_reviewed.is_none());
246 }
247
248 #[test]
249 fn test_full_scoring_config_parse() {
250 let yaml = r#"
251base_score: 100
252age: "+1 per 1h"
253approvals: "x2 per 1"
254size:
255 exclude:
256 - "*.lock"
257 - "package-lock.json"
258 buckets:
259 - range: "<100"
260 effect: "x5"
261 - range: ">=500"
262 effect: "x0.5"
263"#;
264 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
265 assert_eq!(config.base_score, Some(100.0));
266 assert_eq!(config.age, Some("+1 per 1h".to_string()));
267 assert_eq!(config.approvals, Some("x2 per 1".to_string()));
268
269 let size = config.size.unwrap();
270 assert_eq!(size.exclude.unwrap().len(), 2);
271 assert_eq!(size.buckets.as_ref().unwrap().len(), 2);
272 }
273
274 #[test]
275 fn test_empty_scoring_config_parse() {
276 let yaml = "{}";
277 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
278 assert!(config.base_score.is_none());
279 assert!(config.age.is_none());
280 assert!(config.approvals.is_none());
281 assert!(config.size.is_none());
282 assert!(config.labels.is_none());
283 assert!(config.previously_reviewed.is_none());
284 }
285
286 #[test]
287 fn test_size_config_without_exclude() {
288 let yaml = r#"
289buckets:
290 - range: "<100"
291 effect: "x5"
292"#;
293 let config: SizeConfig = serde_saphyr::from_str(yaml).unwrap();
294 assert!(config.exclude.is_none());
295 assert_eq!(config.buckets.as_ref().unwrap().len(), 1);
296 }
297
298 #[test]
299 fn test_size_config_exclude_only() {
300 let yaml = r#"
301exclude:
302 - "*.lock"
303 - "package-lock.json"
304"#;
305 let config: SizeConfig = serde_saphyr::from_str(yaml).unwrap();
306 assert_eq!(config.exclude.as_ref().unwrap().len(), 2);
307 assert!(config.buckets.is_none());
308 }
309
310 #[test]
311 fn test_labels_config_parse() {
312 let yaml = r#"
313labels:
314 - name: "urgent"
315 effect: "+10"
316 - name: "wip"
317 effect: "x0.5"
318"#;
319 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
320 let labels = config.labels.unwrap();
321 assert_eq!(labels.len(), 2);
322 assert_eq!(labels[0].name, "urgent");
323 assert_eq!(labels[0].effect, "+10");
324 assert_eq!(labels[1].name, "wip");
325 assert_eq!(labels[1].effect, "x0.5");
326 }
327
328 #[test]
329 fn test_previously_reviewed_config_parse() {
330 let yaml = r#"
331previously_reviewed: "x0.5"
332"#;
333 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
334 assert_eq!(config.previously_reviewed, Some("x0.5".to_string()));
335 }
336
337 #[test]
338 fn test_draft_config_parse() {
339 let yaml = r#"
340draft: "x0.1"
341"#;
342 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
343 assert_eq!(config.draft, Some("x0.1".to_string()));
344 }
345
346 #[test]
347 fn test_full_config_with_all_factors() {
348 let yaml = r#"
349base_score: 100
350age: "+1 per 1h"
351approvals: "x2 per 1"
352size:
353 buckets:
354 - range: "<100"
355 effect: "x5"
356labels:
357 - name: "urgent"
358 effect: "+20"
359previously_reviewed: "x0.5"
360draft: "x0.1"
361"#;
362 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
363 assert_eq!(config.base_score, Some(100.0));
364 assert_eq!(config.age, Some("+1 per 1h".to_string()));
365 assert_eq!(config.approvals, Some("x2 per 1".to_string()));
366 assert!(config.size.is_some());
367 assert_eq!(config.labels.as_ref().unwrap().len(), 1);
368 assert_eq!(config.previously_reviewed, Some("x0.5".to_string()));
369 assert_eq!(config.draft, Some("x0.1".to_string()));
370 }
371
372 #[test]
375 fn test_merge_no_query_returns_global() {
376 let global = ScoringConfig::default();
377 let result = merge_scoring_configs(&global, None);
378 assert_eq!(result, global);
379 }
380
381 #[test]
382 fn test_merge_partial_query_preserves_global_fields() {
383 let global = ScoringConfig {
384 base_score: Some(100.0),
385 age: Some("+1 per 1h".to_string()),
386 approvals: Some("+10 per 1".to_string()),
387 size: Some(SizeConfig {
388 exclude: Some(vec!["*.lock".to_string()]),
389 buckets: Some(vec![SizeBucket {
390 range: "<100".to_string(),
391 effect: "x5".to_string(),
392 }]),
393 }),
394 labels: Some(vec![LabelEffect {
395 name: "urgent".to_string(),
396 effect: "+10".to_string(),
397 }]),
398 previously_reviewed: Some("x0.5".to_string()),
399 draft: None,
400 };
401
402 let query = ScoringConfig {
404 base_score: None,
405 age: Some("+5 per 1h".to_string()),
406 approvals: None,
407 size: None,
408 labels: None,
409 previously_reviewed: None,
410 draft: None,
411 };
412
413 let result = merge_scoring_configs(&global, Some(&query));
414 assert_eq!(result.base_score, Some(100.0)); assert_eq!(result.age, Some("+5 per 1h".to_string())); assert_eq!(result.approvals, Some("+10 per 1".to_string())); assert!(result.size.is_some()); assert_eq!(
419 result.size.as_ref().unwrap().exclude,
420 Some(vec!["*.lock".to_string()])
421 );
422 assert_eq!(result.labels.as_ref().unwrap().len(), 1); assert_eq!(result.previously_reviewed, Some("x0.5".to_string())); }
425
426 #[test]
427 fn test_merge_query_overrides_global() {
428 let global = ScoringConfig {
429 base_score: Some(100.0),
430 age: Some("+1 per 1h".to_string()),
431 approvals: None,
432 size: None,
433 labels: None,
434 previously_reviewed: None,
435 draft: None,
436 };
437
438 let query = ScoringConfig {
439 base_score: Some(200.0),
440 age: Some("+5 per 1h".to_string()),
441 approvals: None,
442 size: None,
443 labels: None,
444 previously_reviewed: None,
445 draft: None,
446 };
447
448 let result = merge_scoring_configs(&global, Some(&query));
449 assert_eq!(result.base_score, Some(200.0)); assert_eq!(result.age, Some("+5 per 1h".to_string())); }
452
453 #[test]
454 fn test_merge_size_config_preserves_global_exclude() {
455 let global = ScoringConfig {
456 base_score: None,
457 age: None,
458 approvals: None,
459 size: Some(SizeConfig {
460 exclude: Some(vec!["*.lock".to_string()]),
461 buckets: Some(vec![SizeBucket {
462 range: "<100".to_string(),
463 effect: "x5".to_string(),
464 }]),
465 }),
466 labels: None,
467 previously_reviewed: None,
468 draft: None,
469 };
470
471 let query = ScoringConfig {
473 base_score: None,
474 age: None,
475 approvals: None,
476 size: Some(SizeConfig {
477 exclude: None,
478 buckets: Some(vec![SizeBucket {
479 range: "<50".to_string(),
480 effect: "x10".to_string(),
481 }]),
482 }),
483 labels: None,
484 previously_reviewed: None,
485 draft: None,
486 };
487
488 let result = merge_scoring_configs(&global, Some(&query));
489 let size = result.size.unwrap();
490 assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
492 let buckets = size.buckets.unwrap();
494 assert_eq!(buckets.len(), 1);
495 assert_eq!(buckets[0].range, "<50");
496 }
497
498 #[test]
499 fn test_merge_size_config_absent_buckets_falls_through() {
500 let global = ScoringConfig {
501 base_score: None,
502 age: None,
503 approvals: None,
504 size: Some(SizeConfig {
505 exclude: None,
506 buckets: Some(vec![SizeBucket {
507 range: "<100".to_string(),
508 effect: "x5".to_string(),
509 }]),
510 }),
511 labels: None,
512 previously_reviewed: None,
513 draft: None,
514 };
515
516 let query = ScoringConfig {
518 base_score: None,
519 age: None,
520 approvals: None,
521 size: Some(SizeConfig {
522 exclude: None,
523 buckets: None,
524 }),
525 labels: None,
526 previously_reviewed: None,
527 draft: None,
528 };
529
530 let result = merge_scoring_configs(&global, Some(&query));
531 let size = result.size.unwrap();
532 let buckets = size.buckets.unwrap();
534 assert_eq!(buckets.len(), 1);
535 assert_eq!(buckets[0].range, "<100");
536 }
537
538 #[test]
539 fn test_merge_size_config_query_exclude_overrides_global() {
540 let global = ScoringConfig {
541 base_score: None,
542 age: None,
543 approvals: None,
544 size: Some(SizeConfig {
545 exclude: Some(vec!["*.lock".to_string()]),
546 buckets: None,
547 }),
548 labels: None,
549 previously_reviewed: None,
550 draft: None,
551 };
552
553 let query = ScoringConfig {
554 base_score: None,
555 age: None,
556 approvals: None,
557 size: Some(SizeConfig {
558 exclude: Some(vec!["*.json".to_string()]),
559 buckets: None,
560 }),
561 labels: None,
562 previously_reviewed: None,
563 draft: None,
564 };
565
566 let result = merge_scoring_configs(&global, Some(&query));
567 let size = result.size.unwrap();
568 assert_eq!(size.exclude, Some(vec!["*.json".to_string()]));
570 }
571
572 #[test]
573 fn test_merge_all_none_query() {
574 let global = ScoringConfig::default();
575
576 let query = ScoringConfig {
578 base_score: None,
579 age: None,
580 approvals: None,
581 size: None,
582 labels: None,
583 previously_reviewed: None,
584 draft: None,
585 };
586
587 let result = merge_scoring_configs(&global, Some(&query));
588 assert_eq!(result, global);
590 }
591
592 #[test]
595 fn test_merge_size_exclude_inherits_global_buckets() {
596 let global = ScoringConfig {
598 base_score: None,
599 age: None,
600 approvals: None,
601 size: Some(SizeConfig {
602 exclude: None,
603 buckets: Some(vec![SizeBucket {
604 range: "<100".to_string(),
605 effect: "x5".to_string(),
606 }]),
607 }),
608 labels: None,
609 previously_reviewed: None,
610 draft: None,
611 };
612
613 let query = ScoringConfig {
614 base_score: None,
615 age: None,
616 approvals: None,
617 size: Some(SizeConfig {
618 exclude: Some(vec!["*.lock".to_string()]),
619 buckets: None, }),
621 labels: None,
622 previously_reviewed: None,
623 draft: None,
624 };
625
626 let result = merge_scoring_configs(&global, Some(&query));
627 let size = result.size.unwrap();
628 assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
630 let buckets = size.buckets.unwrap();
632 assert_eq!(buckets.len(), 1);
633 assert_eq!(buckets[0].range, "<100");
634 }
635
636 #[test]
637 fn test_merge_size_buckets_inherits_global_exclude() {
638 let global = ScoringConfig {
640 base_score: None,
641 age: None,
642 approvals: None,
643 size: Some(SizeConfig {
644 exclude: Some(vec!["*.lock".to_string()]),
645 buckets: None,
646 }),
647 labels: None,
648 previously_reviewed: None,
649 draft: None,
650 };
651
652 let query = ScoringConfig {
653 base_score: None,
654 age: None,
655 approvals: None,
656 size: Some(SizeConfig {
657 exclude: None, buckets: Some(vec![SizeBucket {
659 range: "<200".to_string(),
660 effect: "x3".to_string(),
661 }]),
662 }),
663 labels: None,
664 previously_reviewed: None,
665 draft: None,
666 };
667
668 let result = merge_scoring_configs(&global, Some(&query));
669 let size = result.size.unwrap();
670 assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
672 let buckets = size.buckets.unwrap();
674 assert_eq!(buckets.len(), 1);
675 assert_eq!(buckets[0].range, "<200");
676 }
677
678 #[test]
681 fn test_merge_labels_by_name_query_wins() {
682 let global = ScoringConfig {
683 base_score: None,
684 age: None,
685 approvals: None,
686 size: None,
687 labels: Some(vec![LabelEffect {
688 name: "foo".to_string(),
689 effect: "x3".to_string(),
690 }]),
691 previously_reviewed: None,
692 draft: None,
693 };
694
695 let query = ScoringConfig {
696 base_score: None,
697 age: None,
698 approvals: None,
699 size: None,
700 labels: Some(vec![LabelEffect {
701 name: "foo".to_string(),
702 effect: "x2".to_string(),
703 }]),
704 previously_reviewed: None,
705 draft: None,
706 };
707
708 let result = merge_scoring_configs(&global, Some(&query));
709 let labels = result.labels.unwrap();
710 assert_eq!(labels.len(), 1);
711 assert_eq!(labels[0].name, "foo");
712 assert_eq!(labels[0].effect, "x2"); }
714
715 #[test]
716 fn test_merge_labels_preserves_unmentioned_global() {
717 let global = ScoringConfig {
718 base_score: None,
719 age: None,
720 approvals: None,
721 size: None,
722 labels: Some(vec![
723 LabelEffect {
724 name: "foo".to_string(),
725 effect: "+5".to_string(),
726 },
727 LabelEffect {
728 name: "bar".to_string(),
729 effect: "+10".to_string(),
730 },
731 ]),
732 previously_reviewed: None,
733 draft: None,
734 };
735
736 let query = ScoringConfig {
737 base_score: None,
738 age: None,
739 approvals: None,
740 size: None,
741 labels: Some(vec![LabelEffect {
742 name: "foo".to_string(),
743 effect: "+20".to_string(),
744 }]),
745 previously_reviewed: None,
746 draft: None,
747 };
748
749 let result = merge_scoring_configs(&global, Some(&query));
750 let labels = result.labels.unwrap();
751 assert_eq!(labels.len(), 2);
752 let foo = labels.iter().find(|l| l.name == "foo").unwrap();
754 assert_eq!(foo.effect, "+20"); let bar = labels.iter().find(|l| l.name == "bar").unwrap();
756 assert_eq!(bar.effect, "+10"); }
758
759 #[test]
760 fn test_merge_labels_case_insensitive() {
761 let global = ScoringConfig {
762 base_score: None,
763 age: None,
764 approvals: None,
765 size: None,
766 labels: Some(vec![LabelEffect {
767 name: "Urgent".to_string(),
768 effect: "+10".to_string(),
769 }]),
770 previously_reviewed: None,
771 draft: None,
772 };
773
774 let query = ScoringConfig {
775 base_score: None,
776 age: None,
777 approvals: None,
778 size: None,
779 labels: Some(vec![LabelEffect {
780 name: "urgent".to_string(),
781 effect: "+20".to_string(),
782 }]),
783 previously_reviewed: None,
784 draft: None,
785 };
786
787 let result = merge_scoring_configs(&global, Some(&query));
788 let labels = result.labels.unwrap();
789 assert_eq!(labels.len(), 1);
791 assert_eq!(labels[0].name, "urgent"); assert_eq!(labels[0].effect, "+20"); }
794
795 #[test]
796 fn test_merge_labels_no_global() {
797 let global = ScoringConfig {
798 base_score: None,
799 age: None,
800 approvals: None,
801 size: None,
802 labels: None,
803 previously_reviewed: None,
804 draft: None,
805 };
806
807 let query = ScoringConfig {
808 base_score: None,
809 age: None,
810 approvals: None,
811 size: None,
812 labels: Some(vec![LabelEffect {
813 name: "foo".to_string(),
814 effect: "+5".to_string(),
815 }]),
816 previously_reviewed: None,
817 draft: None,
818 };
819
820 let result = merge_scoring_configs(&global, Some(&query));
821 let labels = result.labels.unwrap();
822 assert_eq!(labels.len(), 1);
823 assert_eq!(labels[0].name, "foo");
824 }
825
826 #[test]
827 fn test_merge_labels_no_query() {
828 let global = ScoringConfig {
829 base_score: None,
830 age: None,
831 approvals: None,
832 size: None,
833 labels: Some(vec![LabelEffect {
834 name: "bar".to_string(),
835 effect: "+10".to_string(),
836 }]),
837 previously_reviewed: None,
838 draft: None,
839 };
840
841 let query = ScoringConfig {
842 base_score: None,
843 age: None,
844 approvals: None,
845 size: None,
846 labels: None,
847 previously_reviewed: None,
848 draft: None,
849 };
850
851 let result = merge_scoring_configs(&global, Some(&query));
852 let labels = result.labels.unwrap();
853 assert_eq!(labels.len(), 1);
854 assert_eq!(labels[0].name, "bar");
855 }
856}