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