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    CreateReviewRequestInput, ForgeCommandRunner, ForgeFuture, ForgeKind, ForgeRemote,
7    GitHubReviewRequestAdapter, GitLabReviewRequestAdapter, RealForgeCommandRunner,
8    ReviewCommentSnapshot, ReviewRequestError, ReviewRequestMetadata, ReviewRequestSummary,
9    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    /// Detects whether `repo_url` belongs to one supported forge.
19    ///
20    /// # Errors
21    /// Returns [`ReviewRequestError::UnsupportedRemote`] when the remote does
22    /// not map to a supported forge.
23    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError>;
24
25    /// Finds an existing review request for `source_branch`.
26    ///
27    /// # Errors
28    /// Returns a provider-specific review-request error when the forge lookup
29    /// cannot be completed.
30    fn find_by_source_branch(
31        &self,
32        remote: ForgeRemote,
33        source_branch: String,
34    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
35
36    /// Creates a new review request from `input`.
37    ///
38    /// # Errors
39    /// Returns a provider-specific review-request error when creation fails.
40    fn create_review_request(
41        &self,
42        remote: ForgeRemote,
43        input: CreateReviewRequestInput,
44    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
45
46    /// Refreshes one existing review request by provider display id.
47    ///
48    /// # Errors
49    /// Returns a provider-specific review-request error when refresh fails.
50    fn refresh_review_request(
51        &self,
52        remote: ForgeRemote,
53        display_id: String,
54    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
55
56    /// Loads the current title and body of one existing review request.
57    ///
58    /// # Errors
59    /// Returns a provider-specific review-request error when metadata lookup
60    /// fails.
61    fn review_request_metadata(
62        &self,
63        remote: ForgeRemote,
64        display_id: String,
65    ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
66
67    /// Best-effort syncs reconciled metadata after rechecking that the remote
68    /// fields match the values used during evaluation.
69    ///
70    /// The provider CLI update is not atomic with the recheck, so a later
71    /// concurrent manual edit can still be overwritten.
72    ///
73    /// # Errors
74    /// Returns a provider-specific review-request error when metadata lookup,
75    /// update, or refresh fails.
76    fn sync_review_request_metadata(
77        &self,
78        remote: ForgeRemote,
79        display_id: String,
80        input: UpdateReviewRequestInput,
81    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
82
83    /// Returns the browser-openable URL for one review request.
84    ///
85    /// # Errors
86    /// Returns [`ReviewRequestError::OperationFailed`] when the summary does
87    /// not carry a web URL.
88    fn review_request_web_url(
89        &self,
90        review_request: &ReviewRequestSummary,
91    ) -> Result<String, ReviewRequestError>;
92
93    /// Fetches the review-comment snapshot for one open review request.
94    ///
95    /// Returns both inline threads and review-request-wide comments. Threads
96    /// are grouped by `path` and sorted by `(path, line)` by callers; adapters
97    /// return what the forge reports without enforcing an ordering.
98    ///
99    /// # Errors
100    /// Returns a provider-specific review-request error when the snapshot fetch
101    /// cannot be completed (including authentication and host failures).
102    fn fetch_review_comment_snapshot(
103        &self,
104        remote: ForgeRemote,
105        display_id: String,
106    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
107
108    /// Adds one reply to an existing review thread.
109    ///
110    /// # Errors
111    /// Returns a provider-specific review-request error when the reply cannot
112    /// be posted.
113    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    /// Marks one existing review thread resolved.
122    ///
123    /// # Errors
124    /// Returns a provider-specific review-request error when the thread cannot
125    /// be resolved.
126    fn resolve_thread(
127        &self,
128        remote: ForgeRemote,
129        display_id: String,
130        thread_id: String,
131    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
132}
133
134/// Production [`ReviewRequestClient`] that routes to forge-specific adapters.
135pub struct RealReviewRequestClient {
136    command_runner: Arc<dyn ForgeCommandRunner>,
137}
138
139impl RealReviewRequestClient {
140    /// Builds one review-request client from a forge command runner.
141    pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
142        Self { command_runner }
143    }
144
145    /// Runs `call` on an authenticated adapter selected for `remote`.
146    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    /// Returns one adapter implementation for `forge_kind`.
169    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
291/// Provider-specific operation boundary used after client-level authentication.
292///
293/// The production client selects one implementation, calls
294/// [`ReviewRequestAdapter::ensure_authenticated`] once, and then invokes the
295/// requested operation without provider-specific dispatch in each public
296/// method.
297pub(crate) trait ReviewRequestAdapter: Send + Sync {
298    /// Verifies that CLI authentication succeeds for `remote`.
299    ///
300    /// # Errors
301    /// Returns a provider-specific review-request error when the forge CLI is
302    /// unavailable, unauthenticated, or cannot resolve the target host.
303    fn ensure_authenticated(
304        &self,
305        remote: &ForgeRemote,
306    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
307
308    /// Finds one review request after the production client has authenticated.
309    fn find_authenticated_by_source_branch(
310        &self,
311        remote: ForgeRemote,
312        source_branch: String,
313    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
314
315    /// Creates one review request after the production client has
316    /// authenticated.
317    fn create_authenticated_review_request(
318        &self,
319        remote: ForgeRemote,
320        input: CreateReviewRequestInput,
321    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
322
323    /// Refreshes one existing review request after authentication.
324    fn refresh_authenticated_review_request(
325        &self,
326        remote: ForgeRemote,
327        display_id: String,
328    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
329
330    /// Loads current review-request metadata after authentication.
331    fn authenticated_review_request_metadata(
332        &self,
333        remote: ForgeRemote,
334        display_id: String,
335    ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
336
337    /// Synchronizes review-request metadata after authentication.
338    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    /// Fetches a review-comment snapshot after authentication.
346    fn fetch_authenticated_review_comment_snapshot(
347        &self,
348        remote: ForgeRemote,
349        display_id: String,
350    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
351
352    /// Adds one reply after authentication.
353    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    /// Resolves one review thread after authentication.
362    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        // Arrange
381        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        // Act
394        let error = client
395            .review_request_web_url(&review_request)
396            .expect_err("missing URL should be rejected");
397
398        // Assert
399        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        // Arrange
411        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        // Act
424        let web_url = client
425            .review_request_web_url(&review_request)
426            .expect("gitlab review-request URL should be returned directly");
427
428        // Assert
429        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        // Arrange
438        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        // Act
508        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
514        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        // Arrange
532        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        // Act
576        let metadata = client
577            .review_request_metadata(remote, "#42".to_string())
578            .await
579            .expect("GitHub metadata lookup should succeed");
580
581        // Assert
582        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        // Arrange
594        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        // Act
617        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
623        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        // Arrange
638        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        // Act
670        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
683        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        // Arrange
690        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        // Act
728        let review_request = client
729            .refresh_review_request(remote, "!42".to_string())
730            .await
731            .expect("GitLab refresh should succeed");
732
733        // Assert
734        assert_eq!(review_request.display_id, "!42");
735        assert_eq!(review_request.forge_kind, ForgeKind::GitLab);
736    }
737
738    /// Returns whether `command` exactly matches one expected CLI invocation.
739    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    /// Builds one normalized GitHub remote for client routing tests.
753    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    /// Builds one normalized GitLab remote for client routing tests.
766    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    /// Builds one successful command output with `stdout`.
779    fn success_output(stdout: String) -> ForgeCommandOutput {
780        ForgeCommandOutput {
781            exit_code: Some(0),
782            stderr: String::new(),
783            stdout,
784        }
785    }
786
787    /// Builds one failed command output with `stderr`.
788    fn failure_output(stderr: String) -> ForgeCommandOutput {
789        ForgeCommandOutput {
790            exit_code: Some(1),
791            stderr,
792            stdout: String::new(),
793        }
794    }
795
796    /// Returns one representative GitHub pull-request JSON response.
797    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    /// Returns one representative GitLab merge-request JSON response.
814    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}