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
76impl fmt::Display for ForgeKind {
77 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter.write_str(self.as_str())
79 }
80}
81
82impl FromStr for ForgeKind {
83 type Err = String;
84
85 fn from_str(value: &str) -> Result<Self, Self::Err> {
86 match value {
87 "GitHub" => Ok(Self::GitHub),
88 "GitLab" => Ok(Self::GitLab),
89 _ => Err(format!("Unknown review-request forge: {value}")),
90 }
91 }
92}
93
94pub fn is_gitlab_host(host: &str) -> bool {
96 host == "gitlab.com"
97 || host.ends_with(".gitlab.com")
98 || host.starts_with("gitlab.")
99 || host.contains(".gitlab.")
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
170pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
172
173#[derive(Clone, Debug, Eq, PartialEq)]
175pub struct ForgeRemote {
176 pub command_working_directory: Option<PathBuf>,
179 pub forge_kind: ForgeKind,
181 pub host: String,
186 pub namespace: String,
188 pub project: String,
190 pub repo_url: String,
192 pub web_url: String,
194}
195
196impl ForgeRemote {
197 #[must_use]
200 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
201 self.command_working_directory = Some(working_directory);
202
203 self
204 }
205
206 pub fn project_path(&self) -> String {
208 format!("{}/{}", self.namespace, self.project)
209 }
210
211 pub fn review_request_creation_url(
219 &self,
220 source_branch: &str,
221 target_branch: &str,
222 ) -> Result<String, ReviewRequestError> {
223 match self.forge_kind {
224 ForgeKind::GitHub => {
225 github_review_request_creation_url(self, source_branch, target_branch)
226 }
227 ForgeKind::GitLab => {
228 gitlab_review_request_creation_url(self, source_branch, target_branch)
229 }
230 }
231 }
232}
233
234#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct ReviewComment {
237 pub author: String,
239 pub body: String,
241}
242
243#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
245pub enum ReviewCommentAnchorSide {
246 File,
248 New,
250 Old,
252}
253
254#[derive(Clone, Debug, Eq, PartialEq)]
261pub struct ReviewCommentThread {
262 pub anchor_side: ReviewCommentAnchorSide,
264 pub comments: Vec<ReviewComment>,
266 pub id: String,
268 pub is_outdated: Option<bool>,
271 pub is_resolved: bool,
273 pub line: Option<u32>,
275 pub path: String,
277 pub start_line: Option<u32>,
279}
280
281impl ReviewCommentThread {
282 pub fn is_actionable(&self) -> bool {
287 !self.is_resolved
288 }
289}
290
291#[derive(Clone, Debug, Default, Eq, PartialEq)]
298pub struct ReviewCommentSnapshot {
299 pub pr_level_comments: Vec<ReviewComment>,
302 pub threads: Vec<ReviewCommentThread>,
304}
305
306#[derive(Clone, Debug, Eq, PartialEq)]
308pub struct CreateReviewRequestInput {
309 pub body: Option<String>,
311 pub source_branch: String,
313 pub target_branch: String,
315 pub title: String,
317}
318
319#[derive(Clone, Debug, Eq, PartialEq)]
321pub struct ReviewRequestMetadata {
322 pub body: String,
324 pub title: String,
326}
327
328#[derive(Clone, Debug, Eq, PartialEq)]
331pub struct ReviewRequestMetadataFieldUpdate {
332 pub current: String,
334 pub desired: String,
336}
337
338#[derive(Clone, Debug, Eq, PartialEq)]
340pub struct UpdateReviewRequestInput {
341 pub body: Option<ReviewRequestMetadataFieldUpdate>,
343 pub title: Option<ReviewRequestMetadataFieldUpdate>,
345}
346
347#[derive(Clone, Debug, Eq, PartialEq)]
349pub enum ReviewRequestError {
350 CliNotInstalled {
352 forge_kind: ForgeKind,
354 },
355 AuthenticationRequired {
357 forge_kind: ForgeKind,
359 host: String,
361 detail: Option<String>,
363 },
364 HostResolutionFailed {
366 forge_kind: ForgeKind,
368 host: String,
370 },
371 UnsupportedRemote {
373 repo_url: String,
375 },
376 OperationFailed {
378 forge_kind: ForgeKind,
380 message: String,
382 },
383}
384
385impl ReviewRequestError {
386 pub fn detail_message(&self) -> String {
388 match self {
389 Self::CliNotInstalled { forge_kind } => format!(
390 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
391 forge_kind.display_name(),
392 forge_kind.cli_name(),
393 forge_kind.cli_name(),
394 forge_kind.auth_login_command(),
395 ),
396 Self::AuthenticationRequired {
397 forge_kind,
398 host,
399 detail,
400 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
401 Self::HostResolutionFailed { forge_kind, host } => format!(
402 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
403 and your network or DNS setup, then retry.",
404 forge_kind.display_name(),
405 ),
406 Self::UnsupportedRemote { repo_url } => format!(
407 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
408 repository remote is not supported: `{repo_url}`."
409 ),
410 Self::OperationFailed {
411 forge_kind,
412 message,
413 } => format!(
414 "{} review-request operation failed: {message}",
415 forge_kind.display_name()
416 ),
417 }
418 }
419}
420
421fn github_review_request_creation_url(
423 remote: &ForgeRemote,
424 source_branch: &str,
425 target_branch: &str,
426) -> Result<String, ReviewRequestError> {
427 let mut url = parsed_remote_web_url(remote)?;
428 let compare_target = if target_branch.trim().is_empty() {
429 source_branch.to_string()
430 } else {
431 format!("{target_branch}...{source_branch}")
432 };
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("compare");
440 path_segments.push(&compare_target);
441 }
442
443 url.query_pairs_mut().append_pair("expand", "1");
444
445 Ok(url.into())
446}
447
448fn gitlab_review_request_creation_url(
450 remote: &ForgeRemote,
451 source_branch: &str,
452 target_branch: &str,
453) -> Result<String, ReviewRequestError> {
454 let mut url = parsed_remote_web_url(remote)?;
455
456 {
457 let mut path_segments = url
458 .path_segments_mut()
459 .map_err(|()| invalid_web_url_error(remote))?;
460 path_segments.pop_if_empty();
461 path_segments.push("-");
462 path_segments.push("merge_requests");
463 path_segments.push("new");
464 }
465
466 url.query_pairs_mut()
467 .append_pair("merge_request[source_branch]", source_branch)
468 .append_pair("merge_request[target_branch]", target_branch);
469
470 Ok(url.into())
471}
472
473fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
475 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
476}
477
478fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
480 ReviewRequestError::OperationFailed {
481 forge_kind: remote.forge_kind,
482 message: format!(
483 "repository remote is missing a valid web URL: `{}`",
484 remote.web_url
485 ),
486 }
487}
488
489fn authentication_required_message(
492 forge_kind: ForgeKind,
493 host: &str,
494 detail: Option<&str>,
495) -> String {
496 let mut message = format!(
497 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
498 forge_kind.display_name(),
499 forge_kind.auth_login_command(),
500 );
501
502 if let Some(detail) = non_empty_detail(detail) {
503 let _ = write!(
505 message,
506 "\n\nOriginal `{}` error:\n```text\n{detail}",
507 forge_kind.cli_name(),
508 );
509 if !detail.ends_with('\n') {
510 message.push('\n');
511 }
512 message.push_str("```");
513 }
514
515 message
516}
517
518fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
520 detail.and_then(|detail| {
521 let trimmed_detail = detail.trim();
522 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
523 })
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 fn review_comment_thread() -> ReviewCommentThread {
531 ReviewCommentThread {
532 anchor_side: ReviewCommentAnchorSide::New,
533 comments: Vec::new(),
534 id: "thread-1".to_string(),
535 is_outdated: Some(false),
536 is_resolved: false,
537 line: Some(1),
538 path: "src/lib.rs".to_string(),
539 start_line: None,
540 }
541 }
542
543 #[test]
544 fn review_comment_thread_is_actionable_when_unresolved_even_if_outdated() {
545 let actionable = review_comment_thread();
547 let mut resolved = review_comment_thread();
548 resolved.is_resolved = true;
549 let mut outdated = review_comment_thread();
550 outdated.is_outdated = Some(true);
551
552 assert!(actionable.is_actionable());
554 assert!(!resolved.is_actionable());
555 assert!(outdated.is_actionable());
556 }
557
558 #[test]
559 fn forge_kind_from_str_gitlab() {
560 let raw_forge_kind = "GitLab";
562
563 let forge_kind = raw_forge_kind
565 .parse::<ForgeKind>()
566 .expect("gitlab forge kind should parse");
567
568 assert_eq!(forge_kind, ForgeKind::GitLab);
570 assert_eq!(forge_kind.cli_name(), "glab");
571 assert_eq!(forge_kind.review_request_name(), "merge request");
572 assert_eq!(forge_kind.review_request_short_name(), "MR");
573 }
574
575 #[test]
576 fn authentication_required_message_includes_original_cli_error_detail() {
577 let error = ReviewRequestError::AuthenticationRequired {
579 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
580 forge_kind: ForgeKind::GitHub,
581 host: "github.com".to_string(),
582 };
583
584 let message = error.detail_message();
586
587 assert!(message.contains("GitHub review requests require local CLI authentication"));
589 assert!(message.contains("Run `gh auth login` and retry."));
590 assert!(message.contains("Original `gh` error:"));
591 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
592 assert!(message.contains("```text"));
593 }
594
595 #[test]
596 fn authentication_required_message_omits_empty_original_cli_error_detail() {
597 let error = ReviewRequestError::AuthenticationRequired {
599 detail: Some(" \n".to_string()),
600 forge_kind: ForgeKind::GitHub,
601 host: "github.com".to_string(),
602 };
603
604 let message = error.detail_message();
606
607 assert!(message.contains("Run `gh auth login` and retry."));
609 assert!(!message.contains("Original `gh` error:"));
610 }
611
612 #[test]
613 fn review_request_creation_url_returns_github_compare_link() {
614 let remote = ForgeRemote {
616 command_working_directory: None,
617 forge_kind: ForgeKind::GitHub,
618 host: "github.com".to_string(),
619 namespace: "agentty-xyz".to_string(),
620 project: "agentty".to_string(),
621 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
622 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
623 };
624
625 let url = remote
627 .review_request_creation_url("review/custom-branch", "main")
628 .expect("github compare URL should be created");
629
630 assert_eq!(
632 url,
633 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
634 );
635 }
636
637 #[test]
638 fn review_request_creation_url_rejects_invalid_web_url() {
639 let remote = ForgeRemote {
641 command_working_directory: None,
642 forge_kind: ForgeKind::GitHub,
643 host: "github.com".to_string(),
644 namespace: "agentty-xyz".to_string(),
645 project: "agentty".to_string(),
646 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
647 web_url: "not a url".to_string(),
648 };
649
650 let error = remote
652 .review_request_creation_url("review/custom-branch", "main")
653 .expect_err("invalid web URL should be rejected");
654
655 assert_eq!(
657 error,
658 ReviewRequestError::OperationFailed {
659 forge_kind: ForgeKind::GitHub,
660 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
661 }
662 );
663 }
664
665 #[test]
666 fn review_request_creation_url_returns_gitlab_merge_request_link() {
667 let remote = ForgeRemote {
669 command_working_directory: None,
670 forge_kind: ForgeKind::GitLab,
671 host: "gitlab.com".to_string(),
672 namespace: "agentty-xyz".to_string(),
673 project: "agentty".to_string(),
674 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
675 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
676 };
677
678 let url = remote
680 .review_request_creation_url("review/custom-branch", "main")
681 .expect("gitlab merge-request URL should be created");
682
683 assert_eq!(
685 url,
686 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
687 );
688 }
689}