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 pub fn supports_review_comments_preview(self) -> bool {
81 match self {
82 Self::GitHub | Self::GitLab => true,
83 }
84 }
85}
86
87pub fn is_gitlab_host(host: &str) -> bool {
89 host == "gitlab.com"
90 || host.ends_with(".gitlab.com")
91 || host.starts_with("gitlab.")
92 || host.contains(".gitlab.")
93}
94
95impl fmt::Display for ForgeKind {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 formatter.write_str(self.as_str())
98 }
99}
100
101impl FromStr for ForgeKind {
102 type Err = String;
103
104 fn from_str(value: &str) -> Result<Self, Self::Err> {
105 match value {
106 "GitHub" => Ok(Self::GitHub),
107 "GitLab" => Ok(Self::GitLab),
108 _ => Err(format!("Unknown review-request forge: {value}")),
109 }
110 }
111}
112
113#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115pub enum ReviewRequestState {
116 Open,
118 Merged,
120 Closed,
122}
123
124impl ReviewRequestState {
125 pub fn as_str(self) -> &'static str {
127 match self {
128 Self::Open => "Open",
129 Self::Merged => "Merged",
130 Self::Closed => "Closed",
131 }
132 }
133}
134
135impl fmt::Display for ReviewRequestState {
136 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137 formatter.write_str(self.as_str())
138 }
139}
140
141impl FromStr for ReviewRequestState {
142 type Err = String;
143
144 fn from_str(value: &str) -> Result<Self, Self::Err> {
145 match value {
146 "Open" => Ok(Self::Open),
147 "Merged" => Ok(Self::Merged),
148 "Closed" => Ok(Self::Closed),
149 _ => Err(format!("Unknown review-request state: {value}")),
150 }
151 }
152}
153
154#[derive(Clone, Debug, Eq, PartialEq)]
162pub struct ReviewRequestSummary {
163 pub display_id: String,
165 pub forge_kind: ForgeKind,
167 pub source_branch: String,
169 pub state: ReviewRequestState,
171 pub status_summary: Option<String>,
173 pub target_branch: String,
175 pub title: String,
177 pub web_url: String,
179}
180
181#[derive(Clone, Copy, Debug, Eq, PartialEq)]
184pub enum RequestedReviewAudience {
185 Personal,
187 Group,
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct RequestedReview {
195 pub audience: RequestedReviewAudience,
198 pub body: Option<String>,
200 pub comment_snapshot: Option<ReviewCommentSnapshot>,
205 pub display_id: String,
207 pub forge_kind: ForgeKind,
209 pub repository: String,
211 pub status_summary: Option<String>,
213 pub title: String,
215 pub updated_at: Option<String>,
217 pub web_url: String,
219}
220
221pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
223
224#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct ForgeRemote {
227 pub command_working_directory: Option<PathBuf>,
230 pub forge_kind: ForgeKind,
232 pub host: String,
237 pub namespace: String,
239 pub project: String,
241 pub repo_url: String,
243 pub web_url: String,
245}
246
247impl ForgeRemote {
248 #[must_use]
251 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
252 self.command_working_directory = Some(working_directory);
253
254 self
255 }
256
257 pub fn project_path(&self) -> String {
259 format!("{}/{}", self.namespace, self.project)
260 }
261
262 pub fn review_request_creation_url(
270 &self,
271 source_branch: &str,
272 target_branch: &str,
273 ) -> Result<String, ReviewRequestError> {
274 match self.forge_kind {
275 ForgeKind::GitHub => {
276 github_review_request_creation_url(self, source_branch, target_branch)
277 }
278 ForgeKind::GitLab => {
279 gitlab_review_request_creation_url(self, source_branch, target_branch)
280 }
281 }
282 }
283}
284
285#[derive(Clone, Debug, Eq, PartialEq)]
287pub struct ReviewComment {
288 pub author: String,
290 pub body: String,
292}
293
294#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
296pub enum ReviewCommentAnchorSide {
297 File,
299 New,
301 Old,
303}
304
305#[derive(Clone, Debug, Eq, PartialEq)]
311pub struct ReviewCommentThread {
312 pub anchor_side: ReviewCommentAnchorSide,
314 pub comments: Vec<ReviewComment>,
316 pub is_outdated: Option<bool>,
319 pub is_resolved: bool,
321 pub line: Option<u32>,
323 pub path: String,
325 pub start_line: Option<u32>,
327}
328
329#[derive(Clone, Debug, Default, Eq, PartialEq)]
336pub struct ReviewCommentSnapshot {
337 pub pr_level_comments: Vec<ReviewComment>,
340 pub threads: Vec<ReviewCommentThread>,
342}
343
344#[derive(Clone, Debug, Eq, PartialEq)]
346pub struct CreateReviewRequestInput {
347 pub body: Option<String>,
349 pub source_branch: String,
351 pub target_branch: String,
353 pub title: String,
355}
356
357#[derive(Clone, Debug, Eq, PartialEq)]
360pub struct UpdateReviewRequestInput {
361 pub body: Option<String>,
363 pub title: String,
365}
366
367#[derive(Clone, Debug, Eq, PartialEq)]
369pub enum ReviewRequestError {
370 CliNotInstalled { forge_kind: ForgeKind },
372 AuthenticationRequired {
374 forge_kind: ForgeKind,
376 host: String,
378 detail: Option<String>,
380 },
381 HostResolutionFailed { forge_kind: ForgeKind, host: String },
383 UnsupportedRemote { repo_url: String },
385 OperationFailed {
387 forge_kind: ForgeKind,
388 message: String,
389 },
390}
391
392impl ReviewRequestError {
393 pub fn detail_message(&self) -> String {
395 match self {
396 Self::CliNotInstalled { forge_kind } => format!(
397 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
398 forge_kind.display_name(),
399 forge_kind.cli_name(),
400 forge_kind.cli_name(),
401 forge_kind.auth_login_command(),
402 ),
403 Self::AuthenticationRequired {
404 forge_kind,
405 host,
406 detail,
407 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
408 Self::HostResolutionFailed { forge_kind, host } => format!(
409 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
410 and your network or DNS setup, then retry.",
411 forge_kind.display_name(),
412 ),
413 Self::UnsupportedRemote { repo_url } => format!(
414 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
415 repository remote is not supported: `{repo_url}`."
416 ),
417 Self::OperationFailed {
418 forge_kind,
419 message,
420 } => format!(
421 "{} review-request operation failed: {message}",
422 forge_kind.display_name()
423 ),
424 }
425 }
426}
427
428fn authentication_required_message(
431 forge_kind: ForgeKind,
432 host: &str,
433 detail: Option<&str>,
434) -> String {
435 let mut message = format!(
436 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
437 forge_kind.display_name(),
438 forge_kind.auth_login_command(),
439 );
440
441 if let Some(detail) = non_empty_detail(detail) {
442 let _ = write!(
444 message,
445 "\n\nOriginal `{}` error:\n```text\n{detail}",
446 forge_kind.cli_name(),
447 );
448 if !detail.ends_with('\n') {
449 message.push('\n');
450 }
451 message.push_str("```");
452 }
453
454 message
455}
456
457fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
459 detail.and_then(|detail| {
460 let trimmed_detail = detail.trim();
461 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
462 })
463}
464
465fn github_review_request_creation_url(
467 remote: &ForgeRemote,
468 source_branch: &str,
469 target_branch: &str,
470) -> Result<String, ReviewRequestError> {
471 let mut url = parsed_remote_web_url(remote)?;
472 let compare_target = if target_branch.trim().is_empty() {
473 source_branch.to_string()
474 } else {
475 format!("{target_branch}...{source_branch}")
476 };
477
478 {
479 let mut path_segments = url
480 .path_segments_mut()
481 .map_err(|()| invalid_web_url_error(remote))?;
482 path_segments.pop_if_empty();
483 path_segments.push("compare");
484 path_segments.push(&compare_target);
485 }
486
487 url.query_pairs_mut().append_pair("expand", "1");
488
489 Ok(url.into())
490}
491
492fn gitlab_review_request_creation_url(
494 remote: &ForgeRemote,
495 source_branch: &str,
496 target_branch: &str,
497) -> Result<String, ReviewRequestError> {
498 let mut url = parsed_remote_web_url(remote)?;
499
500 {
501 let mut path_segments = url
502 .path_segments_mut()
503 .map_err(|()| invalid_web_url_error(remote))?;
504 path_segments.pop_if_empty();
505 path_segments.push("-");
506 path_segments.push("merge_requests");
507 path_segments.push("new");
508 }
509
510 url.query_pairs_mut()
511 .append_pair("merge_request[source_branch]", source_branch)
512 .append_pair("merge_request[target_branch]", target_branch);
513
514 Ok(url.into())
515}
516
517fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
519 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
520}
521
522fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
524 ReviewRequestError::OperationFailed {
525 forge_kind: remote.forge_kind,
526 message: format!(
527 "repository remote is missing a valid web URL: `{}`",
528 remote.web_url
529 ),
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn authentication_required_message_includes_original_cli_error_detail() {
539 let error = ReviewRequestError::AuthenticationRequired {
541 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
542 forge_kind: ForgeKind::GitHub,
543 host: "github.com".to_string(),
544 };
545
546 let message = error.detail_message();
548
549 assert!(message.contains("GitHub review requests require local CLI authentication"));
551 assert!(message.contains("Run `gh auth login` and retry."));
552 assert!(message.contains("Original `gh` error:"));
553 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
554 assert!(message.contains("```text"));
555 }
556
557 #[test]
558 fn authentication_required_message_omits_empty_original_cli_error_detail() {
559 let error = ReviewRequestError::AuthenticationRequired {
561 detail: Some(" \n".to_string()),
562 forge_kind: ForgeKind::GitHub,
563 host: "github.com".to_string(),
564 };
565
566 let message = error.detail_message();
568
569 assert!(message.contains("Run `gh auth login` and retry."));
571 assert!(!message.contains("Original `gh` error:"));
572 }
573
574 #[test]
575 fn review_request_creation_url_returns_github_compare_link() {
576 let remote = ForgeRemote {
578 command_working_directory: None,
579 forge_kind: ForgeKind::GitHub,
580 host: "github.com".to_string(),
581 namespace: "agentty-xyz".to_string(),
582 project: "agentty".to_string(),
583 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
584 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
585 };
586
587 let url = remote
589 .review_request_creation_url("review/custom-branch", "main")
590 .expect("github compare URL should be created");
591
592 assert_eq!(
594 url,
595 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
596 );
597 }
598
599 #[test]
600 fn review_request_creation_url_rejects_invalid_web_url() {
601 let remote = ForgeRemote {
603 command_working_directory: None,
604 forge_kind: ForgeKind::GitHub,
605 host: "github.com".to_string(),
606 namespace: "agentty-xyz".to_string(),
607 project: "agentty".to_string(),
608 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
609 web_url: "not a url".to_string(),
610 };
611
612 let error = remote
614 .review_request_creation_url("review/custom-branch", "main")
615 .expect_err("invalid web URL should be rejected");
616
617 assert_eq!(
619 error,
620 ReviewRequestError::OperationFailed {
621 forge_kind: ForgeKind::GitHub,
622 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
623 }
624 );
625 }
626
627 #[test]
628 fn forge_kind_from_str_gitlab() {
629 let raw_forge_kind = "GitLab";
631
632 let forge_kind = raw_forge_kind
634 .parse::<ForgeKind>()
635 .expect("gitlab forge kind should parse");
636
637 assert_eq!(forge_kind, ForgeKind::GitLab);
639 assert_eq!(forge_kind.cli_name(), "glab");
640 assert_eq!(forge_kind.review_request_name(), "merge request");
641 assert_eq!(forge_kind.review_request_short_name(), "MR");
642 }
643
644 #[test]
645 fn supports_review_comments_preview_returns_true_for_supported_forges() {
646 assert!(ForgeKind::GitHub.supports_review_comments_preview());
648 assert!(ForgeKind::GitLab.supports_review_comments_preview());
649 }
650
651 #[test]
652 fn review_request_creation_url_returns_gitlab_merge_request_link() {
653 let remote = ForgeRemote {
655 command_working_directory: None,
656 forge_kind: ForgeKind::GitLab,
657 host: "gitlab.com".to_string(),
658 namespace: "agentty-xyz".to_string(),
659 project: "agentty".to_string(),
660 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
661 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
662 };
663
664 let url = remote
666 .review_request_creation_url("review/custom-branch", "main")
667 .expect("gitlab merge-request URL should be created");
668
669 assert_eq!(
671 url,
672 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
673 );
674 }
675}