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
76pub fn is_gitlab_host(host: &str) -> bool {
78 host == "gitlab.com"
79 || host.ends_with(".gitlab.com")
80 || host.starts_with("gitlab.")
81 || host.contains(".gitlab.")
82}
83
84impl fmt::Display for ForgeKind {
85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 formatter.write_str(self.as_str())
87 }
88}
89
90impl FromStr for ForgeKind {
91 type Err = String;
92
93 fn from_str(value: &str) -> Result<Self, Self::Err> {
94 match value {
95 "GitHub" => Ok(Self::GitHub),
96 "GitLab" => Ok(Self::GitLab),
97 _ => Err(format!("Unknown review-request forge: {value}")),
98 }
99 }
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)]
344pub struct ReviewCommentThread {
345 pub anchor_side: ReviewCommentAnchorSide,
347 pub comments: Vec<ReviewComment>,
349 pub is_outdated: Option<bool>,
352 pub is_resolved: bool,
354 pub line: Option<u32>,
356 pub path: String,
358 pub start_line: Option<u32>,
360}
361
362#[derive(Clone, Debug, Default, Eq, PartialEq)]
369pub struct ReviewCommentSnapshot {
370 pub pr_level_comments: Vec<ReviewComment>,
373 pub threads: Vec<ReviewCommentThread>,
375}
376
377#[derive(Clone, Debug, Eq, PartialEq)]
379pub struct CreateReviewRequestInput {
380 pub body: Option<String>,
382 pub source_branch: String,
384 pub target_branch: String,
386 pub title: String,
388}
389
390#[derive(Clone, Debug, Eq, PartialEq)]
393pub struct UpdateReviewRequestInput {
394 pub body: Option<String>,
396 pub title: String,
398}
399
400#[derive(Clone, Debug, Eq, PartialEq)]
402pub enum ReviewRequestError {
403 CliNotInstalled { forge_kind: ForgeKind },
405 AuthenticationRequired {
407 forge_kind: ForgeKind,
409 host: String,
411 detail: Option<String>,
413 },
414 HostResolutionFailed { forge_kind: ForgeKind, host: String },
416 UnsupportedRemote { repo_url: String },
418 OperationFailed {
420 forge_kind: ForgeKind,
421 message: String,
422 },
423}
424
425impl ReviewRequestError {
426 pub fn detail_message(&self) -> String {
428 match self {
429 Self::CliNotInstalled { forge_kind } => format!(
430 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
431 forge_kind.display_name(),
432 forge_kind.cli_name(),
433 forge_kind.cli_name(),
434 forge_kind.auth_login_command(),
435 ),
436 Self::AuthenticationRequired {
437 forge_kind,
438 host,
439 detail,
440 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
441 Self::HostResolutionFailed { forge_kind, host } => format!(
442 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
443 and your network or DNS setup, then retry.",
444 forge_kind.display_name(),
445 ),
446 Self::UnsupportedRemote { repo_url } => format!(
447 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
448 repository remote is not supported: `{repo_url}`."
449 ),
450 Self::OperationFailed {
451 forge_kind,
452 message,
453 } => format!(
454 "{} review-request operation failed: {message}",
455 forge_kind.display_name()
456 ),
457 }
458 }
459}
460
461fn authentication_required_message(
464 forge_kind: ForgeKind,
465 host: &str,
466 detail: Option<&str>,
467) -> String {
468 let mut message = format!(
469 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
470 forge_kind.display_name(),
471 forge_kind.auth_login_command(),
472 );
473
474 if let Some(detail) = non_empty_detail(detail) {
475 let _ = write!(
477 message,
478 "\n\nOriginal `{}` error:\n```text\n{detail}",
479 forge_kind.cli_name(),
480 );
481 if !detail.ends_with('\n') {
482 message.push('\n');
483 }
484 message.push_str("```");
485 }
486
487 message
488}
489
490fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
492 detail.and_then(|detail| {
493 let trimmed_detail = detail.trim();
494 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
495 })
496}
497
498fn github_review_request_creation_url(
500 remote: &ForgeRemote,
501 source_branch: &str,
502 target_branch: &str,
503) -> Result<String, ReviewRequestError> {
504 let mut url = parsed_remote_web_url(remote)?;
505 let compare_target = if target_branch.trim().is_empty() {
506 source_branch.to_string()
507 } else {
508 format!("{target_branch}...{source_branch}")
509 };
510
511 {
512 let mut path_segments = url
513 .path_segments_mut()
514 .map_err(|()| invalid_web_url_error(remote))?;
515 path_segments.pop_if_empty();
516 path_segments.push("compare");
517 path_segments.push(&compare_target);
518 }
519
520 url.query_pairs_mut().append_pair("expand", "1");
521
522 Ok(url.into())
523}
524
525fn gitlab_review_request_creation_url(
527 remote: &ForgeRemote,
528 source_branch: &str,
529 target_branch: &str,
530) -> Result<String, ReviewRequestError> {
531 let mut url = parsed_remote_web_url(remote)?;
532
533 {
534 let mut path_segments = url
535 .path_segments_mut()
536 .map_err(|()| invalid_web_url_error(remote))?;
537 path_segments.pop_if_empty();
538 path_segments.push("-");
539 path_segments.push("merge_requests");
540 path_segments.push("new");
541 }
542
543 url.query_pairs_mut()
544 .append_pair("merge_request[source_branch]", source_branch)
545 .append_pair("merge_request[target_branch]", target_branch);
546
547 Ok(url.into())
548}
549
550fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
552 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
553}
554
555fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
557 ReviewRequestError::OperationFailed {
558 forge_kind: remote.forge_kind,
559 message: format!(
560 "repository remote is missing a valid web URL: `{}`",
561 remote.web_url
562 ),
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 #[test]
571 fn authentication_required_message_includes_original_cli_error_detail() {
572 let error = ReviewRequestError::AuthenticationRequired {
574 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
575 forge_kind: ForgeKind::GitHub,
576 host: "github.com".to_string(),
577 };
578
579 let message = error.detail_message();
581
582 assert!(message.contains("GitHub review requests require local CLI authentication"));
584 assert!(message.contains("Run `gh auth login` and retry."));
585 assert!(message.contains("Original `gh` error:"));
586 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
587 assert!(message.contains("```text"));
588 }
589
590 #[test]
591 fn authentication_required_message_omits_empty_original_cli_error_detail() {
592 let error = ReviewRequestError::AuthenticationRequired {
594 detail: Some(" \n".to_string()),
595 forge_kind: ForgeKind::GitHub,
596 host: "github.com".to_string(),
597 };
598
599 let message = error.detail_message();
601
602 assert!(message.contains("Run `gh auth login` and retry."));
604 assert!(!message.contains("Original `gh` error:"));
605 }
606
607 #[test]
608 fn review_request_creation_url_returns_github_compare_link() {
609 let remote = ForgeRemote {
611 command_working_directory: None,
612 forge_kind: ForgeKind::GitHub,
613 host: "github.com".to_string(),
614 namespace: "agentty-xyz".to_string(),
615 project: "agentty".to_string(),
616 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
617 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
618 };
619
620 let url = remote
622 .review_request_creation_url("review/custom-branch", "main")
623 .expect("github compare URL should be created");
624
625 assert_eq!(
627 url,
628 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
629 );
630 }
631
632 #[test]
633 fn review_request_creation_url_rejects_invalid_web_url() {
634 let remote = ForgeRemote {
636 command_working_directory: None,
637 forge_kind: ForgeKind::GitHub,
638 host: "github.com".to_string(),
639 namespace: "agentty-xyz".to_string(),
640 project: "agentty".to_string(),
641 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
642 web_url: "not a url".to_string(),
643 };
644
645 let error = remote
647 .review_request_creation_url("review/custom-branch", "main")
648 .expect_err("invalid web URL should be rejected");
649
650 assert_eq!(
652 error,
653 ReviewRequestError::OperationFailed {
654 forge_kind: ForgeKind::GitHub,
655 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
656 }
657 );
658 }
659
660 #[test]
661 fn forge_kind_from_str_gitlab() {
662 let raw_forge_kind = "GitLab";
664
665 let forge_kind = raw_forge_kind
667 .parse::<ForgeKind>()
668 .expect("gitlab forge kind should parse");
669
670 assert_eq!(forge_kind, ForgeKind::GitLab);
672 assert_eq!(forge_kind.cli_name(), "glab");
673 assert_eq!(forge_kind.review_request_name(), "merge request");
674 assert_eq!(forge_kind.review_request_short_name(), "MR");
675 }
676
677 #[test]
678 fn review_request_creation_url_returns_gitlab_merge_request_link() {
679 let remote = ForgeRemote {
681 command_working_directory: None,
682 forge_kind: ForgeKind::GitLab,
683 host: "gitlab.com".to_string(),
684 namespace: "agentty-xyz".to_string(),
685 project: "agentty".to_string(),
686 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
687 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
688 };
689
690 let url = remote
692 .review_request_creation_url("review/custom-branch", "main")
693 .expect("gitlab merge-request URL should be created");
694
695 assert_eq!(
697 url,
698 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
699 );
700 }
701}