Skip to main content

pr_bro/scoring/
engine.rs

1use super::config::ScoringConfig;
2use super::factors::Effect;
3use crate::github::types::PullRequest;
4
5#[derive(Debug, Clone)]
6pub struct FactorContribution {
7    pub label: String,       // e.g. "Age", "Approvals", "Size"
8    pub description: String, // e.g. "+1 per 1h (24 units)", "matched '0' -> x0.5"
9    pub before: f64,         // Score before this factor
10    pub after: f64,          // Score after this factor
11}
12
13#[derive(Debug, Clone)]
14pub struct ScoreBreakdown {
15    pub base_score: f64,
16    pub factors: Vec<FactorContribution>,
17}
18
19#[derive(Debug, Clone)]
20pub struct ScoreResult {
21    pub score: f64,
22    pub incomplete: bool,
23    pub breakdown: ScoreBreakdown,
24}
25
26pub fn calculate_score(pr: &PullRequest, config: &ScoringConfig) -> ScoreResult {
27    let base_score = config.base_score.unwrap_or(100.0);
28    let mut score = base_score;
29    let incomplete = false;
30    let mut factors = Vec::new();
31
32    // Apply age factor (always available - created_at always present)
33    if let Some(ref age_str) = config.age {
34        if let Ok(effect) = Effect::parse(age_str) {
35            let before = score;
36            let age = pr.age();
37            let units = calculate_units(&effect, age);
38            score = effect.apply(score, units);
39
40            // Build description for age factor
41            let description = match &effect {
42                Effect::AddPerUnit(n, _) => format!("{:+} per unit ({} units)", n, units),
43                Effect::MultiplyPerUnit(n, _) => format!("x{} per unit ({} units)", n, units),
44                Effect::Add(n) => format!("{:+}", n),
45                Effect::Multiply(n) => format!("x{}", n),
46            };
47
48            factors.push(FactorContribution {
49                label: "Age".to_string(),
50                description,
51                before,
52                after: score,
53            });
54        }
55    }
56
57    // Apply approvals factor
58    if let Some(ref approvals_str) = config.approvals {
59        // For approvals, "per N" means "per N approvals", not per time unit
60        // Convert formats like "+10 per 1" or "x2 per 1" to use a dummy time unit for parsing
61        // The time unit is ignored; we use approval count as units instead
62        let parseable_str = if let Some((effect_part, per_part)) = approvals_str.split_once(" per ")
63        {
64            // Check if per_part is just a number (no time unit)
65            if per_part.trim().chars().all(|c| c.is_numeric() || c == '.') {
66                format!("{} per 1sec", effect_part)
67            } else {
68                approvals_str.clone()
69            }
70        } else {
71            approvals_str.clone()
72        };
73
74        if let Ok(effect) = Effect::parse(&parseable_str) {
75            let before = score;
76            let units = pr.approvals as u64;
77            score = effect.apply(score, units);
78
79            let description = format!("{} approvals, effect: {}", pr.approvals, approvals_str);
80            factors.push(FactorContribution {
81                label: "Approvals".to_string(),
82                description,
83                before,
84                after: score,
85            });
86        }
87    }
88
89    // Apply size factor
90    if let Some(ref size_config) = config.size {
91        if let Some(ref buckets) = size_config.buckets {
92            let size = pr.size();
93            let before = score;
94            let result = apply_bucket_effect(score, size, buckets, |b| &b.range, |b| &b.effect);
95            score = result.score;
96
97            // Only add contribution if a bucket matched
98            if let (Some(range), Some(effect)) = (result.matched_range, result.matched_effect) {
99                let description = format!("{} lines, matched '{}' -> {}", size, range, effect);
100                factors.push(FactorContribution {
101                    label: "Size".to_string(),
102                    description,
103                    before,
104                    after: score,
105                });
106            }
107        }
108    }
109
110    // Apply label factors (multiple matching labels compound)
111    if let Some(ref label_configs) = config.labels {
112        for label_config in label_configs {
113            if pr
114                .labels
115                .iter()
116                .any(|l| l.eq_ignore_ascii_case(&label_config.name))
117            {
118                if let Ok(effect) = Effect::parse(&label_config.effect) {
119                    let before = score;
120                    score = effect.apply(score, 1);
121                    factors.push(FactorContribution {
122                        label: format!("Label: {}", label_config.name),
123                        description: format!(
124                            "matched label '{}' -> {}",
125                            label_config.name, label_config.effect
126                        ),
127                        before,
128                        after: score,
129                    });
130                }
131            }
132        }
133    }
134
135    // Apply previously_reviewed factor
136    if let Some(ref reviewed_effect_str) = config.previously_reviewed {
137        if pr.user_has_reviewed {
138            if let Ok(effect) = Effect::parse(reviewed_effect_str) {
139                let before = score;
140                score = effect.apply(score, 1);
141                factors.push(FactorContribution {
142                    label: "Previously Reviewed".to_string(),
143                    description: format!("You have previously reviewed -> {}", reviewed_effect_str),
144                    before,
145                    after: score,
146                });
147            }
148        }
149    }
150
151    // Apply draft factor
152    if let Some(ref draft_effect_str) = config.draft {
153        if pr.draft {
154            if let Ok(effect) = Effect::parse(draft_effect_str) {
155                let before = score;
156                score = effect.apply(score, 1);
157                factors.push(FactorContribution {
158                    label: "Draft".to_string(),
159                    description: format!("PR is a draft -> {}", draft_effect_str),
160                    before,
161                    after: score,
162                });
163            }
164        }
165    }
166
167    // Floor at zero
168    ScoreResult {
169        score: score.max(0.0),
170        incomplete,
171        breakdown: ScoreBreakdown {
172            base_score,
173            factors,
174        },
175    }
176}
177
178fn calculate_units(effect: &Effect, age: chrono::Duration) -> u64 {
179    if let Some(unit_duration) = effect.unit_duration() {
180        let age_secs = age.num_seconds().max(0) as u64;
181        let unit_secs = unit_duration.as_secs();
182        age_secs.checked_div(unit_secs).unwrap_or(0)
183    } else {
184        1 // Non-per-unit effects apply once
185    }
186}
187
188struct BucketResult {
189    score: f64,
190    matched_range: Option<String>,
191    matched_effect: Option<String>,
192}
193
194fn apply_bucket_effect<T, F1, F2>(
195    score: f64,
196    value: u64,
197    buckets: &[T],
198    get_range: F1,
199    get_effect: F2,
200) -> BucketResult
201where
202    F1: Fn(&T) -> &str,
203    F2: Fn(&T) -> &str,
204{
205    use super::factors::RangeOp;
206
207    for bucket in buckets {
208        let range_str = get_range(bucket);
209        let effect_str = get_effect(bucket);
210        if let Ok(range) = RangeOp::parse(range_str) {
211            if range.matches(value) {
212                if let Ok(effect) = Effect::parse(effect_str) {
213                    return BucketResult {
214                        score: effect.apply(score, 1),
215                        matched_range: Some(range_str.to_string()),
216                        matched_effect: Some(effect_str.to_string()),
217                    };
218                }
219            }
220        }
221    }
222    BucketResult {
223        score,
224        matched_range: None,
225        matched_effect: None,
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::scoring::{LabelEffect, SizeBucket, SizeConfig};
233    use chrono::{Duration as ChronoDuration, Utc};
234
235    fn sample_pr(age_hours: i64, approvals: u32, size: u64) -> PullRequest {
236        PullRequest {
237            title: "Test PR".to_string(),
238            number: 1,
239            author: "user".to_string(),
240            repo: "owner/repo".to_string(),
241            url: "https://github.com/owner/repo/pull/1".to_string(),
242            created_at: Utc::now() - ChronoDuration::hours(age_hours),
243            updated_at: Utc::now(),
244            additions: size / 2,
245            deletions: size / 2,
246            approvals,
247            draft: false,
248            labels: vec![],
249            user_has_reviewed: false,
250            filtered_size: None,
251        }
252    }
253
254    #[test]
255    fn test_base_score_only() {
256        let pr = sample_pr(1, 0, 100);
257        let result = calculate_score(
258            &pr,
259            &ScoringConfig {
260                base_score: Some(100.0),
261                age: None,
262                approvals: None,
263                size: None,
264                labels: None,
265                previously_reviewed: None,
266                draft: None,
267            },
268        );
269        assert_eq!(result.score, 100.0);
270        assert!(!result.incomplete);
271    }
272
273    #[test]
274    fn test_age_factor_additive() {
275        let pr = sample_pr(5, 0, 100);
276        let result = calculate_score(
277            &pr,
278            &ScoringConfig {
279                base_score: Some(100.0),
280                age: Some("+1 per 1h".to_string()),
281                approvals: None,
282                size: None,
283                labels: None,
284                previously_reviewed: None,
285                draft: None,
286            },
287        );
288        assert_eq!(result.score, 105.0); // 100 + 5*1
289    }
290
291    #[test]
292    fn test_score_floors_at_zero() {
293        let pr = sample_pr(1, 0, 100);
294        let result = calculate_score(
295            &pr,
296            &ScoringConfig {
297                base_score: Some(10.0),
298                age: Some("+-20 per 1h".to_string()), // Would go negative
299                approvals: None,
300                size: None,
301                labels: None,
302                previously_reviewed: None,
303                draft: None,
304            },
305        );
306        assert_eq!(result.score, 0.0);
307    }
308
309    #[test]
310    fn test_approvals_flat_effect() {
311        let pr = sample_pr(1, 0, 100);
312        let result = calculate_score(
313            &pr,
314            &ScoringConfig {
315                base_score: Some(100.0),
316                age: None,
317                approvals: Some("x0.5".to_string()),
318                size: None,
319                labels: None,
320                previously_reviewed: None,
321                draft: None,
322            },
323        );
324        assert_eq!(result.score, 50.0);
325    }
326
327    #[test]
328    fn test_size_bucket() {
329        let pr = sample_pr(1, 0, 50);
330        let result = calculate_score(
331            &pr,
332            &ScoringConfig {
333                base_score: Some(100.0),
334                age: None,
335                approvals: None,
336                size: Some(SizeConfig {
337                    exclude: None,
338                    buckets: Some(vec![SizeBucket {
339                        range: "<100".to_string(),
340                        effect: "x2".to_string(),
341                    }]),
342                }),
343                labels: None,
344                previously_reviewed: None,
345                draft: None,
346            },
347        );
348        assert_eq!(result.score, 200.0);
349    }
350
351    #[test]
352    fn test_full_scoring_flow() {
353        // PR: 24h old, 1 approval, 150 lines
354        let pr = sample_pr(24, 1, 150);
355
356        let config = ScoringConfig {
357            base_score: Some(100.0),
358            age: Some("+1 per 1h".to_string()), // +24 for age
359            approvals: Some("x1.5 per 1".to_string()), // x1.5 for 1 approval
360            size: Some(SizeConfig {
361                exclude: None,
362                buckets: Some(vec![
363                    SizeBucket {
364                        range: "<100".to_string(),
365                        effect: "x2".to_string(),
366                    },
367                    SizeBucket {
368                        range: ">=100".to_string(),
369                        effect: "x1".to_string(),
370                    },
371                ]),
372            }),
373            labels: None,
374            previously_reviewed: None,
375            draft: None,
376        };
377
378        let result = calculate_score(&pr, &config);
379
380        // Expected: (100 + 24) * 1.5^1 * 1 = 186
381        assert!((result.score - 186.0).abs() < 0.1);
382        assert!(!result.incomplete);
383    }
384
385    #[test]
386    fn test_default_config_scoring() {
387        let pr = sample_pr(5, 0, 50);
388        let config = ScoringConfig::default();
389        let result = calculate_score(&pr, &config);
390
391        // Default config has factors: base=100, +1/h age, +10 per 1 approval (0 approvals = +0), <100 size=x5
392        // Expected: (100 + 5 + 0) * 5 = 525
393        assert!((result.score - 525.0).abs() < 0.1);
394    }
395
396    #[test]
397    fn test_multiplicative_age_factor() {
398        let pr = sample_pr(3, 0, 100);
399        let config = ScoringConfig {
400            base_score: Some(100.0),
401            age: Some("x1.1 per 1h".to_string()),
402            approvals: None,
403            size: None,
404            labels: None,
405            previously_reviewed: None,
406            draft: None,
407        };
408
409        let result = calculate_score(&pr, &config);
410        // 100 * 1.1^3 = 133.1
411        assert!((result.score - 133.1).abs() < 0.1);
412    }
413
414    #[test]
415    fn test_bucket_first_match_wins() {
416        let pr = sample_pr(1, 0, 50); // Size 50
417
418        let config = ScoringConfig {
419            base_score: Some(100.0),
420            age: None,
421            approvals: None,
422            size: Some(SizeConfig {
423                exclude: None,
424                buckets: Some(vec![
425                    SizeBucket {
426                        range: "<100".to_string(),
427                        effect: "x2".to_string(),
428                    }, // Matches first
429                    SizeBucket {
430                        range: "<200".to_string(),
431                        effect: "x3".to_string(),
432                    }, // Also matches but not used
433                ]),
434            }),
435            labels: None,
436            previously_reviewed: None,
437            draft: None,
438        };
439
440        let result = calculate_score(&pr, &config);
441        assert_eq!(result.score, 200.0); // First match (x2), not second (x3)
442    }
443
444    #[test]
445    fn test_label_factor_additive() {
446        let mut pr = sample_pr(1, 0, 100);
447        pr.labels = vec!["urgent".to_string()];
448
449        let config = ScoringConfig {
450            base_score: Some(100.0),
451            age: None,
452            approvals: None,
453            size: None,
454            labels: Some(vec![LabelEffect {
455                name: "urgent".to_string(),
456                effect: "+10".to_string(),
457            }]),
458            previously_reviewed: None,
459            draft: None,
460        };
461
462        let result = calculate_score(&pr, &config);
463        assert_eq!(result.score, 110.0);
464    }
465
466    #[test]
467    fn test_label_factor_multiplicative() {
468        let mut pr = sample_pr(1, 0, 100);
469        pr.labels = vec!["wip".to_string()];
470
471        let config = ScoringConfig {
472            base_score: Some(100.0),
473            age: None,
474            approvals: None,
475            size: None,
476            labels: Some(vec![LabelEffect {
477                name: "wip".to_string(),
478                effect: "x0.5".to_string(),
479            }]),
480            previously_reviewed: None,
481            draft: None,
482        };
483
484        let result = calculate_score(&pr, &config);
485        assert_eq!(result.score, 50.0);
486    }
487
488    #[test]
489    fn test_label_case_insensitive() {
490        let mut pr = sample_pr(1, 0, 100);
491        pr.labels = vec!["Urgent".to_string()]; // Capital U
492
493        let config = ScoringConfig {
494            base_score: Some(100.0),
495            age: None,
496            approvals: None,
497            size: None,
498            labels: Some(vec![
499                LabelEffect {
500                    name: "urgent".to_string(),
501                    effect: "+10".to_string(),
502                }, // lowercase
503            ]),
504            previously_reviewed: None,
505            draft: None,
506        };
507
508        let result = calculate_score(&pr, &config);
509        assert_eq!(result.score, 110.0); // Should match despite case difference
510    }
511
512    #[test]
513    fn test_multiple_labels_compound() {
514        let mut pr = sample_pr(1, 0, 100);
515        pr.labels = vec!["urgent".to_string(), "critical".to_string()];
516
517        let config = ScoringConfig {
518            base_score: Some(100.0),
519            age: None,
520            approvals: None,
521            size: None,
522            labels: Some(vec![
523                LabelEffect {
524                    name: "urgent".to_string(),
525                    effect: "+10".to_string(),
526                },
527                LabelEffect {
528                    name: "critical".to_string(),
529                    effect: "x2".to_string(),
530                },
531            ]),
532            previously_reviewed: None,
533            draft: None,
534        };
535
536        let result = calculate_score(&pr, &config);
537        // (100 + 10) * 2 = 220
538        assert_eq!(result.score, 220.0);
539    }
540
541    #[test]
542    fn test_label_no_match() {
543        let mut pr = sample_pr(1, 0, 100);
544        pr.labels = vec!["bug".to_string()];
545
546        let config = ScoringConfig {
547            base_score: Some(100.0),
548            age: None,
549            approvals: None,
550            size: None,
551            labels: Some(vec![LabelEffect {
552                name: "urgent".to_string(),
553                effect: "+10".to_string(),
554            }]),
555            previously_reviewed: None,
556            draft: None,
557        };
558
559        let result = calculate_score(&pr, &config);
560        assert_eq!(result.score, 100.0); // No label match, no effect
561    }
562
563    #[test]
564    fn test_previously_reviewed_applies() {
565        let mut pr = sample_pr(1, 0, 100);
566        pr.user_has_reviewed = true;
567
568        let config = ScoringConfig {
569            base_score: Some(100.0),
570            age: None,
571            approvals: None,
572            size: None,
573            labels: None,
574            previously_reviewed: Some("x0.5".to_string()),
575            draft: None,
576        };
577
578        let result = calculate_score(&pr, &config);
579        assert_eq!(result.score, 50.0);
580    }
581
582    #[test]
583    fn test_previously_reviewed_not_reviewed() {
584        let mut pr = sample_pr(1, 0, 100);
585        pr.user_has_reviewed = false;
586
587        let config = ScoringConfig {
588            base_score: Some(100.0),
589            age: None,
590            approvals: None,
591            size: None,
592            labels: None,
593            previously_reviewed: Some("x0.5".to_string()),
594            draft: None,
595        };
596
597        let result = calculate_score(&pr, &config);
598        assert_eq!(result.score, 100.0); // Not reviewed, effect not applied
599    }
600
601    #[test]
602    fn test_size_uses_filtered_size() {
603        let mut pr = sample_pr(1, 0, 1000); // additions=500, deletions=500, total=1000
604        pr.filtered_size = Some(50); // After exclusion, only 50 lines
605
606        let config = ScoringConfig {
607            base_score: Some(100.0),
608            age: None,
609            approvals: None,
610            size: Some(SizeConfig {
611                exclude: None,
612                buckets: Some(vec![
613                    SizeBucket {
614                        range: "<100".to_string(),
615                        effect: "x5".to_string(),
616                    },
617                    SizeBucket {
618                        range: ">=100".to_string(),
619                        effect: "x1".to_string(),
620                    },
621                ]),
622            }),
623            labels: None,
624            previously_reviewed: None,
625            draft: None,
626        };
627
628        let result = calculate_score(&pr, &config);
629        // filtered_size=50, matches <100 bucket -> x5, so 100 * 5 = 500
630        assert_eq!(result.score, 500.0);
631    }
632
633    #[test]
634    fn test_full_scoring_with_all_factors() {
635        let mut pr = sample_pr(5, 2, 50); // 5h old, 2 approvals, 50 lines
636        pr.labels = vec!["urgent".to_string()];
637        pr.user_has_reviewed = false;
638
639        let config = ScoringConfig {
640            base_score: Some(100.0),
641            age: Some("+1 per 1h".to_string()),
642            approvals: Some("+10 per 1".to_string()),
643            size: Some(SizeConfig {
644                exclude: None,
645                buckets: Some(vec![SizeBucket {
646                    range: "<100".to_string(),
647                    effect: "x2".to_string(),
648                }]),
649            }),
650            labels: Some(vec![LabelEffect {
651                name: "urgent".to_string(),
652                effect: "+20".to_string(),
653            }]),
654            previously_reviewed: Some("x0.5".to_string()),
655            draft: None,
656        };
657
658        let result = calculate_score(&pr, &config);
659        // (100 + 5 + 20) * 2 + 20 = 270
660        // Wait, order: base=100, age=+5 (105), approvals=+20 (125), size=x2 (250), labels=+20 (270), previously_reviewed not applied (false)
661        assert_eq!(result.score, 270.0);
662    }
663
664    #[test]
665    fn test_draft_applies() {
666        let mut pr = sample_pr(1, 0, 100);
667        pr.draft = true;
668
669        let config = ScoringConfig {
670            base_score: Some(100.0),
671            age: None,
672            approvals: None,
673            size: None,
674            labels: None,
675            previously_reviewed: None,
676            draft: Some("x0.1".to_string()),
677        };
678
679        let result = calculate_score(&pr, &config);
680        assert!((result.score - 10.0).abs() < 0.1);
681    }
682
683    #[test]
684    fn test_draft_not_applied_when_not_draft() {
685        let mut pr = sample_pr(1, 0, 100);
686        pr.draft = false;
687
688        let config = ScoringConfig {
689            base_score: Some(100.0),
690            age: None,
691            approvals: None,
692            size: None,
693            labels: None,
694            previously_reviewed: None,
695            draft: Some("x0.1".to_string()),
696        };
697
698        let result = calculate_score(&pr, &config);
699        assert_eq!(result.score, 100.0);
700    }
701}