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, Debug, Eq, PartialEq)]
183pub struct RequestedReview {
184 pub display_id: String,
186 pub forge_kind: ForgeKind,
188 pub repository: String,
190 pub status_summary: Option<String>,
192 pub title: String,
194 pub updated_at: Option<String>,
196 pub web_url: String,
198}
199
200pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
202
203#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct ForgeRemote {
206 pub command_working_directory: Option<PathBuf>,
209 pub forge_kind: ForgeKind,
211 pub host: String,
216 pub namespace: String,
218 pub project: String,
220 pub repo_url: String,
222 pub web_url: String,
224}
225
226impl ForgeRemote {
227 #[must_use]
230 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
231 self.command_working_directory = Some(working_directory);
232
233 self
234 }
235
236 pub fn project_path(&self) -> String {
238 format!("{}/{}", self.namespace, self.project)
239 }
240
241 pub fn review_request_creation_url(
249 &self,
250 source_branch: &str,
251 target_branch: &str,
252 ) -> Result<String, ReviewRequestError> {
253 match self.forge_kind {
254 ForgeKind::GitHub => {
255 github_review_request_creation_url(self, source_branch, target_branch)
256 }
257 ForgeKind::GitLab => {
258 gitlab_review_request_creation_url(self, source_branch, target_branch)
259 }
260 }
261 }
262}
263
264#[derive(Clone, Debug, Eq, PartialEq)]
266pub struct ReviewComment {
267 pub author: String,
269 pub body: String,
271}
272
273#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
275pub enum ReviewCommentAnchorSide {
276 File,
278 New,
280 Old,
282}
283
284#[derive(Clone, Debug, Eq, PartialEq)]
290pub struct ReviewCommentThread {
291 pub anchor_side: ReviewCommentAnchorSide,
293 pub comments: Vec<ReviewComment>,
295 pub is_outdated: Option<bool>,
298 pub is_resolved: bool,
300 pub line: Option<u32>,
302 pub path: String,
304 pub start_line: Option<u32>,
306}
307
308#[derive(Clone, Debug, Default, Eq, PartialEq)]
315pub struct ReviewCommentSnapshot {
316 pub pr_level_comments: Vec<ReviewComment>,
319 pub threads: Vec<ReviewCommentThread>,
321}
322
323#[derive(Clone, Debug, Eq, PartialEq)]
325pub struct CreateReviewRequestInput {
326 pub body: Option<String>,
328 pub source_branch: String,
330 pub target_branch: String,
332 pub title: String,
334}
335
336#[derive(Clone, Debug, Eq, PartialEq)]
338pub enum ReviewRequestError {
339 CliNotInstalled { forge_kind: ForgeKind },
341 AuthenticationRequired {
343 forge_kind: ForgeKind,
345 host: String,
347 detail: Option<String>,
349 },
350 HostResolutionFailed { forge_kind: ForgeKind, host: String },
352 UnsupportedRemote { repo_url: String },
354 OperationFailed {
356 forge_kind: ForgeKind,
357 message: String,
358 },
359}
360
361impl ReviewRequestError {
362 pub fn detail_message(&self) -> String {
364 match self {
365 Self::CliNotInstalled { forge_kind } => format!(
366 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
367 forge_kind.display_name(),
368 forge_kind.cli_name(),
369 forge_kind.cli_name(),
370 forge_kind.auth_login_command(),
371 ),
372 Self::AuthenticationRequired {
373 forge_kind,
374 host,
375 detail,
376 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
377 Self::HostResolutionFailed { forge_kind, host } => format!(
378 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
379 and your network or DNS setup, then retry.",
380 forge_kind.display_name(),
381 ),
382 Self::UnsupportedRemote { repo_url } => format!(
383 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
384 repository remote is not supported: `{repo_url}`."
385 ),
386 Self::OperationFailed {
387 forge_kind,
388 message,
389 } => format!(
390 "{} review-request operation failed: {message}",
391 forge_kind.display_name()
392 ),
393 }
394 }
395}
396
397fn authentication_required_message(
400 forge_kind: ForgeKind,
401 host: &str,
402 detail: Option<&str>,
403) -> String {
404 let mut message = format!(
405 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
406 forge_kind.display_name(),
407 forge_kind.auth_login_command(),
408 );
409
410 if let Some(detail) = non_empty_detail(detail) {
411 let _ = write!(
413 message,
414 "\n\nOriginal `{}` error:\n```text\n{detail}",
415 forge_kind.cli_name(),
416 );
417 if !detail.ends_with('\n') {
418 message.push('\n');
419 }
420 message.push_str("```");
421 }
422
423 message
424}
425
426fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
428 detail.and_then(|detail| {
429 let trimmed_detail = detail.trim();
430 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
431 })
432}
433
434fn github_review_request_creation_url(
436 remote: &ForgeRemote,
437 source_branch: &str,
438 target_branch: &str,
439) -> Result<String, ReviewRequestError> {
440 let mut url = parsed_remote_web_url(remote)?;
441 let compare_target = if target_branch.trim().is_empty() {
442 source_branch.to_string()
443 } else {
444 format!("{target_branch}...{source_branch}")
445 };
446
447 {
448 let mut path_segments = url
449 .path_segments_mut()
450 .map_err(|()| invalid_web_url_error(remote))?;
451 path_segments.pop_if_empty();
452 path_segments.push("compare");
453 path_segments.push(&compare_target);
454 }
455
456 url.query_pairs_mut().append_pair("expand", "1");
457
458 Ok(url.into())
459}
460
461fn gitlab_review_request_creation_url(
463 remote: &ForgeRemote,
464 source_branch: &str,
465 target_branch: &str,
466) -> Result<String, ReviewRequestError> {
467 let mut url = parsed_remote_web_url(remote)?;
468
469 {
470 let mut path_segments = url
471 .path_segments_mut()
472 .map_err(|()| invalid_web_url_error(remote))?;
473 path_segments.pop_if_empty();
474 path_segments.push("-");
475 path_segments.push("merge_requests");
476 path_segments.push("new");
477 }
478
479 url.query_pairs_mut()
480 .append_pair("merge_request[source_branch]", source_branch)
481 .append_pair("merge_request[target_branch]", target_branch);
482
483 Ok(url.into())
484}
485
486fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
488 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
489}
490
491fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
493 ReviewRequestError::OperationFailed {
494 forge_kind: remote.forge_kind,
495 message: format!(
496 "repository remote is missing a valid web URL: `{}`",
497 remote.web_url
498 ),
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn authentication_required_message_includes_original_cli_error_detail() {
508 let error = ReviewRequestError::AuthenticationRequired {
510 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
511 forge_kind: ForgeKind::GitHub,
512 host: "github.com".to_string(),
513 };
514
515 let message = error.detail_message();
517
518 assert!(message.contains("GitHub review requests require local CLI authentication"));
520 assert!(message.contains("Run `gh auth login` and retry."));
521 assert!(message.contains("Original `gh` error:"));
522 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
523 assert!(message.contains("```text"));
524 }
525
526 #[test]
527 fn authentication_required_message_omits_empty_original_cli_error_detail() {
528 let error = ReviewRequestError::AuthenticationRequired {
530 detail: Some(" \n".to_string()),
531 forge_kind: ForgeKind::GitHub,
532 host: "github.com".to_string(),
533 };
534
535 let message = error.detail_message();
537
538 assert!(message.contains("Run `gh auth login` and retry."));
540 assert!(!message.contains("Original `gh` error:"));
541 }
542
543 #[test]
544 fn review_request_creation_url_returns_github_compare_link() {
545 let remote = ForgeRemote {
547 command_working_directory: None,
548 forge_kind: ForgeKind::GitHub,
549 host: "github.com".to_string(),
550 namespace: "agentty-xyz".to_string(),
551 project: "agentty".to_string(),
552 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
553 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
554 };
555
556 let url = remote
558 .review_request_creation_url("review/custom-branch", "main")
559 .expect("github compare URL should be created");
560
561 assert_eq!(
563 url,
564 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
565 );
566 }
567
568 #[test]
569 fn review_request_creation_url_rejects_invalid_web_url() {
570 let remote = ForgeRemote {
572 command_working_directory: None,
573 forge_kind: ForgeKind::GitHub,
574 host: "github.com".to_string(),
575 namespace: "agentty-xyz".to_string(),
576 project: "agentty".to_string(),
577 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
578 web_url: "not a url".to_string(),
579 };
580
581 let error = remote
583 .review_request_creation_url("review/custom-branch", "main")
584 .expect_err("invalid web URL should be rejected");
585
586 assert_eq!(
588 error,
589 ReviewRequestError::OperationFailed {
590 forge_kind: ForgeKind::GitHub,
591 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
592 }
593 );
594 }
595
596 #[test]
597 fn forge_kind_from_str_gitlab() {
598 let raw_forge_kind = "GitLab";
600
601 let forge_kind = raw_forge_kind
603 .parse::<ForgeKind>()
604 .expect("gitlab forge kind should parse");
605
606 assert_eq!(forge_kind, ForgeKind::GitLab);
608 assert_eq!(forge_kind.cli_name(), "glab");
609 assert_eq!(forge_kind.review_request_name(), "merge request");
610 assert_eq!(forge_kind.review_request_short_name(), "MR");
611 }
612
613 #[test]
614 fn supports_review_comments_preview_returns_true_for_supported_forges() {
615 assert!(ForgeKind::GitHub.supports_review_comments_preview());
617 assert!(ForgeKind::GitLab.supports_review_comments_preview());
618 }
619
620 #[test]
621 fn review_request_creation_url_returns_gitlab_merge_request_link() {
622 let remote = ForgeRemote {
624 command_working_directory: None,
625 forge_kind: ForgeKind::GitLab,
626 host: "gitlab.com".to_string(),
627 namespace: "agentty-xyz".to_string(),
628 project: "agentty".to_string(),
629 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
630 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
631 };
632
633 let url = remote
635 .review_request_creation_url("review/custom-branch", "main")
636 .expect("gitlab merge-request URL should be created");
637
638 assert_eq!(
640 url,
641 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
642 );
643 }
644}