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, ForgeRemote,
7    GitHubReviewRequestAdapter, GitLabReviewRequestAdapter, RealForgeCommandRunner,
8    ReviewCommentSnapshot, ReviewRequestError, ReviewRequestSummary, detect_remote,
9};
10
11/// Async boundary used by app orchestration for forge review requests.
12///
13/// The app layer depends on this narrow contract so provider-specific request
14/// formats remain isolated inside concrete adapters.
15#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
16pub trait ReviewRequestClient: Send + Sync {
17    /// Detects whether `repo_url` belongs to one supported forge.
18    ///
19    /// # Errors
20    /// Returns [`ReviewRequestError::UnsupportedRemote`] when the remote does
21    /// not map to a supported forge.
22    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError>;
23
24    /// Finds an existing review request for `source_branch`.
25    ///
26    /// # Errors
27    /// Returns a provider-specific review-request error when the forge lookup
28    /// cannot be completed.
29    fn find_by_source_branch(
30        &self,
31        remote: ForgeRemote,
32        source_branch: String,
33    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
34
35    /// Creates a new review request from `input`.
36    ///
37    /// # Errors
38    /// Returns a provider-specific review-request error when creation fails.
39    fn create_review_request(
40        &self,
41        remote: ForgeRemote,
42        input: CreateReviewRequestInput,
43    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
44
45    /// Refreshes one existing review request by provider display id.
46    ///
47    /// # Errors
48    /// Returns a provider-specific review-request error when refresh fails.
49    fn refresh_review_request(
50        &self,
51        remote: ForgeRemote,
52        display_id: String,
53    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
54
55    /// Returns the browser-openable URL for one review request.
56    ///
57    /// # Errors
58    /// Returns [`ReviewRequestError::OperationFailed`] when the summary does
59    /// not carry a web URL.
60    fn review_request_web_url(
61        &self,
62        review_request: &ReviewRequestSummary,
63    ) -> Result<String, ReviewRequestError>;
64
65    /// Fetches the review-comment snapshot for one open review request.
66    ///
67    /// Returns both inline threads and review-request-wide comments. Threads
68    /// are grouped by `path` and sorted by `(path, line)` by callers; adapters
69    /// return what the forge reports without enforcing an ordering.
70    ///
71    /// # Errors
72    /// Returns a provider-specific review-request error when the snapshot fetch
73    /// cannot be completed (including authentication and host failures).
74    fn fetch_review_comment_snapshot(
75        &self,
76        remote: ForgeRemote,
77        display_id: String,
78    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
79}
80
81/// Production [`ReviewRequestClient`] that routes to forge-specific adapters.
82pub struct RealReviewRequestClient {
83    command_runner: Arc<dyn ForgeCommandRunner>,
84}
85
86impl RealReviewRequestClient {
87    /// Builds one review-request client from a forge command runner.
88    pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
89        Self { command_runner }
90    }
91}
92
93impl Default for RealReviewRequestClient {
94    fn default() -> Self {
95        Self::new(Arc::new(RealForgeCommandRunner))
96    }
97}
98
99impl ReviewRequestClient for RealReviewRequestClient {
100    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
101        detect_remote(&repo_url)
102    }
103
104    fn find_by_source_branch(
105        &self,
106        remote: ForgeRemote,
107        source_branch: String,
108    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
109        match remote.forge_kind {
110            super::ForgeKind::GitHub => {
111                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
112
113                Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
114            }
115            super::ForgeKind::GitLab => {
116                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
117
118                Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
119            }
120        }
121    }
122
123    fn create_review_request(
124        &self,
125        remote: ForgeRemote,
126        input: CreateReviewRequestInput,
127    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
128        match remote.forge_kind {
129            super::ForgeKind::GitHub => {
130                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
131
132                Box::pin(async move { adapter.create_review_request(remote, input).await })
133            }
134            super::ForgeKind::GitLab => {
135                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
136
137                Box::pin(async move { adapter.create_review_request(remote, input).await })
138            }
139        }
140    }
141
142    fn refresh_review_request(
143        &self,
144        remote: ForgeRemote,
145        display_id: String,
146    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
147        match remote.forge_kind {
148            super::ForgeKind::GitHub => {
149                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
150
151                Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
152            }
153            super::ForgeKind::GitLab => {
154                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
155
156                Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
157            }
158        }
159    }
160
161    fn review_request_web_url(
162        &self,
163        review_request: &ReviewRequestSummary,
164    ) -> Result<String, ReviewRequestError> {
165        if review_request.web_url.trim().is_empty() {
166            return Err(ReviewRequestError::OperationFailed {
167                forge_kind: review_request.forge_kind,
168                message: "review request summary is missing a web URL".to_string(),
169            });
170        }
171
172        Ok(review_request.web_url.clone())
173    }
174
175    fn fetch_review_comment_snapshot(
176        &self,
177        remote: ForgeRemote,
178        display_id: String,
179    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
180        match remote.forge_kind {
181            super::ForgeKind::GitHub => {
182                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
183
184                Box::pin(async move {
185                    adapter
186                        .fetch_review_comment_snapshot(remote, display_id)
187                        .await
188                })
189            }
190            super::ForgeKind::GitLab => {
191                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
192
193                Box::pin(async move {
194                    adapter
195                        .fetch_review_comment_snapshot(remote, display_id)
196                        .await
197                })
198            }
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::{ForgeKind, ReviewRequestState};
207
208    #[test]
209    fn review_request_web_url_returns_error_when_summary_is_missing_url() {
210        // Arrange
211        let client = RealReviewRequestClient::default();
212        let review_request = ReviewRequestSummary {
213            display_id: "#42".to_string(),
214            forge_kind: ForgeKind::GitHub,
215            source_branch: "feature/forge".to_string(),
216            state: ReviewRequestState::Open,
217            status_summary: Some("Mergeable".to_string()),
218            target_branch: "main".to_string(),
219            title: "Add forge boundary".to_string(),
220            web_url: String::new(),
221        };
222
223        // Act
224        let error = client
225            .review_request_web_url(&review_request)
226            .expect_err("missing URL should be rejected");
227
228        // Assert
229        assert_eq!(
230            error,
231            ReviewRequestError::OperationFailed {
232                forge_kind: ForgeKind::GitHub,
233                message: "review request summary is missing a web URL".to_string(),
234            }
235        );
236    }
237
238    #[test]
239    fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
240        // Arrange
241        let client = RealReviewRequestClient::default();
242        let review_request = ReviewRequestSummary {
243            display_id: "!42".to_string(),
244            forge_kind: ForgeKind::GitLab,
245            source_branch: "feature/forge".to_string(),
246            state: ReviewRequestState::Open,
247            status_summary: Some("Draft".to_string()),
248            target_branch: "main".to_string(),
249            title: "Add forge boundary".to_string(),
250            web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
251        };
252
253        // Act
254        let web_url = client
255            .review_request_web_url(&review_request)
256            .expect("gitlab review-request URL should be returned directly");
257
258        // Assert
259        assert_eq!(
260            web_url,
261            "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
262        );
263    }
264}