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 => Box::pin(async move {
191                Err(ReviewRequestError::OperationFailed {
192                    forge_kind: remote.forge_kind,
193                    message: "GitLab review-comment preview is not yet supported".to_string(),
194                })
195            }),
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::{ForgeKind, ReviewRequestState};
204
205    #[test]
206    fn review_request_web_url_returns_error_when_summary_is_missing_url() {
207        // Arrange
208        let client = RealReviewRequestClient::default();
209        let review_request = ReviewRequestSummary {
210            display_id: "#42".to_string(),
211            forge_kind: ForgeKind::GitHub,
212            source_branch: "feature/forge".to_string(),
213            state: ReviewRequestState::Open,
214            status_summary: Some("Mergeable".to_string()),
215            target_branch: "main".to_string(),
216            title: "Add forge boundary".to_string(),
217            web_url: String::new(),
218        };
219
220        // Act
221        let error = client
222            .review_request_web_url(&review_request)
223            .expect_err("missing URL should be rejected");
224
225        // Assert
226        assert_eq!(
227            error,
228            ReviewRequestError::OperationFailed {
229                forge_kind: ForgeKind::GitHub,
230                message: "review request summary is missing a web URL".to_string(),
231            }
232        );
233    }
234
235    #[test]
236    fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
237        // Arrange
238        let client = RealReviewRequestClient::default();
239        let review_request = ReviewRequestSummary {
240            display_id: "!42".to_string(),
241            forge_kind: ForgeKind::GitLab,
242            source_branch: "feature/forge".to_string(),
243            state: ReviewRequestState::Open,
244            status_summary: Some("Draft".to_string()),
245            target_branch: "main".to_string(),
246            title: "Add forge boundary".to_string(),
247            web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
248        };
249
250        // Act
251        let web_url = client
252            .review_request_web_url(&review_request)
253            .expect("gitlab review-request URL should be returned directly");
254
255        // Assert
256        assert_eq!(
257            web_url,
258            "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
259        );
260    }
261}