Skip to main content

ag_forge/
client.rs

1//! Public review-request trait boundary and production client wiring.
2
3use std::sync::Arc;
4
5use super::{
6    AssignedIssue, CreateReviewRequestInput, ForgeCommandRunner, ForgeFuture, ForgeKind,
7    ForgeRemote, GitHubReviewRequestAdapter, GitLabReviewRequestAdapter, IssueDetail,
8    RealForgeCommandRunner, RequestedReview, ReviewCommentSnapshot, ReviewRequestError,
9    ReviewRequestMetadata, ReviewRequestSummary, UpdateReviewRequestInput, detect_remote,
10};
11
12/// Async boundary used by app orchestration for forge review requests.
13///
14/// The app layer depends on this narrow contract so provider-specific request
15/// formats remain isolated inside concrete adapters.
16#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
17pub trait ReviewRequestClient: Send + Sync {
18    /// Lists open GitHub issues assigned to the authenticated user in `remote`.
19    ///
20    /// # Errors
21    /// Returns a GitHub CLI error when the issue search cannot be completed.
22    fn list_assigned_issues(
23        &self,
24        remote: ForgeRemote,
25    ) -> ForgeFuture<Result<Vec<AssignedIssue>, ReviewRequestError>>;
26
27    /// Fetches base details for one GitHub issue without comments.
28    ///
29    /// # Errors
30    /// Returns a GitHub CLI error when the issue lookup cannot be completed.
31    fn fetch_issue_detail(
32        &self,
33        remote: ForgeRemote,
34        display_id: String,
35    ) -> ForgeFuture<Result<IssueDetail, ReviewRequestError>>;
36
37    /// Detects whether `repo_url` belongs to one supported forge.
38    ///
39    /// # Errors
40    /// Returns [`ReviewRequestError::UnsupportedRemote`] when the remote does
41    /// not map to a supported forge.
42    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError>;
43
44    /// Finds an existing review request for `source_branch`.
45    ///
46    /// # Errors
47    /// Returns a provider-specific review-request error when the forge lookup
48    /// cannot be completed.
49    fn find_by_source_branch(
50        &self,
51        remote: ForgeRemote,
52        source_branch: String,
53    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
54
55    /// Creates a new review request from `input`.
56    ///
57    /// # Errors
58    /// Returns a provider-specific review-request error when creation fails.
59    fn create_review_request(
60        &self,
61        remote: ForgeRemote,
62        input: CreateReviewRequestInput,
63    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
64
65    /// Refreshes one existing review request by provider display id.
66    ///
67    /// # Errors
68    /// Returns a provider-specific review-request error when refresh fails.
69    fn refresh_review_request(
70        &self,
71        remote: ForgeRemote,
72        display_id: String,
73    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
74
75    /// Loads the current title and body of one existing review request.
76    ///
77    /// # Errors
78    /// Returns a provider-specific review-request error when metadata lookup
79    /// fails.
80    fn review_request_metadata(
81        &self,
82        remote: ForgeRemote,
83        display_id: String,
84    ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
85
86    /// Best-effort syncs reconciled metadata after rechecking that the remote
87    /// fields match the values used during evaluation.
88    ///
89    /// The provider CLI update is not atomic with the recheck, so a later
90    /// concurrent manual edit can still be overwritten.
91    ///
92    /// # Errors
93    /// Returns a provider-specific review-request error when metadata lookup,
94    /// update, or refresh fails.
95    fn sync_review_request_metadata(
96        &self,
97        remote: ForgeRemote,
98        display_id: String,
99        input: UpdateReviewRequestInput,
100    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
101
102    /// Returns the browser-openable URL for one review request.
103    ///
104    /// # Errors
105    /// Returns [`ReviewRequestError::OperationFailed`] when the summary does
106    /// not carry a web URL.
107    fn review_request_web_url(
108        &self,
109        review_request: &ReviewRequestSummary,
110    ) -> Result<String, ReviewRequestError>;
111
112    /// Fetches the review-comment snapshot for one open review request.
113    ///
114    /// Returns both inline threads and review-request-wide comments. Threads
115    /// are grouped by `path` and sorted by `(path, line)` by callers; adapters
116    /// return what the forge reports without enforcing an ordering.
117    ///
118    /// # Errors
119    /// Returns a provider-specific review-request error when the snapshot fetch
120    /// cannot be completed (including authentication and host failures).
121    fn fetch_review_comment_snapshot(
122        &self,
123        remote: ForgeRemote,
124        display_id: String,
125    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
126
127    /// Adds one reply to an existing review thread.
128    ///
129    /// # Errors
130    /// Returns a provider-specific review-request error when the reply cannot
131    /// be posted.
132    fn reply_to_thread(
133        &self,
134        remote: ForgeRemote,
135        display_id: String,
136        thread_id: String,
137        body: String,
138    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
139
140    /// Marks one existing review thread resolved.
141    ///
142    /// # Errors
143    /// Returns a provider-specific review-request error when the thread cannot
144    /// be resolved.
145    fn resolve_thread(
146        &self,
147        remote: ForgeRemote,
148        display_id: String,
149        thread_id: String,
150    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
151
152    /// Lists open review requests asking the current authenticated user to
153    /// review the selected repository.
154    ///
155    /// # Errors
156    /// Returns a provider-specific review-request error when the list fetch
157    /// cannot be completed.
158    fn list_requested_reviews(
159        &self,
160        remote: ForgeRemote,
161    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
162}
163
164/// Production [`ReviewRequestClient`] that routes to forge-specific adapters.
165pub struct RealReviewRequestClient {
166    command_runner: Arc<dyn ForgeCommandRunner>,
167}
168
169impl RealReviewRequestClient {
170    /// Builds one review-request client from a forge command runner.
171    pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
172        Self { command_runner }
173    }
174
175    /// Runs `call` on an authenticated adapter selected for `remote`.
176    fn call_with_authenticated_adapter<T>(
177        &self,
178        remote: ForgeRemote,
179        call: impl FnOnce(
180            Arc<dyn ReviewRequestAdapter>,
181            ForgeRemote,
182        ) -> ForgeFuture<Result<T, ReviewRequestError>>
183        + Send
184        + 'static,
185    ) -> ForgeFuture<Result<T, ReviewRequestError>>
186    where
187        T: Send + 'static,
188    {
189        let adapter = self.adapter_for(remote.forge_kind);
190
191        Box::pin(async move {
192            adapter.ensure_authenticated(&remote).await?;
193
194            call(adapter, remote).await
195        })
196    }
197
198    /// Returns one adapter implementation for `forge_kind`.
199    fn adapter_for(&self, forge_kind: ForgeKind) -> Arc<dyn ReviewRequestAdapter> {
200        match forge_kind {
201            ForgeKind::GitHub => Arc::new(GitHubReviewRequestAdapter::new(Arc::clone(
202                &self.command_runner,
203            ))),
204            ForgeKind::GitLab => Arc::new(GitLabReviewRequestAdapter::new(Arc::clone(
205                &self.command_runner,
206            ))),
207        }
208    }
209}
210
211impl Default for RealReviewRequestClient {
212    fn default() -> Self {
213        Self::new(Arc::new(RealForgeCommandRunner))
214    }
215}
216
217impl ReviewRequestClient for RealReviewRequestClient {
218    fn list_assigned_issues(
219        &self,
220        remote: ForgeRemote,
221    ) -> ForgeFuture<Result<Vec<AssignedIssue>, ReviewRequestError>> {
222        if remote.forge_kind != ForgeKind::GitHub {
223            return Box::pin(async move {
224                Err(ReviewRequestError::OperationFailed {
225                    forge_kind: remote.forge_kind,
226                    message: "assigned issue lists require a GitHub project remote".to_string(),
227                })
228            });
229        }
230
231        GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner))
232            .list_assigned_issues(remote)
233    }
234
235    fn fetch_issue_detail(
236        &self,
237        remote: ForgeRemote,
238        display_id: String,
239    ) -> ForgeFuture<Result<IssueDetail, ReviewRequestError>> {
240        if remote.forge_kind != ForgeKind::GitHub {
241            return Box::pin(async move {
242                Err(ReviewRequestError::OperationFailed {
243                    forge_kind: remote.forge_kind,
244                    message: "issue details require a GitHub project remote".to_string(),
245                })
246            });
247        }
248
249        GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner))
250            .fetch_issue_detail(remote, display_id)
251    }
252
253    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
254        detect_remote(&repo_url)
255    }
256
257    fn find_by_source_branch(
258        &self,
259        remote: ForgeRemote,
260        source_branch: String,
261    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
262        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
263            adapter.find_authenticated_by_source_branch(remote, source_branch)
264        })
265    }
266
267    fn create_review_request(
268        &self,
269        remote: ForgeRemote,
270        input: CreateReviewRequestInput,
271    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
272        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
273            adapter.create_authenticated_review_request(remote, input)
274        })
275    }
276
277    fn refresh_review_request(
278        &self,
279        remote: ForgeRemote,
280        display_id: String,
281    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
282        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
283            adapter.refresh_authenticated_review_request(remote, display_id)
284        })
285    }
286
287    fn review_request_metadata(
288        &self,
289        remote: ForgeRemote,
290        display_id: String,
291    ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>> {
292        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
293            adapter.authenticated_review_request_metadata(remote, display_id)
294        })
295    }
296
297    fn sync_review_request_metadata(
298        &self,
299        remote: ForgeRemote,
300        display_id: String,
301        input: UpdateReviewRequestInput,
302    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
303        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
304            adapter.sync_authenticated_review_request_metadata(remote, display_id, input)
305        })
306    }
307
308    fn review_request_web_url(
309        &self,
310        review_request: &ReviewRequestSummary,
311    ) -> Result<String, ReviewRequestError> {
312        if review_request.web_url.trim().is_empty() {
313            return Err(ReviewRequestError::OperationFailed {
314                forge_kind: review_request.forge_kind,
315                message: "review request summary is missing a web URL".to_string(),
316            });
317        }
318
319        Ok(review_request.web_url.clone())
320    }
321
322    fn fetch_review_comment_snapshot(
323        &self,
324        remote: ForgeRemote,
325        display_id: String,
326    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
327        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
328            adapter.fetch_authenticated_review_comment_snapshot(remote, display_id)
329        })
330    }
331
332    fn reply_to_thread(
333        &self,
334        remote: ForgeRemote,
335        display_id: String,
336        thread_id: String,
337        body: String,
338    ) -> ForgeFuture<Result<(), ReviewRequestError>> {
339        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
340            adapter.reply_to_authenticated_thread(remote, display_id, thread_id, body)
341        })
342    }
343
344    fn resolve_thread(
345        &self,
346        remote: ForgeRemote,
347        display_id: String,
348        thread_id: String,
349    ) -> ForgeFuture<Result<(), ReviewRequestError>> {
350        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
351            adapter.resolve_authenticated_thread(remote, display_id, thread_id)
352        })
353    }
354
355    fn list_requested_reviews(
356        &self,
357        remote: ForgeRemote,
358    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>> {
359        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
360            adapter.list_authenticated_requested_reviews(remote)
361        })
362    }
363}
364
365/// Provider-specific operation boundary used after client-level authentication.
366///
367/// The production client selects one implementation, calls
368/// [`ReviewRequestAdapter::ensure_authenticated`] once, and then invokes the
369/// requested operation without provider-specific dispatch in each public
370/// method.
371pub(crate) trait ReviewRequestAdapter: Send + Sync {
372    /// Verifies that CLI authentication succeeds for `remote`.
373    ///
374    /// # Errors
375    /// Returns a provider-specific review-request error when the forge CLI is
376    /// unavailable, unauthenticated, or cannot resolve the target host.
377    fn ensure_authenticated(
378        &self,
379        remote: &ForgeRemote,
380    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
381
382    /// Finds one review request after the production client has authenticated.
383    fn find_authenticated_by_source_branch(
384        &self,
385        remote: ForgeRemote,
386        source_branch: String,
387    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
388
389    /// Creates one review request after the production client has
390    /// authenticated.
391    fn create_authenticated_review_request(
392        &self,
393        remote: ForgeRemote,
394        input: CreateReviewRequestInput,
395    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
396
397    /// Refreshes one existing review request after authentication.
398    fn refresh_authenticated_review_request(
399        &self,
400        remote: ForgeRemote,
401        display_id: String,
402    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
403
404    /// Loads current review-request metadata after authentication.
405    fn authenticated_review_request_metadata(
406        &self,
407        remote: ForgeRemote,
408        display_id: String,
409    ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
410
411    /// Synchronizes review-request metadata after authentication.
412    fn sync_authenticated_review_request_metadata(
413        &self,
414        remote: ForgeRemote,
415        display_id: String,
416        input: UpdateReviewRequestInput,
417    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
418
419    /// Fetches a review-comment snapshot after authentication.
420    fn fetch_authenticated_review_comment_snapshot(
421        &self,
422        remote: ForgeRemote,
423        display_id: String,
424    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
425
426    /// Adds one reply after authentication.
427    fn reply_to_authenticated_thread(
428        &self,
429        remote: ForgeRemote,
430        display_id: String,
431        thread_id: String,
432        body: String,
433    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
434
435    /// Resolves one review thread after authentication.
436    fn resolve_authenticated_thread(
437        &self,
438        remote: ForgeRemote,
439        display_id: String,
440        thread_id: String,
441    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
442
443    /// Lists requested reviews after authentication.
444    fn list_authenticated_requested_reviews(
445        &self,
446        remote: ForgeRemote,
447    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
448}
449
450#[cfg(test)]
451mod tests {
452    use mockall::Sequence;
453
454    use super::*;
455    use crate::command::{ForgeCommand, ForgeCommandOutput, MockForgeCommandRunner};
456    use crate::{ForgeKind, ReviewRequestState};
457
458    #[test]
459    fn review_request_web_url_returns_error_when_summary_is_missing_url() {
460        // Arrange
461        let client = RealReviewRequestClient::default();
462        let review_request = ReviewRequestSummary {
463            display_id: "#42".to_string(),
464            forge_kind: ForgeKind::GitHub,
465            source_branch: "feature/forge".to_string(),
466            state: ReviewRequestState::Open,
467            status_summary: Some("Mergeable".to_string()),
468            target_branch: "main".to_string(),
469            title: "Add forge boundary".to_string(),
470            web_url: String::new(),
471        };
472
473        // Act
474        let error = client
475            .review_request_web_url(&review_request)
476            .expect_err("missing URL should be rejected");
477
478        // Assert
479        assert_eq!(
480            error,
481            ReviewRequestError::OperationFailed {
482                forge_kind: ForgeKind::GitHub,
483                message: "review request summary is missing a web URL".to_string(),
484            }
485        );
486    }
487
488    #[test]
489    fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
490        // Arrange
491        let client = RealReviewRequestClient::default();
492        let review_request = ReviewRequestSummary {
493            display_id: "!42".to_string(),
494            forge_kind: ForgeKind::GitLab,
495            source_branch: "feature/forge".to_string(),
496            state: ReviewRequestState::Open,
497            status_summary: Some("Draft".to_string()),
498            target_branch: "main".to_string(),
499            title: "Add forge boundary".to_string(),
500            web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
501        };
502
503        // Act
504        let web_url = client
505            .review_request_web_url(&review_request)
506            .expect("gitlab review-request URL should be returned directly");
507
508        // Assert
509        assert_eq!(
510            web_url,
511            "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
512        );
513    }
514
515    #[tokio::test]
516    async fn find_by_source_branch_authenticates_once_before_github_lookup() {
517        // Arrange
518        let remote = github_remote();
519        let mut sequence = Sequence::new();
520        let mut command_runner = MockForgeCommandRunner::new();
521        command_runner
522            .expect_run()
523            .once()
524            .in_sequence(&mut sequence)
525            .withf(|command| {
526                command_arguments_are(
527                    command,
528                    "gh",
529                    &["auth", "status", "--hostname", "github.com"],
530                )
531            })
532            .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
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                        "api",
543                        "--hostname",
544                        "github.com",
545                        "--method",
546                        "GET",
547                        "repos/agentty-xyz/agentty/pulls",
548                        "-f",
549                        "head=agentty-xyz:feature/forge",
550                        "-f",
551                        "state=open",
552                        "-f",
553                        "sort=created",
554                        "-f",
555                        "direction=desc",
556                        "-f",
557                        "per_page=1",
558                    ],
559                )
560            })
561            .returning(|_| {
562                Box::pin(async { Ok(success_output(r#"[{"number":42}]"#.to_string())) })
563            });
564        command_runner
565            .expect_run()
566            .once()
567            .in_sequence(&mut sequence)
568            .withf(|command| {
569                command_arguments_are(
570                    command,
571                    "gh",
572                    &[
573                        "pr",
574                        "view",
575                        "42",
576                        "--repo",
577                        "agentty-xyz/agentty",
578                        "--json",
579                        "number,title,state,url,baseRefName,headRefName,isDraft,mergeStateStatus,\
580                         reviewDecision,mergedAt",
581                    ],
582                )
583            })
584            .returning(|_| Box::pin(async { Ok(success_output(github_view_json())) }));
585        let client = RealReviewRequestClient::new(Arc::new(command_runner));
586
587        // Act
588        let review_request = client
589            .find_by_source_branch(remote, "feature/forge".to_string())
590            .await
591            .expect("GitHub lookup should succeed");
592
593        // Assert
594        assert_eq!(
595            review_request,
596            Some(ReviewRequestSummary {
597                display_id: "#42".to_string(),
598                forge_kind: ForgeKind::GitHub,
599                source_branch: "feature/forge".to_string(),
600                state: ReviewRequestState::Open,
601                status_summary: Some("Approved, Mergeable".to_string()),
602                target_branch: "main".to_string(),
603                title: "Add forge review support".to_string(),
604                web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
605            })
606        );
607    }
608
609    #[tokio::test]
610    async fn review_request_metadata_authenticates_before_github_lookup() {
611        // Arrange
612        let remote = github_remote();
613        let mut sequence = Sequence::new();
614        let mut command_runner = MockForgeCommandRunner::new();
615        command_runner
616            .expect_run()
617            .once()
618            .in_sequence(&mut sequence)
619            .withf(|command| {
620                command_arguments_are(
621                    command,
622                    "gh",
623                    &["auth", "status", "--hostname", "github.com"],
624                )
625            })
626            .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
627        command_runner
628            .expect_run()
629            .once()
630            .in_sequence(&mut sequence)
631            .withf(|command| {
632                command_arguments_are(
633                    command,
634                    "gh",
635                    &[
636                        "pr",
637                        "view",
638                        "42",
639                        "--repo",
640                        "agentty-xyz/agentty",
641                        "--json",
642                        "title,body",
643                    ],
644                )
645            })
646            .returning(|_| {
647                Box::pin(async {
648                    Ok(success_output(
649                        r#"{"title":"Current title","body":"Current body"}"#.to_string(),
650                    ))
651                })
652            });
653        let client = RealReviewRequestClient::new(Arc::new(command_runner));
654
655        // Act
656        let metadata = client
657            .review_request_metadata(remote, "#42".to_string())
658            .await
659            .expect("GitHub metadata lookup should succeed");
660
661        // Assert
662        assert_eq!(
663            metadata,
664            ReviewRequestMetadata {
665                body: "Current body".to_string(),
666                title: "Current title".to_string(),
667            }
668        );
669    }
670
671    #[tokio::test]
672    async fn refresh_review_request_stops_on_github_authentication_error() {
673        // Arrange
674        let remote = github_remote();
675        let mut command_runner = MockForgeCommandRunner::new();
676        command_runner
677            .expect_run()
678            .once()
679            .withf(|command| {
680                command_arguments_are(
681                    command,
682                    "gh",
683                    &["auth", "status", "--hostname", "github.com"],
684                )
685            })
686            .returning(|_| {
687                Box::pin(async {
688                    Ok(failure_output(
689                        "You are not logged into any GitHub hosts. Run `gh auth login`."
690                            .to_string(),
691                    ))
692                })
693            });
694        let client = RealReviewRequestClient::new(Arc::new(command_runner));
695
696        // Act
697        let error = client
698            .refresh_review_request(remote, "#42".to_string())
699            .await
700            .expect_err("missing auth should stop before refresh");
701
702        // Assert
703        assert_eq!(
704            error,
705            ReviewRequestError::AuthenticationRequired {
706                detail: Some(
707                    "You are not logged into any GitHub hosts. Run `gh auth login`.".to_string()
708                ),
709                forge_kind: ForgeKind::GitHub,
710                host: "github.com".to_string(),
711            }
712        );
713    }
714
715    #[tokio::test]
716    async fn review_thread_mutations_authenticate_and_route_to_github_adapter() {
717        // Arrange
718        let remote = github_remote();
719        let mut sequence = Sequence::new();
720        let mut command_runner = MockForgeCommandRunner::new();
721        for expected_mutation in ["addPullRequestReviewThreadReply", "resolveReviewThread"] {
722            command_runner
723                .expect_run()
724                .once()
725                .in_sequence(&mut sequence)
726                .withf(|command| {
727                    command_arguments_are(
728                        command,
729                        "gh",
730                        &["auth", "status", "--hostname", "github.com"],
731                    )
732                })
733                .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
734            command_runner
735                .expect_run()
736                .once()
737                .in_sequence(&mut sequence)
738                .withf(move |command| {
739                    command.executable == "gh"
740                        && command
741                            .arguments
742                            .iter()
743                            .any(|argument| argument.contains(expected_mutation))
744                })
745                .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
746        }
747        let client = RealReviewRequestClient::new(Arc::new(command_runner));
748
749        // Act
750        let reply_result = client
751            .reply_to_thread(
752                remote.clone(),
753                "#42".to_string(),
754                "thread-1".to_string(),
755                "Addressed.".to_string(),
756            )
757            .await;
758        let resolution_result = client
759            .resolve_thread(remote, "#42".to_string(), "thread-1".to_string())
760            .await;
761
762        // Assert
763        assert_eq!(reply_result, Ok(()));
764        assert_eq!(resolution_result, Ok(()));
765    }
766
767    #[tokio::test]
768    async fn refresh_review_request_authenticates_before_gitlab_refresh() {
769        // Arrange
770        let remote = gitlab_remote();
771        let mut sequence = Sequence::new();
772        let mut command_runner = MockForgeCommandRunner::new();
773        command_runner
774            .expect_run()
775            .once()
776            .in_sequence(&mut sequence)
777            .withf(|command| {
778                command_arguments_are(
779                    command,
780                    "glab",
781                    &["auth", "status", "--hostname", "gitlab.com"],
782                )
783            })
784            .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
785        command_runner
786            .expect_run()
787            .once()
788            .in_sequence(&mut sequence)
789            .withf(|command| {
790                command_arguments_are(
791                    command,
792                    "glab",
793                    &[
794                        "mr",
795                        "view",
796                        "42",
797                        "--repo",
798                        "https://gitlab.com/agentty-xyz/agentty",
799                        "--output",
800                        "json",
801                    ],
802                )
803            })
804            .returning(|_| Box::pin(async { Ok(success_output(gitlab_view_json())) }));
805        let client = RealReviewRequestClient::new(Arc::new(command_runner));
806
807        // Act
808        let review_request = client
809            .refresh_review_request(remote, "!42".to_string())
810            .await
811            .expect("GitLab refresh should succeed");
812
813        // Assert
814        assert_eq!(review_request.display_id, "!42");
815        assert_eq!(review_request.forge_kind, ForgeKind::GitLab);
816    }
817
818    /// Returns whether `command` exactly matches one expected CLI invocation.
819    fn command_arguments_are(
820        command: &ForgeCommand,
821        executable: &'static str,
822        arguments: &[&str],
823    ) -> bool {
824        let expected_arguments = arguments
825            .iter()
826            .map(|argument| (*argument).to_string())
827            .collect::<Vec<_>>();
828
829        command.executable == executable && command.arguments == expected_arguments
830    }
831
832    /// Builds one normalized GitHub remote for client routing tests.
833    fn github_remote() -> ForgeRemote {
834        ForgeRemote {
835            command_working_directory: None,
836            forge_kind: ForgeKind::GitHub,
837            host: "github.com".to_string(),
838            namespace: "agentty-xyz".to_string(),
839            project: "agentty".to_string(),
840            repo_url: "https://github.com/agentty-xyz/agentty.git".to_string(),
841            web_url: "https://github.com/agentty-xyz/agentty".to_string(),
842        }
843    }
844
845    /// Builds one normalized GitLab remote for client routing tests.
846    fn gitlab_remote() -> ForgeRemote {
847        ForgeRemote {
848            command_working_directory: None,
849            forge_kind: ForgeKind::GitLab,
850            host: "gitlab.com".to_string(),
851            namespace: "agentty-xyz".to_string(),
852            project: "agentty".to_string(),
853            repo_url: "https://gitlab.com/agentty-xyz/agentty.git".to_string(),
854            web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
855        }
856    }
857
858    /// Builds one successful command output with `stdout`.
859    fn success_output(stdout: String) -> ForgeCommandOutput {
860        ForgeCommandOutput {
861            exit_code: Some(0),
862            stderr: String::new(),
863            stdout,
864        }
865    }
866
867    /// Builds one failed command output with `stderr`.
868    fn failure_output(stderr: String) -> ForgeCommandOutput {
869        ForgeCommandOutput {
870            exit_code: Some(1),
871            stderr,
872            stdout: String::new(),
873        }
874    }
875
876    /// Returns one representative GitHub pull-request JSON response.
877    fn github_view_json() -> String {
878        r#"{
879            "number": 42,
880            "title": "Add forge review support",
881            "state": "OPEN",
882            "url": "https://github.com/agentty-xyz/agentty/pull/42",
883            "baseRefName": "main",
884            "headRefName": "feature/forge",
885            "isDraft": false,
886            "mergeStateStatus": "CLEAN",
887            "reviewDecision": "APPROVED",
888            "mergedAt": null
889        }"#
890        .to_string()
891    }
892
893    /// Returns one representative GitLab merge-request JSON response.
894    fn gitlab_view_json() -> String {
895        r#"{
896            "draft": true,
897            "detailed_merge_status": "can_be_merged",
898            "iid": 42,
899            "merge_status": "can_be_merged",
900            "merged_at": null,
901            "source_branch": "feature/forge",
902            "state": "opened",
903            "target_branch": "main",
904            "title": "Add forge review support",
905            "description": "Current description.",
906            "web_url": "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
907        }"#
908        .to_string()
909    }
910}