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