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
223pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
225
226#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct ForgeRemote {
229 pub command_working_directory: Option<PathBuf>,
232 pub forge_kind: ForgeKind,
234 pub host: String,
239 pub namespace: String,
241 pub project: String,
243 pub repo_url: String,
245 pub web_url: String,
247}
248
249impl ForgeRemote {
250 #[must_use]
253 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
254 self.command_working_directory = Some(working_directory);
255
256 self
257 }
258
259 pub fn project_path(&self) -> String {
261 format!("{}/{}", self.namespace, self.project)
262 }
263
264 pub fn review_request_creation_url(
272 &self,
273 source_branch: &str,
274 target_branch: &str,
275 ) -> Result<String, ReviewRequestError> {
276 match self.forge_kind {
277 ForgeKind::GitHub => {
278 github_review_request_creation_url(self, source_branch, target_branch)
279 }
280 ForgeKind::GitLab => {
281 gitlab_review_request_creation_url(self, source_branch, target_branch)
282 }
283 }
284 }
285}
286
287#[derive(Clone, Debug, Eq, PartialEq)]
289pub struct ReviewComment {
290 pub author: String,
292 pub body: String,
294}
295
296#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
298pub enum ReviewCommentAnchorSide {
299 File,
301 New,
303 Old,
305}
306
307#[derive(Clone, Debug, Eq, PartialEq)]
313pub struct ReviewCommentThread {
314 pub anchor_side: ReviewCommentAnchorSide,
316 pub comments: Vec<ReviewComment>,
318 pub is_outdated: Option<bool>,
321 pub is_resolved: bool,
323 pub line: Option<u32>,
325 pub path: String,
327 pub start_line: Option<u32>,
329}
330
331#[derive(Clone, Debug, Default, Eq, PartialEq)]
338pub struct ReviewCommentSnapshot {
339 pub pr_level_comments: Vec<ReviewComment>,
342 pub threads: Vec<ReviewCommentThread>,
344}
345
346#[derive(Clone, Debug, Eq, PartialEq)]
348pub struct CreateReviewRequestInput {
349 pub body: Option<String>,
351 pub source_branch: String,
353 pub target_branch: String,
355 pub title: String,
357}
358
359#[derive(Clone, Debug, Eq, PartialEq)]
362pub struct UpdateReviewRequestInput {
363 pub body: Option<String>,
365 pub title: String,
367}
368
369#[derive(Clone, Debug, Eq, PartialEq)]
371pub enum ReviewRequestError {
372 CliNotInstalled { forge_kind: ForgeKind },
374 AuthenticationRequired {
376 forge_kind: ForgeKind,
378 host: String,
380 detail: Option<String>,
382 },
383 HostResolutionFailed { forge_kind: ForgeKind, host: String },
385 UnsupportedRemote { repo_url: String },
387 OperationFailed {
389 forge_kind: ForgeKind,
390 message: String,
391 },
392}
393
394impl ReviewRequestError {
395 pub fn detail_message(&self) -> String {
397 match self {
398 Self::CliNotInstalled { forge_kind } => format!(
399 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
400 forge_kind.display_name(),
401 forge_kind.cli_name(),
402 forge_kind.cli_name(),
403 forge_kind.auth_login_command(),
404 ),
405 Self::AuthenticationRequired {
406 forge_kind,
407 host,
408 detail,
409 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
410 Self::HostResolutionFailed { forge_kind, host } => format!(
411 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
412 and your network or DNS setup, then retry.",
413 forge_kind.display_name(),
414 ),
415 Self::UnsupportedRemote { repo_url } => format!(
416 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
417 repository remote is not supported: `{repo_url}`."
418 ),
419 Self::OperationFailed {
420 forge_kind,
421 message,
422 } => format!(
423 "{} review-request operation failed: {message}",
424 forge_kind.display_name()
425 ),
426 }
427 }
428}
429
430fn authentication_required_message(
433 forge_kind: ForgeKind,
434 host: &str,
435 detail: Option<&str>,
436) -> String {
437 let mut message = format!(
438 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
439 forge_kind.display_name(),
440 forge_kind.auth_login_command(),
441 );
442
443 if let Some(detail) = non_empty_detail(detail) {
444 let _ = write!(
446 message,
447 "\n\nOriginal `{}` error:\n```text\n{detail}",
448 forge_kind.cli_name(),
449 );
450 if !detail.ends_with('\n') {
451 message.push('\n');
452 }
453 message.push_str("```");
454 }
455
456 message
457}
458
459fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
461 detail.and_then(|detail| {
462 let trimmed_detail = detail.trim();
463 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
464 })
465}
466
467fn github_review_request_creation_url(
469 remote: &ForgeRemote,
470 source_branch: &str,
471 target_branch: &str,
472) -> Result<String, ReviewRequestError> {
473 let mut url = parsed_remote_web_url(remote)?;
474 let compare_target = if target_branch.trim().is_empty() {
475 source_branch.to_string()
476 } else {
477 format!("{target_branch}...{source_branch}")
478 };
479
480 {
481 let mut path_segments = url
482 .path_segments_mut()
483 .map_err(|()| invalid_web_url_error(remote))?;
484 path_segments.pop_if_empty();
485 path_segments.push("compare");
486 path_segments.push(&compare_target);
487 }
488
489 url.query_pairs_mut().append_pair("expand", "1");
490
491 Ok(url.into())
492}
493
494fn gitlab_review_request_creation_url(
496 remote: &ForgeRemote,
497 source_branch: &str,
498 target_branch: &str,
499) -> Result<String, ReviewRequestError> {
500 let mut url = parsed_remote_web_url(remote)?;
501
502 {
503 let mut path_segments = url
504 .path_segments_mut()
505 .map_err(|()| invalid_web_url_error(remote))?;
506 path_segments.pop_if_empty();
507 path_segments.push("-");
508 path_segments.push("merge_requests");
509 path_segments.push("new");
510 }
511
512 url.query_pairs_mut()
513 .append_pair("merge_request[source_branch]", source_branch)
514 .append_pair("merge_request[target_branch]", target_branch);
515
516 Ok(url.into())
517}
518
519fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
521 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
522}
523
524fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
526 ReviewRequestError::OperationFailed {
527 forge_kind: remote.forge_kind,
528 message: format!(
529 "repository remote is missing a valid web URL: `{}`",
530 remote.web_url
531 ),
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[test]
540 fn authentication_required_message_includes_original_cli_error_detail() {
541 let error = ReviewRequestError::AuthenticationRequired {
543 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
544 forge_kind: ForgeKind::GitHub,
545 host: "github.com".to_string(),
546 };
547
548 let message = error.detail_message();
550
551 assert!(message.contains("GitHub review requests require local CLI authentication"));
553 assert!(message.contains("Run `gh auth login` and retry."));
554 assert!(message.contains("Original `gh` error:"));
555 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
556 assert!(message.contains("```text"));
557 }
558
559 #[test]
560 fn authentication_required_message_omits_empty_original_cli_error_detail() {
561 let error = ReviewRequestError::AuthenticationRequired {
563 detail: Some(" \n".to_string()),
564 forge_kind: ForgeKind::GitHub,
565 host: "github.com".to_string(),
566 };
567
568 let message = error.detail_message();
570
571 assert!(message.contains("Run `gh auth login` and retry."));
573 assert!(!message.contains("Original `gh` error:"));
574 }
575
576 #[test]
577 fn review_request_creation_url_returns_github_compare_link() {
578 let remote = ForgeRemote {
580 command_working_directory: None,
581 forge_kind: ForgeKind::GitHub,
582 host: "github.com".to_string(),
583 namespace: "agentty-xyz".to_string(),
584 project: "agentty".to_string(),
585 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
586 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
587 };
588
589 let url = remote
591 .review_request_creation_url("review/custom-branch", "main")
592 .expect("github compare URL should be created");
593
594 assert_eq!(
596 url,
597 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
598 );
599 }
600
601 #[test]
602 fn review_request_creation_url_rejects_invalid_web_url() {
603 let remote = ForgeRemote {
605 command_working_directory: None,
606 forge_kind: ForgeKind::GitHub,
607 host: "github.com".to_string(),
608 namespace: "agentty-xyz".to_string(),
609 project: "agentty".to_string(),
610 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
611 web_url: "not a url".to_string(),
612 };
613
614 let error = remote
616 .review_request_creation_url("review/custom-branch", "main")
617 .expect_err("invalid web URL should be rejected");
618
619 assert_eq!(
621 error,
622 ReviewRequestError::OperationFailed {
623 forge_kind: ForgeKind::GitHub,
624 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
625 }
626 );
627 }
628
629 #[test]
630 fn forge_kind_from_str_gitlab() {
631 let raw_forge_kind = "GitLab";
633
634 let forge_kind = raw_forge_kind
636 .parse::<ForgeKind>()
637 .expect("gitlab forge kind should parse");
638
639 assert_eq!(forge_kind, ForgeKind::GitLab);
641 assert_eq!(forge_kind.cli_name(), "glab");
642 assert_eq!(forge_kind.review_request_name(), "merge request");
643 assert_eq!(forge_kind.review_request_short_name(), "MR");
644 }
645
646 #[test]
647 fn supports_review_comments_preview_returns_true_for_supported_forges() {
648 assert!(ForgeKind::GitHub.supports_review_comments_preview());
650 assert!(ForgeKind::GitLab.supports_review_comments_preview());
651 }
652
653 #[test]
654 fn review_request_creation_url_returns_gitlab_merge_request_link() {
655 let remote = ForgeRemote {
657 command_working_directory: None,
658 forge_kind: ForgeKind::GitLab,
659 host: "gitlab.com".to_string(),
660 namespace: "agentty-xyz".to_string(),
661 project: "agentty".to_string(),
662 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
663 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
664 };
665
666 let url = remote
668 .review_request_creation_url("review/custom-branch", "main")
669 .expect("gitlab merge-request URL should be created");
670
671 assert_eq!(
673 url,
674 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
675 );
676 }
677}