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 {
82 match self {
83 Self::GitHub => true,
84 Self::GitLab => false,
85 }
86 }
87}
88
89pub fn is_gitlab_host(host: &str) -> bool {
91 host == "gitlab.com"
92 || host.ends_with(".gitlab.com")
93 || host.starts_with("gitlab.")
94 || host.contains(".gitlab.")
95}
96
97impl fmt::Display for ForgeKind {
98 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99 formatter.write_str(self.as_str())
100 }
101}
102
103impl FromStr for ForgeKind {
104 type Err = String;
105
106 fn from_str(value: &str) -> Result<Self, Self::Err> {
107 match value {
108 "GitHub" => Ok(Self::GitHub),
109 "GitLab" => Ok(Self::GitLab),
110 _ => Err(format!("Unknown review-request forge: {value}")),
111 }
112 }
113}
114
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum ReviewRequestState {
118 Open,
120 Merged,
122 Closed,
124}
125
126impl ReviewRequestState {
127 pub fn as_str(self) -> &'static str {
129 match self {
130 Self::Open => "Open",
131 Self::Merged => "Merged",
132 Self::Closed => "Closed",
133 }
134 }
135}
136
137impl fmt::Display for ReviewRequestState {
138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139 formatter.write_str(self.as_str())
140 }
141}
142
143impl FromStr for ReviewRequestState {
144 type Err = String;
145
146 fn from_str(value: &str) -> Result<Self, Self::Err> {
147 match value {
148 "Open" => Ok(Self::Open),
149 "Merged" => Ok(Self::Merged),
150 "Closed" => Ok(Self::Closed),
151 _ => Err(format!("Unknown review-request state: {value}")),
152 }
153 }
154}
155
156#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct ReviewRequestSummary {
165 pub display_id: String,
167 pub forge_kind: ForgeKind,
169 pub source_branch: String,
171 pub state: ReviewRequestState,
173 pub status_summary: Option<String>,
175 pub target_branch: String,
177 pub title: String,
179 pub web_url: String,
181}
182
183pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
185
186#[derive(Clone, Debug, Eq, PartialEq)]
188pub struct ForgeRemote {
189 pub command_working_directory: Option<PathBuf>,
192 pub forge_kind: ForgeKind,
194 pub host: String,
199 pub namespace: String,
201 pub project: String,
203 pub repo_url: String,
205 pub web_url: String,
207}
208
209impl ForgeRemote {
210 #[must_use]
213 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
214 self.command_working_directory = Some(working_directory);
215
216 self
217 }
218
219 pub fn project_path(&self) -> String {
221 format!("{}/{}", self.namespace, self.project)
222 }
223
224 pub fn review_request_creation_url(
232 &self,
233 source_branch: &str,
234 target_branch: &str,
235 ) -> Result<String, ReviewRequestError> {
236 match self.forge_kind {
237 ForgeKind::GitHub => {
238 github_review_request_creation_url(self, source_branch, target_branch)
239 }
240 ForgeKind::GitLab => {
241 gitlab_review_request_creation_url(self, source_branch, target_branch)
242 }
243 }
244 }
245}
246
247#[derive(Clone, Debug, Eq, PartialEq)]
249pub struct ReviewComment {
250 pub author: String,
252 pub body: String,
254}
255
256#[derive(Clone, Debug, Eq, PartialEq)]
262pub struct ReviewCommentThread {
263 pub comments: Vec<ReviewComment>,
265 pub is_resolved: bool,
267 pub line: Option<u32>,
269 pub path: String,
271}
272
273#[derive(Clone, Debug, Default, Eq, PartialEq)]
280pub struct ReviewCommentSnapshot {
281 pub pr_level_comments: Vec<ReviewComment>,
284 pub threads: Vec<ReviewCommentThread>,
286}
287
288#[derive(Clone, Debug, Eq, PartialEq)]
290pub struct CreateReviewRequestInput {
291 pub body: Option<String>,
293 pub source_branch: String,
295 pub target_branch: String,
297 pub title: String,
299}
300
301#[derive(Clone, Debug, Eq, PartialEq)]
303pub enum ReviewRequestError {
304 CliNotInstalled { forge_kind: ForgeKind },
306 AuthenticationRequired {
308 forge_kind: ForgeKind,
310 host: String,
312 detail: Option<String>,
314 },
315 HostResolutionFailed { forge_kind: ForgeKind, host: String },
317 UnsupportedRemote { repo_url: String },
319 OperationFailed {
321 forge_kind: ForgeKind,
322 message: String,
323 },
324}
325
326impl ReviewRequestError {
327 pub fn detail_message(&self) -> String {
329 match self {
330 Self::CliNotInstalled { forge_kind } => format!(
331 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
332 forge_kind.display_name(),
333 forge_kind.cli_name(),
334 forge_kind.cli_name(),
335 forge_kind.auth_login_command(),
336 ),
337 Self::AuthenticationRequired {
338 forge_kind,
339 host,
340 detail,
341 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
342 Self::HostResolutionFailed { forge_kind, host } => format!(
343 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
344 and your network or DNS setup, then retry.",
345 forge_kind.display_name(),
346 ),
347 Self::UnsupportedRemote { repo_url } => format!(
348 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
349 repository remote is not supported: `{repo_url}`."
350 ),
351 Self::OperationFailed {
352 forge_kind,
353 message,
354 } => format!(
355 "{} review-request operation failed: {message}",
356 forge_kind.display_name()
357 ),
358 }
359 }
360}
361
362fn authentication_required_message(
365 forge_kind: ForgeKind,
366 host: &str,
367 detail: Option<&str>,
368) -> String {
369 let mut message = format!(
370 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
371 forge_kind.display_name(),
372 forge_kind.auth_login_command(),
373 );
374
375 if let Some(detail) = non_empty_detail(detail) {
376 let _ = write!(
378 message,
379 "\n\nOriginal `{}` error:\n```text\n{detail}",
380 forge_kind.cli_name(),
381 );
382 if !detail.ends_with('\n') {
383 message.push('\n');
384 }
385 message.push_str("```");
386 }
387
388 message
389}
390
391fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
393 detail.and_then(|detail| {
394 let trimmed_detail = detail.trim();
395 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
396 })
397}
398
399fn github_review_request_creation_url(
401 remote: &ForgeRemote,
402 source_branch: &str,
403 target_branch: &str,
404) -> Result<String, ReviewRequestError> {
405 let mut url = parsed_remote_web_url(remote)?;
406 let compare_target = if target_branch.trim().is_empty() {
407 source_branch.to_string()
408 } else {
409 format!("{target_branch}...{source_branch}")
410 };
411
412 {
413 let mut path_segments = url
414 .path_segments_mut()
415 .map_err(|()| invalid_web_url_error(remote))?;
416 path_segments.pop_if_empty();
417 path_segments.push("compare");
418 path_segments.push(&compare_target);
419 }
420
421 url.query_pairs_mut().append_pair("expand", "1");
422
423 Ok(url.into())
424}
425
426fn gitlab_review_request_creation_url(
428 remote: &ForgeRemote,
429 source_branch: &str,
430 target_branch: &str,
431) -> Result<String, ReviewRequestError> {
432 let mut url = parsed_remote_web_url(remote)?;
433
434 {
435 let mut path_segments = url
436 .path_segments_mut()
437 .map_err(|()| invalid_web_url_error(remote))?;
438 path_segments.pop_if_empty();
439 path_segments.push("-");
440 path_segments.push("merge_requests");
441 path_segments.push("new");
442 }
443
444 url.query_pairs_mut()
445 .append_pair("merge_request[source_branch]", source_branch)
446 .append_pair("merge_request[target_branch]", target_branch);
447
448 Ok(url.into())
449}
450
451fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
453 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
454}
455
456fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
458 ReviewRequestError::OperationFailed {
459 forge_kind: remote.forge_kind,
460 message: format!(
461 "repository remote is missing a valid web URL: `{}`",
462 remote.web_url
463 ),
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn authentication_required_message_includes_original_cli_error_detail() {
473 let error = ReviewRequestError::AuthenticationRequired {
475 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
476 forge_kind: ForgeKind::GitHub,
477 host: "github.com".to_string(),
478 };
479
480 let message = error.detail_message();
482
483 assert!(message.contains("GitHub review requests require local CLI authentication"));
485 assert!(message.contains("Run `gh auth login` and retry."));
486 assert!(message.contains("Original `gh` error:"));
487 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
488 assert!(message.contains("```text"));
489 }
490
491 #[test]
492 fn authentication_required_message_omits_empty_original_cli_error_detail() {
493 let error = ReviewRequestError::AuthenticationRequired {
495 detail: Some(" \n".to_string()),
496 forge_kind: ForgeKind::GitHub,
497 host: "github.com".to_string(),
498 };
499
500 let message = error.detail_message();
502
503 assert!(message.contains("Run `gh auth login` and retry."));
505 assert!(!message.contains("Original `gh` error:"));
506 }
507
508 #[test]
509 fn review_request_creation_url_returns_github_compare_link() {
510 let remote = ForgeRemote {
512 command_working_directory: None,
513 forge_kind: ForgeKind::GitHub,
514 host: "github.com".to_string(),
515 namespace: "agentty-xyz".to_string(),
516 project: "agentty".to_string(),
517 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
518 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
519 };
520
521 let url = remote
523 .review_request_creation_url("review/custom-branch", "main")
524 .expect("github compare URL should be created");
525
526 assert_eq!(
528 url,
529 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
530 );
531 }
532
533 #[test]
534 fn review_request_creation_url_rejects_invalid_web_url() {
535 let remote = ForgeRemote {
537 command_working_directory: None,
538 forge_kind: ForgeKind::GitHub,
539 host: "github.com".to_string(),
540 namespace: "agentty-xyz".to_string(),
541 project: "agentty".to_string(),
542 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
543 web_url: "not a url".to_string(),
544 };
545
546 let error = remote
548 .review_request_creation_url("review/custom-branch", "main")
549 .expect_err("invalid web URL should be rejected");
550
551 assert_eq!(
553 error,
554 ReviewRequestError::OperationFailed {
555 forge_kind: ForgeKind::GitHub,
556 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
557 }
558 );
559 }
560
561 #[test]
562 fn forge_kind_from_str_gitlab() {
563 let raw_forge_kind = "GitLab";
565
566 let forge_kind = raw_forge_kind
568 .parse::<ForgeKind>()
569 .expect("gitlab forge kind should parse");
570
571 assert_eq!(forge_kind, ForgeKind::GitLab);
573 assert_eq!(forge_kind.cli_name(), "glab");
574 assert_eq!(forge_kind.review_request_name(), "merge request");
575 assert_eq!(forge_kind.review_request_short_name(), "MR");
576 }
577
578 #[test]
579 fn supports_review_comments_preview_only_returns_true_for_github() {
580 assert!(ForgeKind::GitHub.supports_review_comments_preview());
582 assert!(!ForgeKind::GitLab.supports_review_comments_preview());
583 }
584
585 #[test]
586 fn review_request_creation_url_returns_gitlab_merge_request_link() {
587 let remote = ForgeRemote {
589 command_working_directory: None,
590 forge_kind: ForgeKind::GitLab,
591 host: "gitlab.com".to_string(),
592 namespace: "agentty-xyz".to_string(),
593 project: "agentty".to_string(),
594 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
595 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
596 };
597
598 let url = remote
600 .review_request_creation_url("review/custom-branch", "main")
601 .expect("gitlab merge-request URL should be created");
602
603 assert_eq!(
605 url,
606 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
607 );
608 }
609}