1use std::sync::Arc;
4
5use super::{
6 CreateReviewRequestInput, ForgeCommandRunner, ForgeFuture, ForgeKind, ForgeRemote,
7 GitHubReviewRequestAdapter, GitLabReviewRequestAdapter, RealForgeCommandRunner,
8 ReviewCommentSnapshot, ReviewRequestError, ReviewRequestMetadata, ReviewRequestSummary,
9 UpdateReviewRequestInput, detect_remote,
10};
11
12#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
17pub trait ReviewRequestClient: Send + Sync {
18 fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError>;
24
25 fn find_by_source_branch(
31 &self,
32 remote: ForgeRemote,
33 source_branch: String,
34 ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
35
36 fn create_review_request(
41 &self,
42 remote: ForgeRemote,
43 input: CreateReviewRequestInput,
44 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
45
46 fn refresh_review_request(
51 &self,
52 remote: ForgeRemote,
53 display_id: String,
54 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
55
56 fn review_request_metadata(
62 &self,
63 remote: ForgeRemote,
64 display_id: String,
65 ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
66
67 fn sync_review_request_metadata(
77 &self,
78 remote: ForgeRemote,
79 display_id: String,
80 input: UpdateReviewRequestInput,
81 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
82
83 fn review_request_web_url(
89 &self,
90 review_request: &ReviewRequestSummary,
91 ) -> Result<String, ReviewRequestError>;
92
93 fn fetch_review_comment_snapshot(
103 &self,
104 remote: ForgeRemote,
105 display_id: String,
106 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
107
108 fn reply_to_thread(
114 &self,
115 remote: ForgeRemote,
116 display_id: String,
117 thread_id: String,
118 body: String,
119 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
120
121 fn resolve_thread(
127 &self,
128 remote: ForgeRemote,
129 display_id: String,
130 thread_id: String,
131 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
132}
133
134pub struct RealReviewRequestClient {
136 command_runner: Arc<dyn ForgeCommandRunner>,
137}
138
139impl RealReviewRequestClient {
140 pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
142 Self { command_runner }
143 }
144
145 fn call_with_authenticated_adapter<T>(
147 &self,
148 remote: ForgeRemote,
149 call: impl FnOnce(
150 Arc<dyn ReviewRequestAdapter>,
151 ForgeRemote,
152 ) -> ForgeFuture<Result<T, ReviewRequestError>>
153 + Send
154 + 'static,
155 ) -> ForgeFuture<Result<T, ReviewRequestError>>
156 where
157 T: Send + 'static,
158 {
159 let adapter = self.adapter_for(remote.forge_kind);
160
161 Box::pin(async move {
162 adapter.ensure_authenticated(&remote).await?;
163
164 call(adapter, remote).await
165 })
166 }
167
168 fn adapter_for(&self, forge_kind: ForgeKind) -> Arc<dyn ReviewRequestAdapter> {
170 match forge_kind {
171 ForgeKind::GitHub => Arc::new(GitHubReviewRequestAdapter::new(Arc::clone(
172 &self.command_runner,
173 ))),
174 ForgeKind::GitLab => Arc::new(GitLabReviewRequestAdapter::new(Arc::clone(
175 &self.command_runner,
176 ))),
177 }
178 }
179}
180
181impl Default for RealReviewRequestClient {
182 fn default() -> Self {
183 Self::new(Arc::new(RealForgeCommandRunner))
184 }
185}
186
187impl ReviewRequestClient for RealReviewRequestClient {
188 fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
189 detect_remote(&repo_url)
190 }
191
192 fn find_by_source_branch(
193 &self,
194 remote: ForgeRemote,
195 source_branch: String,
196 ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
197 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
198 adapter.find_authenticated_by_source_branch(remote, source_branch)
199 })
200 }
201
202 fn create_review_request(
203 &self,
204 remote: ForgeRemote,
205 input: CreateReviewRequestInput,
206 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
207 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
208 adapter.create_authenticated_review_request(remote, input)
209 })
210 }
211
212 fn refresh_review_request(
213 &self,
214 remote: ForgeRemote,
215 display_id: String,
216 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
217 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
218 adapter.refresh_authenticated_review_request(remote, display_id)
219 })
220 }
221
222 fn review_request_metadata(
223 &self,
224 remote: ForgeRemote,
225 display_id: String,
226 ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>> {
227 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
228 adapter.authenticated_review_request_metadata(remote, display_id)
229 })
230 }
231
232 fn sync_review_request_metadata(
233 &self,
234 remote: ForgeRemote,
235 display_id: String,
236 input: UpdateReviewRequestInput,
237 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
238 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
239 adapter.sync_authenticated_review_request_metadata(remote, display_id, input)
240 })
241 }
242
243 fn review_request_web_url(
244 &self,
245 review_request: &ReviewRequestSummary,
246 ) -> Result<String, ReviewRequestError> {
247 if review_request.web_url.trim().is_empty() {
248 return Err(ReviewRequestError::OperationFailed {
249 forge_kind: review_request.forge_kind,
250 message: "review request summary is missing a web URL".to_string(),
251 });
252 }
253
254 Ok(review_request.web_url.clone())
255 }
256
257 fn fetch_review_comment_snapshot(
258 &self,
259 remote: ForgeRemote,
260 display_id: String,
261 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
262 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
263 adapter.fetch_authenticated_review_comment_snapshot(remote, display_id)
264 })
265 }
266
267 fn reply_to_thread(
268 &self,
269 remote: ForgeRemote,
270 display_id: String,
271 thread_id: String,
272 body: String,
273 ) -> ForgeFuture<Result<(), ReviewRequestError>> {
274 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
275 adapter.reply_to_authenticated_thread(remote, display_id, thread_id, body)
276 })
277 }
278
279 fn resolve_thread(
280 &self,
281 remote: ForgeRemote,
282 display_id: String,
283 thread_id: String,
284 ) -> ForgeFuture<Result<(), ReviewRequestError>> {
285 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
286 adapter.resolve_authenticated_thread(remote, display_id, thread_id)
287 })
288 }
289}
290
291pub(crate) trait ReviewRequestAdapter: Send + Sync {
298 fn ensure_authenticated(
304 &self,
305 remote: &ForgeRemote,
306 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
307
308 fn find_authenticated_by_source_branch(
310 &self,
311 remote: ForgeRemote,
312 source_branch: String,
313 ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
314
315 fn create_authenticated_review_request(
318 &self,
319 remote: ForgeRemote,
320 input: CreateReviewRequestInput,
321 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
322
323 fn refresh_authenticated_review_request(
325 &self,
326 remote: ForgeRemote,
327 display_id: String,
328 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
329
330 fn authenticated_review_request_metadata(
332 &self,
333 remote: ForgeRemote,
334 display_id: String,
335 ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
336
337 fn sync_authenticated_review_request_metadata(
339 &self,
340 remote: ForgeRemote,
341 display_id: String,
342 input: UpdateReviewRequestInput,
343 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
344
345 fn fetch_authenticated_review_comment_snapshot(
347 &self,
348 remote: ForgeRemote,
349 display_id: String,
350 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
351
352 fn reply_to_authenticated_thread(
354 &self,
355 remote: ForgeRemote,
356 display_id: String,
357 thread_id: String,
358 body: String,
359 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
360
361 fn resolve_authenticated_thread(
363 &self,
364 remote: ForgeRemote,
365 display_id: String,
366 thread_id: String,
367 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
368}
369
370#[cfg(test)]
371mod tests {
372 use mockall::Sequence;
373
374 use super::*;
375 use crate::command::{ForgeCommand, ForgeCommandOutput, MockForgeCommandRunner};
376 use crate::{ForgeKind, ReviewRequestState};
377
378 #[test]
379 fn review_request_web_url_returns_error_when_summary_is_missing_url() {
380 let client = RealReviewRequestClient::default();
382 let review_request = ReviewRequestSummary {
383 display_id: "#42".to_string(),
384 forge_kind: ForgeKind::GitHub,
385 source_branch: "feature/forge".to_string(),
386 state: ReviewRequestState::Open,
387 status_summary: Some("Mergeable".to_string()),
388 target_branch: "main".to_string(),
389 title: "Add forge boundary".to_string(),
390 web_url: String::new(),
391 };
392
393 let error = client
395 .review_request_web_url(&review_request)
396 .expect_err("missing URL should be rejected");
397
398 assert_eq!(
400 error,
401 ReviewRequestError::OperationFailed {
402 forge_kind: ForgeKind::GitHub,
403 message: "review request summary is missing a web URL".to_string(),
404 }
405 );
406 }
407
408 #[test]
409 fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
410 let client = RealReviewRequestClient::default();
412 let review_request = ReviewRequestSummary {
413 display_id: "!42".to_string(),
414 forge_kind: ForgeKind::GitLab,
415 source_branch: "feature/forge".to_string(),
416 state: ReviewRequestState::Open,
417 status_summary: Some("Draft".to_string()),
418 target_branch: "main".to_string(),
419 title: "Add forge boundary".to_string(),
420 web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
421 };
422
423 let web_url = client
425 .review_request_web_url(&review_request)
426 .expect("gitlab review-request URL should be returned directly");
427
428 assert_eq!(
430 web_url,
431 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
432 );
433 }
434
435 #[tokio::test]
436 async fn find_by_source_branch_authenticates_once_before_github_lookup() {
437 let remote = github_remote();
439 let mut sequence = Sequence::new();
440 let mut command_runner = MockForgeCommandRunner::new();
441 command_runner
442 .expect_run()
443 .once()
444 .in_sequence(&mut sequence)
445 .withf(|command| {
446 command_arguments_are(
447 command,
448 "gh",
449 &["auth", "status", "--hostname", "github.com"],
450 )
451 })
452 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
453 command_runner
454 .expect_run()
455 .once()
456 .in_sequence(&mut sequence)
457 .withf(|command| {
458 command_arguments_are(
459 command,
460 "gh",
461 &[
462 "api",
463 "--hostname",
464 "github.com",
465 "--method",
466 "GET",
467 "repos/agentty-xyz/agentty/pulls",
468 "-f",
469 "head=agentty-xyz:feature/forge",
470 "-f",
471 "state=open",
472 "-f",
473 "sort=created",
474 "-f",
475 "direction=desc",
476 "-f",
477 "per_page=1",
478 ],
479 )
480 })
481 .returning(|_| {
482 Box::pin(async { Ok(success_output(r#"[{"number":42}]"#.to_string())) })
483 });
484 command_runner
485 .expect_run()
486 .once()
487 .in_sequence(&mut sequence)
488 .withf(|command| {
489 command_arguments_are(
490 command,
491 "gh",
492 &[
493 "pr",
494 "view",
495 "42",
496 "--repo",
497 "agentty-xyz/agentty",
498 "--json",
499 "number,title,state,url,baseRefName,headRefName,isDraft,mergeStateStatus,\
500 reviewDecision,mergedAt",
501 ],
502 )
503 })
504 .returning(|_| Box::pin(async { Ok(success_output(github_view_json())) }));
505 let client = RealReviewRequestClient::new(Arc::new(command_runner));
506
507 let review_request = client
509 .find_by_source_branch(remote, "feature/forge".to_string())
510 .await
511 .expect("GitHub lookup should succeed");
512
513 assert_eq!(
515 review_request,
516 Some(ReviewRequestSummary {
517 display_id: "#42".to_string(),
518 forge_kind: ForgeKind::GitHub,
519 source_branch: "feature/forge".to_string(),
520 state: ReviewRequestState::Open,
521 status_summary: Some("Approved, Mergeable".to_string()),
522 target_branch: "main".to_string(),
523 title: "Add forge review support".to_string(),
524 web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
525 })
526 );
527 }
528
529 #[tokio::test]
530 async fn review_request_metadata_authenticates_before_github_lookup() {
531 let remote = github_remote();
533 let mut sequence = Sequence::new();
534 let mut command_runner = MockForgeCommandRunner::new();
535 command_runner
536 .expect_run()
537 .once()
538 .in_sequence(&mut sequence)
539 .withf(|command| {
540 command_arguments_are(
541 command,
542 "gh",
543 &["auth", "status", "--hostname", "github.com"],
544 )
545 })
546 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
547 command_runner
548 .expect_run()
549 .once()
550 .in_sequence(&mut sequence)
551 .withf(|command| {
552 command_arguments_are(
553 command,
554 "gh",
555 &[
556 "pr",
557 "view",
558 "42",
559 "--repo",
560 "agentty-xyz/agentty",
561 "--json",
562 "title,body",
563 ],
564 )
565 })
566 .returning(|_| {
567 Box::pin(async {
568 Ok(success_output(
569 r#"{"title":"Current title","body":"Current body"}"#.to_string(),
570 ))
571 })
572 });
573 let client = RealReviewRequestClient::new(Arc::new(command_runner));
574
575 let metadata = client
577 .review_request_metadata(remote, "#42".to_string())
578 .await
579 .expect("GitHub metadata lookup should succeed");
580
581 assert_eq!(
583 metadata,
584 ReviewRequestMetadata {
585 body: "Current body".to_string(),
586 title: "Current title".to_string(),
587 }
588 );
589 }
590
591 #[tokio::test]
592 async fn refresh_review_request_stops_on_github_authentication_error() {
593 let remote = github_remote();
595 let mut command_runner = MockForgeCommandRunner::new();
596 command_runner
597 .expect_run()
598 .once()
599 .withf(|command| {
600 command_arguments_are(
601 command,
602 "gh",
603 &["auth", "status", "--hostname", "github.com"],
604 )
605 })
606 .returning(|_| {
607 Box::pin(async {
608 Ok(failure_output(
609 "You are not logged into any GitHub hosts. Run `gh auth login`."
610 .to_string(),
611 ))
612 })
613 });
614 let client = RealReviewRequestClient::new(Arc::new(command_runner));
615
616 let error = client
618 .refresh_review_request(remote, "#42".to_string())
619 .await
620 .expect_err("missing auth should stop before refresh");
621
622 assert_eq!(
624 error,
625 ReviewRequestError::AuthenticationRequired {
626 detail: Some(
627 "You are not logged into any GitHub hosts. Run `gh auth login`.".to_string()
628 ),
629 forge_kind: ForgeKind::GitHub,
630 host: "github.com".to_string(),
631 }
632 );
633 }
634
635 #[tokio::test]
636 async fn review_thread_mutations_authenticate_and_route_to_github_adapter() {
637 let remote = github_remote();
639 let mut sequence = Sequence::new();
640 let mut command_runner = MockForgeCommandRunner::new();
641 for expected_mutation in ["addPullRequestReviewThreadReply", "resolveReviewThread"] {
642 command_runner
643 .expect_run()
644 .once()
645 .in_sequence(&mut sequence)
646 .withf(|command| {
647 command_arguments_are(
648 command,
649 "gh",
650 &["auth", "status", "--hostname", "github.com"],
651 )
652 })
653 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
654 command_runner
655 .expect_run()
656 .once()
657 .in_sequence(&mut sequence)
658 .withf(move |command| {
659 command.executable == "gh"
660 && command
661 .arguments
662 .iter()
663 .any(|argument| argument.contains(expected_mutation))
664 })
665 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
666 }
667 let client = RealReviewRequestClient::new(Arc::new(command_runner));
668
669 let reply_result = client
671 .reply_to_thread(
672 remote.clone(),
673 "#42".to_string(),
674 "thread-1".to_string(),
675 "Addressed.".to_string(),
676 )
677 .await;
678 let resolution_result = client
679 .resolve_thread(remote, "#42".to_string(), "thread-1".to_string())
680 .await;
681
682 assert_eq!(reply_result, Ok(()));
684 assert_eq!(resolution_result, Ok(()));
685 }
686
687 #[tokio::test]
688 async fn refresh_review_request_authenticates_before_gitlab_refresh() {
689 let remote = gitlab_remote();
691 let mut sequence = Sequence::new();
692 let mut command_runner = MockForgeCommandRunner::new();
693 command_runner
694 .expect_run()
695 .once()
696 .in_sequence(&mut sequence)
697 .withf(|command| {
698 command_arguments_are(
699 command,
700 "glab",
701 &["auth", "status", "--hostname", "gitlab.com"],
702 )
703 })
704 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
705 command_runner
706 .expect_run()
707 .once()
708 .in_sequence(&mut sequence)
709 .withf(|command| {
710 command_arguments_are(
711 command,
712 "glab",
713 &[
714 "mr",
715 "view",
716 "42",
717 "--repo",
718 "https://gitlab.com/agentty-xyz/agentty",
719 "--output",
720 "json",
721 ],
722 )
723 })
724 .returning(|_| Box::pin(async { Ok(success_output(gitlab_view_json())) }));
725 let client = RealReviewRequestClient::new(Arc::new(command_runner));
726
727 let review_request = client
729 .refresh_review_request(remote, "!42".to_string())
730 .await
731 .expect("GitLab refresh should succeed");
732
733 assert_eq!(review_request.display_id, "!42");
735 assert_eq!(review_request.forge_kind, ForgeKind::GitLab);
736 }
737
738 fn command_arguments_are(
740 command: &ForgeCommand,
741 executable: &'static str,
742 arguments: &[&str],
743 ) -> bool {
744 let expected_arguments = arguments
745 .iter()
746 .map(|argument| (*argument).to_string())
747 .collect::<Vec<_>>();
748
749 command.executable == executable && command.arguments == expected_arguments
750 }
751
752 fn github_remote() -> ForgeRemote {
754 ForgeRemote {
755 command_working_directory: None,
756 forge_kind: ForgeKind::GitHub,
757 host: "github.com".to_string(),
758 namespace: "agentty-xyz".to_string(),
759 project: "agentty".to_string(),
760 repo_url: "https://github.com/agentty-xyz/agentty.git".to_string(),
761 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
762 }
763 }
764
765 fn gitlab_remote() -> ForgeRemote {
767 ForgeRemote {
768 command_working_directory: None,
769 forge_kind: ForgeKind::GitLab,
770 host: "gitlab.com".to_string(),
771 namespace: "agentty-xyz".to_string(),
772 project: "agentty".to_string(),
773 repo_url: "https://gitlab.com/agentty-xyz/agentty.git".to_string(),
774 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
775 }
776 }
777
778 fn success_output(stdout: String) -> ForgeCommandOutput {
780 ForgeCommandOutput {
781 exit_code: Some(0),
782 stderr: String::new(),
783 stdout,
784 }
785 }
786
787 fn failure_output(stderr: String) -> ForgeCommandOutput {
789 ForgeCommandOutput {
790 exit_code: Some(1),
791 stderr,
792 stdout: String::new(),
793 }
794 }
795
796 fn github_view_json() -> String {
798 r#"{
799 "number": 42,
800 "title": "Add forge review support",
801 "state": "OPEN",
802 "url": "https://github.com/agentty-xyz/agentty/pull/42",
803 "baseRefName": "main",
804 "headRefName": "feature/forge",
805 "isDraft": false,
806 "mergeStateStatus": "CLEAN",
807 "reviewDecision": "APPROVED",
808 "mergedAt": null
809 }"#
810 .to_string()
811 }
812
813 fn gitlab_view_json() -> String {
815 r#"{
816 "draft": true,
817 "detailed_merge_status": "can_be_merged",
818 "iid": 42,
819 "merge_status": "can_be_merged",
820 "merged_at": null,
821 "source_branch": "feature/forge",
822 "state": "opened",
823 "target_branch": "main",
824 "title": "Add forge review support",
825 "description": "Current description.",
826 "web_url": "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
827 }"#
828 .to_string()
829 }
830}