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