1use std::sync::Arc;
4
5use super::{
6 CreateReviewRequestInput, ForgeCommandRunner, ForgeFuture, ForgeKind, ForgeRemote,
7 GitHubReviewRequestAdapter, GitLabReviewRequestAdapter, RealForgeCommandRunner,
8 RequestedReview, ReviewCommentSnapshot, ReviewRequestError, ReviewRequestSummary,
9 UpdateReviewRequestInput, detect_remote,
10};
11
12#[cfg_attr(any(test, feature = "test-utils"), mockall::automock)]
17pub trait ReviewRequestClient: Send + Sync {
18 fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError>;
24
25 fn find_by_source_branch(
31 &self,
32 remote: ForgeRemote,
33 source_branch: String,
34 ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
35
36 fn create_review_request(
41 &self,
42 remote: ForgeRemote,
43 input: CreateReviewRequestInput,
44 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
45
46 fn refresh_review_request(
51 &self,
52 remote: ForgeRemote,
53 display_id: String,
54 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
55
56 fn sync_review_request_metadata(
63 &self,
64 remote: ForgeRemote,
65 display_id: String,
66 input: UpdateReviewRequestInput,
67 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
68
69 fn review_request_web_url(
75 &self,
76 review_request: &ReviewRequestSummary,
77 ) -> Result<String, ReviewRequestError>;
78
79 fn fetch_review_comment_snapshot(
89 &self,
90 remote: ForgeRemote,
91 display_id: String,
92 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
93
94 fn list_requested_reviews(
101 &self,
102 remote: ForgeRemote,
103 ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
104}
105
106pub struct RealReviewRequestClient {
108 command_runner: Arc<dyn ForgeCommandRunner>,
109}
110
111impl RealReviewRequestClient {
112 pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
114 Self { command_runner }
115 }
116}
117
118impl Default for RealReviewRequestClient {
119 fn default() -> Self {
120 Self::new(Arc::new(RealForgeCommandRunner))
121 }
122}
123
124impl ReviewRequestClient for RealReviewRequestClient {
125 fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
126 detect_remote(&repo_url)
127 }
128
129 fn find_by_source_branch(
130 &self,
131 remote: ForgeRemote,
132 source_branch: String,
133 ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
134 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
135 adapter.find_authenticated_by_source_branch(remote, source_branch)
136 })
137 }
138
139 fn create_review_request(
140 &self,
141 remote: ForgeRemote,
142 input: CreateReviewRequestInput,
143 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
144 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
145 adapter.create_authenticated_review_request(remote, input)
146 })
147 }
148
149 fn refresh_review_request(
150 &self,
151 remote: ForgeRemote,
152 display_id: String,
153 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
154 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
155 adapter.refresh_authenticated_review_request(remote, display_id)
156 })
157 }
158
159 fn sync_review_request_metadata(
160 &self,
161 remote: ForgeRemote,
162 display_id: String,
163 input: UpdateReviewRequestInput,
164 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
165 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
166 adapter.sync_authenticated_review_request_metadata(remote, display_id, input)
167 })
168 }
169
170 fn review_request_web_url(
171 &self,
172 review_request: &ReviewRequestSummary,
173 ) -> Result<String, ReviewRequestError> {
174 if review_request.web_url.trim().is_empty() {
175 return Err(ReviewRequestError::OperationFailed {
176 forge_kind: review_request.forge_kind,
177 message: "review request summary is missing a web URL".to_string(),
178 });
179 }
180
181 Ok(review_request.web_url.clone())
182 }
183
184 fn fetch_review_comment_snapshot(
185 &self,
186 remote: ForgeRemote,
187 display_id: String,
188 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
189 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
190 adapter.fetch_authenticated_review_comment_snapshot(remote, display_id)
191 })
192 }
193
194 fn list_requested_reviews(
195 &self,
196 remote: ForgeRemote,
197 ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>> {
198 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
199 adapter.list_authenticated_requested_reviews(remote)
200 })
201 }
202}
203
204impl RealReviewRequestClient {
205 fn adapter_for(&self, forge_kind: ForgeKind) -> Arc<dyn ReviewRequestAdapter> {
207 match forge_kind {
208 ForgeKind::GitHub => Arc::new(GitHubReviewRequestAdapter::new(Arc::clone(
209 &self.command_runner,
210 ))),
211 ForgeKind::GitLab => Arc::new(GitLabReviewRequestAdapter::new(Arc::clone(
212 &self.command_runner,
213 ))),
214 }
215 }
216
217 fn call_with_authenticated_adapter<T>(
219 &self,
220 remote: ForgeRemote,
221 call: impl FnOnce(
222 Arc<dyn ReviewRequestAdapter>,
223 ForgeRemote,
224 ) -> ForgeFuture<Result<T, ReviewRequestError>>
225 + Send
226 + 'static,
227 ) -> ForgeFuture<Result<T, ReviewRequestError>>
228 where
229 T: Send + 'static,
230 {
231 let adapter = self.adapter_for(remote.forge_kind);
232
233 Box::pin(async move {
234 adapter.ensure_authenticated(&remote).await?;
235
236 call(adapter, remote).await
237 })
238 }
239}
240
241pub(crate) trait ReviewRequestAdapter: Send + Sync {
248 fn ensure_authenticated(
254 &self,
255 remote: &ForgeRemote,
256 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
257
258 fn find_authenticated_by_source_branch(
260 &self,
261 remote: ForgeRemote,
262 source_branch: String,
263 ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
264
265 fn create_authenticated_review_request(
268 &self,
269 remote: ForgeRemote,
270 input: CreateReviewRequestInput,
271 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
272
273 fn refresh_authenticated_review_request(
275 &self,
276 remote: ForgeRemote,
277 display_id: String,
278 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
279
280 fn sync_authenticated_review_request_metadata(
282 &self,
283 remote: ForgeRemote,
284 display_id: String,
285 input: UpdateReviewRequestInput,
286 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
287
288 fn fetch_authenticated_review_comment_snapshot(
290 &self,
291 remote: ForgeRemote,
292 display_id: String,
293 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
294
295 fn list_authenticated_requested_reviews(
297 &self,
298 remote: ForgeRemote,
299 ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
300}
301
302#[cfg(test)]
303mod tests {
304 use mockall::Sequence;
305
306 use super::*;
307 use crate::command::{ForgeCommand, ForgeCommandOutput, MockForgeCommandRunner};
308 use crate::{ForgeKind, ReviewRequestState};
309
310 #[test]
311 fn review_request_web_url_returns_error_when_summary_is_missing_url() {
312 let client = RealReviewRequestClient::default();
314 let review_request = ReviewRequestSummary {
315 display_id: "#42".to_string(),
316 forge_kind: ForgeKind::GitHub,
317 source_branch: "feature/forge".to_string(),
318 state: ReviewRequestState::Open,
319 status_summary: Some("Mergeable".to_string()),
320 target_branch: "main".to_string(),
321 title: "Add forge boundary".to_string(),
322 web_url: String::new(),
323 };
324
325 let error = client
327 .review_request_web_url(&review_request)
328 .expect_err("missing URL should be rejected");
329
330 assert_eq!(
332 error,
333 ReviewRequestError::OperationFailed {
334 forge_kind: ForgeKind::GitHub,
335 message: "review request summary is missing a web URL".to_string(),
336 }
337 );
338 }
339
340 #[test]
341 fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
342 let client = RealReviewRequestClient::default();
344 let review_request = ReviewRequestSummary {
345 display_id: "!42".to_string(),
346 forge_kind: ForgeKind::GitLab,
347 source_branch: "feature/forge".to_string(),
348 state: ReviewRequestState::Open,
349 status_summary: Some("Draft".to_string()),
350 target_branch: "main".to_string(),
351 title: "Add forge boundary".to_string(),
352 web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
353 };
354
355 let web_url = client
357 .review_request_web_url(&review_request)
358 .expect("gitlab review-request URL should be returned directly");
359
360 assert_eq!(
362 web_url,
363 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
364 );
365 }
366
367 #[tokio::test]
368 async fn find_by_source_branch_authenticates_once_before_github_lookup() {
369 let remote = github_remote();
371 let mut sequence = Sequence::new();
372 let mut command_runner = MockForgeCommandRunner::new();
373 command_runner
374 .expect_run()
375 .once()
376 .in_sequence(&mut sequence)
377 .withf(|command| {
378 command_arguments_are(
379 command,
380 "gh",
381 &["auth", "status", "--hostname", "github.com"],
382 )
383 })
384 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
385 command_runner
386 .expect_run()
387 .once()
388 .in_sequence(&mut sequence)
389 .withf(|command| {
390 command_arguments_are(
391 command,
392 "gh",
393 &[
394 "api",
395 "--hostname",
396 "github.com",
397 "--method",
398 "GET",
399 "repos/agentty-xyz/agentty/pulls",
400 "-f",
401 "head=agentty-xyz:feature/forge",
402 "-f",
403 "state=open",
404 "-f",
405 "sort=created",
406 "-f",
407 "direction=desc",
408 "-f",
409 "per_page=1",
410 ],
411 )
412 })
413 .returning(|_| {
414 Box::pin(async { Ok(success_output(r#"[{"number":42}]"#.to_string())) })
415 });
416 command_runner
417 .expect_run()
418 .once()
419 .in_sequence(&mut sequence)
420 .withf(|command| {
421 command_arguments_are(
422 command,
423 "gh",
424 &[
425 "pr",
426 "view",
427 "42",
428 "--repo",
429 "agentty-xyz/agentty",
430 "--json",
431 "number,title,state,url,baseRefName,headRefName,isDraft,mergeStateStatus,\
432 reviewDecision,mergedAt",
433 ],
434 )
435 })
436 .returning(|_| Box::pin(async { Ok(success_output(github_view_json())) }));
437 let client = RealReviewRequestClient::new(Arc::new(command_runner));
438
439 let review_request = client
441 .find_by_source_branch(remote, "feature/forge".to_string())
442 .await
443 .expect("GitHub lookup should succeed");
444
445 assert_eq!(
447 review_request,
448 Some(ReviewRequestSummary {
449 display_id: "#42".to_string(),
450 forge_kind: ForgeKind::GitHub,
451 source_branch: "feature/forge".to_string(),
452 state: ReviewRequestState::Open,
453 status_summary: Some("Approved, Mergeable".to_string()),
454 target_branch: "main".to_string(),
455 title: "Add forge review support".to_string(),
456 web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
457 })
458 );
459 }
460
461 #[tokio::test]
462 async fn refresh_review_request_stops_on_github_authentication_error() {
463 let remote = github_remote();
465 let mut command_runner = MockForgeCommandRunner::new();
466 command_runner
467 .expect_run()
468 .once()
469 .withf(|command| {
470 command_arguments_are(
471 command,
472 "gh",
473 &["auth", "status", "--hostname", "github.com"],
474 )
475 })
476 .returning(|_| {
477 Box::pin(async {
478 Ok(failure_output(
479 "You are not logged into any GitHub hosts. Run `gh auth login`."
480 .to_string(),
481 ))
482 })
483 });
484 let client = RealReviewRequestClient::new(Arc::new(command_runner));
485
486 let error = client
488 .refresh_review_request(remote, "#42".to_string())
489 .await
490 .expect_err("missing auth should stop before refresh");
491
492 assert_eq!(
494 error,
495 ReviewRequestError::AuthenticationRequired {
496 detail: Some(
497 "You are not logged into any GitHub hosts. Run `gh auth login`.".to_string()
498 ),
499 forge_kind: ForgeKind::GitHub,
500 host: "github.com".to_string(),
501 }
502 );
503 }
504
505 #[tokio::test]
506 async fn refresh_review_request_authenticates_before_gitlab_refresh() {
507 let remote = gitlab_remote();
509 let mut sequence = Sequence::new();
510 let mut command_runner = MockForgeCommandRunner::new();
511 command_runner
512 .expect_run()
513 .once()
514 .in_sequence(&mut sequence)
515 .withf(|command| {
516 command_arguments_are(
517 command,
518 "glab",
519 &["auth", "status", "--hostname", "gitlab.com"],
520 )
521 })
522 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
523 command_runner
524 .expect_run()
525 .once()
526 .in_sequence(&mut sequence)
527 .withf(|command| {
528 command_arguments_are(
529 command,
530 "glab",
531 &[
532 "mr",
533 "view",
534 "42",
535 "--repo",
536 "https://gitlab.com/agentty-xyz/agentty",
537 "--output",
538 "json",
539 ],
540 )
541 })
542 .returning(|_| Box::pin(async { Ok(success_output(gitlab_view_json())) }));
543 let client = RealReviewRequestClient::new(Arc::new(command_runner));
544
545 let review_request = client
547 .refresh_review_request(remote, "!42".to_string())
548 .await
549 .expect("GitLab refresh should succeed");
550
551 assert_eq!(review_request.display_id, "!42");
553 assert_eq!(review_request.forge_kind, ForgeKind::GitLab);
554 }
555
556 fn command_arguments_are(
558 command: &ForgeCommand,
559 executable: &'static str,
560 arguments: &[&str],
561 ) -> bool {
562 let expected_arguments = arguments
563 .iter()
564 .map(|argument| (*argument).to_string())
565 .collect::<Vec<_>>();
566
567 command.executable == executable && command.arguments == expected_arguments
568 }
569
570 fn github_remote() -> ForgeRemote {
572 ForgeRemote {
573 command_working_directory: None,
574 forge_kind: ForgeKind::GitHub,
575 host: "github.com".to_string(),
576 namespace: "agentty-xyz".to_string(),
577 project: "agentty".to_string(),
578 repo_url: "https://github.com/agentty-xyz/agentty.git".to_string(),
579 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
580 }
581 }
582
583 fn gitlab_remote() -> ForgeRemote {
585 ForgeRemote {
586 command_working_directory: None,
587 forge_kind: ForgeKind::GitLab,
588 host: "gitlab.com".to_string(),
589 namespace: "agentty-xyz".to_string(),
590 project: "agentty".to_string(),
591 repo_url: "https://gitlab.com/agentty-xyz/agentty.git".to_string(),
592 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
593 }
594 }
595
596 fn success_output(stdout: String) -> ForgeCommandOutput {
598 ForgeCommandOutput {
599 exit_code: Some(0),
600 stderr: String::new(),
601 stdout,
602 }
603 }
604
605 fn failure_output(stderr: String) -> ForgeCommandOutput {
607 ForgeCommandOutput {
608 exit_code: Some(1),
609 stderr,
610 stdout: String::new(),
611 }
612 }
613
614 fn github_view_json() -> String {
616 r#"{
617 "number": 42,
618 "title": "Add forge review support",
619 "state": "OPEN",
620 "url": "https://github.com/agentty-xyz/agentty/pull/42",
621 "baseRefName": "main",
622 "headRefName": "feature/forge",
623 "isDraft": false,
624 "mergeStateStatus": "CLEAN",
625 "reviewDecision": "APPROVED",
626 "mergedAt": null
627 }"#
628 .to_string()
629 }
630
631 fn gitlab_view_json() -> String {
633 r#"{
634 "draft": true,
635 "detailed_merge_status": "can_be_merged",
636 "iid": 42,
637 "merge_status": "can_be_merged",
638 "merged_at": null,
639 "source_branch": "feature/forge",
640 "state": "opened",
641 "target_branch": "main",
642 "title": "Add forge review support",
643 "description": "Current description.",
644 "web_url": "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
645 }"#
646 .to_string()
647 }
648}