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 age_secs.checked_div(unit_secs).unwrap_or(0)
183 } else {
184 1 }
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); }
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()), 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 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()), approvals: Some("x1.5 per 1".to_string()), 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 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 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 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); 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 }, SizeBucket {
430 range: "<200".to_string(),
431 effect: "x3".to_string(),
432 }, ]),
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); }
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()]; 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 }, ]),
504 previously_reviewed: None,
505 draft: None,
506 };
507
508 let result = calculate_score(&pr, &config);
509 assert_eq!(result.score, 110.0); }
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 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); }
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); }
600
601 #[test]
602 fn test_size_uses_filtered_size() {
603 let mut pr = sample_pr(1, 0, 1000); pr.filtered_size = Some(50); 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 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); 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 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}