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    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    /// Syncs an existing review request title/body to `input` after checking
76    /// the current remote metadata.
77    ///
78    /// # Errors
79    /// Returns a provider-specific review-request error when metadata lookup,
80    /// update, or refresh fails.
81    fn sync_review_request_metadata(
82        &self,
83        remote: ForgeRemote,
84        display_id: String,
85        input: UpdateReviewRequestInput,
86    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
87
88    /// Returns the browser-openable URL for one review request.
89    ///
90    /// # Errors
91    /// Returns [`ReviewRequestError::OperationFailed`] when the summary does
92    /// not carry a web URL.
93    fn review_request_web_url(
94        &self,
95        review_request: &ReviewRequestSummary,
96    ) -> Result<String, ReviewRequestError>;
97
98    /// Fetches the review-comment snapshot for one open review request.
99    ///
100    /// Returns both inline threads and review-request-wide comments. Threads
101    /// are grouped by `path` and sorted by `(path, line)` by callers; adapters
102    /// return what the forge reports without enforcing an ordering.
103    ///
104    /// # Errors
105    /// Returns a provider-specific review-request error when the snapshot fetch
106    /// cannot be completed (including authentication and host failures).
107    fn fetch_review_comment_snapshot(
108        &self,
109        remote: ForgeRemote,
110        display_id: String,
111    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
112
113    /// Adds one reply to an existing review thread.
114    ///
115    /// # Errors
116    /// Returns a provider-specific review-request error when the reply cannot
117    /// be posted.
118    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    /// Marks one existing review thread resolved.
127    ///
128    /// # Errors
129    /// Returns a provider-specific review-request error when the thread cannot
130    /// be resolved.
131    fn resolve_thread(
132        &self,
133        remote: ForgeRemote,
134        display_id: String,
135        thread_id: String,
136    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
137
138    /// Lists open review requests asking the current authenticated user to
139    /// review the selected repository.
140    ///
141    /// # Errors
142    /// Returns a provider-specific review-request error when the list fetch
143    /// cannot be completed.
144    fn list_requested_reviews(
145        &self,
146        remote: ForgeRemote,
147    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
148}
149
150/// Production [`ReviewRequestClient`] that routes to forge-specific adapters.
151pub struct RealReviewRequestClient {
152    command_runner: Arc<dyn ForgeCommandRunner>,
153}
154
155impl RealReviewRequestClient {
156    /// Builds one review-request client from a forge command runner.
157    pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
158        Self { command_runner }
159    }
160
161    /// Runs `call` on an authenticated adapter selected for `remote`.
162    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    /// Returns one adapter implementation for `forge_kind`.
185    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
341/// Provider-specific operation boundary used after client-level authentication.
342///
343/// The production client selects one implementation, calls
344/// [`ReviewRequestAdapter::ensure_authenticated`] once, and then invokes the
345/// requested operation without provider-specific dispatch in each public
346/// method.
347pub(crate) trait ReviewRequestAdapter: Send + Sync {
348    /// Verifies that CLI authentication succeeds for `remote`.
349    ///
350    /// # Errors
351    /// Returns a provider-specific review-request error when the forge CLI is
352    /// unavailable, unauthenticated, or cannot resolve the target host.
353    fn ensure_authenticated(
354        &self,
355        remote: &ForgeRemote,
356    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
357
358    /// Finds one review request after the production client has authenticated.
359    fn find_authenticated_by_source_branch(
360        &self,
361        remote: ForgeRemote,
362        source_branch: String,
363    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
364
365    /// Creates one review request after the production client has
366    /// authenticated.
367    fn create_authenticated_review_request(
368        &self,
369        remote: ForgeRemote,
370        input: CreateReviewRequestInput,
371    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
372
373    /// Refreshes one existing review request after authentication.
374    fn refresh_authenticated_review_request(
375        &self,
376        remote: ForgeRemote,
377        display_id: String,
378    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
379
380    /// Synchronizes review-request metadata after authentication.
381    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    /// Fetches a review-comment snapshot after authentication.
389    fn fetch_authenticated_review_comment_snapshot(
390        &self,
391        remote: ForgeRemote,
392        display_id: String,
393    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
394
395    /// Adds one reply after authentication.
396    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    /// Resolves one review thread after authentication.
405    fn resolve_authenticated_thread(
406        &self,
407        remote: ForgeRemote,
408        display_id: String,
409        thread_id: String,
410    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
411
412    /// Lists requested reviews after authentication.
413    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        // Arrange
430        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        // Act
443        let error = client
444            .review_request_web_url(&review_request)
445            .expect_err("missing URL should be rejected");
446
447        // Assert
448        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        // Arrange
460        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        // Act
473        let web_url = client
474            .review_request_web_url(&review_request)
475            .expect("gitlab review-request URL should be returned directly");
476
477        // Assert
478        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        // Arrange
487        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        // Act
557        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
563        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        // Arrange
581        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        // Act
604        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
610        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        // Arrange
625        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        // Act
657        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
670        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        // Arrange
677        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        // Act
715        let review_request = client
716            .refresh_review_request(remote, "!42".to_string())
717            .await
718            .expect("GitLab refresh should succeed");
719
720        // Assert
721        assert_eq!(review_request.display_id, "!42");
722        assert_eq!(review_request.forge_kind, ForgeKind::GitLab);
723    }
724
725    /// Returns whether `command` exactly matches one expected CLI invocation.
726    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    /// Builds one normalized GitHub remote for client routing tests.
740    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    /// Builds one normalized GitLab remote for client routing tests.
753    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    /// Builds one successful command output with `stdout`.
766    fn success_output(stdout: String) -> ForgeCommandOutput {
767        ForgeCommandOutput {
768            exit_code: Some(0),
769            stderr: String::new(),
770            stdout,
771        }
772    }
773
774    /// Builds one failed command output with `stderr`.
775    fn failure_output(stderr: String) -> ForgeCommandOutput {
776        ForgeCommandOutput {
777            exit_code: Some(1),
778            stderr,
779            stdout: String::new(),
780        }
781    }
782
783    /// Returns one representative GitHub pull-request JSON response.
784    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    /// Returns one representative GitLab merge-request JSON response.
801    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}