Skip to main content

ag_forge/
model.rs

1//! Shared forge review-request types.
2
3use 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/// Prefix for the per-operation identity marker Agentty appends to review
13/// thread replies.
14pub const AGENTTY_REVIEW_REPLY_MARKER_PREFIX: &str = "<!-- agentty review resolution:";
15
16/// Shared forge family enum reused by persistence and forge adapters.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum ForgeKind {
19    /// GitHub-hosted pull requests.
20    GitHub,
21    /// GitLab-hosted merge requests.
22    GitLab,
23}
24
25impl ForgeKind {
26    /// Returns the user-facing forge name.
27    pub fn display_name(self) -> &'static str {
28        match self {
29            Self::GitHub => "GitHub",
30            Self::GitLab => "GitLab",
31        }
32    }
33
34    /// Returns the CLI executable name used for this forge.
35    pub fn cli_name(self) -> &'static str {
36        match self {
37            Self::GitHub => "gh",
38            Self::GitLab => "glab",
39        }
40    }
41
42    /// Returns the login command users should run to authorize forge access.
43    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    /// Returns the persisted string representation for this forge kind.
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::GitHub => "GitHub",
54            Self::GitLab => "GitLab",
55        }
56    }
57
58    /// Returns the forge-native review-request noun shown in user-facing copy.
59    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    /// Returns the combined forge and review-request name for user-facing copy.
67    pub fn review_request_display_name(self) -> String {
68        format!("{} {}", self.display_name(), self.review_request_name())
69    }
70
71    /// Returns the short UI indicator label for one review request.
72    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
98/// Returns whether `host` looks like one GitLab instance hostname.
99pub 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/// Normalized remote lifecycle state for one linked review request.
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub enum ReviewRequestState {
109    /// The linked review request is still open.
110    Open,
111    /// The linked review request was merged upstream.
112    Merged,
113    /// The linked review request was closed without merge.
114    Closed,
115}
116
117impl ReviewRequestState {
118    /// Returns the persisted string representation for this remote state.
119    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/// Normalized remote summary for one linked review request.
148///
149/// Local session lifecycle transitions such as `Rebasing`, `Done`, and
150/// `Canceled` retain this metadata so the session can continue to reference the
151/// same remote review request. Remote terminal outcomes are stored in
152/// `state` instead of clearing the link; only an explicit unlink action or
153/// session deletion should remove this metadata.
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct ReviewRequestSummary {
156    /// Provider display id such as GitHub `#123`.
157    pub display_id: String,
158    /// Forge family that owns the linked review request.
159    pub forge_kind: ForgeKind,
160    /// Source branch published for review.
161    pub source_branch: String,
162    /// Latest normalized remote lifecycle state.
163    pub state: ReviewRequestState,
164    /// Provider-specific condensed status text for UI display.
165    pub status_summary: Option<String>,
166    /// Target branch receiving the review request.
167    pub target_branch: String,
168    /// Remote review-request title.
169    pub title: String,
170    /// Browser-openable review-request URL.
171    pub web_url: String,
172}
173
174/// Boxed async result used by review-request trait methods.
175pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
176
177/// Normalized repository remote metadata for one supported forge.
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct ForgeRemote {
180    /// Repository worktree used when forge CLI commands need local git
181    /// context.
182    pub command_working_directory: Option<PathBuf>,
183    /// Forge family inferred from the repository remote.
184    pub forge_kind: ForgeKind,
185    /// Forge hostname used for browser and API calls.
186    ///
187    /// HTTPS remotes keep any explicit web/API port, while SSH transport ports
188    /// are stripped during remote normalization.
189    pub host: String,
190    /// Repository namespace or owner path.
191    pub namespace: String,
192    /// Repository name without a trailing `.git` suffix.
193    pub project: String,
194    /// Credential-free remote URL suitable for display and diagnostics.
195    pub repo_url: String,
196    /// Browser-openable repository URL derived from the remote.
197    pub web_url: String,
198}
199
200impl ForgeRemote {
201    /// Returns one remote copy that runs forge CLI commands from
202    /// `working_directory`.
203    #[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    /// Returns the `<namespace>/<project>` path used by forge CLIs and URLs.
211    pub fn project_path(&self) -> String {
212        format!("{}/{}", self.namespace, self.project)
213    }
214
215    /// Returns the browser-openable URL that starts one new pull request or
216    /// review request for `source_branch` into `target_branch`.
217    ///
218    /// # Errors
219    /// Returns [`ReviewRequestError::OperationFailed`] when the stored
220    /// repository web URL is invalid or cannot be converted into a forge
221    /// review-request creation URL.
222    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/// One inline review comment emitted by a reviewer on a forge review thread.
239#[derive(Clone, Debug, Eq, PartialEq)]
240pub struct ReviewComment {
241    /// Forge author login or display name.
242    pub author: String,
243    /// Whether the forge reports that the authenticated user authored this
244    /// comment.
245    pub authored_by_current_user: bool,
246    /// Markdown body as authored on the forge.
247    pub body: String,
248}
249
250impl ReviewComment {
251    /// Returns whether this comment ends with an Agentty reply marker.
252    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/// Diff side used to anchor one inline review-thread comment.
269#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
270pub enum ReviewCommentAnchorSide {
271    /// A file-level thread that is not attached to a specific diff line.
272    File,
273    /// A thread anchored to the new/right side of the diff.
274    New,
275    /// A thread anchored to the old/left side of the diff.
276    Old,
277}
278
279/// One review thread anchored to a line of the review request diff.
280///
281/// Threads group chronological `comments` that share the same anchor. Agentty
282/// renders these in session review-comment views, grouped by file and sorted
283/// by `(path, line)` before display. Session-linked review workflows also use
284/// the native `id` to reply and resolve a thread after its fix is pushed.
285#[derive(Clone, Debug, Eq, PartialEq)]
286pub struct ReviewCommentThread {
287    /// Diff side used with `line` when placing this thread inline.
288    pub anchor_side: ReviewCommentAnchorSide,
289    /// Chronological reviewer comments attached to this thread.
290    pub comments: Vec<ReviewComment>,
291    /// Opaque forge-native thread identifier used for replies and resolution.
292    pub id: String,
293    /// Whether newer changes made the thread's original diff position stale,
294    /// when the forge exposes that state.
295    pub is_outdated: Option<bool>,
296    /// Whether the thread has been marked resolved on the forge.
297    pub is_resolved: bool,
298    /// Anchor line number on `anchor_side`, when the forge exposes one.
299    pub line: Option<u32>,
300    /// File path the thread is anchored to, relative to the repository root.
301    pub path: String,
302    /// Optional first line for a multi-line thread on `anchor_side`.
303    pub start_line: Option<u32>,
304}
305
306impl ReviewCommentThread {
307    /// Returns whether this thread has an unaddressed reviewer message.
308    ///
309    /// Outdated threads remain actionable because their forge-native thread
310    /// identifiers survive after their original line anchors become stale. An
311    /// unresolved thread whose latest comment is an Agentty reply becomes
312    /// actionable again when a reviewer adds a follow-up comment.
313    pub fn is_actionable(&self) -> bool {
314        !self.is_resolved && !self.is_addressed_by_agentty()
315    }
316
317    /// Returns whether Agentty authored the latest comment on this unresolved
318    /// thread.
319    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
328/// Returns whether `value` has the canonical hyphenated UUID shape used for
329/// review-reply tokens.
330fn 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/// Full review-comments payload captured for one review request.
344///
345/// Separates forge-native `threads` (anchored to a file + line) from
346/// `pr_level_comments` (review-request-wide discussion comments that do not
347/// anchor to the diff). The UI renders the two categories side-by-side with a
348/// synthetic "General discussion" entry on top of the comments file tree.
349#[derive(Clone, Debug, Default, Eq, PartialEq)]
350pub struct ReviewCommentSnapshot {
351    /// Chronological review-request-wide comments that do not anchor to a file
352    /// or line.
353    pub pr_level_comments: Vec<ReviewComment>,
354    /// Inline threads grouped by the file and line they are anchored to.
355    pub threads: Vec<ReviewCommentThread>,
356}
357
358/// Input required to create a review request on one forge.
359#[derive(Clone, Debug, Eq, PartialEq)]
360pub struct CreateReviewRequestInput {
361    /// Optional body or description submitted with the review request.
362    pub body: Option<String>,
363    /// Source branch that should be reviewed.
364    pub source_branch: String,
365    /// Target branch that receives the review request.
366    pub target_branch: String,
367    /// Title shown in the forge review-request UI.
368    pub title: String,
369}
370
371/// Current remote review-request title and description.
372#[derive(Clone, Debug, Eq, PartialEq)]
373pub struct ReviewRequestMetadata {
374    /// Current body or description.
375    pub body: String,
376    /// Current title.
377    pub title: String,
378}
379
380/// One review-request field update guarded by the remote value used during
381/// semantic reconciliation.
382#[derive(Clone, Debug, Eq, PartialEq)]
383pub struct ReviewRequestMetadataFieldUpdate {
384    /// Remote value read before semantic reconciliation.
385    pub current: String,
386    /// Reconciled value to publish if the remote field is still unchanged.
387    pub desired: String,
388}
389
390/// Input required to conditionally update reconciled review-request metadata.
391#[derive(Clone, Debug, Eq, PartialEq)]
392pub struct UpdateReviewRequestInput {
393    /// Optional description update.
394    pub body: Option<ReviewRequestMetadataFieldUpdate>,
395    /// Optional title update.
396    pub title: Option<ReviewRequestMetadataFieldUpdate>,
397}
398
399/// Review-request failures normalized for actionable UI messaging.
400#[derive(Clone, Debug, Eq, PartialEq)]
401pub enum ReviewRequestError {
402    /// The required forge CLI is not available on the user's machine.
403    CliNotInstalled {
404        /// Forge family whose CLI is unavailable.
405        forge_kind: ForgeKind,
406    },
407    /// The forge CLI is installed but not authorized for the target host.
408    AuthenticationRequired {
409        /// Forge family that reported the authentication failure.
410        forge_kind: ForgeKind,
411        /// Forge host the CLI attempted to access.
412        host: String,
413        /// Original CLI error detail captured from stdout or stderr.
414        detail: Option<String>,
415    },
416    /// The forge host from the repository remote could not be resolved.
417    HostResolutionFailed {
418        /// Forge family inferred from the repository remote.
419        forge_kind: ForgeKind,
420        /// Hostname that could not be resolved.
421        host: String,
422    },
423    /// The repository remote does not map to a supported forge.
424    UnsupportedRemote {
425        /// Repository remote URL that could not be classified.
426        repo_url: String,
427    },
428    /// A forge CLI command ran but failed.
429    OperationFailed {
430        /// Forge family whose command failed.
431        forge_kind: ForgeKind,
432        /// Original CLI failure detail.
433        message: String,
434    },
435}
436
437impl ReviewRequestError {
438    /// Returns actionable user-facing copy for the failure.
439    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
473/// Builds one GitHub compare URL that opens the new pull-request flow.
474fn 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
500/// Builds one GitLab URL that opens the new merge-request flow.
501fn 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
525/// Parses the stored repository web URL for one forge remote.
526fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
527    Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
528}
529
530/// Returns one normalized invalid-remote-url error for review-request links.
531fn 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
541/// Returns actionable copy for one CLI authentication failure and preserves
542/// the original CLI output when it is available.
543fn 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        // Infallible: writing to a String cannot fail.
556        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
570/// Returns one trimmed CLI error detail when the captured output is not empty.
571fn 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        // Arrange
598        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        // Act, Assert
633        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        // Arrange
647        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        // Act
655        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
662        assert!(results.iter().all(|comment| !comment.is_agentty_reply()));
663    }
664
665    #[test]
666    fn forge_kind_from_str_gitlab() {
667        // Arrange
668        let raw_forge_kind = "GitLab";
669
670        // Act
671        let forge_kind = raw_forge_kind
672            .parse::<ForgeKind>()
673            .expect("gitlab forge kind should parse");
674
675        // Assert
676        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        // Arrange
685        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        // Act
692        let message = error.detail_message();
693
694        // Assert
695        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        // Arrange
705        let error = ReviewRequestError::AuthenticationRequired {
706            detail: Some("   \n".to_string()),
707            forge_kind: ForgeKind::GitHub,
708            host: "github.com".to_string(),
709        };
710
711        // Act
712        let message = error.detail_message();
713
714        // Assert
715        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        // Arrange
722        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        // Act
733        let url = remote
734            .review_request_creation_url("review/custom-branch", "main")
735            .expect("github compare URL should be created");
736
737        // Assert
738        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        // Arrange
747        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        // Act
758        let error = remote
759            .review_request_creation_url("review/custom-branch", "main")
760            .expect_err("invalid web URL should be rejected");
761
762        // Assert
763        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        // Arrange
775        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        // Act
786        let url = remote
787            .review_request_creation_url("review/custom-branch", "main")
788            .expect("gitlab merge-request URL should be created");
789
790        // Assert
791        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}