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    RequestedReview, ReviewCommentSnapshot, ReviewRequestError, 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    /// Syncs an existing review request title/body to `input` after checking
57    /// the current remote metadata.
58    ///
59    /// # Errors
60    /// Returns a provider-specific review-request error when metadata lookup,
61    /// update, or refresh fails.
62    fn sync_review_request_metadata(
63        &self,
64        remote: ForgeRemote,
65        display_id: String,
66        input: UpdateReviewRequestInput,
67    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
68
69    /// Returns the browser-openable URL for one review request.
70    ///
71    /// # Errors
72    /// Returns [`ReviewRequestError::OperationFailed`] when the summary does
73    /// not carry a web URL.
74    fn review_request_web_url(
75        &self,
76        review_request: &ReviewRequestSummary,
77    ) -> Result<String, ReviewRequestError>;
78
79    /// Fetches the review-comment snapshot for one open review request.
80    ///
81    /// Returns both inline threads and review-request-wide comments. Threads
82    /// are grouped by `path` and sorted by `(path, line)` by callers; adapters
83    /// return what the forge reports without enforcing an ordering.
84    ///
85    /// # Errors
86    /// Returns a provider-specific review-request error when the snapshot fetch
87    /// cannot be completed (including authentication and host failures).
88    fn fetch_review_comment_snapshot(
89        &self,
90        remote: ForgeRemote,
91        display_id: String,
92    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
93
94    /// Lists open review requests asking the current authenticated user to
95    /// review the selected repository.
96    ///
97    /// # Errors
98    /// Returns a provider-specific review-request error when the list fetch
99    /// cannot be completed.
100    fn list_requested_reviews(
101        &self,
102        remote: ForgeRemote,
103    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
104}
105
106/// Production [`ReviewRequestClient`] that routes to forge-specific adapters.
107pub struct RealReviewRequestClient {
108    command_runner: Arc<dyn ForgeCommandRunner>,
109}
110
111impl RealReviewRequestClient {
112    /// Builds one review-request client from a forge command runner.
113    pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
114        Self { command_runner }
115    }
116}
117
118impl Default for RealReviewRequestClient {
119    fn default() -> Self {
120        Self::new(Arc::new(RealForgeCommandRunner))
121    }
122}
123
124impl ReviewRequestClient for RealReviewRequestClient {
125    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
126        detect_remote(&repo_url)
127    }
128
129    fn find_by_source_branch(
130        &self,
131        remote: ForgeRemote,
132        source_branch: String,
133    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
134        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
135            adapter.find_authenticated_by_source_branch(remote, source_branch)
136        })
137    }
138
139    fn create_review_request(
140        &self,
141        remote: ForgeRemote,
142        input: CreateReviewRequestInput,
143    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
144        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
145            adapter.create_authenticated_review_request(remote, input)
146        })
147    }
148
149    fn refresh_review_request(
150        &self,
151        remote: ForgeRemote,
152        display_id: String,
153    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
154        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
155            adapter.refresh_authenticated_review_request(remote, display_id)
156        })
157    }
158
159    fn sync_review_request_metadata(
160        &self,
161        remote: ForgeRemote,
162        display_id: String,
163        input: UpdateReviewRequestInput,
164    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
165        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
166            adapter.sync_authenticated_review_request_metadata(remote, display_id, input)
167        })
168    }
169
170    fn review_request_web_url(
171        &self,
172        review_request: &ReviewRequestSummary,
173    ) -> Result<String, ReviewRequestError> {
174        if review_request.web_url.trim().is_empty() {
175            return Err(ReviewRequestError::OperationFailed {
176                forge_kind: review_request.forge_kind,
177                message: "review request summary is missing a web URL".to_string(),
178            });
179        }
180
181        Ok(review_request.web_url.clone())
182    }
183
184    fn fetch_review_comment_snapshot(
185        &self,
186        remote: ForgeRemote,
187        display_id: String,
188    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
189        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
190            adapter.fetch_authenticated_review_comment_snapshot(remote, display_id)
191        })
192    }
193
194    fn list_requested_reviews(
195        &self,
196        remote: ForgeRemote,
197    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>> {
198        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
199            adapter.list_authenticated_requested_reviews(remote)
200        })
201    }
202}
203
204impl RealReviewRequestClient {
205    /// Returns one adapter implementation for `forge_kind`.
206    fn adapter_for(&self, forge_kind: ForgeKind) -> Arc<dyn ReviewRequestAdapter> {
207        match forge_kind {
208            ForgeKind::GitHub => Arc::new(GitHubReviewRequestAdapter::new(Arc::clone(
209                &self.command_runner,
210            ))),
211            ForgeKind::GitLab => Arc::new(GitLabReviewRequestAdapter::new(Arc::clone(
212                &self.command_runner,
213            ))),
214        }
215    }
216
217    /// Runs `call` on an authenticated adapter selected for `remote`.
218    fn call_with_authenticated_adapter<T>(
219        &self,
220        remote: ForgeRemote,
221        call: impl FnOnce(
222            Arc<dyn ReviewRequestAdapter>,
223            ForgeRemote,
224        ) -> ForgeFuture<Result<T, ReviewRequestError>>
225        + Send
226        + 'static,
227    ) -> ForgeFuture<Result<T, ReviewRequestError>>
228    where
229        T: Send + 'static,
230    {
231        let adapter = self.adapter_for(remote.forge_kind);
232
233        Box::pin(async move {
234            adapter.ensure_authenticated(&remote).await?;
235
236            call(adapter, remote).await
237        })
238    }
239}
240
241/// Provider-specific operation boundary used after client-level authentication.
242///
243/// The production client selects one implementation, calls
244/// [`ReviewRequestAdapter::ensure_authenticated`] once, and then invokes the
245/// requested operation without provider-specific dispatch in each public
246/// method.
247pub(crate) trait ReviewRequestAdapter: Send + Sync {
248    /// Verifies that CLI authentication succeeds for `remote`.
249    ///
250    /// # Errors
251    /// Returns a provider-specific review-request error when the forge CLI is
252    /// unavailable, unauthenticated, or cannot resolve the target host.
253    fn ensure_authenticated(
254        &self,
255        remote: &ForgeRemote,
256    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
257
258    /// Finds one review request after the production client has authenticated.
259    fn find_authenticated_by_source_branch(
260        &self,
261        remote: ForgeRemote,
262        source_branch: String,
263    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
264
265    /// Creates one review request after the production client has
266    /// authenticated.
267    fn create_authenticated_review_request(
268        &self,
269        remote: ForgeRemote,
270        input: CreateReviewRequestInput,
271    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
272
273    /// Refreshes one existing review request after authentication.
274    fn refresh_authenticated_review_request(
275        &self,
276        remote: ForgeRemote,
277        display_id: String,
278    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
279
280    /// Synchronizes review-request metadata after authentication.
281    fn sync_authenticated_review_request_metadata(
282        &self,
283        remote: ForgeRemote,
284        display_id: String,
285        input: UpdateReviewRequestInput,
286    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
287
288    /// Fetches a review-comment snapshot after authentication.
289    fn fetch_authenticated_review_comment_snapshot(
290        &self,
291        remote: ForgeRemote,
292        display_id: String,
293    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
294
295    /// Lists requested reviews after authentication.
296    fn list_authenticated_requested_reviews(
297        &self,
298        remote: ForgeRemote,
299    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
300}
301
302#[cfg(test)]
303mod tests {
304    use mockall::Sequence;
305
306    use super::*;
307    use crate::command::{ForgeCommand, ForgeCommandOutput, MockForgeCommandRunner};
308    use crate::{ForgeKind, ReviewRequestState};
309
310    #[test]
311    fn review_request_web_url_returns_error_when_summary_is_missing_url() {
312        // Arrange
313        let client = RealReviewRequestClient::default();
314        let review_request = ReviewRequestSummary {
315            display_id: "#42".to_string(),
316            forge_kind: ForgeKind::GitHub,
317            source_branch: "feature/forge".to_string(),
318            state: ReviewRequestState::Open,
319            status_summary: Some("Mergeable".to_string()),
320            target_branch: "main".to_string(),
321            title: "Add forge boundary".to_string(),
322            web_url: String::new(),
323        };
324
325        // Act
326        let error = client
327            .review_request_web_url(&review_request)
328            .expect_err("missing URL should be rejected");
329
330        // Assert
331        assert_eq!(
332            error,
333            ReviewRequestError::OperationFailed {
334                forge_kind: ForgeKind::GitHub,
335                message: "review request summary is missing a web URL".to_string(),
336            }
337        );
338    }
339
340    #[test]
341    fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
342        // Arrange
343        let client = RealReviewRequestClient::default();
344        let review_request = ReviewRequestSummary {
345            display_id: "!42".to_string(),
346            forge_kind: ForgeKind::GitLab,
347            source_branch: "feature/forge".to_string(),
348            state: ReviewRequestState::Open,
349            status_summary: Some("Draft".to_string()),
350            target_branch: "main".to_string(),
351            title: "Add forge boundary".to_string(),
352            web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
353        };
354
355        // Act
356        let web_url = client
357            .review_request_web_url(&review_request)
358            .expect("gitlab review-request URL should be returned directly");
359
360        // Assert
361        assert_eq!(
362            web_url,
363            "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
364        );
365    }
366
367    #[tokio::test]
368    async fn find_by_source_branch_authenticates_once_before_github_lookup() {
369        // Arrange
370        let remote = github_remote();
371        let mut sequence = Sequence::new();
372        let mut command_runner = MockForgeCommandRunner::new();
373        command_runner
374            .expect_run()
375            .once()
376            .in_sequence(&mut sequence)
377            .withf(|command| {
378                command_arguments_are(
379                    command,
380                    "gh",
381                    &["auth", "status", "--hostname", "github.com"],
382                )
383            })
384            .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
385        command_runner
386            .expect_run()
387            .once()
388            .in_sequence(&mut sequence)
389            .withf(|command| {
390                command_arguments_are(
391                    command,
392                    "gh",
393                    &[
394                        "api",
395                        "--hostname",
396                        "github.com",
397                        "--method",
398                        "GET",
399                        "repos/agentty-xyz/agentty/pulls",
400                        "-f",
401                        "head=agentty-xyz:feature/forge",
402                        "-f",
403                        "state=open",
404                        "-f",
405                        "sort=created",
406                        "-f",
407                        "direction=desc",
408                        "-f",
409                        "per_page=1",
410                    ],
411                )
412            })
413            .returning(|_| {
414                Box::pin(async { Ok(success_output(r#"[{"number":42}]"#.to_string())) })
415            });
416        command_runner
417            .expect_run()
418            .once()
419            .in_sequence(&mut sequence)
420            .withf(|command| {
421                command_arguments_are(
422                    command,
423                    "gh",
424                    &[
425                        "pr",
426                        "view",
427                        "42",
428                        "--repo",
429                        "agentty-xyz/agentty",
430                        "--json",
431                        "number,title,state,url,baseRefName,headRefName,isDraft,mergeStateStatus,\
432                         reviewDecision,mergedAt",
433                    ],
434                )
435            })
436            .returning(|_| Box::pin(async { Ok(success_output(github_view_json())) }));
437        let client = RealReviewRequestClient::new(Arc::new(command_runner));
438
439        // Act
440        let review_request = client
441            .find_by_source_branch(remote, "feature/forge".to_string())
442            .await
443            .expect("GitHub lookup should succeed");
444
445        // Assert
446        assert_eq!(
447            review_request,
448            Some(ReviewRequestSummary {
449                display_id: "#42".to_string(),
450                forge_kind: ForgeKind::GitHub,
451                source_branch: "feature/forge".to_string(),
452                state: ReviewRequestState::Open,
453                status_summary: Some("Approved, Mergeable".to_string()),
454                target_branch: "main".to_string(),
455                title: "Add forge review support".to_string(),
456                web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
457            })
458        );
459    }
460
461    #[tokio::test]
462    async fn refresh_review_request_stops_on_github_authentication_error() {
463        // Arrange
464        let remote = github_remote();
465        let mut command_runner = MockForgeCommandRunner::new();
466        command_runner
467            .expect_run()
468            .once()
469            .withf(|command| {
470                command_arguments_are(
471                    command,
472                    "gh",
473                    &["auth", "status", "--hostname", "github.com"],
474                )
475            })
476            .returning(|_| {
477                Box::pin(async {
478                    Ok(failure_output(
479                        "You are not logged into any GitHub hosts. Run `gh auth login`."
480                            .to_string(),
481                    ))
482                })
483            });
484        let client = RealReviewRequestClient::new(Arc::new(command_runner));
485
486        // Act
487        let error = client
488            .refresh_review_request(remote, "#42".to_string())
489            .await
490            .expect_err("missing auth should stop before refresh");
491
492        // Assert
493        assert_eq!(
494            error,
495            ReviewRequestError::AuthenticationRequired {
496                detail: Some(
497                    "You are not logged into any GitHub hosts. Run `gh auth login`.".to_string()
498                ),
499                forge_kind: ForgeKind::GitHub,
500                host: "github.com".to_string(),
501            }
502        );
503    }
504
505    #[tokio::test]
506    async fn refresh_review_request_authenticates_before_gitlab_refresh() {
507        // Arrange
508        let remote = gitlab_remote();
509        let mut sequence = Sequence::new();
510        let mut command_runner = MockForgeCommandRunner::new();
511        command_runner
512            .expect_run()
513            .once()
514            .in_sequence(&mut sequence)
515            .withf(|command| {
516                command_arguments_are(
517                    command,
518                    "glab",
519                    &["auth", "status", "--hostname", "gitlab.com"],
520                )
521            })
522            .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
523        command_runner
524            .expect_run()
525            .once()
526            .in_sequence(&mut sequence)
527            .withf(|command| {
528                command_arguments_are(
529                    command,
530                    "glab",
531                    &[
532                        "mr",
533                        "view",
534                        "42",
535                        "--repo",
536                        "https://gitlab.com/agentty-xyz/agentty",
537                        "--output",
538                        "json",
539                    ],
540                )
541            })
542            .returning(|_| Box::pin(async { Ok(success_output(gitlab_view_json())) }));
543        let client = RealReviewRequestClient::new(Arc::new(command_runner));
544
545        // Act
546        let review_request = client
547            .refresh_review_request(remote, "!42".to_string())
548            .await
549            .expect("GitLab refresh should succeed");
550
551        // Assert
552        assert_eq!(review_request.display_id, "!42");
553        assert_eq!(review_request.forge_kind, ForgeKind::GitLab);
554    }
555
556    /// Returns whether `command` exactly matches one expected CLI invocation.
557    fn command_arguments_are(
558        command: &ForgeCommand,
559        executable: &'static str,
560        arguments: &[&str],
561    ) -> bool {
562        let expected_arguments = arguments
563            .iter()
564            .map(|argument| (*argument).to_string())
565            .collect::<Vec<_>>();
566
567        command.executable == executable && command.arguments == expected_arguments
568    }
569
570    /// Builds one normalized GitHub remote for client routing tests.
571    fn github_remote() -> ForgeRemote {
572        ForgeRemote {
573            command_working_directory: None,
574            forge_kind: ForgeKind::GitHub,
575            host: "github.com".to_string(),
576            namespace: "agentty-xyz".to_string(),
577            project: "agentty".to_string(),
578            repo_url: "https://github.com/agentty-xyz/agentty.git".to_string(),
579            web_url: "https://github.com/agentty-xyz/agentty".to_string(),
580        }
581    }
582
583    /// Builds one normalized GitLab remote for client routing tests.
584    fn gitlab_remote() -> ForgeRemote {
585        ForgeRemote {
586            command_working_directory: None,
587            forge_kind: ForgeKind::GitLab,
588            host: "gitlab.com".to_string(),
589            namespace: "agentty-xyz".to_string(),
590            project: "agentty".to_string(),
591            repo_url: "https://gitlab.com/agentty-xyz/agentty.git".to_string(),
592            web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
593        }
594    }
595
596    /// Builds one successful command output with `stdout`.
597    fn success_output(stdout: String) -> ForgeCommandOutput {
598        ForgeCommandOutput {
599            exit_code: Some(0),
600            stderr: String::new(),
601            stdout,
602        }
603    }
604
605    /// Builds one failed command output with `stderr`.
606    fn failure_output(stderr: String) -> ForgeCommandOutput {
607        ForgeCommandOutput {
608            exit_code: Some(1),
609            stderr,
610            stdout: String::new(),
611        }
612    }
613
614    /// Returns one representative GitHub pull-request JSON response.
615    fn github_view_json() -> String {
616        r#"{
617            "number": 42,
618            "title": "Add forge review support",
619            "state": "OPEN",
620            "url": "https://github.com/agentty-xyz/agentty/pull/42",
621            "baseRefName": "main",
622            "headRefName": "feature/forge",
623            "isDraft": false,
624            "mergeStateStatus": "CLEAN",
625            "reviewDecision": "APPROVED",
626            "mergedAt": null
627        }"#
628        .to_string()
629    }
630
631    /// Returns one representative GitLab merge-request JSON response.
632    fn gitlab_view_json() -> String {
633        r#"{
634            "draft": true,
635            "detailed_merge_status": "can_be_merged",
636            "iid": 42,
637            "merge_status": "can_be_merged",
638            "merged_at": null,
639            "source_branch": "feature/forge",
640            "state": "opened",
641            "target_branch": "main",
642            "title": "Add forge review support",
643            "description": "Current description.",
644            "web_url": "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
645        }"#
646        .to_string()
647    }
648}