1use std::fmt;
4use std::fmt::Write as _;
5use std::future::Future;
6use std::path::PathBuf;
7use std::pin::Pin;
8use std::str::FromStr;
9
10use url::Url;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum ForgeKind {
15 GitHub,
17 GitLab,
19}
20
21impl ForgeKind {
22 pub fn display_name(self) -> &'static str {
24 match self {
25 Self::GitHub => "GitHub",
26 Self::GitLab => "GitLab",
27 }
28 }
29
30 pub fn cli_name(self) -> &'static str {
32 match self {
33 Self::GitHub => "gh",
34 Self::GitLab => "glab",
35 }
36 }
37
38 pub fn auth_login_command(self) -> &'static str {
40 match self {
41 Self::GitHub => "gh auth login",
42 Self::GitLab => "glab auth login",
43 }
44 }
45
46 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::GitHub => "GitHub",
50 Self::GitLab => "GitLab",
51 }
52 }
53
54 pub fn review_request_name(self) -> &'static str {
56 match self {
57 Self::GitHub => "pull request",
58 Self::GitLab => "merge request",
59 }
60 }
61
62 pub fn review_request_display_name(self) -> String {
64 format!("{} {}", self.display_name(), self.review_request_name())
65 }
66
67 pub fn review_request_short_name(self) -> &'static str {
69 match self {
70 Self::GitHub => "PR",
71 Self::GitLab => "MR",
72 }
73 }
74}
75
76impl fmt::Display for ForgeKind {
77 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter.write_str(self.as_str())
79 }
80}
81
82impl FromStr for ForgeKind {
83 type Err = String;
84
85 fn from_str(value: &str) -> Result<Self, Self::Err> {
86 match value {
87 "GitHub" => Ok(Self::GitHub),
88 "GitLab" => Ok(Self::GitLab),
89 _ => Err(format!("Unknown review-request forge: {value}")),
90 }
91 }
92}
93
94pub fn is_gitlab_host(host: &str) -> bool {
96 host == "gitlab.com"
97 || host.ends_with(".gitlab.com")
98 || host.starts_with("gitlab.")
99 || host.contains(".gitlab.")
100}
101
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum ReviewRequestState {
105 Open,
107 Merged,
109 Closed,
111}
112
113impl ReviewRequestState {
114 pub fn as_str(self) -> &'static str {
116 match self {
117 Self::Open => "Open",
118 Self::Merged => "Merged",
119 Self::Closed => "Closed",
120 }
121 }
122}
123
124impl fmt::Display for ReviewRequestState {
125 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126 formatter.write_str(self.as_str())
127 }
128}
129
130impl FromStr for ReviewRequestState {
131 type Err = String;
132
133 fn from_str(value: &str) -> Result<Self, Self::Err> {
134 match value {
135 "Open" => Ok(Self::Open),
136 "Merged" => Ok(Self::Merged),
137 "Closed" => Ok(Self::Closed),
138 _ => Err(format!("Unknown review-request state: {value}")),
139 }
140 }
141}
142
143#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct ReviewRequestSummary {
152 pub display_id: String,
154 pub forge_kind: ForgeKind,
156 pub source_branch: String,
158 pub state: ReviewRequestState,
160 pub status_summary: Option<String>,
162 pub target_branch: String,
164 pub title: String,
166 pub web_url: String,
168}
169
170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub enum RequestedReviewAudience {
174 Personal,
176 Group,
178}
179
180#[derive(Clone, Debug, Eq, PartialEq)]
183pub struct RequestedReview {
184 pub audience: RequestedReviewAudience,
187 pub author: String,
189 pub body: Option<String>,
191 pub comment_snapshot: Option<ReviewCommentSnapshot>,
196 pub display_id: String,
198 pub forge_kind: ForgeKind,
200 pub repository: String,
202 pub status_summary: Option<String>,
204 pub title: String,
206 pub updated_at: Option<String>,
208 pub web_url: String,
210}
211
212#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct AssignedIssue {
215 pub display_id: String,
217 pub repository: String,
219 pub title: String,
221 pub updated_at: Option<String>,
223 pub web_url: String,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct IssueDetail {
230 pub assignees: Vec<String>,
232 pub author: String,
234 pub body: Option<String>,
236 pub created_at: Option<String>,
238 pub display_id: String,
240 pub labels: Vec<String>,
242 pub repository: String,
244 pub state: String,
246 pub title: String,
248 pub updated_at: Option<String>,
250 pub web_url: String,
252}
253
254pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
256
257#[derive(Clone, Debug, Eq, PartialEq)]
259pub struct ForgeRemote {
260 pub command_working_directory: Option<PathBuf>,
263 pub forge_kind: ForgeKind,
265 pub host: String,
270 pub namespace: String,
272 pub project: String,
274 pub repo_url: String,
276 pub web_url: String,
278}
279
280impl ForgeRemote {
281 #[must_use]
284 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
285 self.command_working_directory = Some(working_directory);
286
287 self
288 }
289
290 pub fn project_path(&self) -> String {
292 format!("{}/{}", self.namespace, self.project)
293 }
294
295 pub fn review_request_creation_url(
303 &self,
304 source_branch: &str,
305 target_branch: &str,
306 ) -> Result<String, ReviewRequestError> {
307 match self.forge_kind {
308 ForgeKind::GitHub => {
309 github_review_request_creation_url(self, source_branch, target_branch)
310 }
311 ForgeKind::GitLab => {
312 gitlab_review_request_creation_url(self, source_branch, target_branch)
313 }
314 }
315 }
316}
317
318#[derive(Clone, Debug, Eq, PartialEq)]
320pub struct ReviewComment {
321 pub author: String,
323 pub body: String,
325}
326
327#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
329pub enum ReviewCommentAnchorSide {
330 File,
332 New,
334 Old,
336}
337
338#[derive(Clone, Debug, Eq, PartialEq)]
345pub struct ReviewCommentThread {
346 pub anchor_side: ReviewCommentAnchorSide,
348 pub comments: Vec<ReviewComment>,
350 pub id: String,
352 pub is_outdated: Option<bool>,
355 pub is_resolved: bool,
357 pub line: Option<u32>,
359 pub path: String,
361 pub start_line: Option<u32>,
363}
364
365impl ReviewCommentThread {
366 pub fn is_actionable(&self) -> bool {
371 !self.is_resolved
372 }
373}
374
375#[derive(Clone, Debug, Default, Eq, PartialEq)]
382pub struct ReviewCommentSnapshot {
383 pub pr_level_comments: Vec<ReviewComment>,
386 pub threads: Vec<ReviewCommentThread>,
388}
389
390#[derive(Clone, Debug, Eq, PartialEq)]
392pub struct CreateReviewRequestInput {
393 pub body: Option<String>,
395 pub source_branch: String,
397 pub target_branch: String,
399 pub title: String,
401}
402
403#[derive(Clone, Debug, Eq, PartialEq)]
405pub struct ReviewRequestMetadata {
406 pub body: String,
408 pub title: String,
410}
411
412#[derive(Clone, Debug, Eq, PartialEq)]
415pub struct ReviewRequestMetadataFieldUpdate {
416 pub current: String,
418 pub desired: String,
420}
421
422#[derive(Clone, Debug, Eq, PartialEq)]
424pub struct UpdateReviewRequestInput {
425 pub body: Option<ReviewRequestMetadataFieldUpdate>,
427 pub title: Option<ReviewRequestMetadataFieldUpdate>,
429}
430
431#[derive(Clone, Debug, Eq, PartialEq)]
433pub enum ReviewRequestError {
434 CliNotInstalled {
436 forge_kind: ForgeKind,
438 },
439 AuthenticationRequired {
441 forge_kind: ForgeKind,
443 host: String,
445 detail: Option<String>,
447 },
448 HostResolutionFailed {
450 forge_kind: ForgeKind,
452 host: String,
454 },
455 UnsupportedRemote {
457 repo_url: String,
459 },
460 OperationFailed {
462 forge_kind: ForgeKind,
464 message: String,
466 },
467}
468
469impl ReviewRequestError {
470 pub fn detail_message(&self) -> String {
472 match self {
473 Self::CliNotInstalled { forge_kind } => format!(
474 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
475 forge_kind.display_name(),
476 forge_kind.cli_name(),
477 forge_kind.cli_name(),
478 forge_kind.auth_login_command(),
479 ),
480 Self::AuthenticationRequired {
481 forge_kind,
482 host,
483 detail,
484 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
485 Self::HostResolutionFailed { forge_kind, host } => format!(
486 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
487 and your network or DNS setup, then retry.",
488 forge_kind.display_name(),
489 ),
490 Self::UnsupportedRemote { repo_url } => format!(
491 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
492 repository remote is not supported: `{repo_url}`."
493 ),
494 Self::OperationFailed {
495 forge_kind,
496 message,
497 } => format!(
498 "{} review-request operation failed: {message}",
499 forge_kind.display_name()
500 ),
501 }
502 }
503}
504
505fn github_review_request_creation_url(
507 remote: &ForgeRemote,
508 source_branch: &str,
509 target_branch: &str,
510) -> Result<String, ReviewRequestError> {
511 let mut url = parsed_remote_web_url(remote)?;
512 let compare_target = if target_branch.trim().is_empty() {
513 source_branch.to_string()
514 } else {
515 format!("{target_branch}...{source_branch}")
516 };
517
518 {
519 let mut path_segments = url
520 .path_segments_mut()
521 .map_err(|()| invalid_web_url_error(remote))?;
522 path_segments.pop_if_empty();
523 path_segments.push("compare");
524 path_segments.push(&compare_target);
525 }
526
527 url.query_pairs_mut().append_pair("expand", "1");
528
529 Ok(url.into())
530}
531
532fn gitlab_review_request_creation_url(
534 remote: &ForgeRemote,
535 source_branch: &str,
536 target_branch: &str,
537) -> Result<String, ReviewRequestError> {
538 let mut url = parsed_remote_web_url(remote)?;
539
540 {
541 let mut path_segments = url
542 .path_segments_mut()
543 .map_err(|()| invalid_web_url_error(remote))?;
544 path_segments.pop_if_empty();
545 path_segments.push("-");
546 path_segments.push("merge_requests");
547 path_segments.push("new");
548 }
549
550 url.query_pairs_mut()
551 .append_pair("merge_request[source_branch]", source_branch)
552 .append_pair("merge_request[target_branch]", target_branch);
553
554 Ok(url.into())
555}
556
557fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
559 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
560}
561
562fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
564 ReviewRequestError::OperationFailed {
565 forge_kind: remote.forge_kind,
566 message: format!(
567 "repository remote is missing a valid web URL: `{}`",
568 remote.web_url
569 ),
570 }
571}
572
573fn authentication_required_message(
576 forge_kind: ForgeKind,
577 host: &str,
578 detail: Option<&str>,
579) -> String {
580 let mut message = format!(
581 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
582 forge_kind.display_name(),
583 forge_kind.auth_login_command(),
584 );
585
586 if let Some(detail) = non_empty_detail(detail) {
587 let _ = write!(
589 message,
590 "\n\nOriginal `{}` error:\n```text\n{detail}",
591 forge_kind.cli_name(),
592 );
593 if !detail.ends_with('\n') {
594 message.push('\n');
595 }
596 message.push_str("```");
597 }
598
599 message
600}
601
602fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
604 detail.and_then(|detail| {
605 let trimmed_detail = detail.trim();
606 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
607 })
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 fn review_comment_thread() -> ReviewCommentThread {
615 ReviewCommentThread {
616 anchor_side: ReviewCommentAnchorSide::New,
617 comments: Vec::new(),
618 id: "thread-1".to_string(),
619 is_outdated: Some(false),
620 is_resolved: false,
621 line: Some(1),
622 path: "src/lib.rs".to_string(),
623 start_line: None,
624 }
625 }
626
627 #[test]
628 fn review_comment_thread_is_actionable_when_unresolved_even_if_outdated() {
629 let actionable = review_comment_thread();
631 let mut resolved = review_comment_thread();
632 resolved.is_resolved = true;
633 let mut outdated = review_comment_thread();
634 outdated.is_outdated = Some(true);
635
636 assert!(actionable.is_actionable());
638 assert!(!resolved.is_actionable());
639 assert!(outdated.is_actionable());
640 }
641
642 #[test]
643 fn forge_kind_from_str_gitlab() {
644 let raw_forge_kind = "GitLab";
646
647 let forge_kind = raw_forge_kind
649 .parse::<ForgeKind>()
650 .expect("gitlab forge kind should parse");
651
652 assert_eq!(forge_kind, ForgeKind::GitLab);
654 assert_eq!(forge_kind.cli_name(), "glab");
655 assert_eq!(forge_kind.review_request_name(), "merge request");
656 assert_eq!(forge_kind.review_request_short_name(), "MR");
657 }
658
659 #[test]
660 fn authentication_required_message_includes_original_cli_error_detail() {
661 let error = ReviewRequestError::AuthenticationRequired {
663 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
664 forge_kind: ForgeKind::GitHub,
665 host: "github.com".to_string(),
666 };
667
668 let message = error.detail_message();
670
671 assert!(message.contains("GitHub review requests require local CLI authentication"));
673 assert!(message.contains("Run `gh auth login` and retry."));
674 assert!(message.contains("Original `gh` error:"));
675 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
676 assert!(message.contains("```text"));
677 }
678
679 #[test]
680 fn authentication_required_message_omits_empty_original_cli_error_detail() {
681 let error = ReviewRequestError::AuthenticationRequired {
683 detail: Some(" \n".to_string()),
684 forge_kind: ForgeKind::GitHub,
685 host: "github.com".to_string(),
686 };
687
688 let message = error.detail_message();
690
691 assert!(message.contains("Run `gh auth login` and retry."));
693 assert!(!message.contains("Original `gh` error:"));
694 }
695
696 #[test]
697 fn review_request_creation_url_returns_github_compare_link() {
698 let remote = ForgeRemote {
700 command_working_directory: None,
701 forge_kind: ForgeKind::GitHub,
702 host: "github.com".to_string(),
703 namespace: "agentty-xyz".to_string(),
704 project: "agentty".to_string(),
705 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
706 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
707 };
708
709 let url = remote
711 .review_request_creation_url("review/custom-branch", "main")
712 .expect("github compare URL should be created");
713
714 assert_eq!(
716 url,
717 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
718 );
719 }
720
721 #[test]
722 fn review_request_creation_url_rejects_invalid_web_url() {
723 let remote = ForgeRemote {
725 command_working_directory: None,
726 forge_kind: ForgeKind::GitHub,
727 host: "github.com".to_string(),
728 namespace: "agentty-xyz".to_string(),
729 project: "agentty".to_string(),
730 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
731 web_url: "not a url".to_string(),
732 };
733
734 let error = remote
736 .review_request_creation_url("review/custom-branch", "main")
737 .expect_err("invalid web URL should be rejected");
738
739 assert_eq!(
741 error,
742 ReviewRequestError::OperationFailed {
743 forge_kind: ForgeKind::GitHub,
744 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
745 }
746 );
747 }
748
749 #[test]
750 fn review_request_creation_url_returns_gitlab_merge_request_link() {
751 let remote = ForgeRemote {
753 command_working_directory: None,
754 forge_kind: ForgeKind::GitLab,
755 host: "gitlab.com".to_string(),
756 namespace: "agentty-xyz".to_string(),
757 project: "agentty".to_string(),
758 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
759 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
760 };
761
762 let url = remote
764 .review_request_creation_url("review/custom-branch", "main")
765 .expect("gitlab merge-request URL should be created");
766
767 assert_eq!(
769 url,
770 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
771 );
772 }
773}