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 {
369 !self.is_resolved && self.is_outdated != Some(true)
370 }
371}
372
373#[derive(Clone, Debug, Default, Eq, PartialEq)]
380pub struct ReviewCommentSnapshot {
381 pub pr_level_comments: Vec<ReviewComment>,
384 pub threads: Vec<ReviewCommentThread>,
386}
387
388#[derive(Clone, Debug, Eq, PartialEq)]
390pub struct CreateReviewRequestInput {
391 pub body: Option<String>,
393 pub source_branch: String,
395 pub target_branch: String,
397 pub title: String,
399}
400
401#[derive(Clone, Debug, Eq, PartialEq)]
404pub struct UpdateReviewRequestInput {
405 pub body: Option<String>,
407 pub title: String,
409}
410
411#[derive(Clone, Debug, Eq, PartialEq)]
413pub enum ReviewRequestError {
414 CliNotInstalled {
416 forge_kind: ForgeKind,
418 },
419 AuthenticationRequired {
421 forge_kind: ForgeKind,
423 host: String,
425 detail: Option<String>,
427 },
428 HostResolutionFailed {
430 forge_kind: ForgeKind,
432 host: String,
434 },
435 UnsupportedRemote {
437 repo_url: String,
439 },
440 OperationFailed {
442 forge_kind: ForgeKind,
444 message: String,
446 },
447}
448
449impl ReviewRequestError {
450 pub fn detail_message(&self) -> String {
452 match self {
453 Self::CliNotInstalled { forge_kind } => format!(
454 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
455 forge_kind.display_name(),
456 forge_kind.cli_name(),
457 forge_kind.cli_name(),
458 forge_kind.auth_login_command(),
459 ),
460 Self::AuthenticationRequired {
461 forge_kind,
462 host,
463 detail,
464 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
465 Self::HostResolutionFailed { forge_kind, host } => format!(
466 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
467 and your network or DNS setup, then retry.",
468 forge_kind.display_name(),
469 ),
470 Self::UnsupportedRemote { repo_url } => format!(
471 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
472 repository remote is not supported: `{repo_url}`."
473 ),
474 Self::OperationFailed {
475 forge_kind,
476 message,
477 } => format!(
478 "{} review-request operation failed: {message}",
479 forge_kind.display_name()
480 ),
481 }
482 }
483}
484
485fn github_review_request_creation_url(
487 remote: &ForgeRemote,
488 source_branch: &str,
489 target_branch: &str,
490) -> Result<String, ReviewRequestError> {
491 let mut url = parsed_remote_web_url(remote)?;
492 let compare_target = if target_branch.trim().is_empty() {
493 source_branch.to_string()
494 } else {
495 format!("{target_branch}...{source_branch}")
496 };
497
498 {
499 let mut path_segments = url
500 .path_segments_mut()
501 .map_err(|()| invalid_web_url_error(remote))?;
502 path_segments.pop_if_empty();
503 path_segments.push("compare");
504 path_segments.push(&compare_target);
505 }
506
507 url.query_pairs_mut().append_pair("expand", "1");
508
509 Ok(url.into())
510}
511
512fn gitlab_review_request_creation_url(
514 remote: &ForgeRemote,
515 source_branch: &str,
516 target_branch: &str,
517) -> Result<String, ReviewRequestError> {
518 let mut url = parsed_remote_web_url(remote)?;
519
520 {
521 let mut path_segments = url
522 .path_segments_mut()
523 .map_err(|()| invalid_web_url_error(remote))?;
524 path_segments.pop_if_empty();
525 path_segments.push("-");
526 path_segments.push("merge_requests");
527 path_segments.push("new");
528 }
529
530 url.query_pairs_mut()
531 .append_pair("merge_request[source_branch]", source_branch)
532 .append_pair("merge_request[target_branch]", target_branch);
533
534 Ok(url.into())
535}
536
537fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
539 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
540}
541
542fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
544 ReviewRequestError::OperationFailed {
545 forge_kind: remote.forge_kind,
546 message: format!(
547 "repository remote is missing a valid web URL: `{}`",
548 remote.web_url
549 ),
550 }
551}
552
553fn authentication_required_message(
556 forge_kind: ForgeKind,
557 host: &str,
558 detail: Option<&str>,
559) -> String {
560 let mut message = format!(
561 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
562 forge_kind.display_name(),
563 forge_kind.auth_login_command(),
564 );
565
566 if let Some(detail) = non_empty_detail(detail) {
567 let _ = write!(
569 message,
570 "\n\nOriginal `{}` error:\n```text\n{detail}",
571 forge_kind.cli_name(),
572 );
573 if !detail.ends_with('\n') {
574 message.push('\n');
575 }
576 message.push_str("```");
577 }
578
579 message
580}
581
582fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
584 detail.and_then(|detail| {
585 let trimmed_detail = detail.trim();
586 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
587 })
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 fn review_comment_thread() -> ReviewCommentThread {
595 ReviewCommentThread {
596 anchor_side: ReviewCommentAnchorSide::New,
597 comments: Vec::new(),
598 id: "thread-1".to_string(),
599 is_outdated: Some(false),
600 is_resolved: false,
601 line: Some(1),
602 path: "src/lib.rs".to_string(),
603 start_line: None,
604 }
605 }
606
607 #[test]
608 fn review_comment_thread_is_actionable_only_when_current_and_unresolved() {
609 let actionable = review_comment_thread();
611 let mut resolved = review_comment_thread();
612 resolved.is_resolved = true;
613 let mut outdated = review_comment_thread();
614 outdated.is_outdated = Some(true);
615
616 assert!(actionable.is_actionable());
618 assert!(!resolved.is_actionable());
619 assert!(!outdated.is_actionable());
620 }
621
622 #[test]
623 fn forge_kind_from_str_gitlab() {
624 let raw_forge_kind = "GitLab";
626
627 let forge_kind = raw_forge_kind
629 .parse::<ForgeKind>()
630 .expect("gitlab forge kind should parse");
631
632 assert_eq!(forge_kind, ForgeKind::GitLab);
634 assert_eq!(forge_kind.cli_name(), "glab");
635 assert_eq!(forge_kind.review_request_name(), "merge request");
636 assert_eq!(forge_kind.review_request_short_name(), "MR");
637 }
638
639 #[test]
640 fn authentication_required_message_includes_original_cli_error_detail() {
641 let error = ReviewRequestError::AuthenticationRequired {
643 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
644 forge_kind: ForgeKind::GitHub,
645 host: "github.com".to_string(),
646 };
647
648 let message = error.detail_message();
650
651 assert!(message.contains("GitHub review requests require local CLI authentication"));
653 assert!(message.contains("Run `gh auth login` and retry."));
654 assert!(message.contains("Original `gh` error:"));
655 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
656 assert!(message.contains("```text"));
657 }
658
659 #[test]
660 fn authentication_required_message_omits_empty_original_cli_error_detail() {
661 let error = ReviewRequestError::AuthenticationRequired {
663 detail: Some(" \n".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("Run `gh auth login` and retry."));
673 assert!(!message.contains("Original `gh` error:"));
674 }
675
676 #[test]
677 fn review_request_creation_url_returns_github_compare_link() {
678 let remote = ForgeRemote {
680 command_working_directory: None,
681 forge_kind: ForgeKind::GitHub,
682 host: "github.com".to_string(),
683 namespace: "agentty-xyz".to_string(),
684 project: "agentty".to_string(),
685 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
686 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
687 };
688
689 let url = remote
691 .review_request_creation_url("review/custom-branch", "main")
692 .expect("github compare URL should be created");
693
694 assert_eq!(
696 url,
697 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
698 );
699 }
700
701 #[test]
702 fn review_request_creation_url_rejects_invalid_web_url() {
703 let remote = ForgeRemote {
705 command_working_directory: None,
706 forge_kind: ForgeKind::GitHub,
707 host: "github.com".to_string(),
708 namespace: "agentty-xyz".to_string(),
709 project: "agentty".to_string(),
710 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
711 web_url: "not a url".to_string(),
712 };
713
714 let error = remote
716 .review_request_creation_url("review/custom-branch", "main")
717 .expect_err("invalid web URL should be rejected");
718
719 assert_eq!(
721 error,
722 ReviewRequestError::OperationFailed {
723 forge_kind: ForgeKind::GitHub,
724 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
725 }
726 );
727 }
728
729 #[test]
730 fn review_request_creation_url_returns_gitlab_merge_request_link() {
731 let remote = ForgeRemote {
733 command_working_directory: None,
734 forge_kind: ForgeKind::GitLab,
735 host: "gitlab.com".to_string(),
736 namespace: "agentty-xyz".to_string(),
737 project: "agentty".to_string(),
738 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
739 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
740 };
741
742 let url = remote
744 .review_request_creation_url("review/custom-branch", "main")
745 .expect("gitlab merge-request URL should be created");
746
747 assert_eq!(
749 url,
750 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
751 );
752 }
753}