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    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        match remote.forge_kind {
135            super::ForgeKind::GitHub => {
136                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
137
138                Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
139            }
140            super::ForgeKind::GitLab => {
141                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
142
143                Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
144            }
145        }
146    }
147
148    fn create_review_request(
149        &self,
150        remote: ForgeRemote,
151        input: CreateReviewRequestInput,
152    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
153        match remote.forge_kind {
154            super::ForgeKind::GitHub => {
155                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
156
157                Box::pin(async move { adapter.create_review_request(remote, input).await })
158            }
159            super::ForgeKind::GitLab => {
160                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
161
162                Box::pin(async move { adapter.create_review_request(remote, input).await })
163            }
164        }
165    }
166
167    fn refresh_review_request(
168        &self,
169        remote: ForgeRemote,
170        display_id: String,
171    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
172        match remote.forge_kind {
173            super::ForgeKind::GitHub => {
174                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
175
176                Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
177            }
178            super::ForgeKind::GitLab => {
179                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
180
181                Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
182            }
183        }
184    }
185
186    fn sync_review_request_metadata(
187        &self,
188        remote: ForgeRemote,
189        display_id: String,
190        input: UpdateReviewRequestInput,
191    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
192        match remote.forge_kind {
193            super::ForgeKind::GitHub => {
194                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
195
196                Box::pin(async move {
197                    adapter
198                        .sync_review_request_metadata(remote, display_id, input)
199                        .await
200                })
201            }
202            super::ForgeKind::GitLab => {
203                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
204
205                Box::pin(async move {
206                    adapter
207                        .sync_review_request_metadata(remote, display_id, input)
208                        .await
209                })
210            }
211        }
212    }
213
214    fn review_request_web_url(
215        &self,
216        review_request: &ReviewRequestSummary,
217    ) -> Result<String, ReviewRequestError> {
218        if review_request.web_url.trim().is_empty() {
219            return Err(ReviewRequestError::OperationFailed {
220                forge_kind: review_request.forge_kind,
221                message: "review request summary is missing a web URL".to_string(),
222            });
223        }
224
225        Ok(review_request.web_url.clone())
226    }
227
228    fn fetch_review_comment_snapshot(
229        &self,
230        remote: ForgeRemote,
231        display_id: String,
232    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
233        match remote.forge_kind {
234            super::ForgeKind::GitHub => {
235                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
236
237                Box::pin(async move {
238                    adapter
239                        .fetch_review_comment_snapshot(remote, display_id)
240                        .await
241                })
242            }
243            super::ForgeKind::GitLab => {
244                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
245
246                Box::pin(async move {
247                    adapter
248                        .fetch_review_comment_snapshot(remote, display_id)
249                        .await
250                })
251            }
252        }
253    }
254
255    fn list_requested_reviews(
256        &self,
257        remote: ForgeRemote,
258    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>> {
259        match remote.forge_kind {
260            super::ForgeKind::GitHub => {
261                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
262
263                Box::pin(async move { adapter.list_requested_reviews(remote).await })
264            }
265            super::ForgeKind::GitLab => {
266                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
267
268                Box::pin(async move { adapter.list_requested_reviews(remote).await })
269            }
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::{ForgeKind, ReviewRequestState};
278
279    #[test]
280    fn review_request_web_url_returns_error_when_summary_is_missing_url() {
281        // Arrange
282        let client = RealReviewRequestClient::default();
283        let review_request = ReviewRequestSummary {
284            display_id: "#42".to_string(),
285            forge_kind: ForgeKind::GitHub,
286            source_branch: "feature/forge".to_string(),
287            state: ReviewRequestState::Open,
288            status_summary: Some("Mergeable".to_string()),
289            target_branch: "main".to_string(),
290            title: "Add forge boundary".to_string(),
291            web_url: String::new(),
292        };
293
294        // Act
295        let error = client
296            .review_request_web_url(&review_request)
297            .expect_err("missing URL should be rejected");
298
299        // Assert
300        assert_eq!(
301            error,
302            ReviewRequestError::OperationFailed {
303                forge_kind: ForgeKind::GitHub,
304                message: "review request summary is missing a web URL".to_string(),
305            }
306        );
307    }
308
309    #[test]
310    fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
311        // Arrange
312        let client = RealReviewRequestClient::default();
313        let review_request = ReviewRequestSummary {
314            display_id: "!42".to_string(),
315            forge_kind: ForgeKind::GitLab,
316            source_branch: "feature/forge".to_string(),
317            state: ReviewRequestState::Open,
318            status_summary: Some("Draft".to_string()),
319            target_branch: "main".to_string(),
320            title: "Add forge boundary".to_string(),
321            web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
322        };
323
324        // Act
325        let web_url = client
326            .review_request_web_url(&review_request)
327            .expect("gitlab review-request URL should be returned directly");
328
329        // Assert
330        assert_eq!(
331            web_url,
332            "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
333        );
334    }
335}