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, RealForgeCommandRunner, ReviewRequestError, ReviewRequestSummary,
8    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.
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        let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
95
96        Box::pin(async move { adapter.find_by_source_branch(remote, source_branch).await })
97    }
98
99    fn create_review_request(
100        &self,
101        remote: ForgeRemote,
102        input: CreateReviewRequestInput,
103    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
104        let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
105
106        Box::pin(async move { adapter.create_review_request(remote, input).await })
107    }
108
109    fn refresh_review_request(
110        &self,
111        remote: ForgeRemote,
112        display_id: String,
113    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
114        let adapter = GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner));
115
116        Box::pin(async move { adapter.refresh_review_request(remote, display_id).await })
117    }
118
119    fn review_request_web_url(
120        &self,
121        review_request: &ReviewRequestSummary,
122    ) -> Result<String, ReviewRequestError> {
123        if review_request.web_url.trim().is_empty() {
124            return Err(ReviewRequestError::OperationFailed {
125                forge_kind: review_request.forge_kind,
126                message: "review request summary is missing a web URL".to_string(),
127            });
128        }
129
130        Ok(review_request.web_url.clone())
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::{ForgeKind, ReviewRequestState};
138
139    #[test]
140    fn review_request_web_url_returns_error_when_summary_is_missing_url() {
141        // Arrange
142        let client = RealReviewRequestClient::default();
143        let review_request = ReviewRequestSummary {
144            display_id: "#42".to_string(),
145            forge_kind: ForgeKind::GitHub,
146            source_branch: "feature/forge".to_string(),
147            state: ReviewRequestState::Open,
148            status_summary: Some("Mergeable".to_string()),
149            target_branch: "main".to_string(),
150            title: "Add forge boundary".to_string(),
151            web_url: String::new(),
152        };
153
154        // Act
155        let error = client
156            .review_request_web_url(&review_request)
157            .expect_err("missing URL should be rejected");
158
159        // Assert
160        assert_eq!(
161            error,
162            ReviewRequestError::OperationFailed {
163                forge_kind: ForgeKind::GitHub,
164                message: "review request summary is missing a web URL".to_string(),
165            }
166        );
167    }
168}