1use std::sync::Arc;
4
5use super::{
6 AssignedIssue, CreateReviewRequestInput, ForgeCommandRunner, ForgeFuture, ForgeKind,
7 ForgeRemote, GitHubReviewRequestAdapter, GitLabReviewRequestAdapter, IssueDetail,
8 RealForgeCommandRunner, RequestedReview, ReviewCommentSnapshot, ReviewRequestError,
9 ReviewRequestMetadata, 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 review_request_metadata(
81 &self,
82 remote: ForgeRemote,
83 display_id: String,
84 ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
85
86 fn sync_review_request_metadata(
96 &self,
97 remote: ForgeRemote,
98 display_id: String,
99 input: UpdateReviewRequestInput,
100 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
101
102 fn review_request_web_url(
108 &self,
109 review_request: &ReviewRequestSummary,
110 ) -> Result<String, ReviewRequestError>;
111
112 fn fetch_review_comment_snapshot(
122 &self,
123 remote: ForgeRemote,
124 display_id: String,
125 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
126
127 fn reply_to_thread(
133 &self,
134 remote: ForgeRemote,
135 display_id: String,
136 thread_id: String,
137 body: String,
138 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
139
140 fn resolve_thread(
146 &self,
147 remote: ForgeRemote,
148 display_id: String,
149 thread_id: String,
150 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
151
152 fn list_requested_reviews(
159 &self,
160 remote: ForgeRemote,
161 ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
162}
163
164pub struct RealReviewRequestClient {
166 command_runner: Arc<dyn ForgeCommandRunner>,
167}
168
169impl RealReviewRequestClient {
170 pub(crate) fn new(command_runner: Arc<dyn ForgeCommandRunner>) -> Self {
172 Self { command_runner }
173 }
174
175 fn call_with_authenticated_adapter<T>(
177 &self,
178 remote: ForgeRemote,
179 call: impl FnOnce(
180 Arc<dyn ReviewRequestAdapter>,
181 ForgeRemote,
182 ) -> ForgeFuture<Result<T, ReviewRequestError>>
183 + Send
184 + 'static,
185 ) -> ForgeFuture<Result<T, ReviewRequestError>>
186 where
187 T: Send + 'static,
188 {
189 let adapter = self.adapter_for(remote.forge_kind);
190
191 Box::pin(async move {
192 adapter.ensure_authenticated(&remote).await?;
193
194 call(adapter, remote).await
195 })
196 }
197
198 fn adapter_for(&self, forge_kind: ForgeKind) -> Arc<dyn ReviewRequestAdapter> {
200 match forge_kind {
201 ForgeKind::GitHub => Arc::new(GitHubReviewRequestAdapter::new(Arc::clone(
202 &self.command_runner,
203 ))),
204 ForgeKind::GitLab => Arc::new(GitLabReviewRequestAdapter::new(Arc::clone(
205 &self.command_runner,
206 ))),
207 }
208 }
209}
210
211impl Default for RealReviewRequestClient {
212 fn default() -> Self {
213 Self::new(Arc::new(RealForgeCommandRunner))
214 }
215}
216
217impl ReviewRequestClient for RealReviewRequestClient {
218 fn list_assigned_issues(
219 &self,
220 remote: ForgeRemote,
221 ) -> ForgeFuture<Result<Vec<AssignedIssue>, ReviewRequestError>> {
222 if remote.forge_kind != ForgeKind::GitHub {
223 return Box::pin(async move {
224 Err(ReviewRequestError::OperationFailed {
225 forge_kind: remote.forge_kind,
226 message: "assigned issue lists require a GitHub project remote".to_string(),
227 })
228 });
229 }
230
231 GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner))
232 .list_assigned_issues(remote)
233 }
234
235 fn fetch_issue_detail(
236 &self,
237 remote: ForgeRemote,
238 display_id: String,
239 ) -> ForgeFuture<Result<IssueDetail, ReviewRequestError>> {
240 if remote.forge_kind != ForgeKind::GitHub {
241 return Box::pin(async move {
242 Err(ReviewRequestError::OperationFailed {
243 forge_kind: remote.forge_kind,
244 message: "issue details require a GitHub project remote".to_string(),
245 })
246 });
247 }
248
249 GitHubReviewRequestAdapter::new(Arc::clone(&self.command_runner))
250 .fetch_issue_detail(remote, display_id)
251 }
252
253 fn detect_remote(&self, repo_url: String) -> Result<ForgeRemote, ReviewRequestError> {
254 detect_remote(&repo_url)
255 }
256
257 fn find_by_source_branch(
258 &self,
259 remote: ForgeRemote,
260 source_branch: String,
261 ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>> {
262 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
263 adapter.find_authenticated_by_source_branch(remote, source_branch)
264 })
265 }
266
267 fn create_review_request(
268 &self,
269 remote: ForgeRemote,
270 input: CreateReviewRequestInput,
271 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
272 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
273 adapter.create_authenticated_review_request(remote, input)
274 })
275 }
276
277 fn refresh_review_request(
278 &self,
279 remote: ForgeRemote,
280 display_id: String,
281 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
282 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
283 adapter.refresh_authenticated_review_request(remote, display_id)
284 })
285 }
286
287 fn review_request_metadata(
288 &self,
289 remote: ForgeRemote,
290 display_id: String,
291 ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>> {
292 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
293 adapter.authenticated_review_request_metadata(remote, display_id)
294 })
295 }
296
297 fn sync_review_request_metadata(
298 &self,
299 remote: ForgeRemote,
300 display_id: String,
301 input: UpdateReviewRequestInput,
302 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>> {
303 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
304 adapter.sync_authenticated_review_request_metadata(remote, display_id, input)
305 })
306 }
307
308 fn review_request_web_url(
309 &self,
310 review_request: &ReviewRequestSummary,
311 ) -> Result<String, ReviewRequestError> {
312 if review_request.web_url.trim().is_empty() {
313 return Err(ReviewRequestError::OperationFailed {
314 forge_kind: review_request.forge_kind,
315 message: "review request summary is missing a web URL".to_string(),
316 });
317 }
318
319 Ok(review_request.web_url.clone())
320 }
321
322 fn fetch_review_comment_snapshot(
323 &self,
324 remote: ForgeRemote,
325 display_id: String,
326 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>> {
327 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
328 adapter.fetch_authenticated_review_comment_snapshot(remote, display_id)
329 })
330 }
331
332 fn reply_to_thread(
333 &self,
334 remote: ForgeRemote,
335 display_id: String,
336 thread_id: String,
337 body: String,
338 ) -> ForgeFuture<Result<(), ReviewRequestError>> {
339 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
340 adapter.reply_to_authenticated_thread(remote, display_id, thread_id, body)
341 })
342 }
343
344 fn resolve_thread(
345 &self,
346 remote: ForgeRemote,
347 display_id: String,
348 thread_id: String,
349 ) -> ForgeFuture<Result<(), ReviewRequestError>> {
350 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
351 adapter.resolve_authenticated_thread(remote, display_id, thread_id)
352 })
353 }
354
355 fn list_requested_reviews(
356 &self,
357 remote: ForgeRemote,
358 ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>> {
359 self.call_with_authenticated_adapter(remote, move |adapter, remote| {
360 adapter.list_authenticated_requested_reviews(remote)
361 })
362 }
363}
364
365pub(crate) trait ReviewRequestAdapter: Send + Sync {
372 fn ensure_authenticated(
378 &self,
379 remote: &ForgeRemote,
380 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
381
382 fn find_authenticated_by_source_branch(
384 &self,
385 remote: ForgeRemote,
386 source_branch: String,
387 ) -> ForgeFuture<Result<Option<ReviewRequestSummary>, ReviewRequestError>>;
388
389 fn create_authenticated_review_request(
392 &self,
393 remote: ForgeRemote,
394 input: CreateReviewRequestInput,
395 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
396
397 fn refresh_authenticated_review_request(
399 &self,
400 remote: ForgeRemote,
401 display_id: String,
402 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
403
404 fn authenticated_review_request_metadata(
406 &self,
407 remote: ForgeRemote,
408 display_id: String,
409 ) -> ForgeFuture<Result<ReviewRequestMetadata, ReviewRequestError>>;
410
411 fn sync_authenticated_review_request_metadata(
413 &self,
414 remote: ForgeRemote,
415 display_id: String,
416 input: UpdateReviewRequestInput,
417 ) -> ForgeFuture<Result<ReviewRequestSummary, ReviewRequestError>>;
418
419 fn fetch_authenticated_review_comment_snapshot(
421 &self,
422 remote: ForgeRemote,
423 display_id: String,
424 ) -> ForgeFuture<Result<ReviewCommentSnapshot, ReviewRequestError>>;
425
426 fn reply_to_authenticated_thread(
428 &self,
429 remote: ForgeRemote,
430 display_id: String,
431 thread_id: String,
432 body: String,
433 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
434
435 fn resolve_authenticated_thread(
437 &self,
438 remote: ForgeRemote,
439 display_id: String,
440 thread_id: String,
441 ) -> ForgeFuture<Result<(), ReviewRequestError>>;
442
443 fn list_authenticated_requested_reviews(
445 &self,
446 remote: ForgeRemote,
447 ) -> ForgeFuture<Result<Vec<RequestedReview>, ReviewRequestError>>;
448}
449
450#[cfg(test)]
451mod tests {
452 use mockall::Sequence;
453
454 use super::*;
455 use crate::command::{ForgeCommand, ForgeCommandOutput, MockForgeCommandRunner};
456 use crate::{ForgeKind, ReviewRequestState};
457
458 #[test]
459 fn review_request_web_url_returns_error_when_summary_is_missing_url() {
460 let client = RealReviewRequestClient::default();
462 let review_request = ReviewRequestSummary {
463 display_id: "#42".to_string(),
464 forge_kind: ForgeKind::GitHub,
465 source_branch: "feature/forge".to_string(),
466 state: ReviewRequestState::Open,
467 status_summary: Some("Mergeable".to_string()),
468 target_branch: "main".to_string(),
469 title: "Add forge boundary".to_string(),
470 web_url: String::new(),
471 };
472
473 let error = client
475 .review_request_web_url(&review_request)
476 .expect_err("missing URL should be rejected");
477
478 assert_eq!(
480 error,
481 ReviewRequestError::OperationFailed {
482 forge_kind: ForgeKind::GitHub,
483 message: "review request summary is missing a web URL".to_string(),
484 }
485 );
486 }
487
488 #[test]
489 fn review_request_web_url_returns_gitlab_url_without_provider_routing() {
490 let client = RealReviewRequestClient::default();
492 let review_request = ReviewRequestSummary {
493 display_id: "!42".to_string(),
494 forge_kind: ForgeKind::GitLab,
495 source_branch: "feature/forge".to_string(),
496 state: ReviewRequestState::Open,
497 status_summary: Some("Draft".to_string()),
498 target_branch: "main".to_string(),
499 title: "Add forge boundary".to_string(),
500 web_url: "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42".to_string(),
501 };
502
503 let web_url = client
505 .review_request_web_url(&review_request)
506 .expect("gitlab review-request URL should be returned directly");
507
508 assert_eq!(
510 web_url,
511 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
512 );
513 }
514
515 #[tokio::test]
516 async fn find_by_source_branch_authenticates_once_before_github_lookup() {
517 let remote = github_remote();
519 let mut sequence = Sequence::new();
520 let mut command_runner = MockForgeCommandRunner::new();
521 command_runner
522 .expect_run()
523 .once()
524 .in_sequence(&mut sequence)
525 .withf(|command| {
526 command_arguments_are(
527 command,
528 "gh",
529 &["auth", "status", "--hostname", "github.com"],
530 )
531 })
532 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
533 command_runner
534 .expect_run()
535 .once()
536 .in_sequence(&mut sequence)
537 .withf(|command| {
538 command_arguments_are(
539 command,
540 "gh",
541 &[
542 "api",
543 "--hostname",
544 "github.com",
545 "--method",
546 "GET",
547 "repos/agentty-xyz/agentty/pulls",
548 "-f",
549 "head=agentty-xyz:feature/forge",
550 "-f",
551 "state=open",
552 "-f",
553 "sort=created",
554 "-f",
555 "direction=desc",
556 "-f",
557 "per_page=1",
558 ],
559 )
560 })
561 .returning(|_| {
562 Box::pin(async { Ok(success_output(r#"[{"number":42}]"#.to_string())) })
563 });
564 command_runner
565 .expect_run()
566 .once()
567 .in_sequence(&mut sequence)
568 .withf(|command| {
569 command_arguments_are(
570 command,
571 "gh",
572 &[
573 "pr",
574 "view",
575 "42",
576 "--repo",
577 "agentty-xyz/agentty",
578 "--json",
579 "number,title,state,url,baseRefName,headRefName,isDraft,mergeStateStatus,\
580 reviewDecision,mergedAt",
581 ],
582 )
583 })
584 .returning(|_| Box::pin(async { Ok(success_output(github_view_json())) }));
585 let client = RealReviewRequestClient::new(Arc::new(command_runner));
586
587 let review_request = client
589 .find_by_source_branch(remote, "feature/forge".to_string())
590 .await
591 .expect("GitHub lookup should succeed");
592
593 assert_eq!(
595 review_request,
596 Some(ReviewRequestSummary {
597 display_id: "#42".to_string(),
598 forge_kind: ForgeKind::GitHub,
599 source_branch: "feature/forge".to_string(),
600 state: ReviewRequestState::Open,
601 status_summary: Some("Approved, Mergeable".to_string()),
602 target_branch: "main".to_string(),
603 title: "Add forge review support".to_string(),
604 web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
605 })
606 );
607 }
608
609 #[tokio::test]
610 async fn review_request_metadata_authenticates_before_github_lookup() {
611 let remote = github_remote();
613 let mut sequence = Sequence::new();
614 let mut command_runner = MockForgeCommandRunner::new();
615 command_runner
616 .expect_run()
617 .once()
618 .in_sequence(&mut sequence)
619 .withf(|command| {
620 command_arguments_are(
621 command,
622 "gh",
623 &["auth", "status", "--hostname", "github.com"],
624 )
625 })
626 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
627 command_runner
628 .expect_run()
629 .once()
630 .in_sequence(&mut sequence)
631 .withf(|command| {
632 command_arguments_are(
633 command,
634 "gh",
635 &[
636 "pr",
637 "view",
638 "42",
639 "--repo",
640 "agentty-xyz/agentty",
641 "--json",
642 "title,body",
643 ],
644 )
645 })
646 .returning(|_| {
647 Box::pin(async {
648 Ok(success_output(
649 r#"{"title":"Current title","body":"Current body"}"#.to_string(),
650 ))
651 })
652 });
653 let client = RealReviewRequestClient::new(Arc::new(command_runner));
654
655 let metadata = client
657 .review_request_metadata(remote, "#42".to_string())
658 .await
659 .expect("GitHub metadata lookup should succeed");
660
661 assert_eq!(
663 metadata,
664 ReviewRequestMetadata {
665 body: "Current body".to_string(),
666 title: "Current title".to_string(),
667 }
668 );
669 }
670
671 #[tokio::test]
672 async fn refresh_review_request_stops_on_github_authentication_error() {
673 let remote = github_remote();
675 let mut command_runner = MockForgeCommandRunner::new();
676 command_runner
677 .expect_run()
678 .once()
679 .withf(|command| {
680 command_arguments_are(
681 command,
682 "gh",
683 &["auth", "status", "--hostname", "github.com"],
684 )
685 })
686 .returning(|_| {
687 Box::pin(async {
688 Ok(failure_output(
689 "You are not logged into any GitHub hosts. Run `gh auth login`."
690 .to_string(),
691 ))
692 })
693 });
694 let client = RealReviewRequestClient::new(Arc::new(command_runner));
695
696 let error = client
698 .refresh_review_request(remote, "#42".to_string())
699 .await
700 .expect_err("missing auth should stop before refresh");
701
702 assert_eq!(
704 error,
705 ReviewRequestError::AuthenticationRequired {
706 detail: Some(
707 "You are not logged into any GitHub hosts. Run `gh auth login`.".to_string()
708 ),
709 forge_kind: ForgeKind::GitHub,
710 host: "github.com".to_string(),
711 }
712 );
713 }
714
715 #[tokio::test]
716 async fn review_thread_mutations_authenticate_and_route_to_github_adapter() {
717 let remote = github_remote();
719 let mut sequence = Sequence::new();
720 let mut command_runner = MockForgeCommandRunner::new();
721 for expected_mutation in ["addPullRequestReviewThreadReply", "resolveReviewThread"] {
722 command_runner
723 .expect_run()
724 .once()
725 .in_sequence(&mut sequence)
726 .withf(|command| {
727 command_arguments_are(
728 command,
729 "gh",
730 &["auth", "status", "--hostname", "github.com"],
731 )
732 })
733 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
734 command_runner
735 .expect_run()
736 .once()
737 .in_sequence(&mut sequence)
738 .withf(move |command| {
739 command.executable == "gh"
740 && command
741 .arguments
742 .iter()
743 .any(|argument| argument.contains(expected_mutation))
744 })
745 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
746 }
747 let client = RealReviewRequestClient::new(Arc::new(command_runner));
748
749 let reply_result = client
751 .reply_to_thread(
752 remote.clone(),
753 "#42".to_string(),
754 "thread-1".to_string(),
755 "Addressed.".to_string(),
756 )
757 .await;
758 let resolution_result = client
759 .resolve_thread(remote, "#42".to_string(), "thread-1".to_string())
760 .await;
761
762 assert_eq!(reply_result, Ok(()));
764 assert_eq!(resolution_result, Ok(()));
765 }
766
767 #[tokio::test]
768 async fn refresh_review_request_authenticates_before_gitlab_refresh() {
769 let remote = gitlab_remote();
771 let mut sequence = Sequence::new();
772 let mut command_runner = MockForgeCommandRunner::new();
773 command_runner
774 .expect_run()
775 .once()
776 .in_sequence(&mut sequence)
777 .withf(|command| {
778 command_arguments_are(
779 command,
780 "glab",
781 &["auth", "status", "--hostname", "gitlab.com"],
782 )
783 })
784 .returning(|_| Box::pin(async { Ok(success_output(String::new())) }));
785 command_runner
786 .expect_run()
787 .once()
788 .in_sequence(&mut sequence)
789 .withf(|command| {
790 command_arguments_are(
791 command,
792 "glab",
793 &[
794 "mr",
795 "view",
796 "42",
797 "--repo",
798 "https://gitlab.com/agentty-xyz/agentty",
799 "--output",
800 "json",
801 ],
802 )
803 })
804 .returning(|_| Box::pin(async { Ok(success_output(gitlab_view_json())) }));
805 let client = RealReviewRequestClient::new(Arc::new(command_runner));
806
807 let review_request = client
809 .refresh_review_request(remote, "!42".to_string())
810 .await
811 .expect("GitLab refresh should succeed");
812
813 assert_eq!(review_request.display_id, "!42");
815 assert_eq!(review_request.forge_kind, ForgeKind::GitLab);
816 }
817
818 fn command_arguments_are(
820 command: &ForgeCommand,
821 executable: &'static str,
822 arguments: &[&str],
823 ) -> bool {
824 let expected_arguments = arguments
825 .iter()
826 .map(|argument| (*argument).to_string())
827 .collect::<Vec<_>>();
828
829 command.executable == executable && command.arguments == expected_arguments
830 }
831
832 fn github_remote() -> ForgeRemote {
834 ForgeRemote {
835 command_working_directory: None,
836 forge_kind: ForgeKind::GitHub,
837 host: "github.com".to_string(),
838 namespace: "agentty-xyz".to_string(),
839 project: "agentty".to_string(),
840 repo_url: "https://github.com/agentty-xyz/agentty.git".to_string(),
841 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
842 }
843 }
844
845 fn gitlab_remote() -> ForgeRemote {
847 ForgeRemote {
848 command_working_directory: None,
849 forge_kind: ForgeKind::GitLab,
850 host: "gitlab.com".to_string(),
851 namespace: "agentty-xyz".to_string(),
852 project: "agentty".to_string(),
853 repo_url: "https://gitlab.com/agentty-xyz/agentty.git".to_string(),
854 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
855 }
856 }
857
858 fn success_output(stdout: String) -> ForgeCommandOutput {
860 ForgeCommandOutput {
861 exit_code: Some(0),
862 stderr: String::new(),
863 stdout,
864 }
865 }
866
867 fn failure_output(stderr: String) -> ForgeCommandOutput {
869 ForgeCommandOutput {
870 exit_code: Some(1),
871 stderr,
872 stdout: String::new(),
873 }
874 }
875
876 fn github_view_json() -> String {
878 r#"{
879 "number": 42,
880 "title": "Add forge review support",
881 "state": "OPEN",
882 "url": "https://github.com/agentty-xyz/agentty/pull/42",
883 "baseRefName": "main",
884 "headRefName": "feature/forge",
885 "isDraft": false,
886 "mergeStateStatus": "CLEAN",
887 "reviewDecision": "APPROVED",
888 "mergedAt": null
889 }"#
890 .to_string()
891 }
892
893 fn gitlab_view_json() -> String {
895 r#"{
896 "draft": true,
897 "detailed_merge_status": "can_be_merged",
898 "iid": 42,
899 "merge_status": "can_be_merged",
900 "merged_at": null,
901 "source_branch": "feature/forge",
902 "state": "opened",
903 "target_branch": "main",
904 "title": "Add forge review support",
905 "description": "Current description.",
906 "web_url": "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/42"
907 }"#
908 .to_string()
909 }
910}