Skip to main content

interprex_test/
code_reviews.rs

1use async_trait::async_trait;
2use interprex::{
3    BranchUpdateError, BranchUpdateObservation, BranchUpdatesProvider, ChangeRequest,
4    ChangeRequestCommentsProvider, ChangeRequestHead, ChangeRequestNumber, ChangeRequestState,
5    CheckOutcome, CheckRun, CodeReviewsProvider, FindingResolution, FindingResolutionRecord,
6    FindingResolutionReply, ProviderError, Repository, Result, ReviewActor, ReviewActorId,
7    ReviewActorKind, ReviewAnchor, ReviewAuthor, ReviewComment, ReviewCommentId, ReviewFinding,
8    ReviewId, ReviewLineRange, ReviewLocation, ReviewPublicationKey, ReviewPublishingProvider,
9    ReviewRequest, ReviewRequestId, ReviewRequestTarget, ReviewRequestTargetInspection,
10    ReviewState, ReviewSubmission, ReviewTarget, ReviewTargetsProvider, ReviewTeam, ReviewTeamId,
11    ReviewTeamKind, ReviewThread, ReviewThreadId, ReviewThreadStatus, ReviewerApplication,
12    ReviewerApplicationsProvider,
13};
14
15use crate::state::{FakeProvider, FakeReviewPublication, FakeReviewPublicationKey, missing};
16
17#[async_trait]
18impl BranchUpdatesProvider for FakeProvider {
19    async fn branch_update(
20        &self,
21        repository: &Repository,
22        number: ChangeRequestNumber,
23    ) -> Result<BranchUpdateObservation> {
24        self.state
25            .read()
26            .await
27            .branch_updates
28            .get(&(repository.clone(), number))
29            .cloned()
30            .ok_or_else(|| {
31                missing(format!(
32                    "branch update for change request {number:?} in {repository}"
33                ))
34            })
35    }
36
37    async fn update_change_request_branch(
38        &self,
39        repository: &Repository,
40        number: ChangeRequestNumber,
41        expected_head_sha: &str,
42    ) -> std::result::Result<(), BranchUpdateError> {
43        if expected_head_sha.is_empty() {
44            return Err(ProviderError::InvalidInput {
45                provider: "fake",
46                fact: "expected change-request head must not be empty".to_owned(),
47            }
48            .into());
49        }
50        let mut state = self.state.write().await;
51        let observation = state
52            .branch_updates
53            .get(&(repository.clone(), number))
54            .ok_or_else(|| {
55                missing(format!(
56                    "branch update for change request {number:?} in {repository}"
57                ))
58            })
59            .map_err(BranchUpdateError::from)?;
60        if observation.commit_range.head_sha != expected_head_sha {
61            return Err(BranchUpdateError::StaleHead {
62                expected_head_sha: expected_head_sha.to_owned(),
63                observed_head_sha: observation.commit_range.head_sha.clone(),
64            });
65        }
66        state.accepted_branch_updates.push((
67            repository.clone(),
68            number,
69            expected_head_sha.to_owned(),
70        ));
71        Ok(())
72    }
73}
74
75#[async_trait]
76impl CodeReviewsProvider for FakeProvider {
77    async fn change_request(
78        &self,
79        repository: &Repository,
80        number: ChangeRequestNumber,
81    ) -> Result<ChangeRequest> {
82        self.state
83            .read()
84            .await
85            .change_requests
86            .get(&(repository.clone(), number))
87            .cloned()
88            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))
89    }
90
91    async fn open_change_requests(
92        &self,
93        repository: &Repository,
94        head: &ChangeRequestHead,
95    ) -> Result<Vec<ChangeRequestNumber>> {
96        Ok(self
97            .state
98            .read()
99            .await
100            .change_requests
101            .iter()
102            .filter(|((targeted, _), change_request)| {
103                targeted == repository
104                    && change_request.state == ChangeRequestState::Open
105                    && change_request.head.as_ref() == Some(head)
106            })
107            .map(|((_, number), _)| *number)
108            .collect())
109    }
110
111    async fn resolve_thread(
112        &self,
113        repository: &Repository,
114        number: ChangeRequestNumber,
115        thread_id: &ReviewThreadId,
116    ) -> Result<()> {
117        let mut state = self.state.write().await;
118        let change_request = state
119            .change_requests
120            .get_mut(&(repository.clone(), number))
121            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))?;
122        if let Some(finding) = change_request
123            .reviews
124            .iter_mut()
125            .flat_map(|review| review.findings.iter_mut())
126            .find(|thread| &thread.id == thread_id)
127        {
128            finding.status = ReviewThreadStatus::Resolved;
129            return Ok(());
130        }
131        if let Some(thread) = change_request
132            .standalone_threads
133            .iter_mut()
134            .find(|thread| &thread.id == thread_id)
135        {
136            thread.status = ReviewThreadStatus::Resolved;
137            return Ok(());
138        }
139        Err(missing(format!("review thread {}", thread_id.as_str())))
140    }
141
142    async fn resolve_finding(
143        &self,
144        repository: &Repository,
145        number: ChangeRequestNumber,
146        thread_id: &ReviewThreadId,
147        resolution: FindingResolution,
148        reply: &FindingResolutionReply,
149    ) -> Result<()> {
150        let mut state = self.state.write().await;
151        let change_request = state
152            .change_requests
153            .get_mut(&(repository.clone(), number))
154            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))?;
155        let observed_at = change_request.updated_at;
156        let finding = change_request
157            .reviews
158            .iter_mut()
159            .flat_map(|review| review.findings.iter_mut())
160            .find(|thread| &thread.id == thread_id)
161            .ok_or_else(|| missing(format!("finding thread {}", thread_id.as_str())))?;
162        if let Some(FindingResolutionRecord::Unsupported {
163            metadata_format, ..
164        }) = &finding.resolution
165        {
166            return Err(ProviderError::Unrepresentable {
167                provider: "fake",
168                fact: format!(
169                    "finding thread {} contains unsupported resolution metadata format {metadata_format}",
170                    thread_id.as_str()
171                ),
172            });
173        }
174        if matches!(
175            &finding.resolution,
176            Some(FindingResolutionRecord::Supported {
177                resolution: recorded,
178                ..
179            }) if *recorded == resolution
180        ) {
181            finding.status = ReviewThreadStatus::Resolved;
182            return Ok(());
183        }
184        let author = ReviewActor {
185            id: ReviewActorId::new("fake-provider:authenticated-actor")
186                .expect("fake actor identity is nonempty"),
187            login: "fake-provider".to_owned(),
188            kind: ReviewActorKind::User,
189        };
190        let written_at = finding
191            .replies
192            .iter()
193            .map(|comment| comment.updated_at.unwrap_or(comment.created_at))
194            .chain([observed_at])
195            .max()
196            .expect("the observed change supplies one timestamp")
197            + std::time::Duration::from_micros(1);
198        let reply_number = finding
199            .replies
200            .iter()
201            .filter(|comment| comment.id.as_str().starts_with("fake-resolution:"))
202            .count()
203            + 1;
204        let reply_id = ReviewCommentId::new(format!(
205            "fake-resolution:{}:{reply_number}",
206            thread_id.as_str()
207        ))
208        .expect("fake resolution identity is nonempty");
209        let source_reply = ReviewComment {
210            id: reply_id,
211            author,
212            body: reply.as_str().to_owned(),
213            created_at: written_at,
214            updated_at: None,
215        };
216        let source_reply_id = source_reply.id.clone();
217        finding.replies.push(source_reply);
218        finding.resolution = Some(FindingResolutionRecord::Supported {
219            resolution,
220            source_reply_id,
221        });
222        finding.status = ReviewThreadStatus::Resolved;
223        Ok(())
224    }
225
226    async fn request_reviewers(
227        &self,
228        repository: &Repository,
229        number: ChangeRequestNumber,
230        reviewers: &[ReviewRequestTarget],
231    ) -> Result<()> {
232        let mut state = self.state.write().await;
233        let change_request = state
234            .change_requests
235            .get_mut(&(repository.clone(), number))
236            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))?;
237        let observed_at = change_request.updated_at;
238        for target in reviewers {
239            if change_request
240                .outstanding_requests
241                .iter()
242                .any(|request| request.request_target.as_ref() == Some(target))
243            {
244                continue;
245            }
246            let requested_at = change_request
247                .outstanding_requests
248                .iter()
249                .filter_map(|request| request.requested_at)
250                .chain([observed_at])
251                .max()
252                .expect("the observed change supplies one timestamp")
253                + std::time::Duration::from_micros(1);
254            change_request
255                .outstanding_requests
256                .push(fake_review_request(
257                    repository,
258                    number,
259                    target,
260                    requested_at,
261                ));
262        }
263        Ok(())
264    }
265
266    async fn mark_ready(&self, repository: &Repository, number: ChangeRequestNumber) -> Result<()> {
267        let mut state = self.state.write().await;
268        let change_request = state
269            .change_requests
270            .get_mut(&(repository.clone(), number))
271            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))?;
272        change_request.draft = false;
273        Ok(())
274    }
275
276    async fn checks(&self, repository: &Repository, head_sha: &str) -> Result<Vec<CheckRun>> {
277        Ok(self
278            .state
279            .read()
280            .await
281            .check_runs
282            .get(&(repository.clone(), head_sha.to_owned()))
283            .cloned()
284            .unwrap_or_default())
285    }
286
287    async fn publish_check(
288        &self,
289        repository: &Repository,
290        app_name: &str,
291        outcome: &CheckOutcome,
292    ) -> Result<()> {
293        self.state.write().await.published_checks.push((
294            repository.clone(),
295            app_name.to_owned(),
296            outcome.clone(),
297        ));
298        Ok(())
299    }
300}
301
302#[async_trait]
303impl ChangeRequestCommentsProvider for FakeProvider {
304    async fn create_unanchored_comment(
305        &self,
306        repository: &Repository,
307        number: ChangeRequestNumber,
308        body: &str,
309    ) -> Result<ReviewCommentId> {
310        let mut state = self.state.write().await;
311        let change_request = state
312            .change_requests
313            .get_mut(&(repository.clone(), number))
314            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))?;
315        let comment_number = change_request.unanchored_comments.len() + 1;
316        let id = ReviewCommentId::new(format!(
317            "fake-comment:{}:{}:{}:{}:{}:{comment_number}",
318            repository.owner().len(),
319            repository.owner(),
320            repository.name().len(),
321            repository.name(),
322            number.get(),
323        ))
324        .expect("fake comment identity is nonempty");
325        let created_at = change_request
326            .unanchored_comments
327            .iter()
328            .map(|comment| comment.updated_at.unwrap_or(comment.created_at))
329            .chain([change_request.updated_at])
330            .max()
331            .expect("the observed change supplies one timestamp")
332            + std::time::Duration::from_micros(1);
333        change_request.unanchored_comments.push(ReviewComment {
334            id: id.clone(),
335            author: ReviewActor {
336                id: ReviewActorId::new("fake-provider:authenticated-actor")
337                    .expect("fake actor identity is nonempty"),
338                login: "fake-provider".to_owned(),
339                kind: ReviewActorKind::User,
340            },
341            body: body.to_owned(),
342            created_at,
343            updated_at: None,
344        });
345        Ok(id)
346    }
347}
348
349#[async_trait]
350impl ReviewTargetsProvider for FakeProvider {
351    async fn inspect_review_request_target(
352        &self,
353        repository: &Repository,
354        target: &ReviewRequestTarget,
355    ) -> Result<ReviewRequestTargetInspection> {
356        Ok(self
357            .state
358            .read()
359            .await
360            .review_target_observations
361            .iter()
362            .find(|(seeded_repository, seeded_target, _)| {
363                seeded_repository == repository && seeded_target == target
364            })
365            .map_or(
366                ReviewRequestTargetInspection::NotResolvable,
367                |(_, _, observed)| {
368                    ReviewRequestTargetInspection::from_observation(target, observed.clone())
369                },
370            ))
371    }
372}
373
374#[async_trait]
375impl ReviewerApplicationsProvider for FakeProvider {
376    async fn resolve_reviewer_application(
377        &self,
378        repository: &Repository,
379        slug: &str,
380    ) -> Result<ReviewerApplication> {
381        self.state
382            .read()
383            .await
384            .reviewer_applications
385            .get(&(repository.clone(), slug.to_owned()))
386            .cloned()
387            .ok_or_else(|| missing(format!("reviewer application {slug} in {repository}")))
388    }
389}
390
391#[async_trait]
392impl ReviewPublishingProvider for FakeProvider {
393    async fn publish_review(
394        &self,
395        repository: &Repository,
396        number: ChangeRequestNumber,
397        reviewer: &ReviewerApplication,
398        submission: &ReviewSubmission,
399    ) -> Result<ReviewId> {
400        let publication_key =
401            fake_publication_key(repository, number, reviewer, submission.publication_key());
402        let mut state = self.state.write().await;
403        if let Some(existing) = state.review_publications.get(&publication_key) {
404            if existing.submission == *submission {
405                return Ok(existing.review_id.clone());
406            }
407            return Err(ProviderError::InvalidInput {
408                provider: "fake",
409                fact: format!(
410                    "review publication key {:?} already names a different review",
411                    submission.publication_key().as_str()
412                ),
413            });
414        }
415
416        let change_request = state
417            .change_requests
418            .get_mut(&(repository.clone(), number))
419            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))?;
420        let submitted_at = next_review_timestamp(change_request);
421        let review_id = fake_review_id(repository, number, reviewer, submission.publication_key());
422        change_request.reviews.push(fake_review(
423            reviewer,
424            submission,
425            review_id.clone(),
426            ReviewState::Submitted {
427                disposition: submission.disposition().into(),
428                submitted_at,
429            },
430            submitted_at,
431        ));
432        change_request.updated_at = submitted_at;
433        state.review_publications.insert(
434            publication_key,
435            FakeReviewPublication {
436                submission: submission.clone(),
437                review_id: review_id.clone(),
438            },
439        );
440        Ok(review_id)
441    }
442
443    async fn resume_review_publication(
444        &self,
445        repository: &Repository,
446        number: ChangeRequestNumber,
447        reviewer: &ReviewerApplication,
448        key: &ReviewPublicationKey,
449    ) -> Result<Option<ReviewId>> {
450        let publication_key = fake_publication_key(repository, number, reviewer, key);
451        let mut state = self.state.write().await;
452        if !state
453            .change_requests
454            .contains_key(&(repository.clone(), number))
455        {
456            return Err(missing(format!(
457                "change request {number:?} in {repository}"
458            )));
459        }
460        let Some(publication) = state.review_publications.get(&publication_key).cloned() else {
461            return Ok(None);
462        };
463        let change_request = state
464            .change_requests
465            .get_mut(&(repository.clone(), number))
466            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))?;
467        let submitted_at = next_review_timestamp(change_request);
468        let review = change_request
469            .reviews
470            .iter_mut()
471            .find(|review| review.id == publication.review_id)
472            .ok_or_else(|| unreconciled_publication("recorded review is absent"))?;
473        match &review.state {
474            ReviewState::Draft => {
475                review.state = ReviewState::Submitted {
476                    disposition: publication.submission.disposition().into(),
477                    submitted_at,
478                };
479                change_request.updated_at = submitted_at;
480            }
481            ReviewState::Submitted { disposition, .. }
482                if *disposition != publication.submission.disposition().into() =>
483            {
484                return Err(unreconciled_publication(
485                    "submitted review has a different disposition",
486                ));
487            }
488            ReviewState::Submitted { .. } => {}
489        }
490        Ok(Some(publication.review_id))
491    }
492}
493
494impl FakeProvider {
495    /// Seeds the state left after a provider created one complete draft but the
496    /// caller lost the publication response before final submission.
497    pub async fn seed_pending_review_publication(
498        &self,
499        repository: Repository,
500        number: ChangeRequestNumber,
501        reviewer: ReviewerApplication,
502        submission: ReviewSubmission,
503    ) -> Result<ReviewId> {
504        let publication_key =
505            fake_publication_key(&repository, number, &reviewer, submission.publication_key());
506        let mut state = self.state.write().await;
507        if state.review_publications.contains_key(&publication_key) {
508            return Err(ProviderError::InvalidInput {
509                provider: "fake",
510                fact: format!(
511                    "review publication key {:?} is already seeded",
512                    submission.publication_key().as_str()
513                ),
514            });
515        }
516        let change_request = state
517            .change_requests
518            .get_mut(&(repository.clone(), number))
519            .ok_or_else(|| missing(format!("change request {number:?} in {repository}")))?;
520        let created_at = next_review_timestamp(change_request);
521        let review_id =
522            fake_review_id(&repository, number, &reviewer, submission.publication_key());
523        change_request.reviews.push(fake_review(
524            &reviewer,
525            &submission,
526            review_id.clone(),
527            ReviewState::Draft,
528            created_at,
529        ));
530        change_request.updated_at = created_at;
531        state.review_publications.insert(
532            publication_key,
533            FakeReviewPublication {
534                submission,
535                review_id: review_id.clone(),
536            },
537        );
538        Ok(review_id)
539    }
540}
541
542fn fake_review_id(
543    repository: &Repository,
544    number: ChangeRequestNumber,
545    reviewer: &ReviewerApplication,
546    key: &ReviewPublicationKey,
547) -> ReviewId {
548    ReviewId::new(format!(
549        "fake-review:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
550        repository.owner().len(),
551        repository.owner(),
552        repository.name().len(),
553        repository.name(),
554        number.get(),
555        reviewer.app().id.as_str().len(),
556        reviewer.app().id.as_str(),
557        reviewer.bot().id.as_str().len(),
558        reviewer.bot().id.as_str(),
559        key.as_str().len(),
560        key.as_str(),
561    ))
562    .expect("fake review identity is nonempty")
563}
564
565fn fake_publication_key(
566    repository: &Repository,
567    number: ChangeRequestNumber,
568    reviewer: &ReviewerApplication,
569    key: &ReviewPublicationKey,
570) -> FakeReviewPublicationKey {
571    (
572        repository.clone(),
573        number,
574        reviewer.app().id.clone(),
575        reviewer.bot().id.clone(),
576        key.clone(),
577    )
578}
579
580fn next_review_timestamp(change_request: &ChangeRequest) -> chrono::DateTime<chrono::Utc> {
581    change_request
582        .reviews
583        .iter()
584        .filter_map(|review| match review.state {
585            ReviewState::Draft => None,
586            ReviewState::Submitted { submitted_at, .. } => Some(submitted_at),
587        })
588        .chain([change_request.updated_at])
589        .max()
590        .expect("the observed change supplies one timestamp")
591        + std::time::Duration::from_micros(1)
592}
593
594fn fake_review(
595    reviewer: &ReviewerApplication,
596    submission: &ReviewSubmission,
597    review_id: ReviewId,
598    state: ReviewState,
599    created_at: chrono::DateTime<chrono::Utc>,
600) -> interprex::Review {
601    let findings = submission
602        .findings()
603        .iter()
604        .enumerate()
605        .map(|(index, finding)| {
606            let line_range = ReviewLineRange {
607                start: None,
608                end: finding.line(),
609            };
610            let identity = format!("{}:{}", review_id.as_str(), index + 1);
611            ReviewFinding {
612                thread: ReviewThread {
613                    id: ReviewThreadId::new(format!("fake-thread:{identity}"))
614                        .expect("fake thread identity is nonempty"),
615                    location: ReviewLocation {
616                        path: finding.path().to_owned(),
617                        anchor: ReviewAnchor::Lines {
618                            side: finding.side().clone(),
619                            original: line_range.clone(),
620                            current: Some(line_range),
621                        },
622                    },
623                    outdated: false,
624                    status: ReviewThreadStatus::Open,
625                    comment: ReviewComment {
626                        id: ReviewCommentId::new(format!("fake-comment:{identity}"))
627                            .expect("fake comment identity is nonempty"),
628                        author: reviewer.bot().clone(),
629                        body: finding.body().to_owned(),
630                        created_at,
631                        updated_at: None,
632                    },
633                    replies: Vec::new(),
634                },
635                resolution: None,
636            }
637        })
638        .collect();
639    interprex::Review {
640        id: review_id,
641        author: ReviewAuthor::Other(reviewer.bot().clone()),
642        via_app: Some(reviewer.app().clone()),
643        revision: submission.revision().clone(),
644        state,
645        summary: Some(submission.summary().to_owned()),
646        findings,
647    }
648}
649
650fn unreconciled_publication(message: impl Into<String>) -> ProviderError {
651    ProviderError::External {
652        provider: "fake",
653        operation: "resume review publication",
654        message: message.into(),
655    }
656}
657
658/// Builds the request the fake records for one newly requested target.
659///
660/// `requested_at` comes from the seeded observation rather than the wall
661/// clock: the caller advances it past the change request's last update and
662/// past every request already outstanding, so a test that seeds the same
663/// change request twice reads the same times both times and still sees
664/// requests ordered by when they were made.
665fn fake_review_request(
666    repository: &Repository,
667    number: ChangeRequestNumber,
668    target: &ReviewRequestTarget,
669    requested_at: chrono::DateTime<chrono::Utc>,
670) -> ReviewRequest {
671    let request_target = target.clone();
672    let (identity, target) = match target {
673        ReviewRequestTarget::User(login) => (
674            format!("user:{login}"),
675            ReviewTarget::Actor(ReviewActor {
676                id: ReviewActorId::new(format!("fake-user:{login}"))
677                    .expect("fake user identity is nonempty"),
678                login: login.clone(),
679                kind: ReviewActorKind::User,
680            }),
681        ),
682        ReviewRequestTarget::Bot(login) => (
683            format!("bot:{login}"),
684            ReviewTarget::Actor(ReviewActor {
685                id: ReviewActorId::new(format!("fake-bot:{login}"))
686                    .expect("fake bot identity is nonempty"),
687                login: login.clone(),
688                kind: ReviewActorKind::Bot,
689            }),
690        ),
691        ReviewRequestTarget::Team(identifier) => {
692            let slug = identifier
693                .rsplit('/')
694                .next()
695                .unwrap_or(identifier)
696                .to_owned();
697            (
698                format!("team:{identifier}"),
699                ReviewTarget::Team(ReviewTeam {
700                    id: ReviewTeamId::new(format!("fake-team:{identifier}"))
701                        .expect("fake team identity is nonempty"),
702                    slug: slug.clone(),
703                    name: slug,
704                    kind: ReviewTeamKind::Organization,
705                }),
706            )
707        }
708    };
709    ReviewRequest {
710        id: ReviewRequestId::new(format!(
711            "fake-request:{}:{}:{}:{}:{}:{}:{}",
712            repository.owner().len(),
713            repository.owner(),
714            repository.name().len(),
715            repository.name(),
716            number.get(),
717            identity.len(),
718            identity
719        ))
720        .expect("fake request identity is nonempty"),
721        target,
722        request_target: Some(request_target),
723        requested_at: Some(requested_at),
724        as_code_owner: false,
725    }
726}