Skip to main content

open_kioku_git/
reviewers.rs

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}