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