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        if unit_secs > 0 {
183            age_secs / unit_secs
184        } else {
185            0
186        }
187    } else {
188        1 // Non-per-unit effects apply once
189    }
190}
191
192struct BucketResult {
193    score: f64,
194    matched_range: Option<String>,
195    matched_effect: Option<String>,
196}
197
198fn apply_bucket_effect<T, F1, F2>(
199    score: f64,
200    value: u64,
201    buckets: &[T],
202    get_range: F1,
203    get_effect: F2,
204) -> BucketResult
205where
206    F1: Fn(&T) -> &str,
207    F2: Fn(&T) -> &str,
208{
209    use super::factors::RangeOp;
210
211    for bucket in buckets {
212        let range_str = get_range(bucket);
213        let effect_str = get_effect(bucket);
214        if let Ok(range) = RangeOp::parse(range_str) {
215            if range.matches(value) {
216                if let Ok(effect) = Effect::parse(effect_str) {
217                    return BucketResult {
218                        score: effect.apply(score, 1),
219                        matched_range: Some(range_str.to_string()),
220                        matched_effect: Some(effect_str.to_string()),
221                    };
222                }
223            }
224        }
225    }
226    BucketResult {
227        score,
228        matched_range: None,
229        matched_effect: None,
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::scoring::{LabelEffect, SizeBucket, SizeConfig};
237    use chrono::{Duration as ChronoDuration, Utc};
238
239    fn sample_pr(age_hours: i64, approvals: u32, size: u64) -> PullRequest {
240        PullRequest {
241            title: "Test PR".to_string(),
242            number: 1,
243            author: "user".to_string(),
244            repo: "owner/repo".to_string(),
245            url: "https://github.com/owner/repo/pull/1".to_string(),
246            created_at: Utc::now() - ChronoDuration::hours(age_hours),
247            updated_at: Utc::now(),
248            additions: size / 2,
249            deletions: size / 2,
250            approvals,
251            draft: false,
252            labels: vec![],
253            user_has_reviewed: false,
254            filtered_size: None,
255        }
256    }
257
258    #[test]
259    fn test_base_score_only() {
260        let pr = sample_pr(1, 0, 100);
261        let result = calculate_score(
262            &pr,
263            &ScoringConfig {
264                base_score: Some(100.0),
265                age: None,
266                approvals: None,
267                size: None,
268                labels: None,
269                previously_reviewed: None,
270                draft: None,
271            },
272        );
273        assert_eq!(result.score, 100.0);
274        assert!(!result.incomplete);
275    }
276
277    #[test]
278    fn test_age_factor_additive() {
279        let pr = sample_pr(5, 0, 100);
280        let result = calculate_score(
281            &pr,
282            &ScoringConfig {
283                base_score: Some(100.0),
284                age: Some("+1 per 1h".to_string()),
285                approvals: None,
286                size: None,
287                labels: None,
288                previously_reviewed: None,
289                draft: None,
290            },
291        );
292        assert_eq!(result.score, 105.0); // 100 + 5*1
293    }
294
295    #[test]
296    fn test_score_floors_at_zero() {
297        let pr = sample_pr(1, 0, 100);
298        let result = calculate_score(
299            &pr,
300            &ScoringConfig {
301                base_score: Some(10.0),
302                age: Some("+-20 per 1h".to_string()), // Would go negative
303                approvals: None,
304                size: None,
305                labels: None,
306                previously_reviewed: None,
307                draft: None,
308            },
309        );
310        assert_eq!(result.score, 0.0);
311    }
312
313    #[test]
314    fn test_approvals_flat_effect() {
315        let pr = sample_pr(1, 0, 100);
316        let result = calculate_score(
317            &pr,
318            &ScoringConfig {
319                base_score: Some(100.0),
320                age: None,
321                approvals: Some("x0.5".to_string()),
322                size: None,
323                labels: None,
324                previously_reviewed: None,
325                draft: None,
326            },
327        );
328        assert_eq!(result.score, 50.0);
329    }
330
331    #[test]
332    fn test_size_bucket() {
333        let pr = sample_pr(1, 0, 50);
334        let result = calculate_score(
335            &pr,
336            &ScoringConfig {
337                base_score: Some(100.0),
338                age: None,
339                approvals: None,
340                size: Some(SizeConfig {
341                    exclude: None,
342                    buckets: Some(vec![SizeBucket {
343                        range: "<100".to_string(),
344                        effect: "x2".to_string(),
345                    }]),
346                }),
347                labels: None,
348                previously_reviewed: None,
349                draft: None,
350            },
351        );
352        assert_eq!(result.score, 200.0);
353    }
354
355    #[test]
356    fn test_full_scoring_flow() {
357        // PR: 24h old, 1 approval, 150 lines
358        let pr = sample_pr(24, 1, 150);
359
360        let config = ScoringConfig {
361            base_score: Some(100.0),
362            age: Some("+1 per 1h".to_string()), // +24 for age
363            approvals: Some("x1.5 per 1".to_string()), // x1.5 for 1 approval
364            size: Some(SizeConfig {
365                exclude: None,
366                buckets: Some(vec![
367                    SizeBucket {
368                        range: "<100".to_string(),
369                        effect: "x2".to_string(),
370                    },
371                    SizeBucket {
372                        range: ">=100".to_string(),
373                        effect: "x1".to_string(),
374                    },
375                ]),
376            }),
377            labels: None,
378            previously_reviewed: None,
379            draft: None,
380        };
381
382        let result = calculate_score(&pr, &config);
383
384        // Expected: (100 + 24) * 1.5^1 * 1 = 186
385        assert!((result.score - 186.0).abs() < 0.1);
386        assert!(!result.incomplete);
387    }
388
389    #[test]
390    fn test_default_config_scoring() {
391        let pr = sample_pr(5, 0, 50);
392        let config = ScoringConfig::default();
393        let result = calculate_score(&pr, &config);
394
395        // Default config has factors: base=100, +1/h age, +10 per 1 approval (0 approvals = +0), <100 size=x5
396        // Expected: (100 + 5 + 0) * 5 = 525
397        assert!((result.score - 525.0).abs() < 0.1);
398    }
399
400    #[test]
401    fn test_multiplicative_age_factor() {
402        let pr = sample_pr(3, 0, 100);
403        let config = ScoringConfig {
404            base_score: Some(100.0),
405            age: Some("x1.1 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 = calculate_score(&pr, &config);
414        // 100 * 1.1^3 = 133.1
415        assert!((result.score - 133.1).abs() < 0.1);
416    }
417
418    #[test]
419    fn test_bucket_first_match_wins() {
420        let pr = sample_pr(1, 0, 50); // Size 50
421
422        let config = ScoringConfig {
423            base_score: Some(100.0),
424            age: None,
425            approvals: None,
426            size: Some(SizeConfig {
427                exclude: None,
428                buckets: Some(vec![
429                    SizeBucket {
430                        range: "<100".to_string(),
431                        effect: "x2".to_string(),
432                    }, // Matches first
433                    SizeBucket {
434                        range: "<200".to_string(),
435                        effect: "x3".to_string(),
436                    }, // Also matches but not used
437                ]),
438            }),
439            labels: None,
440            previously_reviewed: None,
441            draft: None,
442        };
443
444        let result = calculate_score(&pr, &config);
445        assert_eq!(result.score, 200.0); // First match (x2), not second (x3)
446    }
447
448    #[test]
449    fn test_label_factor_additive() {
450        let mut pr = sample_pr(1, 0, 100);
451        pr.labels = vec!["urgent".to_string()];
452
453        let config = ScoringConfig {
454            base_score: Some(100.0),
455            age: None,
456            approvals: None,
457            size: None,
458            labels: Some(vec![LabelEffect {
459                name: "urgent".to_string(),
460                effect: "+10".to_string(),
461            }]),
462            previously_reviewed: None,
463            draft: None,
464        };
465
466        let result = calculate_score(&pr, &config);
467        assert_eq!(result.score, 110.0);
468    }
469
470    #[test]
471    fn test_label_factor_multiplicative() {
472        let mut pr = sample_pr(1, 0, 100);
473        pr.labels = vec!["wip".to_string()];
474
475        let config = ScoringConfig {
476            base_score: Some(100.0),
477            age: None,
478            approvals: None,
479            size: None,
480            labels: Some(vec![LabelEffect {
481                name: "wip".to_string(),
482                effect: "x0.5".to_string(),
483            }]),
484            previously_reviewed: None,
485            draft: None,
486        };
487
488        let result = calculate_score(&pr, &config);
489        assert_eq!(result.score, 50.0);
490    }
491
492    #[test]
493    fn test_label_case_insensitive() {
494        let mut pr = sample_pr(1, 0, 100);
495        pr.labels = vec!["Urgent".to_string()]; // Capital U
496
497        let config = ScoringConfig {
498            base_score: Some(100.0),
499            age: None,
500            approvals: None,
501            size: None,
502            labels: Some(vec![
503                LabelEffect {
504                    name: "urgent".to_string(),
505                    effect: "+10".to_string(),
506                }, // lowercase
507            ]),
508            previously_reviewed: None,
509            draft: None,
510        };
511
512        let result = calculate_score(&pr, &config);
513        assert_eq!(result.score, 110.0); // Should match despite case difference
514    }
515
516    #[test]
517    fn test_multiple_labels_compound() {
518        let mut pr = sample_pr(1, 0, 100);
519        pr.labels = vec!["urgent".to_string(), "critical".to_string()];
520
521        let config = ScoringConfig {
522            base_score: Some(100.0),
523            age: None,
524            approvals: None,
525            size: None,
526            labels: Some(vec![
527                LabelEffect {
528                    name: "urgent".to_string(),
529                    effect: "+10".to_string(),
530                },
531                LabelEffect {
532                    name: "critical".to_string(),
533                    effect: "x2".to_string(),
534                },
535            ]),
536            previously_reviewed: None,
537            draft: None,
538        };
539
540        let result = calculate_score(&pr, &config);
541        // (100 + 10) * 2 = 220
542        assert_eq!(result.score, 220.0);
543    }
544
545    #[test]
546    fn test_label_no_match() {
547        let mut pr = sample_pr(1, 0, 100);
548        pr.labels = vec!["bug".to_string()];
549
550        let config = ScoringConfig {
551            base_score: Some(100.0),
552            age: None,
553            approvals: None,
554            size: None,
555            labels: Some(vec![LabelEffect {
556                name: "urgent".to_string(),
557                effect: "+10".to_string(),
558            }]),
559            previously_reviewed: None,
560            draft: None,
561        };
562
563        let result = calculate_score(&pr, &config);
564        assert_eq!(result.score, 100.0); // No label match, no effect
565    }
566
567    #[test]
568    fn test_previously_reviewed_applies() {
569        let mut pr = sample_pr(1, 0, 100);
570        pr.user_has_reviewed = true;
571
572        let config = ScoringConfig {
573            base_score: Some(100.0),
574            age: None,
575            approvals: None,
576            size: None,
577            labels: None,
578            previously_reviewed: Some("x0.5".to_string()),
579            draft: None,
580        };
581
582        let result = calculate_score(&pr, &config);
583        assert_eq!(result.score, 50.0);
584    }
585
586    #[test]
587    fn test_previously_reviewed_not_reviewed() {
588        let mut pr = sample_pr(1, 0, 100);
589        pr.user_has_reviewed = false;
590
591        let config = ScoringConfig {
592            base_score: Some(100.0),
593            age: None,
594            approvals: None,
595            size: None,
596            labels: None,
597            previously_reviewed: Some("x0.5".to_string()),
598            draft: None,
599        };
600
601        let result = calculate_score(&pr, &config);
602        assert_eq!(result.score, 100.0); // Not reviewed, effect not applied
603    }
604
605    #[test]
606    fn test_size_uses_filtered_size() {
607        let mut pr = sample_pr(1, 0, 1000); // additions=500, deletions=500, total=1000
608        pr.filtered_size = Some(50); // After exclusion, only 50 lines
609
610        let config = ScoringConfig {
611            base_score: Some(100.0),
612            age: None,
613            approvals: None,
614            size: Some(SizeConfig {
615                exclude: None,
616                buckets: Some(vec![
617                    SizeBucket {
618                        range: "<100".to_string(),
619                        effect: "x5".to_string(),
620                    },
621                    SizeBucket {
622                        range: ">=100".to_string(),
623                        effect: "x1".to_string(),
624                    },
625                ]),
626            }),
627            labels: None,
628            previously_reviewed: None,
629            draft: None,
630        };
631
632        let result = calculate_score(&pr, &config);
633        // filtered_size=50, matches <100 bucket -> x5, so 100 * 5 = 500
634        assert_eq!(result.score, 500.0);
635    }
636
637    #[test]
638    fn test_full_scoring_with_all_factors() {
639        let mut pr = sample_pr(5, 2, 50); // 5h old, 2 approvals, 50 lines
640        pr.labels = vec!["urgent".to_string()];
641        pr.user_has_reviewed = false;
642
643        let config = ScoringConfig {
644            base_score: Some(100.0),
645            age: Some("+1 per 1h".to_string()),
646            approvals: Some("+10 per 1".to_string()),
647            size: Some(SizeConfig {
648                exclude: None,
649                buckets: Some(vec![SizeBucket {
650                    range: "<100".to_string(),
651                    effect: "x2".to_string(),
652                }]),
653            }),
654            labels: Some(vec![LabelEffect {
655                name: "urgent".to_string(),
656                effect: "+20".to_string(),
657            }]),
658            previously_reviewed: Some("x0.5".to_string()),
659            draft: None,
660        };
661
662        let result = calculate_score(&pr, &config);
663        // (100 + 5 + 20) * 2 + 20 = 270
664        // Wait, order: base=100, age=+5 (105), approvals=+20 (125), size=x2 (250), labels=+20 (270), previously_reviewed not applied (false)
665        assert_eq!(result.score, 270.0);
666    }
667
668    #[test]
669    fn test_draft_applies() {
670        let mut pr = sample_pr(1, 0, 100);
671        pr.draft = true;
672
673        let config = ScoringConfig {
674            base_score: Some(100.0),
675            age: None,
676            approvals: None,
677            size: None,
678            labels: None,
679            previously_reviewed: None,
680            draft: Some("x0.1".to_string()),
681        };
682
683        let result = calculate_score(&pr, &config);
684        assert!((result.score - 10.0).abs() < 0.1);
685    }
686
687    #[test]
688    fn test_draft_not_applied_when_not_draft() {
689        let mut pr = sample_pr(1, 0, 100);
690        pr.draft = false;
691
692        let config = ScoringConfig {
693            base_score: Some(100.0),
694            age: None,
695            approvals: None,
696            size: None,
697            labels: None,
698            previously_reviewed: None,
699            draft: Some("x0.1".to_string()),
700        };
701
702        let result = calculate_score(&pr, &config);
703        assert_eq!(result.score, 100.0);
704    }
705}