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 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
609fn 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
742fn 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}