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 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
658fn 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}