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