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    ReviewCommentSnapshot, ReviewRequestError, ReviewRequestMetadata, 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    /// Loads the current title and body of one existing review request.
57    ///
58    /// # Errors
59    /// Returns a provider-specific review-request error when metadata lookup
60    /// fails.
61    fn review_request_metadata(
62        &self,
63        remote: ForgeRemote,
64        display_id: String,
65    ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
66
67    /// Best-effort syncs reconciled metadata after rechecking that the remote
68    /// fields match the values used during evaluation.
69    ///
70    /// The provider CLI update is not atomic with the recheck, so a later
71    /// concurrent manual edit can still be overwritten.
72    ///
73    /// # Errors
74    /// Returns a provider-specific review-request error when metadata lookup,
75    /// update, or refresh fails.
76    fn sync_review_request_metadata(
77        &self,
78        remote: ForgeRemote,
79        display_id: String,
80        input: UpdateReviewRequestInput,
81    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
82
83    /// Returns the browser-openable URL for one review request.
84    ///
85    /// # Errors
86    /// Returns [`ReviewRequestError::OperationFailed`] when the summary does
87    /// not carry a web URL.
88    fn review_request_web_url(
89        &self,
90        review_request: &ReviewRequestSummary,
91    ) -> Result<String, ReviewRequestError>;
92
93    /// Fetches the review-comment snapshot for one open review request.
94    ///
95    /// Returns both inline threads and review-request-wide comments. Threads
96    /// are grouped by `path` and sorted by `(path, line)` by callers; adapters
97    /// return what the forge reports without enforcing an ordering.
98    ///
99    /// # Errors
100    /// Returns a provider-specific review-request error when the snapshot fetch
101    /// cannot be completed (including authentication and host failures).
102    fn fetch_review_comment_snapshot(
103        &self,
104        remote: ForgeRemote,
105        display_id: String,
106    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
107
108    /// Adds one reply to an existing review thread.
109    ///
110    /// # Errors
111    /// Returns a provider-specific review-request error when the reply cannot
112    /// be posted.
113    fn reply_to_thread(
114        &self,
115        remote: ForgeRemote,
116        display_id: String,
117        thread_id: String,
118        body: String,
119    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
120
121    /// Marks one existing review thread resolved.
122    ///
123    /// # Errors
124    /// Returns a provider-specific review-request error when the thread cannot
125    /// be resolved.
126    fn resolve_thread(
127        &self,
128        remote: ForgeRemote,
129        display_id: String,
130        thread_id: String,
131    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
132}
133
134/// Production [`ReviewRequestClient`] that routes to forge-specific adapters.
135pub struct RealReviewRequestClient {
136    command_runner: Arc<dyn ForgeCommandRunner>,
137}
138
139impl RealReviewRequestClient {
140    /// Builds one review-request client from a forge command runner.
141    pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
142        Self { command_runner }
143    }
144
145    /// Runs `call` on an authenticated adapter selected for `remote`.
146    fn call_with_authenticated_adapter<T>(
147        &self,
148        remote: ForgeRemote,
149        call: impl FnOnce(
150            Arc<dyn ReviewRequestAdapter>,
151            ForgeRemote,
152        ) -> ForgeFuture<Result<T, ReviewRequestError>>
153        + Send
154        + 'static,
155    ) -> ForgeFuture<Result<T, ReviewRequestError>>
156    where
157        T: Send + 'static,
158    {
159        let adapter = self.adapter_for(remote.forge_kind);
160
161        Box::pin(async move {
162            adapter.ensure_authenticated(&remote).await?;
163
164            call(adapter, remote).await
165        })
166    }
167
168    /// Returns one adapter implementation for `forge_kind`.
169    fn adapter_for(&self, forge_kind: ForgeKind) -> Arc<dyn ReviewRequestAdapter> {
170        match forge_kind {
171            ForgeKind::GitHub => Arc::new(GitHubReviewRequestAdapter::new(Arc::clone(
172                &self.command_runner,
173            ))),
174            ForgeKind::GitLab => Arc::new(GitLabReviewRequestAdapter::new(Arc::clone(
175                &self.command_runner,
176            ))),
177        }
178    }
179}
180
181impl Default for RealReviewRequestClient {
182    fn default() -> Self {
183        Self::new(Arc::new(RealForgeCommandRunner))
184    }
185}
186
187impl ReviewRequestClient for RealReviewRequestClient {
188    fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
189        detect_remote(&repo_url)
190    }
191
192    fn find_by_source_branch(
193        &self,
194        remote: ForgeRemote,
195        source_branch: String,
196    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
197        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
198            adapter.find_authenticated_by_source_branch(remote, source_branch)
199        })
200    }
201
202    fn create_review_request(
203        &self,
204        remote: ForgeRemote,
205        input: CreateReviewRequestInput,
206    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
207        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
208            adapter.create_authenticated_review_request(remote, input)
209        })
210    }
211
212    fn refresh_review_request(
213        &self,
214        remote: ForgeRemote,
215        display_id: String,
216    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
217        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
218            adapter.refresh_authenticated_review_request(remote, display_id)
219        })
220    }
221
222    fn review_request_metadata(
223        &self,
224        remote: ForgeRemote,
225        display_id: String,
226    ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>> {
227        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
228            adapter.authenticated_review_request_metadata(remote, display_id)
229        })
230    }
231
232    fn sync_review_request_metadata(
233        &self,
234        remote: ForgeRemote,
235        display_id: String,
236        input: UpdateReviewRequestInput,
237    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
238        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
239            adapter.sync_authenticated_review_request_metadata(remote, display_id, input)
240        })
241    }
242
243    fn review_request_web_url(
244        &self,
245        review_request: &ReviewRequestSummary,
246    ) -> Result<String, ReviewRequestError> {
247        if review_request.web_url.trim().is_empty() {
248            return Err(ReviewRequestError::OperationFailed {
249                forge_kind: review_request.forge_kind,
250                message: "review request summary is missing a web URL".to_string(),
251            });
252        }
253
254        Ok(review_request.web_url.clone())
255    }
256
257    fn fetch_review_comment_snapshot(
258        &self,
259        remote: ForgeRemote,
260        display_id: String,
261    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
262        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
263            adapter.fetch_authenticated_review_comment_snapshot(remote, display_id)
264        })
265    }
266
267    fn reply_to_thread(
268        &self,
269        remote: ForgeRemote,
270        display_id: String,
271        thread_id: String,
272        body: String,
273    ) -> ForgeFuture<Result<(), ReviewRequestError>> {
274        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
275            adapter.reply_to_authenticated_thread(remote, display_id, thread_id, body)
276        })
277    }
278
279    fn resolve_thread(
280        &self,
281        remote: ForgeRemote,
282        display_id: String,
283        thread_id: String,
284    ) -> ForgeFuture<Result<(), ReviewRequestError>> {
285        self.call_with_authenticated_adapter(remote, move |adapter, remote| {
286            adapter.resolve_authenticated_thread(remote, display_id, thread_id)
287        })
288    }
289}
290
291/// Provider-specific operation boundary used after client-level authentication.
292///
293/// The production client selects one implementation, calls
294/// [`ReviewRequestAdapter::ensure_authenticated`] once, and then invokes the
295/// requested operation without provider-specific dispatch in each public
296/// method.
297pub(crate) trait ReviewRequestAdapter: Send + Sync {
298    /// Verifies that CLI authentication succeeds for `remote`.
299    ///
300    /// # Errors
301    /// Returns a provider-specific review-request error when the forge CLI is
302    /// unavailable, unauthenticated, or cannot resolve the target host.
303    fn ensure_authenticated(
304        &self,
305        remote: &ForgeRemote,
306    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
307
308    /// Finds one review request after the production client has authenticated.
309    fn find_authenticated_by_source_branch(
310        &self,
311        remote: ForgeRemote,
312        source_branch: String,
313    ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
314
315    /// Creates one review request after the production client has
316    /// authenticated.
317    fn create_authenticated_review_request(
318        &self,
319        remote: ForgeRemote,
320        input: CreateReviewRequestInput,
321    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
322
323    /// Refreshes one existing review request after authentication.
324    fn refresh_authenticated_review_request(
325        &self,
326        remote: ForgeRemote,
327        display_id: String,
328    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
329
330    /// Loads current review-request metadata after authentication.
331    fn authenticated_review_request_metadata(
332        &self,
333        remote: ForgeRemote,
334        display_id: String,
335    ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
336
337    /// Synchronizes review-request metadata after authentication.
338    fn sync_authenticated_review_request_metadata(
339        &self,
340        remote: ForgeRemote,
341        display_id: String,
342        input: UpdateReviewRequestInput,
343    ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
344
345    /// Fetches a review-comment snapshot after authentication.
346    fn fetch_authenticated_review_comment_snapshot(
347        &self,
348        remote: ForgeRemote,
349        display_id: String,
350    ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
351
352    /// Adds one reply after authentication.
353    fn reply_to_authenticated_thread(
354        &self,
355        remote: ForgeRemote,
356        display_id: String,
357        thread_id: String,
358        body: String,
359    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
360
361    /// Resolves one review thread after authentication.
362    fn resolve_authenticated_thread(
363        &self,
364        remote: ForgeRemote,
365        display_id: String,
366        thread_id: String,
367    ) -> ForgeFuture<Result<(), ReviewRequestError>>;
368}
369
370#[cfg(test)]
371#[path = "client_test.rs"]
372mod tests;