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 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 }
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); }
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()), 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 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()), approvals: Some("x1.5 per 1".to_string()), 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 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 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 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); 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 }, SizeBucket {
411 range: "<200".to_string(),
412 effect: "x3".to_string(),
413 }, ]),
415 }),
416 labels: None,
417 previously_reviewed: None,
418 };
419
420 let result = calculate_score(&pr, &config);
421 assert_eq!(result.score, 200.0); }
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()]; 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 }, ]),
482 previously_reviewed: None,
483 };
484
485 let result = calculate_score(&pr, &config);
486 assert_eq!(result.score, 110.0); }
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 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); }
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); }
573
574 #[test]
575 fn test_size_uses_filtered_size() {
576 let mut pr = sample_pr(1, 0, 1000); pr.filtered_size = Some(50); 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 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); 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 assert_eq!(result.score, 270.0);
633 }
634}