1use chrono::{DateTime, Duration, Utc};
2use open_kioku_core::{
3 Confidence, Owner, OwnershipReport, OwnershipSourceType, ProvenanceTouch, ReviewerAvailability,
4 ReviewerConfidenceBreakdown, ReviewerRole, ReviewerSignal, ReviewerSignalSourceType,
5 ReviewerSuggestion, ReviewerSuggestionReport,
6};
7use open_kioku_errors::Result;
8use open_kioku_storage::HistoryStore;
9use std::cmp::Ordering;
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::Path;
12
13const HISTORY_LIMIT: usize = 100;
14const STALE_AFTER_DAYS: i64 = 365;
15
16pub struct ReviewerSuggestionInput<'a> {
17 pub path: &'a Path,
18 pub history: &'a dyn HistoryStore,
19 pub ownership: Option<&'a OwnershipReport>,
20}
21
22pub fn suggest_reviewers(input: ReviewerSuggestionInput<'_>) -> Result<ReviewerSuggestionReport> {
23 let generated_at = Utc::now();
24 let path = input.path.to_path_buf();
25 let mut uncertainty = Vec::new();
26 let mut reviewers = BTreeMap::<String, ReviewerAggregate>::new();
27 let mut saw_actual_review_evidence = false;
28
29 match input.history.history_for_file(input.path, HISTORY_LIMIT) {
30 Ok(summary) => {
31 saw_actual_review_evidence = summary
32 .reviewer_evidence
33 .iter()
34 .any(|evidence| is_actual_review_role(evidence.role));
35 uncertainty.extend(summary.uncertainty.iter().cloned());
36 for evidence in summary.reviewer_evidence {
37 let actual_review_evidence = is_actual_review_role(evidence.role);
38 let stale = is_stale(generated_at, evidence.observed_at);
39 let score = reviewer_evidence_score(evidence.role, evidence.confidence, stale);
40 let signal = ReviewerSignal {
41 source_type: ReviewerSignalSourceType::ReviewEvidence,
42 reviewer: evidence.reviewer.clone(),
43 source: evidence.source.clone(),
44 role: Some(evidence.role),
45 message: reviewer_evidence_message(
46 input.path,
47 evidence.role,
48 actual_review_evidence,
49 ),
50 confidence: Confidence::from_score(score),
51 observed_at: Some(evidence.observed_at),
52 stale,
53 actual_review_evidence,
54 };
55 add_signal(&mut reviewers, evidence.reviewer, signal, score);
56 }
57 }
58 Err(err) => uncertainty.push(format!("reviewer history lookup failed: {err}")),
59 }
60
61 add_ownership_signals(
62 input.ownership,
63 generated_at,
64 &mut reviewers,
65 &mut uncertainty,
66 );
67 add_author_signals(
68 input.history,
69 input.path,
70 generated_at,
71 &mut reviewers,
72 &mut uncertainty,
73 );
74
75 let suggestions = reviewer_suggestions(reviewers, &mut uncertainty);
76 let availability = report_availability(&suggestions);
77
78 if !saw_actual_review_evidence {
79 uncertainty.push(
80 "actual PR-review evidence is unavailable in the local index; suggestions are inferred from ownership and/or author history"
81 .into(),
82 );
83 }
84 if suggestions.is_empty() {
85 uncertainty.push(format!(
86 "no reviewer suggestions found for `{}` from review evidence, ownership, or git author history",
87 path.display()
88 ));
89 }
90
91 Ok(ReviewerSuggestionReport {
92 path,
93 generated_at,
94 availability,
95 suggestions,
96 uncertainty,
97 })
98}
99
100#[derive(Debug, Clone)]
101struct ReviewerAggregate {
102 reviewer: Owner,
103 signals: Vec<ReviewerSignal>,
104 review_evidence: f32,
105 ownership: f32,
106 author_history: f32,
107}
108
109impl ReviewerAggregate {
110 fn new(reviewer: Owner) -> Self {
111 Self {
112 reviewer,
113 signals: Vec::new(),
114 review_evidence: 0.0,
115 ownership: 0.0,
116 author_history: 0.0,
117 }
118 }
119
120 fn actual_review_evidence(&self) -> bool {
121 self.signals
122 .iter()
123 .any(|signal| signal.actual_review_evidence)
124 }
125
126 fn inferred_from_authors(&self) -> bool {
127 self.has_author_inference() && !self.actual_review_evidence()
128 }
129
130 fn stale(&self) -> bool {
131 !self.signals.is_empty() && self.signals.iter().all(|signal| signal.stale)
132 }
133
134 fn has_author_inference(&self) -> bool {
135 self.author_history > 0.0
136 || self.signals.iter().any(|signal| {
137 !signal.actual_review_evidence
138 && matches!(
139 signal.role,
140 Some(ReviewerRole::Author | ReviewerRole::Committer)
141 )
142 })
143 }
144
145 fn has_ownership_inference(&self) -> bool {
146 self.ownership > 0.0
147 || self.signals.iter().any(|signal| {
148 !signal.actual_review_evidence && signal.role == Some(ReviewerRole::Owner)
149 })
150 }
151
152 fn source_types(&self) -> Vec<ReviewerSignalSourceType> {
153 [
154 ReviewerSignalSourceType::ReviewEvidence,
155 ReviewerSignalSourceType::Ownership,
156 ReviewerSignalSourceType::GitAuthor,
157 ]
158 .into_iter()
159 .filter(|source| {
160 self.signals
161 .iter()
162 .any(|signal| signal.source_type == *source)
163 })
164 .collect()
165 }
166}
167
168fn add_signal(
169 reviewers: &mut BTreeMap<String, ReviewerAggregate>,
170 reviewer: Owner,
171 signal: ReviewerSignal,
172 score: f32,
173) {
174 let key = reviewer_key(&reviewer);
175 let entry = reviewers
176 .entry(key)
177 .or_insert_with(|| ReviewerAggregate::new(reviewer));
178 match signal.source_type {
179 ReviewerSignalSourceType::ReviewEvidence => {
180 entry.review_evidence = entry.review_evidence.max(score);
181 }
182 ReviewerSignalSourceType::Ownership => {
183 entry.ownership = entry.ownership.max(score);
184 }
185 ReviewerSignalSourceType::GitAuthor => {
186 entry.author_history = entry.author_history.max(score);
187 }
188 }
189 entry.signals.push(signal);
190}
191
192fn add_ownership_signals(
193 ownership: Option<&OwnershipReport>,
194 generated_at: DateTime<Utc>,
195 reviewers: &mut BTreeMap<String, ReviewerAggregate>,
196 uncertainty: &mut Vec<String>,
197) {
198 let Some(ownership) = ownership else {
199 uncertainty.push("ownership evidence was not provided for reviewer suggestions".into());
200 return;
201 };
202 uncertainty.extend(ownership.uncertainty.iter().cloned());
203 if ownership.owners.is_empty() {
204 uncertainty.push("ownership lookup returned no owner suggestions".into());
205 return;
206 }
207
208 for owner in &ownership.owners {
209 let ownership_weight = if owner
210 .source_types
211 .contains(&OwnershipSourceType::Codeowners)
212 {
213 0.58
214 } else if owner
215 .source_types
216 .contains(&OwnershipSourceType::GitHistory)
217 {
218 0.46
219 } else {
220 0.30
221 };
222 let score = (owner.score * ownership_weight).min(0.62);
223 let signal = ReviewerSignal {
224 source_type: ReviewerSignalSourceType::Ownership,
225 reviewer: owner.owner.clone(),
226 source: format!("ownership:{}", ownership.path.display()),
227 role: Some(ReviewerRole::Owner),
228 message: format!(
229 "ownership lookup suggested this reviewer candidate: {}",
230 owner.rationale
231 ),
232 confidence: Confidence::from_score(score),
233 observed_at: Some(generated_at),
234 stale: owner.stale,
235 actual_review_evidence: false,
236 };
237 add_signal(reviewers, owner.owner.clone(), signal, score);
238 }
239}
240
241fn add_author_signals(
242 history: &dyn HistoryStore,
243 path: &Path,
244 generated_at: DateTime<Utc>,
245 reviewers: &mut BTreeMap<String, ReviewerAggregate>,
246 uncertainty: &mut Vec<String>,
247) {
248 let provenance = match history.provenance_for_path(path, HISTORY_LIMIT) {
249 Ok(provenance) => provenance,
250 Err(err) => {
251 uncertainty.push(format!("author history lookup failed: {err}"));
252 return;
253 }
254 };
255 uncertainty.extend(provenance.uncertainty.iter().cloned());
256 if provenance.truncated {
257 uncertainty.push(format!(
258 "author history reviewer evidence for `{}` is truncated at {HISTORY_LIMIT} touches",
259 path.display()
260 ));
261 }
262 let touches = unique_touches(&provenance.recent_touches);
263 if touches.is_empty() {
264 uncertainty.push(format!(
265 "no git author touches were available for `{}`",
266 path.display()
267 ));
268 return;
269 }
270
271 let total = touches.len() as f32;
272 let mut by_author = BTreeMap::<String, AuthorStats>::new();
273 for touch in touches {
274 let key = reviewer_key(&touch.commit.author);
275 let entry = by_author
276 .entry(key)
277 .or_insert_with(|| AuthorStats::new(touch.commit.author.clone()));
278 entry.count += 1;
279 entry.latest = entry.latest.max(Some(touch.commit.committed_at));
280 entry.latest_commit = Some(touch.commit.id.0.clone());
281 entry.latest_summary = Some(touch.commit.summary.clone());
282 }
283
284 for stats in by_author.into_values() {
285 let share = stats.count as f32 / total;
286 let count_factor = 0.60 + ((stats.count as f32 / 3.0).min(1.0) * 0.40);
287 let observed_at = stats.latest.unwrap_or(generated_at);
288 let stale = is_stale(generated_at, observed_at);
289 let freshness_multiplier = if stale { 0.55 } else { 1.0 };
290 let score = ((0.20 + (0.30 * share)) * count_factor * freshness_multiplier).min(0.52);
291 let signal = ReviewerSignal {
292 source_type: ReviewerSignalSourceType::GitAuthor,
293 reviewer: stats.author.clone(),
294 source: format!(
295 "git author:{}",
296 stats.latest_commit.as_deref().unwrap_or("unknown")
297 ),
298 role: Some(ReviewerRole::Author),
299 message: format!(
300 "{} authored {} of {} persisted touch(es) for `{}`; latest `{}`",
301 stats.author.name,
302 stats.count,
303 total as usize,
304 path.display(),
305 stats
306 .latest_summary
307 .as_deref()
308 .unwrap_or("unknown commit summary")
309 ),
310 confidence: Confidence::from_score(score),
311 observed_at: Some(observed_at),
312 stale,
313 actual_review_evidence: false,
314 };
315 add_signal(reviewers, stats.author, signal, score);
316 }
317}
318
319#[derive(Debug)]
320struct AuthorStats {
321 author: Owner,
322 count: usize,
323 latest: Option<DateTime<Utc>>,
324 latest_commit: Option<String>,
325 latest_summary: Option<String>,
326}
327
328impl AuthorStats {
329 fn new(author: Owner) -> Self {
330 Self {
331 author,
332 count: 0,
333 latest: None,
334 latest_commit: None,
335 latest_summary: None,
336 }
337 }
338}
339
340fn unique_touches(touches: &[ProvenanceTouch]) -> Vec<&ProvenanceTouch> {
341 let mut seen = BTreeSet::new();
342 let mut unique = Vec::new();
343 for touch in touches {
344 let key = format!(
345 "{}:{}:{}",
346 touch.commit.id.0,
347 touch.path.display(),
348 touch.qualified_name.as_deref().unwrap_or("<file>")
349 );
350 if seen.insert(key) {
351 unique.push(touch);
352 }
353 }
354 unique
355}
356
357fn reviewer_suggestions(
358 reviewers: BTreeMap<String, ReviewerAggregate>,
359 uncertainty: &mut Vec<String>,
360) -> Vec<ReviewerSuggestion> {
361 let mut drafts = reviewers
362 .into_values()
363 .map(|reviewer| {
364 let freshness = if reviewer.signals.iter().any(|signal| !signal.stale) {
365 0.05
366 } else {
367 0.0
368 };
369 let mut raw_score = (reviewer.review_evidence
370 + reviewer.ownership
371 + reviewer.author_history
372 + freshness)
373 .min(1.0);
374 if !reviewer.actual_review_evidence() {
375 raw_score = raw_score.min(inferred_cap(&reviewer));
376 }
377 ReviewerDraft {
378 reviewer,
379 freshness,
380 raw_score,
381 ambiguity_penalty: 0.0,
382 }
383 })
384 .collect::<Vec<_>>();
385
386 drafts.sort_by(compare_drafts);
387 if let Some(top_score) = drafts.first().map(|draft| draft.raw_score) {
388 let close_inferred = drafts
389 .iter()
390 .filter(|draft| {
391 !draft.reviewer.actual_review_evidence()
392 && (top_score - draft.raw_score).abs() <= 0.06
393 })
394 .count();
395 if close_inferred > 1 {
396 uncertainty.push(format!(
397 "reviewer suggestions are ambiguous across {close_inferred} similarly scored inferred candidates"
398 ));
399 for draft in &mut drafts {
400 if !draft.reviewer.actual_review_evidence()
401 && (top_score - draft.raw_score).abs() <= 0.06
402 {
403 draft.ambiguity_penalty = 0.08;
404 }
405 }
406 }
407 }
408
409 let mut suggestions = drafts
410 .into_iter()
411 .map(|draft| {
412 let final_score = (draft.raw_score - draft.ambiguity_penalty).clamp(0.0, 1.0);
413 let actual_review_evidence = draft.reviewer.actual_review_evidence();
414 let availability = suggestion_availability(&draft.reviewer);
415 ReviewerSuggestion {
416 reviewer: draft.reviewer.reviewer.clone(),
417 rationale: reviewer_rationale(&draft.reviewer, availability),
418 confidence: Confidence::from_score(final_score),
419 score: final_score,
420 availability,
421 source_types: draft.reviewer.source_types(),
422 inferred_from_authors: draft.reviewer.inferred_from_authors(),
423 actual_review_evidence,
424 stale: draft.reviewer.stale(),
425 signals: draft.reviewer.signals,
426 confidence_breakdown: ReviewerConfidenceBreakdown {
427 review_evidence: draft.reviewer.review_evidence,
428 ownership: draft.reviewer.ownership,
429 author_history: draft.reviewer.author_history,
430 freshness: draft.freshness,
431 ambiguity_penalty: draft.ambiguity_penalty,
432 final_score,
433 },
434 }
435 })
436 .collect::<Vec<_>>();
437 suggestions.sort_by(compare_suggestions);
438 suggestions
439}
440
441struct ReviewerDraft {
442 reviewer: ReviewerAggregate,
443 freshness: f32,
444 raw_score: f32,
445 ambiguity_penalty: f32,
446}
447
448fn inferred_cap(reviewer: &ReviewerAggregate) -> f32 {
449 match (
450 reviewer.has_ownership_inference(),
451 reviewer.has_author_inference(),
452 ) {
453 (true, true) => 0.78,
454 (true, false) => 0.68,
455 (false, true) => 0.62,
456 (false, false) => 0.0,
457 }
458}
459
460fn suggestion_availability(reviewer: &ReviewerAggregate) -> ReviewerAvailability {
461 if reviewer.actual_review_evidence() {
462 ReviewerAvailability::ActualReviewEvidence
463 } else {
464 match (
465 reviewer.has_ownership_inference(),
466 reviewer.has_author_inference(),
467 ) {
468 (true, true) => ReviewerAvailability::InferredFromOwnershipAndAuthors,
469 (true, false) => ReviewerAvailability::InferredFromOwnership,
470 (false, true) => ReviewerAvailability::InferredFromAuthors,
471 (false, false) => ReviewerAvailability::Unavailable,
472 }
473 }
474}
475
476fn report_availability(suggestions: &[ReviewerSuggestion]) -> ReviewerAvailability {
477 if suggestions
478 .iter()
479 .any(|suggestion| suggestion.actual_review_evidence)
480 {
481 return ReviewerAvailability::ActualReviewEvidence;
482 }
483 if suggestions.is_empty() {
484 return ReviewerAvailability::Unavailable;
485 }
486 if suggestions.iter().any(|suggestion| {
487 suggestion.availability == ReviewerAvailability::InferredFromOwnershipAndAuthors
488 }) {
489 return ReviewerAvailability::InferredFromOwnershipAndAuthors;
490 }
491 if suggestions
492 .iter()
493 .any(|suggestion| suggestion.availability == ReviewerAvailability::InferredFromOwnership)
494 {
495 return ReviewerAvailability::InferredFromOwnership;
496 }
497 ReviewerAvailability::InferredFromAuthors
498}
499
500fn reviewer_rationale(reviewer: &ReviewerAggregate, availability: ReviewerAvailability) -> String {
501 let mut parts = Vec::new();
502 if reviewer.actual_review_evidence() {
503 parts.push("stored review/approval evidence exists for this path");
504 }
505 if reviewer.ownership > 0.0 {
506 parts.push("ownership lookup supports this reviewer candidate");
507 }
508 if reviewer.author_history > 0.0 {
509 parts.push("local git author history supports this reviewer candidate");
510 }
511 if reviewer.signals.iter().any(|signal| {
512 !signal.actual_review_evidence
513 && matches!(
514 signal.role,
515 Some(ReviewerRole::Author | ReviewerRole::Committer)
516 )
517 }) {
518 parts.push("stored author or committer evidence supports this reviewer candidate");
519 }
520 if reviewer
521 .signals
522 .iter()
523 .any(|signal| !signal.actual_review_evidence && signal.role == Some(ReviewerRole::Owner))
524 {
525 parts.push("stored owner evidence supports this reviewer candidate");
526 }
527 if !matches!(availability, ReviewerAvailability::ActualReviewEvidence) {
528 parts.push("actual PR-review evidence was unavailable, so this is inferred");
529 }
530 if reviewer.stale() {
531 parts.push("all reviewer evidence is stale");
532 }
533 if parts.is_empty() {
534 "reviewer evidence is unavailable".into()
535 } else {
536 parts.join("; ")
537 }
538}
539
540fn compare_drafts(left: &ReviewerDraft, right: &ReviewerDraft) -> Ordering {
541 right
542 .raw_score
543 .partial_cmp(&left.raw_score)
544 .unwrap_or(Ordering::Equal)
545 .then_with(|| {
546 left.reviewer
547 .reviewer
548 .name
549 .cmp(&right.reviewer.reviewer.name)
550 })
551}
552
553fn compare_suggestions(left: &ReviewerSuggestion, right: &ReviewerSuggestion) -> Ordering {
554 right
555 .score
556 .partial_cmp(&left.score)
557 .unwrap_or(Ordering::Equal)
558 .then_with(|| left.reviewer.name.cmp(&right.reviewer.name))
559 .then_with(|| left.reviewer.email.cmp(&right.reviewer.email))
560}
561
562fn reviewer_evidence_score(role: ReviewerRole, confidence: Confidence, stale: bool) -> f32 {
563 let base = match role {
564 ReviewerRole::Approver => 0.78,
565 ReviewerRole::Reviewer => 0.72,
566 ReviewerRole::Owner => 0.50,
567 ReviewerRole::Committer => 0.40,
568 ReviewerRole::Author => 0.35,
569 };
570 let freshness_multiplier = if stale { 0.65 } else { 1.0 };
571 ((base + (0.10 * confidence.score())) * freshness_multiplier).min(0.92)
572}
573
574fn reviewer_evidence_message(path: &Path, role: ReviewerRole, actual: bool) -> String {
575 if actual {
576 format!(
577 "stored {role:?} review evidence matched `{}`",
578 path.display()
579 )
580 } else {
581 format!(
582 "stored reviewer-adjacent {:?} evidence matched `{}` but is not treated as actual PR-review evidence",
583 role,
584 path.display()
585 )
586 }
587}
588
589fn is_actual_review_role(role: ReviewerRole) -> bool {
590 matches!(role, ReviewerRole::Reviewer | ReviewerRole::Approver)
591}
592
593fn is_stale(generated_at: DateTime<Utc>, observed_at: DateTime<Utc>) -> bool {
594 generated_at.signed_duration_since(observed_at) > Duration::days(STALE_AFTER_DAYS)
595}
596
597fn reviewer_key(owner: &Owner) -> String {
598 owner
599 .email
600 .as_ref()
601 .map(|email| email.to_ascii_lowercase())
602 .unwrap_or_else(|| owner.name.to_ascii_lowercase())
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use chrono::TimeZone;
609 use open_kioku_core::{
610 FileProvenance, GitChangeKind, GitCochangeEdge, GitCommitId, GitCommitRecord,
611 HistoryRecordId, HistorySnapshot, HistorySummary, OwnerSuggestion,
612 OwnershipConfidenceBreakdown, OwnershipEvidence, SymbolId, SymbolProvenance,
613 };
614 use open_kioku_storage::HistoryStore;
615 use std::sync::Mutex;
616
617 #[derive(Default)]
618 struct StubHistoryStore {
619 history: Mutex<Option<HistorySummary>>,
620 provenance: Mutex<Option<FileProvenance>>,
621 }
622
623 impl StubHistoryStore {
624 fn with_provenance(provenance: FileProvenance) -> Self {
625 Self {
626 history: Mutex::new(None),
627 provenance: Mutex::new(Some(provenance)),
628 }
629 }
630
631 fn with_history_and_provenance(
632 history: HistorySummary,
633 provenance: FileProvenance,
634 ) -> Self {
635 Self {
636 history: Mutex::new(Some(history)),
637 provenance: Mutex::new(Some(provenance)),
638 }
639 }
640 }
641
642 impl HistoryStore for StubHistoryStore {
643 fn put_history_snapshot(&self, _snapshot: &HistorySnapshot) -> Result<()> {
644 Ok(())
645 }
646
647 fn history_for_file(&self, path: &Path, _limit: usize) -> Result<HistorySummary> {
648 Ok(self
649 .history
650 .lock()
651 .unwrap()
652 .clone()
653 .unwrap_or_else(|| HistorySummary::empty(path)))
654 }
655
656 fn provenance_for_path(&self, path: &Path, _limit: usize) -> Result<FileProvenance> {
657 Ok(self
658 .provenance
659 .lock()
660 .unwrap()
661 .clone()
662 .unwrap_or_else(|| empty_provenance(path)))
663 }
664
665 fn provenance_for_symbol(
666 &self,
667 symbol_id: &SymbolId,
668 _limit: usize,
669 ) -> Result<SymbolProvenance> {
670 Ok(SymbolProvenance {
671 symbol_id: symbol_id.clone(),
672 qualified_name: "unknown".into(),
673 file_path: "src/unknown.rs".into(),
674 range: None,
675 first_seen: None,
676 last_touched: None,
677 recent_touches: Vec::new(),
678 confidence: Confidence::Low,
679 truncated: false,
680 uncertainty: vec!["stub symbol provenance unavailable".into()],
681 })
682 }
683
684 fn cochange_neighbors(&self, _path: &Path, _limit: usize) -> Result<Vec<GitCochangeEdge>> {
685 Ok(Vec::new())
686 }
687
688 fn recent_commits(&self, _limit: usize) -> Result<Vec<GitCommitRecord>> {
689 Ok(Vec::new())
690 }
691 }
692
693 #[test]
694 fn actual_review_evidence_outranks_inferred_author() {
695 let history = StubHistoryStore::with_history_and_provenance(
696 HistorySummary {
697 path: "src/a.rs".into(),
698 recent_commits: Vec::new(),
699 file_touches: Vec::new(),
700 symbol_touches: Vec::new(),
701 cochange_neighbors: Vec::new(),
702 reviewer_evidence: vec![reviewer_evidence(
703 "reviewer@example.com",
704 ReviewerRole::Approver,
705 )],
706 truncated: false,
707 uncertainty: Vec::new(),
708 },
709 provenance(vec![
710 touch("author@example.com", 0),
711 touch("author@example.com", 1),
712 ]),
713 );
714
715 let report = suggest_reviewers(ReviewerSuggestionInput {
716 path: Path::new("src/a.rs"),
717 history: &history,
718 ownership: None,
719 })
720 .unwrap();
721
722 assert_eq!(
723 report.availability,
724 ReviewerAvailability::ActualReviewEvidence
725 );
726 assert_eq!(
727 report.suggestions[0].reviewer.email.as_deref(),
728 Some("reviewer@example.com")
729 );
730 assert!(report.suggestions[0].actual_review_evidence);
731 assert!(!report.suggestions[0].inferred_from_authors);
732 }
733
734 #[test]
735 fn author_only_suggestions_are_explicitly_inferred() {
736 let history = StubHistoryStore::with_provenance(provenance(vec![
737 touch("alice@example.com", 0),
738 touch("alice@example.com", 1),
739 touch("bob@example.com", 2),
740 ]));
741
742 let report = suggest_reviewers(ReviewerSuggestionInput {
743 path: Path::new("src/a.rs"),
744 history: &history,
745 ownership: None,
746 })
747 .unwrap();
748
749 assert_eq!(
750 report.availability,
751 ReviewerAvailability::InferredFromAuthors
752 );
753 assert_eq!(
754 report.suggestions[0].reviewer.email.as_deref(),
755 Some("alice@example.com")
756 );
757 assert!(report.suggestions[0].inferred_from_authors);
758 assert!(!report.suggestions[0].actual_review_evidence);
759 assert!(report
760 .uncertainty
761 .iter()
762 .any(|note| note.contains("actual PR-review evidence is unavailable")));
763 }
764
765 #[test]
766 fn stored_author_evidence_is_inferred_not_actual_review() {
767 let history = StubHistoryStore::with_history_and_provenance(
768 HistorySummary {
769 path: "src/a.rs".into(),
770 recent_commits: Vec::new(),
771 file_touches: Vec::new(),
772 symbol_touches: Vec::new(),
773 cochange_neighbors: Vec::new(),
774 reviewer_evidence: vec![reviewer_evidence(
775 "author@example.com",
776 ReviewerRole::Author,
777 )],
778 truncated: false,
779 uncertainty: Vec::new(),
780 },
781 empty_provenance(Path::new("src/a.rs")),
782 );
783
784 let report = suggest_reviewers(ReviewerSuggestionInput {
785 path: Path::new("src/a.rs"),
786 history: &history,
787 ownership: None,
788 })
789 .unwrap();
790
791 assert_eq!(
792 report.availability,
793 ReviewerAvailability::InferredFromAuthors
794 );
795 assert_eq!(
796 report.suggestions[0].reviewer.email.as_deref(),
797 Some("author@example.com")
798 );
799 assert!(report.suggestions[0].inferred_from_authors);
800 assert!(!report.suggestions[0].actual_review_evidence);
801 }
802
803 #[test]
804 fn ownership_signals_are_incorporated_without_review_certainty() {
805 let history = StubHistoryStore::default();
806 let ownership = ownership_report(owner_suggestion("team@example.com", 0.86));
807
808 let report = suggest_reviewers(ReviewerSuggestionInput {
809 path: Path::new("src/a.rs"),
810 history: &history,
811 ownership: Some(&ownership),
812 })
813 .unwrap();
814
815 assert_eq!(
816 report.availability,
817 ReviewerAvailability::InferredFromOwnership
818 );
819 assert_eq!(
820 report.suggestions[0].reviewer.email.as_deref(),
821 Some("team@example.com")
822 );
823 assert!(!report.suggestions[0].actual_review_evidence);
824 assert_eq!(
825 report.suggestions[0].availability,
826 ReviewerAvailability::InferredFromOwnership
827 );
828 assert!(report.suggestions[0]
829 .source_types
830 .contains(&ReviewerSignalSourceType::Ownership));
831 }
832
833 #[test]
834 fn unavailable_when_no_review_owner_or_author_evidence_exists() {
835 let history = StubHistoryStore::default();
836
837 let report = suggest_reviewers(ReviewerSuggestionInput {
838 path: Path::new("src/missing.rs"),
839 history: &history,
840 ownership: None,
841 })
842 .unwrap();
843
844 assert_eq!(report.availability, ReviewerAvailability::Unavailable);
845 assert!(report.suggestions.is_empty());
846 assert!(report
847 .uncertainty
848 .iter()
849 .any(|note| note.contains("no reviewer suggestions found")));
850 }
851
852 fn reviewer_evidence(email: &str, role: ReviewerRole) -> open_kioku_core::ReviewerEvidence {
853 open_kioku_core::ReviewerEvidence {
854 id: HistoryRecordId::new(format!("review:{email}")),
855 commit_id: Some(GitCommitId::new("commit-review")),
856 path: Some("src/a.rs".into()),
857 reviewer: owner(email),
858 role,
859 observed_at: ts(0),
860 source: "synthetic-pr-review".into(),
861 confidence: Confidence::High,
862 }
863 }
864
865 fn ownership_report(owner: OwnerSuggestion) -> OwnershipReport {
866 OwnershipReport {
867 path: "src/a.rs".into(),
868 components: Vec::new(),
869 generated_at: ts(0),
870 owners: vec![owner],
871 uncertainty: Vec::new(),
872 }
873 }
874
875 fn owner_suggestion(email: &str, score: f32) -> OwnerSuggestion {
876 let owner = owner(email);
877 OwnerSuggestion {
878 owner: owner.clone(),
879 rationale: "CODEOWNERS matched the queried path".into(),
880 confidence: Confidence::from_score(score),
881 score,
882 source_types: vec![OwnershipSourceType::Codeowners],
883 stale: false,
884 evidence: vec![OwnershipEvidence {
885 source_type: OwnershipSourceType::Codeowners,
886 owner,
887 source: ".github/CODEOWNERS:1".into(),
888 message: "CODEOWNERS rule matched".into(),
889 confidence: Confidence::High,
890 observed_at: Some(ts(0)),
891 stale: false,
892 }],
893 confidence_breakdown: OwnershipConfidenceBreakdown {
894 codeowners: score,
895 git_history: 0.0,
896 memory: 0.0,
897 freshness: 0.0,
898 ambiguity_penalty: 0.0,
899 final_score: score,
900 },
901 }
902 }
903
904 fn provenance(touches: Vec<ProvenanceTouch>) -> FileProvenance {
905 FileProvenance {
906 path: "src/a.rs".into(),
907 first_seen: touches.last().cloned(),
908 last_touched: touches.first().cloned(),
909 recent_touches: touches,
910 confidence: Confidence::High,
911 truncated: false,
912 uncertainty: Vec::new(),
913 }
914 }
915
916 fn empty_provenance(path: &Path) -> FileProvenance {
917 FileProvenance {
918 path: path.to_path_buf(),
919 first_seen: None,
920 last_touched: None,
921 recent_touches: Vec::new(),
922 confidence: Confidence::Low,
923 truncated: false,
924 uncertainty: vec!["no persisted provenance is available".into()],
925 }
926 }
927
928 fn touch(email: &str, days_ago: i64) -> ProvenanceTouch {
929 let at = ts(days_ago);
930 ProvenanceTouch {
931 commit: GitCommitRecord {
932 id: GitCommitId::new(format!("commit-{email}-{days_ago}")),
933 parent_ids: Vec::new(),
934 author: owner(email),
935 committer: None,
936 authored_at: at,
937 committed_at: at,
938 summary: format!("touch by {email}"),
939 message: format!("touch by {email}"),
940 file_count: 1,
941 },
942 path: "src/a.rs".into(),
943 previous_path: None,
944 symbol_id: None,
945 qualified_name: None,
946 change_kind: GitChangeKind::Modified,
947 line_ranges: Vec::new(),
948 confidence: Confidence::High,
949 uncertainty: Vec::new(),
950 }
951 }
952
953 fn owner(email: &str) -> Owner {
954 let name = email.split('@').next().unwrap_or(email).to_string();
955 Owner {
956 name,
957 email: Some(email.into()),
958 }
959 }
960
961 fn ts(days_ago: i64) -> DateTime<Utc> {
962 Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap() - Duration::days(days_ago)
963 }
964}