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