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
12pub const AGENTTY_REVIEW_REPLY_MARKER_PREFIX: &str = "<!-- agentty review resolution:";
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum ForgeKind {
19 GitHub,
21 GitLab,
23}
24
25impl ForgeKind {
26 pub fn display_name(self) -> &'static str {
28 match self {
29 Self::GitHub => "GitHub",
30 Self::GitLab => "GitLab",
31 }
32 }
33
34 pub fn cli_name(self) -> &'static str {
36 match self {
37 Self::GitHub => "gh",
38 Self::GitLab => "glab",
39 }
40 }
41
42 pub fn auth_login_command(self) -> &'static str {
44 match self {
45 Self::GitHub => "gh auth login",
46 Self::GitLab => "glab auth login",
47 }
48 }
49
50 pub fn as_str(self) -> &'static str {
52 match self {
53 Self::GitHub => "GitHub",
54 Self::GitLab => "GitLab",
55 }
56 }
57
58 pub fn review_request_name(self) -> &'static str {
60 match self {
61 Self::GitHub => "pull request",
62 Self::GitLab => "merge request",
63 }
64 }
65
66 pub fn review_request_display_name(self) -> String {
68 format!("{} {}", self.display_name(), self.review_request_name())
69 }
70
71 pub fn review_request_short_name(self) -> &'static str {
73 match self {
74 Self::GitHub => "PR",
75 Self::GitLab => "MR",
76 }
77 }
78}
79
80impl fmt::Display for ForgeKind {
81 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82 formatter.write_str(self.as_str())
83 }
84}
85
86impl FromStr for ForgeKind {
87 type Err = String;
88
89 fn from_str(value: &str) -> Result<Self, Self::Err> {
90 match value {
91 "GitHub" => Ok(Self::GitHub),
92 "GitLab" => Ok(Self::GitLab),
93 _ => Err(format!("Unknown review-request forge: {value}")),
94 }
95 }
96}
97
98pub fn is_gitlab_host(host: &str) -> bool {
100 host == "gitlab.com"
101 || host.ends_with(".gitlab.com")
102 || host.starts_with("gitlab.")
103 || host.contains(".gitlab.")
104}
105
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub enum ReviewRequestState {
109 Open,
111 Merged,
113 Closed,
115}
116
117impl ReviewRequestState {
118 pub fn as_str(self) -> &'static str {
120 match self {
121 Self::Open => "Open",
122 Self::Merged => "Merged",
123 Self::Closed => "Closed",
124 }
125 }
126}
127
128impl fmt::Display for ReviewRequestState {
129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130 formatter.write_str(self.as_str())
131 }
132}
133
134impl FromStr for ReviewRequestState {
135 type Err = String;
136
137 fn from_str(value: &str) -> Result<Self, Self::Err> {
138 match value {
139 "Open" => Ok(Self::Open),
140 "Merged" => Ok(Self::Merged),
141 "Closed" => Ok(Self::Closed),
142 _ => Err(format!("Unknown review-request state: {value}")),
143 }
144 }
145}
146
147#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct ReviewRequestSummary {
156 pub display_id: String,
158 pub forge_kind: ForgeKind,
160 pub source_branch: String,
162 pub state: ReviewRequestState,
164 pub status_summary: Option<String>,
166 pub target_branch: String,
168 pub title: String,
170 pub web_url: String,
172}
173
174pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
176
177#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct ForgeRemote {
180 pub command_working_directory: Option<PathBuf>,
183 pub forge_kind: ForgeKind,
185 pub host: String,
190 pub namespace: String,
192 pub project: String,
194 pub repo_url: String,
196 pub web_url: String,
198}
199
200impl ForgeRemote {
201 #[must_use]
204 pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
205 self.command_working_directory = Some(working_directory);
206
207 self
208 }
209
210 pub fn project_path(&self) -> String {
212 format!("{}/{}", self.namespace, self.project)
213 }
214
215 pub fn review_request_creation_url(
223 &self,
224 source_branch: &str,
225 target_branch: &str,
226 ) -> Result<String, ReviewRequestError> {
227 match self.forge_kind {
228 ForgeKind::GitHub => {
229 github_review_request_creation_url(self, source_branch, target_branch)
230 }
231 ForgeKind::GitLab => {
232 gitlab_review_request_creation_url(self, source_branch, target_branch)
233 }
234 }
235 }
236}
237
238#[derive(Clone, Debug, Eq, PartialEq)]
240pub struct ReviewComment {
241 pub author: String,
243 pub authored_by_current_user: bool,
246 pub body: String,
248}
249
250impl ReviewComment {
251 pub fn is_agentty_reply(&self) -> bool {
253 if !self.authored_by_current_user {
254 return false;
255 }
256 let Some((reply, marker)) = self.body.rsplit_once(AGENTTY_REVIEW_REPLY_MARKER_PREFIX)
257 else {
258 return false;
259 };
260 let Some(reply_token) = marker.strip_suffix(" -->") else {
261 return false;
262 };
263
264 reply.ends_with("\n\n") && is_uuid_like(reply_token)
265 }
266}
267
268#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
270pub enum ReviewCommentAnchorSide {
271 File,
273 New,
275 Old,
277}
278
279#[derive(Clone, Debug, Eq, PartialEq)]
286pub struct ReviewCommentThread {
287 pub anchor_side: ReviewCommentAnchorSide,
289 pub comments: Vec<ReviewComment>,
291 pub id: String,
293 pub is_outdated: Option<bool>,
296 pub is_resolved: bool,
298 pub line: Option<u32>,
300 pub path: String,
302 pub start_line: Option<u32>,
304}
305
306impl ReviewCommentThread {
307 pub fn is_actionable(&self) -> bool {
314 !self.is_resolved && !self.is_addressed_by_agentty()
315 }
316
317 pub fn is_addressed_by_agentty(&self) -> bool {
320 !self.is_resolved
321 && self
322 .comments
323 .last()
324 .is_some_and(ReviewComment::is_agentty_reply)
325 }
326}
327
328fn is_uuid_like(value: &str) -> bool {
331 const GROUP_LENGTHS: [usize; 5] = [8, 4, 4, 4, 12];
332
333 value
334 .split('-')
335 .map(str::as_bytes)
336 .zip(GROUP_LENGTHS)
337 .all(|(group, expected_length)| {
338 group.len() == expected_length && group.iter().all(u8::is_ascii_hexdigit)
339 })
340 && value.matches('-').count() == GROUP_LENGTHS.len() - 1
341}
342
343#[derive(Clone, Debug, Default, Eq, PartialEq)]
350pub struct ReviewCommentSnapshot {
351 pub pr_level_comments: Vec<ReviewComment>,
354 pub threads: Vec<ReviewCommentThread>,
356}
357
358#[derive(Clone, Debug, Eq, PartialEq)]
360pub struct CreateReviewRequestInput {
361 pub body: Option<String>,
363 pub source_branch: String,
365 pub target_branch: String,
367 pub title: String,
369}
370
371#[derive(Clone, Debug, Eq, PartialEq)]
373pub struct ReviewRequestMetadata {
374 pub body: String,
376 pub title: String,
378}
379
380#[derive(Clone, Debug, Eq, PartialEq)]
383pub struct ReviewRequestMetadataFieldUpdate {
384 pub current: String,
386 pub desired: String,
388}
389
390#[derive(Clone, Debug, Eq, PartialEq)]
392pub struct UpdateReviewRequestInput {
393 pub body: Option<ReviewRequestMetadataFieldUpdate>,
395 pub title: Option<ReviewRequestMetadataFieldUpdate>,
397}
398
399#[derive(Clone, Debug, Eq, PartialEq)]
401pub enum ReviewRequestError {
402 CliNotInstalled {
404 forge_kind: ForgeKind,
406 },
407 AuthenticationRequired {
409 forge_kind: ForgeKind,
411 host: String,
413 detail: Option<String>,
415 },
416 HostResolutionFailed {
418 forge_kind: ForgeKind,
420 host: String,
422 },
423 UnsupportedRemote {
425 repo_url: String,
427 },
428 OperationFailed {
430 forge_kind: ForgeKind,
432 message: String,
434 },
435}
436
437impl ReviewRequestError {
438 pub fn detail_message(&self) -> String {
440 match self {
441 Self::CliNotInstalled { forge_kind } => format!(
442 "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
443 forge_kind.display_name(),
444 forge_kind.cli_name(),
445 forge_kind.cli_name(),
446 forge_kind.auth_login_command(),
447 ),
448 Self::AuthenticationRequired {
449 forge_kind,
450 host,
451 detail,
452 } => authentication_required_message(*forge_kind, host, detail.as_deref()),
453 Self::HostResolutionFailed { forge_kind, host } => format!(
454 "{} review requests could not reach `{host}`.\nCheck the repository remote host \
455 and your network or DNS setup, then retry.",
456 forge_kind.display_name(),
457 ),
458 Self::UnsupportedRemote { repo_url } => format!(
459 "Review requests are only supported for GitHub and GitLab remotes.\nThis \
460 repository remote is not supported: `{repo_url}`."
461 ),
462 Self::OperationFailed {
463 forge_kind,
464 message,
465 } => format!(
466 "{} review-request operation failed: {message}",
467 forge_kind.display_name()
468 ),
469 }
470 }
471}
472
473fn github_review_request_creation_url(
475 remote: &ForgeRemote,
476 source_branch: &str,
477 target_branch: &str,
478) -> Result<String, ReviewRequestError> {
479 let mut url = parsed_remote_web_url(remote)?;
480 let compare_target = if target_branch.trim().is_empty() {
481 source_branch.to_string()
482 } else {
483 format!("{target_branch}...{source_branch}")
484 };
485
486 {
487 let mut path_segments = url
488 .path_segments_mut()
489 .map_err(|()| invalid_web_url_error(remote))?;
490 path_segments.pop_if_empty();
491 path_segments.push("compare");
492 path_segments.push(&compare_target);
493 }
494
495 url.query_pairs_mut().append_pair("expand", "1");
496
497 Ok(url.into())
498}
499
500fn gitlab_review_request_creation_url(
502 remote: &ForgeRemote,
503 source_branch: &str,
504 target_branch: &str,
505) -> Result<String, ReviewRequestError> {
506 let mut url = parsed_remote_web_url(remote)?;
507
508 {
509 let mut path_segments = url
510 .path_segments_mut()
511 .map_err(|()| invalid_web_url_error(remote))?;
512 path_segments.pop_if_empty();
513 path_segments.push("-");
514 path_segments.push("merge_requests");
515 path_segments.push("new");
516 }
517
518 url.query_pairs_mut()
519 .append_pair("merge_request[source_branch]", source_branch)
520 .append_pair("merge_request[target_branch]", target_branch);
521
522 Ok(url.into())
523}
524
525fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
527 Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
528}
529
530fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
532 ReviewRequestError::OperationFailed {
533 forge_kind: remote.forge_kind,
534 message: format!(
535 "repository remote is missing a valid web URL: `{}`",
536 remote.web_url
537 ),
538 }
539}
540
541fn authentication_required_message(
544 forge_kind: ForgeKind,
545 host: &str,
546 detail: Option<&str>,
547) -> String {
548 let mut message = format!(
549 "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
550 forge_kind.display_name(),
551 forge_kind.auth_login_command(),
552 );
553
554 if let Some(detail) = non_empty_detail(detail) {
555 let _ = write!(
557 message,
558 "\n\nOriginal `{}` error:\n```text\n{detail}",
559 forge_kind.cli_name(),
560 );
561 if !detail.ends_with('\n') {
562 message.push('\n');
563 }
564 message.push_str("```");
565 }
566
567 message
568}
569
570fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
572 detail.and_then(|detail| {
573 let trimmed_detail = detail.trim();
574 (!trimmed_detail.is_empty()).then_some(trimmed_detail)
575 })
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 fn review_comment_thread() -> ReviewCommentThread {
583 ReviewCommentThread {
584 anchor_side: ReviewCommentAnchorSide::New,
585 comments: Vec::new(),
586 id: "thread-1".to_string(),
587 is_outdated: Some(false),
588 is_resolved: false,
589 line: Some(1),
590 path: "src/lib.rs".to_string(),
591 start_line: None,
592 }
593 }
594
595 #[test]
596 fn review_comment_thread_is_actionable_until_agentty_addresses_latest_feedback() {
597 let actionable = review_comment_thread();
599 let mut resolved = review_comment_thread();
600 resolved.is_resolved = true;
601 let mut outdated = review_comment_thread();
602 outdated.is_outdated = Some(true);
603 let mut addressed = review_comment_thread();
604 addressed.comments.push(ReviewComment {
605 author: "agentty".to_string(),
606 authored_by_current_user: true,
607 body: [
608 "No change needed.\n\n",
609 AGENTTY_REVIEW_REPLY_MARKER_PREFIX,
610 "123e4567-e89b-12d3-a456-426614174000 -->",
611 ]
612 .concat(),
613 });
614 let mut followed_up = addressed.clone();
615 followed_up.comments.push(ReviewComment {
616 author: "reviewer".to_string(),
617 authored_by_current_user: false,
618 body: "Please reconsider.".to_string(),
619 });
620 let mut reviewer_marker = review_comment_thread();
621 reviewer_marker.comments.push(ReviewComment {
622 author: "reviewer".to_string(),
623 authored_by_current_user: false,
624 body: [
625 "Please reconsider.\n\n",
626 AGENTTY_REVIEW_REPLY_MARKER_PREFIX,
627 "123e4567-e89b-12d3-a456-426614174000 -->",
628 ]
629 .concat(),
630 });
631
632 assert!(actionable.is_actionable());
634 assert!(!resolved.is_actionable());
635 assert!(outdated.is_actionable());
636 assert!(addressed.is_addressed_by_agentty());
637 assert!(!addressed.is_actionable());
638 assert!(!followed_up.is_addressed_by_agentty());
639 assert!(followed_up.is_actionable());
640 assert!(!reviewer_marker.is_addressed_by_agentty());
641 assert!(reviewer_marker.is_actionable());
642 }
643
644 #[test]
645 fn review_comment_rejects_malformed_agentty_reply_markers() {
646 let malformed_comments = [
648 "Ordinary comment",
649 "No separator<!-- agentty review resolution:123e4567-e89b-12d3-a456-426614174000 -->",
650 "No terminator\n\n<!-- agentty review resolution:123e4567-e89b-12d3-a456-426614174000",
651 "Bad token\n\n<!-- agentty review resolution:not-a-uuid -->",
652 ];
653
654 let results = malformed_comments.map(|body| ReviewComment {
656 author: "agentty".to_string(),
657 authored_by_current_user: true,
658 body: body.to_string(),
659 });
660
661 assert!(results.iter().all(|comment| !comment.is_agentty_reply()));
663 }
664
665 #[test]
666 fn forge_kind_from_str_gitlab() {
667 let raw_forge_kind = "GitLab";
669
670 let forge_kind = raw_forge_kind
672 .parse::<ForgeKind>()
673 .expect("gitlab forge kind should parse");
674
675 assert_eq!(forge_kind, ForgeKind::GitLab);
677 assert_eq!(forge_kind.cli_name(), "glab");
678 assert_eq!(forge_kind.review_request_name(), "merge request");
679 assert_eq!(forge_kind.review_request_short_name(), "MR");
680 }
681
682 #[test]
683 fn authentication_required_message_includes_original_cli_error_detail() {
684 let error = ReviewRequestError::AuthenticationRequired {
686 detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
687 forge_kind: ForgeKind::GitHub,
688 host: "github.com".to_string(),
689 };
690
691 let message = error.detail_message();
693
694 assert!(message.contains("GitHub review requests require local CLI authentication"));
696 assert!(message.contains("Run `gh auth login` and retry."));
697 assert!(message.contains("Original `gh` error:"));
698 assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
699 assert!(message.contains("```text"));
700 }
701
702 #[test]
703 fn authentication_required_message_omits_empty_original_cli_error_detail() {
704 let error = ReviewRequestError::AuthenticationRequired {
706 detail: Some(" \n".to_string()),
707 forge_kind: ForgeKind::GitHub,
708 host: "github.com".to_string(),
709 };
710
711 let message = error.detail_message();
713
714 assert!(message.contains("Run `gh auth login` and retry."));
716 assert!(!message.contains("Original `gh` error:"));
717 }
718
719 #[test]
720 fn review_request_creation_url_returns_github_compare_link() {
721 let remote = ForgeRemote {
723 command_working_directory: None,
724 forge_kind: ForgeKind::GitHub,
725 host: "github.com".to_string(),
726 namespace: "agentty-xyz".to_string(),
727 project: "agentty".to_string(),
728 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
729 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
730 };
731
732 let url = remote
734 .review_request_creation_url("review/custom-branch", "main")
735 .expect("github compare URL should be created");
736
737 assert_eq!(
739 url,
740 "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
741 );
742 }
743
744 #[test]
745 fn review_request_creation_url_rejects_invalid_web_url() {
746 let remote = ForgeRemote {
748 command_working_directory: None,
749 forge_kind: ForgeKind::GitHub,
750 host: "github.com".to_string(),
751 namespace: "agentty-xyz".to_string(),
752 project: "agentty".to_string(),
753 repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
754 web_url: "not a url".to_string(),
755 };
756
757 let error = remote
759 .review_request_creation_url("review/custom-branch", "main")
760 .expect_err("invalid web URL should be rejected");
761
762 assert_eq!(
764 error,
765 ReviewRequestError::OperationFailed {
766 forge_kind: ForgeKind::GitHub,
767 message: "repository remote is missing a valid web URL: `not a url`".to_string(),
768 }
769 );
770 }
771
772 #[test]
773 fn review_request_creation_url_returns_gitlab_merge_request_link() {
774 let remote = ForgeRemote {
776 command_working_directory: None,
777 forge_kind: ForgeKind::GitLab,
778 host: "gitlab.com".to_string(),
779 namespace: "agentty-xyz".to_string(),
780 project: "agentty".to_string(),
781 repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
782 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
783 };
784
785 let url = remote
787 .review_request_creation_url("review/custom-branch", "main")
788 .expect("gitlab merge-request URL should be created");
789
790 assert_eq!(
792 url,
793 "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
794 );
795 }
796}