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/// Shared forge family enum reused by persistence and forge adapters.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum ForgeKind {
15    /// GitHub-hosted pull requests.
16    GitHub,
17    /// GitLab-hosted merge requests.
18    GitLab,
19}
20
21impl ForgeKind {
22    /// Returns the user-facing forge name.
23    pub fn display_name(self) -> &'static str {
24        match self {
25            Self::GitHub => "GitHub",
26            Self::GitLab => "GitLab",
27        }
28    }
29
30    /// Returns the CLI executable name used for this forge.
31    pub fn cli_name(self) -> &'static str {
32        match self {
33            Self::GitHub => "gh",
34            Self::GitLab => "glab",
35        }
36    }
37
38    /// Returns the login command users should run to authorize forge access.
39    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    /// Returns the persisted string representation for this forge kind.
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Self::GitHub => "GitHub",
50            Self::GitLab => "GitLab",
51        }
52    }
53
54    /// Returns the forge-native review-request noun shown in user-facing copy.
55    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    /// Returns the combined forge and review-request name for user-facing copy.
63    pub fn review_request_display_name(self) -> String {
64        format!("{} {}", self.display_name(), self.review_request_name())
65    }
66
67    /// Returns the short UI indicator label for one review request.
68    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
94/// Returns whether `host` looks like one GitLab instance hostname.
95pub 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/// Normalized remote lifecycle state for one linked review request.
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104pub enum ReviewRequestState {
105    /// The linked review request is still open.
106    Open,
107    /// The linked review request was merged upstream.
108    Merged,
109    /// The linked review request was closed without merge.
110    Closed,
111}
112
113impl ReviewRequestState {
114    /// Returns the persisted string representation for this remote state.
115    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/// Normalized remote summary for one linked review request.
144///
145/// Local session lifecycle transitions such as `Rebasing`, `Done`, and
146/// `Canceled` retain this metadata so the session can continue to reference the
147/// same remote review request. Remote terminal outcomes are stored in
148/// `state` instead of clearing the link; only an explicit unlink action or
149/// session deletion should remove this metadata.
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct ReviewRequestSummary {
152    /// Provider display id such as GitHub `#123`.
153    pub display_id: String,
154    /// Forge family that owns the linked review request.
155    pub forge_kind: ForgeKind,
156    /// Source branch published for review.
157    pub source_branch: String,
158    /// Latest normalized remote lifecycle state.
159    pub state: ReviewRequestState,
160    /// Provider-specific condensed status text for UI display.
161    pub status_summary: Option<String>,
162    /// Target branch receiving the review request.
163    pub target_branch: String,
164    /// Remote review-request title.
165    pub title: String,
166    /// Browser-openable review-request URL.
167    pub web_url: String,
168}
169
170/// Review audience that caused one PR or MR to require the current user's
171/// attention.
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub enum RequestedReviewAudience {
174    /// The current user was directly requested as a reviewer.
175    Personal,
176    /// A group or team containing the current user was requested as reviewer.
177    Group,
178}
179
180/// Normalized row for one open PR or MR requesting the current user's
181/// attention.
182#[derive(Clone, Debug, Eq, PartialEq)]
183pub struct RequestedReview {
184    /// Whether the review request targets the user directly or through a
185    /// group membership.
186    pub audience: RequestedReviewAudience,
187    /// Login or display name of the user who opened the review request.
188    pub author: String,
189    /// Optional PR body or MR description text for detail rendering.
190    pub body: Option<String>,
191    /// Optional review-request comments fetched for detail rendering.
192    ///
193    /// `None` means the caller listed the requested review without loading
194    /// the heavier comment snapshot yet.
195    pub comment_snapshot: Option<ReviewCommentSnapshot>,
196    /// Provider display id such as GitHub `#123` or GitLab `!123`.
197    pub display_id: String,
198    /// Forge family that owns the review request.
199    pub forge_kind: ForgeKind,
200    /// Repository path shown for the requested review, such as `owner/repo`.
201    pub repository: String,
202    /// Provider-specific condensed status text for UI display.
203    pub status_summary: Option<String>,
204    /// Remote review-request title.
205    pub title: String,
206    /// Provider update timestamp, when the CLI returns one.
207    pub updated_at: Option<String>,
208    /// Browser-openable review-request URL.
209    pub web_url: String,
210}
211
212/// Normalized row for one open GitHub issue assigned to the authenticated user.
213#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct AssignedIssue {
215    /// Provider display id such as GitHub `#123`.
216    pub display_id: String,
217    /// Repository path shown for the issue, such as `owner/repo`.
218    pub repository: String,
219    /// Remote issue title.
220    pub title: String,
221    /// Provider update timestamp, when the CLI returns one.
222    pub updated_at: Option<String>,
223    /// Browser-openable issue URL.
224    pub web_url: String,
225}
226
227/// Normalized base details for one GitHub issue, excluding comments.
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct IssueDetail {
230    /// GitHub logins currently assigned to the issue.
231    pub assignees: Vec<String>,
232    /// GitHub login of the issue author.
233    pub author: String,
234    /// Optional issue description text.
235    pub body: Option<String>,
236    /// Provider creation timestamp, when the CLI returns one.
237    pub created_at: Option<String>,
238    /// Provider display id such as GitHub `#123`.
239    pub display_id: String,
240    /// Issue label names in provider order.
241    pub labels: Vec<String>,
242    /// Repository path shown for the issue, such as `owner/repo`.
243    pub repository: String,
244    /// Provider issue state, such as `OPEN` or `CLOSED`.
245    pub state: String,
246    /// Remote issue title.
247    pub title: String,
248    /// Provider update timestamp, when the CLI returns one.
249    pub updated_at: Option<String>,
250    /// Browser-openable issue URL.
251    pub web_url: String,
252}
253
254/// Boxed async result used by review-request trait methods.
255pub type ForgeFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
256
257/// Normalized repository remote metadata for one supported forge.
258#[derive(Clone, Debug, Eq, PartialEq)]
259pub struct ForgeRemote {
260    /// Repository worktree used when forge CLI commands need local git
261    /// context.
262    pub command_working_directory: Option<PathBuf>,
263    /// Forge family inferred from the repository remote.
264    pub forge_kind: ForgeKind,
265    /// Forge hostname used for browser and API calls.
266    ///
267    /// HTTPS remotes keep any explicit web/API port, while SSH transport ports
268    /// are stripped during remote normalization.
269    pub host: String,
270    /// Repository namespace or owner path.
271    pub namespace: String,
272    /// Repository name without a trailing `.git` suffix.
273    pub project: String,
274    /// Original remote URL returned by git.
275    pub repo_url: String,
276    /// Browser-openable repository URL derived from the remote.
277    pub web_url: String,
278}
279
280impl ForgeRemote {
281    /// Returns one remote copy that runs forge CLI commands from
282    /// `working_directory`.
283    #[must_use]
284    pub fn with_command_working_directory(mut self, working_directory: PathBuf) -> Self {
285        self.command_working_directory = Some(working_directory);
286
287        self
288    }
289
290    /// Returns the `<namespace>/<project>` path used by forge CLIs and URLs.
291    pub fn project_path(&self) -> String {
292        format!("{}/{}", self.namespace, self.project)
293    }
294
295    /// Returns the browser-openable URL that starts one new pull request or
296    /// review request for `source_branch` into `target_branch`.
297    ///
298    /// # Errors
299    /// Returns [`ReviewRequestError::OperationFailed`] when the stored
300    /// repository web URL is invalid or cannot be converted into a forge
301    /// review-request creation URL.
302    pub fn review_request_creation_url(
303        &self,
304        source_branch: &str,
305        target_branch: &str,
306    ) -> Result<String, ReviewRequestError> {
307        match self.forge_kind {
308            ForgeKind::GitHub => {
309                github_review_request_creation_url(self, source_branch, target_branch)
310            }
311            ForgeKind::GitLab => {
312                gitlab_review_request_creation_url(self, source_branch, target_branch)
313            }
314        }
315    }
316}
317
318/// One inline review comment emitted by a reviewer on a forge review thread.
319#[derive(Clone, Debug, Eq, PartialEq)]
320pub struct ReviewComment {
321    /// Reviewer login or display name.
322    pub author: String,
323    /// Markdown body as authored by the reviewer.
324    pub body: String,
325}
326
327/// Diff side used to anchor one inline review-thread comment.
328#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
329pub enum ReviewCommentAnchorSide {
330    /// A file-level thread that is not attached to a specific diff line.
331    File,
332    /// A thread anchored to the new/right side of the diff.
333    New,
334    /// A thread anchored to the old/left side of the diff.
335    Old,
336}
337
338/// One review thread anchored to a line of the review request diff.
339///
340/// Threads group chronological `comments` that share the same anchor. Agentty
341/// renders these in requested-review detail, grouped by file and sorted by
342/// `(path, line)` before display. Session-linked review workflows also use the
343/// native `id` to reply and resolve a thread after its fix is pushed.
344#[derive(Clone, Debug, Eq, PartialEq)]
345pub struct ReviewCommentThread {
346    /// Diff side used with `line` when placing this thread inline.
347    pub anchor_side: ReviewCommentAnchorSide,
348    /// Chronological reviewer comments attached to this thread.
349    pub comments: Vec<ReviewComment>,
350    /// Opaque forge-native thread identifier used for replies and resolution.
351    pub id: String,
352    /// Whether newer changes made the thread's original diff position stale,
353    /// when the forge exposes that state.
354    pub is_outdated: Option<bool>,
355    /// Whether the thread has been marked resolved on the forge.
356    pub is_resolved: bool,
357    /// Anchor line number on `anchor_side`, when the forge exposes one.
358    pub line: Option<u32>,
359    /// File path the thread is anchored to, relative to the repository root.
360    pub path: String,
361    /// Optional first line for a multi-line thread on `anchor_side`.
362    pub start_line: Option<u32>,
363}
364
365impl ReviewCommentThread {
366    /// Returns whether this thread remains open for a reply and resolution.
367    ///
368    /// Outdated threads remain actionable because their forge-native thread
369    /// identifiers survive after their original line anchors become stale.
370    pub fn is_actionable(&self) -> bool {
371        !self.is_resolved
372    }
373}
374
375/// Full review-comments payload captured for one review request.
376///
377/// Separates forge-native `threads` (anchored to a file + line) from
378/// `pr_level_comments` (review-request-wide discussion comments that do not
379/// anchor to the diff). The UI renders the two categories side-by-side with a
380/// synthetic "General discussion" entry on top of the comments file tree.
381#[derive(Clone, Debug, Default, Eq, PartialEq)]
382pub struct ReviewCommentSnapshot {
383    /// Chronological review-request-wide comments that do not anchor to a file
384    /// or line.
385    pub pr_level_comments: Vec<ReviewComment>,
386    /// Inline threads grouped by the file and line they are anchored to.
387    pub threads: Vec<ReviewCommentThread>,
388}
389
390/// Input required to create a review request on one forge.
391#[derive(Clone, Debug, Eq, PartialEq)]
392pub struct CreateReviewRequestInput {
393    /// Optional body or description submitted with the review request.
394    pub body: Option<String>,
395    /// Source branch that should be reviewed.
396    pub source_branch: String,
397    /// Target branch that receives the review request.
398    pub target_branch: String,
399    /// Title shown in the forge review-request UI.
400    pub title: String,
401}
402
403/// Current remote review-request title and description.
404#[derive(Clone, Debug, Eq, PartialEq)]
405pub struct ReviewRequestMetadata {
406    /// Current body or description.
407    pub body: String,
408    /// Current title.
409    pub title: String,
410}
411
412/// One review-request field update guarded by the remote value used during
413/// semantic reconciliation.
414#[derive(Clone, Debug, Eq, PartialEq)]
415pub struct ReviewRequestMetadataFieldUpdate {
416    /// Remote value read before semantic reconciliation.
417    pub current: String,
418    /// Reconciled value to publish if the remote field is still unchanged.
419    pub desired: String,
420}
421
422/// Input required to conditionally update reconciled review-request metadata.
423#[derive(Clone, Debug, Eq, PartialEq)]
424pub struct UpdateReviewRequestInput {
425    /// Optional description update.
426    pub body: Option<ReviewRequestMetadataFieldUpdate>,
427    /// Optional title update.
428    pub title: Option<ReviewRequestMetadataFieldUpdate>,
429}
430
431/// Review-request failures normalized for actionable UI messaging.
432#[derive(Clone, Debug, Eq, PartialEq)]
433pub enum ReviewRequestError {
434    /// The required forge CLI is not available on the user's machine.
435    CliNotInstalled {
436        /// Forge family whose CLI is unavailable.
437        forge_kind: ForgeKind,
438    },
439    /// The forge CLI is installed but not authorized for the target host.
440    AuthenticationRequired {
441        /// Forge family that reported the authentication failure.
442        forge_kind: ForgeKind,
443        /// Forge host the CLI attempted to access.
444        host: String,
445        /// Original CLI error detail captured from stdout or stderr.
446        detail: Option<String>,
447    },
448    /// The forge host from the repository remote could not be resolved.
449    HostResolutionFailed {
450        /// Forge family inferred from the repository remote.
451        forge_kind: ForgeKind,
452        /// Hostname that could not be resolved.
453        host: String,
454    },
455    /// The repository remote does not map to a supported forge.
456    UnsupportedRemote {
457        /// Repository remote URL that could not be classified.
458        repo_url: String,
459    },
460    /// A forge CLI command ran but failed.
461    OperationFailed {
462        /// Forge family whose command failed.
463        forge_kind: ForgeKind,
464        /// Original CLI failure detail.
465        message: String,
466    },
467}
468
469impl ReviewRequestError {
470    /// Returns actionable user-facing copy for the failure.
471    pub fn detail_message(&self) -> String {
472        match self {
473            Self::CliNotInstalled { forge_kind } => format!(
474                "{} review requests require the `{}` CLI.\nInstall `{}` and run `{}`, then retry.",
475                forge_kind.display_name(),
476                forge_kind.cli_name(),
477                forge_kind.cli_name(),
478                forge_kind.auth_login_command(),
479            ),
480            Self::AuthenticationRequired {
481                forge_kind,
482                host,
483                detail,
484            } => authentication_required_message(*forge_kind, host, detail.as_deref()),
485            Self::HostResolutionFailed { forge_kind, host } => format!(
486                "{} review requests could not reach `{host}`.\nCheck the repository remote host \
487                 and your network or DNS setup, then retry.",
488                forge_kind.display_name(),
489            ),
490            Self::UnsupportedRemote { repo_url } => format!(
491                "Review requests are only supported for GitHub and GitLab remotes.\nThis \
492                 repository remote is not supported: `{repo_url}`."
493            ),
494            Self::OperationFailed {
495                forge_kind,
496                message,
497            } => format!(
498                "{} review-request operation failed: {message}",
499                forge_kind.display_name()
500            ),
501        }
502    }
503}
504
505/// Builds one GitHub compare URL that opens the new pull-request flow.
506fn github_review_request_creation_url(
507    remote: &ForgeRemote,
508    source_branch: &str,
509    target_branch: &str,
510) -> Result<String, ReviewRequestError> {
511    let mut url = parsed_remote_web_url(remote)?;
512    let compare_target = if target_branch.trim().is_empty() {
513        source_branch.to_string()
514    } else {
515        format!("{target_branch}...{source_branch}")
516    };
517
518    {
519        let mut path_segments = url
520            .path_segments_mut()
521            .map_err(|()| invalid_web_url_error(remote))?;
522        path_segments.pop_if_empty();
523        path_segments.push("compare");
524        path_segments.push(&compare_target);
525    }
526
527    url.query_pairs_mut().append_pair("expand", "1");
528
529    Ok(url.into())
530}
531
532/// Builds one GitLab URL that opens the new merge-request flow.
533fn gitlab_review_request_creation_url(
534    remote: &ForgeRemote,
535    source_branch: &str,
536    target_branch: &str,
537) -> Result<String, ReviewRequestError> {
538    let mut url = parsed_remote_web_url(remote)?;
539
540    {
541        let mut path_segments = url
542            .path_segments_mut()
543            .map_err(|()| invalid_web_url_error(remote))?;
544        path_segments.pop_if_empty();
545        path_segments.push("-");
546        path_segments.push("merge_requests");
547        path_segments.push("new");
548    }
549
550    url.query_pairs_mut()
551        .append_pair("merge_request[source_branch]", source_branch)
552        .append_pair("merge_request[target_branch]", target_branch);
553
554    Ok(url.into())
555}
556
557/// Parses the stored repository web URL for one forge remote.
558fn parsed_remote_web_url(remote: &ForgeRemote) -> Result<Url, ReviewRequestError> {
559    Url::parse(&remote.web_url).map_err(|_| invalid_web_url_error(remote))
560}
561
562/// Returns one normalized invalid-remote-url error for review-request links.
563fn invalid_web_url_error(remote: &ForgeRemote) -> ReviewRequestError {
564    ReviewRequestError::OperationFailed {
565        forge_kind: remote.forge_kind,
566        message: format!(
567            "repository remote is missing a valid web URL: `{}`",
568            remote.web_url
569        ),
570    }
571}
572
573/// Returns actionable copy for one CLI authentication failure and preserves
574/// the original CLI output when it is available.
575fn authentication_required_message(
576    forge_kind: ForgeKind,
577    host: &str,
578    detail: Option<&str>,
579) -> String {
580    let mut message = format!(
581        "{} review requests require local CLI authentication for `{host}`.\nRun `{}` and retry.",
582        forge_kind.display_name(),
583        forge_kind.auth_login_command(),
584    );
585
586    if let Some(detail) = non_empty_detail(detail) {
587        // Infallible: writing to a String cannot fail.
588        let _ = write!(
589            message,
590            "\n\nOriginal `{}` error:\n```text\n{detail}",
591            forge_kind.cli_name(),
592        );
593        if !detail.ends_with('\n') {
594            message.push('\n');
595        }
596        message.push_str("```");
597    }
598
599    message
600}
601
602/// Returns one trimmed CLI error detail when the captured output is not empty.
603fn non_empty_detail(detail: Option<&str>) -> Option<&str> {
604    detail.and_then(|detail| {
605        let trimmed_detail = detail.trim();
606        (!trimmed_detail.is_empty()).then_some(trimmed_detail)
607    })
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    fn review_comment_thread() -> ReviewCommentThread {
615        ReviewCommentThread {
616            anchor_side: ReviewCommentAnchorSide::New,
617            comments: Vec::new(),
618            id: "thread-1".to_string(),
619            is_outdated: Some(false),
620            is_resolved: false,
621            line: Some(1),
622            path: "src/lib.rs".to_string(),
623            start_line: None,
624        }
625    }
626
627    #[test]
628    fn review_comment_thread_is_actionable_when_unresolved_even_if_outdated() {
629        // Arrange
630        let actionable = review_comment_thread();
631        let mut resolved = review_comment_thread();
632        resolved.is_resolved = true;
633        let mut outdated = review_comment_thread();
634        outdated.is_outdated = Some(true);
635
636        // Act, Assert
637        assert!(actionable.is_actionable());
638        assert!(!resolved.is_actionable());
639        assert!(outdated.is_actionable());
640    }
641
642    #[test]
643    fn forge_kind_from_str_gitlab() {
644        // Arrange
645        let raw_forge_kind = "GitLab";
646
647        // Act
648        let forge_kind = raw_forge_kind
649            .parse::<ForgeKind>()
650            .expect("gitlab forge kind should parse");
651
652        // Assert
653        assert_eq!(forge_kind, ForgeKind::GitLab);
654        assert_eq!(forge_kind.cli_name(), "glab");
655        assert_eq!(forge_kind.review_request_name(), "merge request");
656        assert_eq!(forge_kind.review_request_short_name(), "MR");
657    }
658
659    #[test]
660    fn authentication_required_message_includes_original_cli_error_detail() {
661        // Arrange
662        let error = ReviewRequestError::AuthenticationRequired {
663            detail: Some("HTTP 401 Unauthorized. Run `gh auth login`.".to_string()),
664            forge_kind: ForgeKind::GitHub,
665            host: "github.com".to_string(),
666        };
667
668        // Act
669        let message = error.detail_message();
670
671        // Assert
672        assert!(message.contains("GitHub review requests require local CLI authentication"));
673        assert!(message.contains("Run `gh auth login` and retry."));
674        assert!(message.contains("Original `gh` error:"));
675        assert!(message.contains("HTTP 401 Unauthorized. Run `gh auth login`."));
676        assert!(message.contains("```text"));
677    }
678
679    #[test]
680    fn authentication_required_message_omits_empty_original_cli_error_detail() {
681        // Arrange
682        let error = ReviewRequestError::AuthenticationRequired {
683            detail: Some("   \n".to_string()),
684            forge_kind: ForgeKind::GitHub,
685            host: "github.com".to_string(),
686        };
687
688        // Act
689        let message = error.detail_message();
690
691        // Assert
692        assert!(message.contains("Run `gh auth login` and retry."));
693        assert!(!message.contains("Original `gh` error:"));
694    }
695
696    #[test]
697    fn review_request_creation_url_returns_github_compare_link() {
698        // Arrange
699        let remote = ForgeRemote {
700            command_working_directory: None,
701            forge_kind: ForgeKind::GitHub,
702            host: "github.com".to_string(),
703            namespace: "agentty-xyz".to_string(),
704            project: "agentty".to_string(),
705            repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
706            web_url: "https://github.com/agentty-xyz/agentty".to_string(),
707        };
708
709        // Act
710        let url = remote
711            .review_request_creation_url("review/custom-branch", "main")
712            .expect("github compare URL should be created");
713
714        // Assert
715        assert_eq!(
716            url,
717            "https://github.com/agentty-xyz/agentty/compare/main...review%2Fcustom-branch?expand=1"
718        );
719    }
720
721    #[test]
722    fn review_request_creation_url_rejects_invalid_web_url() {
723        // Arrange
724        let remote = ForgeRemote {
725            command_working_directory: None,
726            forge_kind: ForgeKind::GitHub,
727            host: "github.com".to_string(),
728            namespace: "agentty-xyz".to_string(),
729            project: "agentty".to_string(),
730            repo_url: "git@github.com:agentty-xyz/agentty.git".to_string(),
731            web_url: "not a url".to_string(),
732        };
733
734        // Act
735        let error = remote
736            .review_request_creation_url("review/custom-branch", "main")
737            .expect_err("invalid web URL should be rejected");
738
739        // Assert
740        assert_eq!(
741            error,
742            ReviewRequestError::OperationFailed {
743                forge_kind: ForgeKind::GitHub,
744                message: "repository remote is missing a valid web URL: `not a url`".to_string(),
745            }
746        );
747    }
748
749    #[test]
750    fn review_request_creation_url_returns_gitlab_merge_request_link() {
751        // Arrange
752        let remote = ForgeRemote {
753            command_working_directory: None,
754            forge_kind: ForgeKind::GitLab,
755            host: "gitlab.com".to_string(),
756            namespace: "agentty-xyz".to_string(),
757            project: "agentty".to_string(),
758            repo_url: "git@gitlab.com:agentty-xyz/agentty.git".to_string(),
759            web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
760        };
761
762        // Act
763        let url = remote
764            .review_request_creation_url("review/custom-branch", "main")
765            .expect("gitlab merge-request URL should be created");
766
767        // Assert
768        assert_eq!(
769            url,
770            "https://gitlab.com/agentty-xyz/agentty/-/merge_requests/new?merge_request%5Bsource_branch%5D=review%2Fcustom-branch&merge_request%5Btarget_branch%5D=main"
771        );
772    }
773}