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 author: String,
200 pub body: Option<String>,
202 pub comment_snapshot: Option<ReviewCommentSnapshot>,
207 pub display_id: String,
209 pub forge_kind: ForgeKind,
211 pub repository: String,
213 pub status_summary: Option<String>,
215 pub title: String,
217 pub updated_at: Option<String>,
219 pub web_url: String,
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct AssignedIssue {
226 pub display_id: String,
228 pub repository: String,
230 pub title: String,
232 pub updated_at: Option<String>,
234 pub web_url: String,
236}
237
238pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
240
241#[derive(Clone, Debug, Eq, PartialEq)]
243pub struct ForgeRemote {
244 pub command_working_directory: Option<PathBuf>,
247 pub forge_kind: ForgeKind,
249 pub host: String,
254 pub namespace: String,
256 pub project: String,
258 pub repo_url: String,
260 pub web_url: String,
262}
263
264impl ForgeRemote {
265 #[must_use]
268 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
269 self.command_working_directory = Some(working_directory);
270
271 self
272 }
273
274 pub fn project_path(&self) -> String {
276 format!("{}/{}", self.namespace, self.project)
277 }
278
279 pub fn review_request_creation_url(
287 &self,
288 source_branch: &str,
289 target_branch: &str,
290 ) -> Result<String, ReviewRequestError> {
291 match self.forge_kind {
292 ForgeKind::GitHub => {
293 github_review_request_creation_url(self, source_branch, target_branch)
294 }
295 ForgeKind::GitLab => {
296 gitlab_review_request_creation_url(self, source_branch, target_branch)
297 }
298 }
299 }
300}
301
302#[derive(Clone, Debug, Eq, PartialEq)]
304pub struct ReviewComment {
305 pub author: String,
307 pub body: String,
309}
310
311#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
313pub enum ReviewCommentAnchorSide {
314 File,
316 New,
318 Old,
320}
321
322#[derive(Clone, Debug, Eq, PartialEq)]
328pub struct ReviewCommentThread {
329 pub anchor_side: ReviewCommentAnchorSide,
331 pub comments: Vec<ReviewComment>,
333 pub is_outdated: Option<bool>,
336 pub is_resolved: bool,
338 pub line: Option<u32>,
340 pub path: String,
342 pub start_line: Option<u32>,
344}
345
346#[derive(Clone, Debug, Default, Eq, PartialEq)]
353pub struct ReviewCommentSnapshot {
354 pub pr_level_comments: Vec<ReviewComment>,
357 pub threads: Vec<ReviewCommentThread>,
359}
360
361#[derive(Clone, Debug, Eq, PartialEq)]
363pub struct CreateReviewRequestInput {
364 pub body: Option<String>,
366 pub source_branch: String,
368 pub target_branch: String,
370 pub title: String,
372}
373
374#[derive(Clone, Debug, Eq, PartialEq)]
377pub struct UpdateReviewRequestInput {
378 pub body: Option<String>,
380 pub title: String,
382}
383
384#[derive(Clone, Debug, Eq, PartialEq)]
386pub enum ReviewRequestError {
387 CliNotInstalled { forge_kind: ForgeKind },
389 AuthenticationRequired {
391 forge_kind: ForgeKind,
393 host: String,
395 detail: Option<String>,
397 },
398 HostResolutionFailed { forge_kind: ForgeKind, host: String },
400 UnsupportedRemote { repo_url: String },
402 OperationFailed {
404 forge_kind: ForgeKind,
405 message: String,
406 },
407}
408
409impl ReviewRequestError {
410 pub fn detail_message(&self) -> String {
412 match self {
413 Self::CliNotInstalled { forge_kind } => format!(
414 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
415 forge_kind.display_name(),
416 forge_kind.cli_name(),
417 forge_kind.cli_name(),
418 forge_kind.auth_login_command(),
419 ),
420 Self::AuthenticationRequired {
421 forge_kind,
422 host,
423 detail,
424 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
425 Self::HostResolutionFailed { forge_kind, host } => format!(
426 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
427 and your network or DNS setup, then retry.",
428 forge_kind.display_name(),
429 ),
430 Self::UnsupportedRemote { repo_url } => format!(
431 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
432 repository remote is not supported: `{repo_url}`."
433 ),
434 Self::OperationFailed {
435 forge_kind,
436 message,
437 } => format!(
438 "{} review-request operation failed: {message}",
439 forge_kind.display_name()
440 ),
441 }
442 }
443}
444
445fn authentication_required_message(
448 forge_kind: ForgeKind,
449 host: &str,
450 detail: Option<&str>,
451) -> String {
452 let mut message = format!(
453 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
454 forge_kind.display_name(),
455 forge_kind.auth_login_command(),
456 );
457
458 if let Some(detail) = non_empty_detail(detail) {
459 let _ = write!(
461 message,
462 "\n\nOriginal `{}` error:\n```text\n{detail}",
463 forge_kind.cli_name(),
464 );
465 if !detail.ends_with('\n') {
466 message.push('\n');
467 }
468 message.push_str("```");
469 }
470
471 message
472}
473
474fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
476 detail.and_then(|detail| {
477 let trimmed_detail = detail.trim();
478 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
479 })
480}
481
482fn github_review_request_creation_url(
484 remote: &ForgeRemote,
485 source_branch: &str,
486 target_branch: &str,
487) -> Result<String, ReviewRequestError> {
488 let mut url = parsed_remote_web_url(remote)?;
489 let compare_target = if target_branch.trim().is_empty() {
490 source_branch.to_string()
491 } else {
492 format!("{target_branch}...{source_branch}")
493 };
494
495 {
496 let mut path_segments = url
497 .path_segments_mut()
498 .map_err(|()| invalid_web_url_error(remote))?;
499 path_segments.pop_if_empty();
500 path_segments.push("compare");
501 path_segments.push(&compare_target);
502 }
503
504 url.query_pairs_mut().append_pair("expand", "1");
505
506 Ok(url.into())
507}
508
509fn gitlab_review_request_creation_url(
511 remote: &ForgeRemote,
512 source_branch: &str,
513 target_branch: &str,
514) -> Result<String, ReviewRequestError> {
515 let mut url = parsed_remote_web_url(remote)?;
516
517 {
518 let mut path_segments = url
519 .path_segments_mut()
520 .map_err(|()| invalid_web_url_error(remote))?;
521 path_segments.pop_if_empty();
522 path_segments.push("-");
523 path_segments.push("merge_requests");
524 path_segments.push("new");
525 }
526
527 url.query_pairs_mut()
528 .append_pair("merge_request[source_branch]", source_branch)
529 .append_pair("merge_request[target_branch]", target_branch);
530
531 Ok(url.into())
532}
533
534fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
536 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
537}
538
539fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
541 ReviewRequestError::OperationFailed {
542 forge_kind: remote.forge_kind,
543 message: format!(
544 "repository remote is missing a valid web URL: `{}`",
545 remote.web_url
546 ),
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn authentication_required_message_includes_original_cli_error_detail() {
556 let error = ReviewRequestError::AuthenticationRequired {
558 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
559 forge_kind: ForgeKind::GitHub,
560 host: "github.com".to_string(),
561 };
562
563 let message = error.detail_message();
565
566 assert!(message.contains("GitHub review requests require local CLI authentication"));
568 assert!(message.contains("Run `gh auth login` and retry."));
569 assert!(message.contains("Original `gh` error:"));
570 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
571 assert!(message.contains("```text"));
572 }
573
574 #[test]
575 fn authentication_required_message_omits_empty_original_cli_error_detail() {
576 let error = ReviewRequestError::AuthenticationRequired {
578 detail: Some(" \n".to_string()),
579 forge_kind: ForgeKind::GitHub,
580 host: "github.com".to_string(),
581 };
582
583 let message = error.detail_message();
585
586 assert!(message.contains("Run `gh auth login` and retry."));
588 assert!(!message.contains("Original `gh` error:"));
589 }
590
591 #[test]
592 fn review_request_creation_url_returns_github_compare_link() {
593 let remote = ForgeRemote {
595 command_working_directory: None,
596 forge_kind: ForgeKind::GitHub,
597 host: "github.com".to_string(),
598 namespace: "agentty-xyz".to_string(),
599 project: "agentty".to_string(),
600 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
601 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
602 };
603
604 let url = remote
606 .review_request_creation_url("review/custom-branch", "main")
607 .expect("github compare URL should be created");
608
609 assert_eq!(
611 url,
612 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
613 );
614 }
615
616 #[test]
617 fn review_request_creation_url_rejects_invalid_web_url() {
618 let remote = ForgeRemote {
620 command_working_directory: None,
621 forge_kind: ForgeKind::GitHub,
622 host: "github.com".to_string(),
623 namespace: "agentty-xyz".to_string(),
624 project: "agentty".to_string(),
625 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
626 web_url: "not a url".to_string(),
627 };
628
629 let error = remote
631 .review_request_creation_url("review/custom-branch", "main")
632 .expect_err("invalid web URL should be rejected");
633
634 assert_eq!(
636 error,
637 ReviewRequestError::OperationFailed {
638 forge_kind: ForgeKind::GitHub,
639 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
640 }
641 );
642 }
643
644 #[test]
645 fn forge_kind_from_str_gitlab() {
646 let raw_forge_kind = "GitLab";
648
649 let forge_kind = raw_forge_kind
651 .parse::<ForgeKind>()
652 .expect("gitlab forge kind should parse");
653
654 assert_eq!(forge_kind, ForgeKind::GitLab);
656 assert_eq!(forge_kind.cli_name(), "glab");
657 assert_eq!(forge_kind.review_request_name(), "merge request");
658 assert_eq!(forge_kind.review_request_short_name(), "MR");
659 }
660
661 #[test]
662 fn supports_review_comments_preview_returns_true_for_supported_forges() {
663 assert!(ForgeKind::GitHub.supports_review_comments_preview());
665 assert!(ForgeKind::GitLab.supports_review_comments_preview());
666 }
667
668 #[test]
669 fn review_request_creation_url_returns_gitlab_merge_request_link() {
670 let remote = ForgeRemote {
672 command_working_directory: None,
673 forge_kind: ForgeKind::GitLab,
674 host: "gitlab.com".to_string(),
675 namespace: "agentty-xyz".to_string(),
676 project: "agentty".to_string(),
677 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
678 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
679 };
680
681 let url = remote
683 .review_request_creation_url("review/custom-branch", "main")
684 .expect("gitlab merge-request URL should be created");
685
686 assert_eq!(
688 url,
689 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
690 );
691 }
692}