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, pub description: String, pub before: f64, pub after: f64, }
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 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 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 if let Some(ref approvals_str) = config.approvals {
59 let parseable_str = if let Some((effect_part, per_part)) = approvals_str.split_once(" per ")
63 {
64 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 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 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 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 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 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 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 }
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); }
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()), 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 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()), approvals: Some("x1.5 per 1".to_string()), 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 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 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 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); 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 }, SizeBucket {
434 range: "<200".to_string(),
435 effect: "x3".to_string(),
436 }, ]),
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); }
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()]; 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 }, ]),
508 previously_reviewed: None,
509 draft: None,
510 };
511
512 let result = calculate_score(&pr, &config);
513 assert_eq!(result.score, 110.0); }
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 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); }
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); }
604
605 #[test]
606 fn test_size_uses_filtered_size() {
607 let mut pr = sample_pr(1, 0, 1000); pr.filtered_size = Some(50); 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 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); 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 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}