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