Skip to main content

ag_forge/
remote.rs

1//! Forge remote detection helpers shared across provider adapters.
2
3use super::{
4    ForgeKind, ForgeRemote, GitHubReviewRequestAdapter, GitLabReviewRequestAdapter,
5    ReviewRequestError,
6};
7
8/// Parsed remote components extracted from one git remote URL.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub(crate) struct ParsedRemote {
11    /// Canonical forge host used for browser and API requests.
12    ///
13    /// SSH transport ports are stripped so review-request commands target the
14    /// authenticated HTTPS host instead of the SSH daemon port.
15    pub(crate) host: String,
16    /// Repository namespace or owner path.
17    pub(crate) namespace: String,
18    /// Repository name without a trailing `.git` suffix.
19    pub(crate) project: String,
20    /// Original remote URL returned by git.
21    pub(crate) repo_url: String,
22    /// Browser-openable repository URL derived from the remote.
23    pub(crate) web_url: String,
24}
25
26impl ParsedRemote {
27    /// Converts the parsed remote into one supported forge remote.
28    pub(crate) fn into_forge_remote(self, forge_kind: ForgeKind) -> ForgeRemote {
29        ForgeRemote {
30            command_working_directory: None,
31            forge_kind,
32            host: self.host,
33            namespace: self.namespace,
34            project: self.project,
35            repo_url: self.repo_url,
36            web_url: self.web_url,
37        }
38    }
39}
40
41/// Detects one supported forge remote from `repo_url`.
42///
43/// # Errors
44/// Returns [`ReviewRequestError::UnsupportedRemote`] when the repository
45/// remote does not map to a supported forge.
46pub fn detect_remote(repo_url: &str) -> Result<ForgeRemote, ReviewRequestError> {
47    if let Some(remote) = GitHubReviewRequestAdapter::detect_remote(repo_url) {
48        return Ok(remote);
49    }
50
51    if let Some(remote) = GitLabReviewRequestAdapter::detect_remote(repo_url) {
52        return Ok(remote);
53    }
54
55    Err(ReviewRequestError::UnsupportedRemote {
56        repo_url: repo_url.to_string(),
57    })
58}
59
60/// Parses a git remote URL into normalized hostname and repository components.
61///
62/// HTTPS remotes may include `username[:password]@` userinfo, which is ignored
63/// when deriving the forge host and browser-openable repository URL.
64pub(crate) fn parse_remote_url(repo_url: &str) -> Option<ParsedRemote> {
65    let trimmed_url = repo_url.trim().trim_end_matches('/');
66    if trimmed_url.is_empty() {
67        return None;
68    }
69
70    if let Some(ssh_remote) = trimmed_url.strip_prefix("git@") {
71        let (host, path) = ssh_remote.split_once(':')?;
72
73        return parsed_remote_from_parts(trimmed_url, host, path, true);
74    }
75
76    let (scheme, scheme_rest) = trimmed_url.split_once("://")?;
77    let scheme_rest = scheme_rest.strip_prefix("git@").unwrap_or(scheme_rest);
78    let (authority, path) = scheme_rest.split_once('/')?;
79    let host = strip_userinfo(authority);
80    let strip_transport_port = scheme.eq_ignore_ascii_case("ssh");
81
82    parsed_remote_from_parts(trimmed_url, host, path, strip_transport_port)
83}
84
85/// Removes any `:port` suffix from `host`.
86pub(crate) fn strip_port(host: &str) -> &str {
87    host.split(':').next().unwrap_or(host)
88}
89
90/// Removes any `username[:password]@` prefix from one URL authority segment.
91fn strip_userinfo(authority: &str) -> &str {
92    authority
93        .rsplit_once('@')
94        .map_or(authority, |(_, host)| host)
95}
96
97/// Builds one parsed remote from extracted host and path components.
98///
99/// When `strip_transport_port` is `true`, the parsed host is normalized for
100/// browser and API access by dropping any SSH transport port.
101fn parsed_remote_from_parts(
102    repo_url: &str,
103    host: &str,
104    path: &str,
105    strip_transport_port: bool,
106) -> Option<ParsedRemote> {
107    let host = host.trim().trim_matches('/').to_ascii_lowercase();
108    let host = if strip_transport_port {
109        strip_port(&host).to_string()
110    } else {
111        host
112    };
113    let path = path.trim().trim_matches('/').trim_end_matches(".git");
114    if host.is_empty() || path.is_empty() {
115        return None;
116    }
117
118    let (namespace, project) = path.rsplit_once('/')?;
119    if namespace.is_empty() || project.is_empty() {
120        return None;
121    }
122
123    Some(ParsedRemote {
124        host: host.clone(),
125        namespace: namespace.to_string(),
126        project: project.to_string(),
127        repo_url: repo_url.to_string(),
128        web_url: format!("https://{host}/{path}"),
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn detect_remote_returns_github_remote_for_https_origin() {
138        // Arrange
139        let repo_url = "https://github.com/agentty-xyz/agentty.git";
140
141        // Act
142        let remote = detect_remote(repo_url).expect("github remote should be supported");
143
144        // Assert
145        assert_eq!(
146            remote,
147            ForgeRemote {
148                command_working_directory: None,
149                forge_kind: ForgeKind::GitHub,
150                host: "github.com".to_string(),
151                namespace: "agentty-xyz".to_string(),
152                project: "agentty".to_string(),
153                repo_url: repo_url.to_string(),
154                web_url: "https://github.com/agentty-xyz/agentty".to_string(),
155            }
156        );
157    }
158
159    #[test]
160    fn detect_remote_ignores_https_userinfo_for_github_origin() {
161        // Arrange
162        let repo_url = "https://build-bot:token123@github.com/agentty-xyz/agentty.git";
163
164        // Act
165        let remote =
166            detect_remote(repo_url).expect("github remote with https credentials should work");
167
168        // Assert
169        assert_eq!(remote.forge_kind, ForgeKind::GitHub);
170        assert_eq!(remote.host, "github.com");
171        assert_eq!(remote.namespace, "agentty-xyz");
172        assert_eq!(remote.project, "agentty");
173        assert_eq!(remote.repo_url, repo_url);
174        assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
175    }
176
177    #[test]
178    fn detect_remote_returns_github_remote_for_ssh_origin() {
179        // Arrange
180        let repo_url = "git@github.com:agentty-xyz/agentty.git";
181
182        // Act
183        let remote = detect_remote(repo_url).expect("github ssh remote should be supported");
184
185        // Assert
186        assert_eq!(remote.forge_kind, ForgeKind::GitHub);
187        assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
188        assert_eq!(remote.project_path(), "agentty-xyz/agentty");
189    }
190
191    #[test]
192    fn detect_remote_returns_unsupported_remote_error_for_non_forge_origin() {
193        // Arrange
194        let repo_url = "https://example.com/team/project.git";
195
196        // Act
197        let error = detect_remote(repo_url).expect_err("non-forge remote should be rejected");
198
199        // Assert
200        assert_eq!(
201            error,
202            ReviewRequestError::UnsupportedRemote {
203                repo_url: repo_url.to_string(),
204            }
205        );
206        assert!(error.detail_message().contains("GitHub and GitLab remotes"));
207        assert!(error.detail_message().contains("example.com"));
208    }
209
210    #[test]
211    fn detect_remote_returns_gitlab_remote_for_https_origin() {
212        // Arrange
213        let repo_url = "https://gitlab.com/agentty-xyz/agentty.git";
214
215        // Act
216        let remote = detect_remote(repo_url).expect("gitlab remote should be supported");
217
218        // Assert
219        assert_eq!(
220            remote,
221            ForgeRemote {
222                command_working_directory: None,
223                forge_kind: ForgeKind::GitLab,
224                host: "gitlab.com".to_string(),
225                namespace: "agentty-xyz".to_string(),
226                project: "agentty".to_string(),
227                repo_url: repo_url.to_string(),
228                web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
229            }
230        );
231    }
232
233    #[test]
234    fn detect_remote_returns_gitlab_remote_for_gitlab_subdomain_origin() {
235        // Arrange
236        let repo_url = "git@gitlab.company.org:team/agentty.git";
237
238        // Act
239        let remote = detect_remote(repo_url).expect("gitlab subdomain remote should be supported");
240
241        // Assert
242        assert_eq!(remote.forge_kind, ForgeKind::GitLab);
243        assert_eq!(remote.host, "gitlab.company.org");
244        assert_eq!(remote.project_path(), "team/agentty");
245        assert_eq!(remote.web_url, "https://gitlab.company.org/team/agentty");
246    }
247}