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, ForgeKind, ForgeRemote,
7    GitHubReviewRequestAdapter, GitLabReviewRequestAdapter, RealForgeCommandRunner,
8    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 GitHub or GitLab.
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
66/// Production [`ReviewRequestClient`] that routes to forge-specific adapters.
67pub struct RealReviewRequestClient {
68    command_runner: Arc<dyn ForgeCommandRunner>,
69}
70
71impl RealReviewRequestClient {
72    /// Builds one review-request client from a forge command runner.
73    pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
74        Self { command_runner }
75    }
76}
77
78impl Default for RealReviewRequestClient {
79    fn default() -> Self {
80        Self::new(Arc::new(RealForgeCommandRunner))
81    }
82}
83
84impl ReviewRequestClient for RealReviewRequestClient {
85    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
86        detect_remote(&repo_url)
87    }
88
89    fn find_by_source_branch(
90        &self,
91        remote: ForgeRemote,
92        source_branch: String,
93    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
94        match remote.forge_kind {
95            ForgeKind::GitHub => {
96                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
97
98                Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
99            }
100            ForgeKind::GitLab => {
101                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
102
103                Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
104            }
105        }
106    }
107
108    fn create_review_request(
109        &self,
110        remote: ForgeRemote,
111        input: CreateReviewRequestInput,
112    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
113        match remote.forge_kind {
114            ForgeKind::GitHub => {
115                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
116
117                Box::pin(async move { adapter.create_review_request(remote, input).await })
118            }
119            ForgeKind::GitLab => {
120                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
121
122                Box::pin(async move { adapter.create_review_request(remote, input).await })
123            }
124        }
125    }
126
127    fn refresh_review_request(
128        &self,
129        remote: ForgeRemote,
130        display_id: String,
131    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
132        match remote.forge_kind {
133            ForgeKind::GitHub => {
134                let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
135
136                Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
137            }
138            ForgeKind::GitLab => {
139                let adapter = GitLabReviewRequestAdapter::new(Arc::clone(&self.command_runner));
140
141                Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
142            }
143        }
144    }
145
146    fn review_request_web_url(
147        &self,
148        review_request: &ReviewRequestSummary,
149    ) -> Result<String, ReviewRequestError> {
150        if review_request.web_url.trim().is_empty() {
151            return Err(ReviewRequestError::OperationFailed {
152                forge_kind: review_request.forge_kind,
153                message: "review request summary is missing a web URL".to_string(),
154            });
155        }
156
157        Ok(review_request.web_url.clone())
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::{ForgeKind, ReviewRequestState};
165
166    #[test]
167    fn review_request_web_url_returns_error_when_summary_is_missing_url() {
168        // Arrange
169        let client = RealReviewRequestClient::default();
170        let review_request = ReviewRequestSummary {
171            display_id: "#42".to_string(),
172            forge_kind: ForgeKind::GitHub,
173            source_branch: "feature/forge".to_string(),
174            state: ReviewRequestState::Open,
175            status_summary: Some("Mergeable".to_string()),
176            target_branch: "main".to_string(),
177            title: "Add forge boundary".to_string(),
178            web_url: String::new(),
179        };
180
181        // Act
182        let error = client
183            .review_request_web_url(&review_request)
184            .expect_err("missing URL should be rejected");
185
186        // Assert
187        assert_eq!(
188            error,
189            ReviewRequestError::OperationFailed {
190                forge_kind: ForgeKind::GitHub,
191                message: "review request summary is missing a web URL".to_string(),
192            }
193        );
194    }
195}