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    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    /// Returns the browser-openable URL for one review request.
57    ///
58    /// # Errors
59    /// Returns [`ReviewRequestError::OperationFailed`] when the summary does
60    /// not carry a web URL.
61    fn review_request_web_url(
62        &self,
63        review_request: &ReviewRequestSummary,
64    ) -> Result<String, ReviewRequestError>;
65
66    /// Fetches the review-comment snapshot for one open review request.
67    ///
68    /// Returns both inline threads and review-request-wide comments. Threads
69    /// are grouped by `path` and sorted by `(path, line)` by callers; adapters
70    /// return what the forge reports without enforcing an ordering.
71    ///
72    /// # Errors
73    /// Returns a provider-specific review-request error when the snapshot fetch
74    /// cannot be completed (including authentication and host failures).
75    fn fetch_review_comment_snapshot(
76        &self,
77        remote: ForgeRemote,
78        display_id: String,
79    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
80
81    /// Lists open review requests asking the current authenticated user to
82    /// review the selected repository.
83    ///
84    /// # Errors
85    /// Returns a provider-specific review-request error when the list fetch
86    /// cannot be completed.
87    fn list_requested_reviews(
88        &self,
89        remote: ForgeRemote,
90    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
91}
92
93/// Production [`ReviewRequestClient`] that routes to forge-specific adapters.
94pub struct RealReviewRequestClient {
95    command_runner: Arc<dyn ForgeCommandRunner>,
96}
97
98impl RealReviewRequestClient {
99    /// Builds one review-request client from a forge command runner.
100    pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
101        Self { command_runner }
102    }
103}
104
105impl Default for RealReviewRequestClient {
106    fn default() -> Self {
107        Self::new(Arc::new(RealForgeCommandRunner))
108    }
109}
110
111impl ReviewRequestClient for RealReviewRequestClient {
112    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
113        detect_remote(&repo_url)
114    }
115
116    fn find_by_source_branch(
117        &self,
118        remote: ForgeRemote,
119        source_branch: String,
120    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
121        match remote.forge_kind {
122            super::ForgeKind::GitHub => {
123                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
124
125                Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
126            }
127            super::ForgeKind::GitLab => {
128                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
129
130                Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
131            }
132        }
133    }
134
135    fn create_review_request(
136        &self,
137        remote: ForgeRemote,
138        input: CreateReviewRequestInput,
139    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
140        match remote.forge_kind {
141            super::ForgeKind::GitHub => {
142                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
143
144                Box::pin(async move { adapter.create_review_request(remote, input).await })
145            }
146            super::ForgeKind::GitLab => {
147                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
148
149                Box::pin(async move { adapter.create_review_request(remote, input).await })
150            }
151        }
152    }
153
154    fn refresh_review_request(
155        &self,
156        remote: ForgeRemote,
157        display_id: String,
158    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
159        match remote.forge_kind {
160            super::ForgeKind::GitHub => {
161                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
162
163                Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
164            }
165            super::ForgeKind::GitLab => {
166                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
167
168                Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
169            }
170        }
171    }
172
173    fn review_request_web_url(
174        &self,
175        review_request: &ReviewRequestSummary,
176    ) -> Result<String, ReviewRequestError> {
177        if review_request.web_url.trim().is_empty() {
178            return Err(ReviewRequestError::OperationFailed {
179                forge_kind: review_request.forge_kind,
180                message: "review request summary is missing a web URL".to_string(),
181            });
182        }
183
184        Ok(review_request.web_url.clone())
185    }
186
187    fn fetch_review_comment_snapshot(
188        &self,
189        remote: ForgeRemote,
190        display_id: String,
191    ) -> ForgeFuture<Result<ReviewCommentSnapshot, 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                        .fetch_review_comment_snapshot(remote, display_id)
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                        .fetch_review_comment_snapshot(remote, display_id)
208                        .await
209                })
210            }
211        }
212    }
213
214    fn list_requested_reviews(
215        &self,
216        remote: ForgeRemote,
217    ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>> {
218        match remote.forge_kind {
219            super::ForgeKind::GitHub => {
220                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
221
222                Box::pin(async move { adapter.list_requested_reviews(remote).await })
223            }
224            super::ForgeKind::GitLab => {
225                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
226
227                Box::pin(async move { adapter.list_requested_reviews(remote).await })
228            }
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::{ForgeKind, ReviewRequestState};
237
238    #[test]
239    fn review_request_web_url_returns_error_when_summary_is_missing_url() {
240        // Arrange
241        let client = RealReviewRequestClient::default();
242        let review_request = ReviewRequestSummary {
243            display_id: "#42".to_string(),
244            forge_kind: ForgeKind::GitHub,
245            source_branch: "feature/forge".to_string(),
246            state: ReviewRequestState::Open,
247            status_summary: Some("Mergeable".to_string()),
248            target_branch: "main".to_string(),
249            title: "Add forge boundary".to_string(),
250            web_url: String::new(),
251        };
252
253        // Act
254        let error = client
255            .review_request_web_url(&review_request)
256            .expect_err("missing URL should be rejected");
257
258        // Assert
259        assert_eq!(
260            error,
261            ReviewRequestError::OperationFailed {
262                forge_kind: ForgeKind::GitHub,
263                message: "review request summary is missing a web URL".to_string(),
264            }
265        );
266    }
267
268    #[test]
269    fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
270        // Arrange
271        let client = RealReviewRequestClient::default();
272        let review_request = ReviewRequestSummary {
273            display_id: "!42".to_string(),
274            forge_kind: ForgeKind::GitLab,
275            source_branch: "feature/forge".to_string(),
276            state: ReviewRequestState::Open,
277            status_summary: Some("Draft".to_string()),
278            target_branch: "main".to_string(),
279            title: "Add forge boundary".to_string(),
280            web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
281        };
282
283        // Act
284        let web_url = client
285            .review_request_web_url(&review_request)
286            .expect("gitlab review-request URL should be returned directly");
287
288        // Assert
289        assert_eq!(
290            web_url,
291            "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
292        );
293    }
294}