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    /// Credential-free remote URL suitable for display and diagnostics.
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: display_safe_remote_url(repo_url),
57    })
58}
59
60/// Parses a git remote URL into normalized hostname and repository components.
61///
62/// URL remotes may include `username[:password]@` userinfo, which is removed
63/// before the remote is retained or used for diagnostics.
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((authority, path)) = trimmed_url.split_once(':')
71        && authority.contains('@')
72    {
73        let host = strip_userinfo(authority);
74
75        return parsed_remote_from_parts(trimmed_url, host, path, true);
76    }
77
78    let (scheme, scheme_rest) = trimmed_url.split_once("://")?;
79    let scheme_rest = scheme_rest.strip_prefix("git@").unwrap_or(scheme_rest);
80    let (authority, path) = scheme_rest.split_once('/')?;
81    let host = strip_userinfo(authority);
82    let strip_transport_port = scheme.eq_ignore_ascii_case("ssh");
83
84    parsed_remote_from_parts(trimmed_url, host, path, strip_transport_port)
85}
86
87/// Removes any `:port` suffix from `host`.
88pub(crate) fn strip_port(host: &str) -> &str {
89    host.split(':').next().unwrap_or(host)
90}
91
92/// Builds one parsed remote from extracted host and path components.
93///
94/// When `strip_transport_port` is `true`, the parsed host is normalized for
95/// browser and API access by dropping any SSH transport port.
96fn parsed_remote_from_parts(
97    repo_url: &str,
98    host: &str,
99    path: &str,
100    strip_transport_port: bool,
101) -> Option<ParsedRemote> {
102    let host = host.trim().trim_matches('/').to_ascii_lowercase();
103    let host = if strip_transport_port {
104        strip_port(&host).to_string()
105    } else {
106        host
107    };
108    let path = path.trim().trim_matches('/').trim_end_matches(".git");
109    if host.is_empty() || path.is_empty() {
110        return None;
111    }
112
113    let (namespace, project) = path.rsplit_once('/')?;
114    if namespace.is_empty() || project.is_empty() {
115        return None;
116    }
117
118    Some(ParsedRemote {
119        host: host.clone(),
120        namespace: namespace.to_string(),
121        project: project.to_string(),
122        repo_url: display_safe_remote_url(repo_url),
123        web_url: format!("https://{host}/{path}"),
124    })
125}
126
127/// Removes URL userinfo so repository remotes are safe to retain or display.
128fn display_safe_remote_url(repo_url: &str) -> String {
129    let trimmed_url = repo_url.trim();
130    let Some((scheme, scheme_rest)) = trimmed_url.split_once("://") else {
131        if let Some((authority, suffix)) = trimmed_url.split_once(':')
132            && authority.contains('@')
133        {
134            return format!("{}:{suffix}", strip_userinfo(authority));
135        }
136
137        return trimmed_url.to_string();
138    };
139    let (authority, suffix) = scheme_rest
140        .split_once('/')
141        .map_or((scheme_rest, ""), |(authority, path)| (authority, path));
142    let authority = strip_userinfo(authority);
143    if suffix.is_empty() {
144        return format!("{scheme}://{authority}");
145    }
146
147    format!("{scheme}://{authority}/{suffix}")
148}
149
150/// Removes any `username[:password]@` prefix from one URL authority segment.
151fn strip_userinfo(authority: &str) -> &str {
152    authority
153        .rsplit_once('@')
154        .map_or(authority, |(_, host)| host)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn detect_remote_returns_github_remote_for_https_origin() {
163        // Arrange
164        let repo_url = "https://github.com/agentty-xyz/agentty.git";
165
166        // Act
167        let remote = detect_remote(repo_url).expect("github remote should be supported");
168
169        // Assert
170        assert_eq!(
171            remote,
172            ForgeRemote {
173                command_working_directory: None,
174                forge_kind: ForgeKind::GitHub,
175                host: "github.com".to_string(),
176                namespace: "agentty-xyz".to_string(),
177                project: "agentty".to_string(),
178                repo_url: repo_url.to_string(),
179                web_url: "https://github.com/agentty-xyz/agentty".to_string(),
180            }
181        );
182    }
183
184    #[test]
185    fn detect_remote_ignores_https_userinfo_for_github_origin() {
186        // Arrange
187        let repo_url = "https://test-user:placeholder@github.com/agentty-xyz/agentty.git";
188
189        // Act
190        let remote =
191            detect_remote(repo_url).expect("github remote with https credentials should work");
192
193        // Assert
194        assert_eq!(remote.forge_kind, ForgeKind::GitHub);
195        assert_eq!(remote.host, "github.com");
196        assert_eq!(remote.namespace, "agentty-xyz");
197        assert_eq!(remote.project, "agentty");
198        assert_eq!(
199            remote.repo_url,
200            "https://github.com/agentty-xyz/agentty.git"
201        );
202        assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
203    }
204
205    #[test]
206    fn detect_remote_redacts_https_userinfo_from_unsupported_remote_error() {
207        // Arrange
208        let repo_url = "https://test-user:placeholder@example.com/team/project.git";
209
210        // Act
211        let error = detect_remote(repo_url).expect_err("unsupported remote should fail");
212        let detail = error.detail_message();
213
214        // Assert
215        assert_eq!(
216            error,
217            ReviewRequestError::UnsupportedRemote {
218                repo_url: "https://example.com/team/project.git".to_string(),
219            }
220        );
221        assert!(!detail.contains("test-user"));
222        assert!(!detail.contains("placeholder"));
223    }
224
225    #[test]
226    fn display_safe_remote_url_redacts_userinfo_without_a_path() {
227        // Arrange
228        let repo_url = "https://test-user:placeholder@example.com";
229
230        // Act
231        let sanitized = display_safe_remote_url(repo_url);
232
233        // Assert
234        assert_eq!(sanitized, "https://example.com");
235    }
236
237    #[test]
238    fn display_safe_remote_url_preserves_scp_style_remote_without_userinfo() {
239        // Arrange
240        let repo_url = "github.com:agentty-xyz/agentty.git";
241
242        // Act
243        let sanitized = display_safe_remote_url(repo_url);
244
245        // Assert
246        assert_eq!(sanitized, repo_url);
247    }
248
249    #[test]
250    fn detect_remote_redacts_scp_style_ssh_userinfo() {
251        // Arrange
252        let repo_url = "test-user@gitlab.com:agentty-xyz/agentty.git";
253
254        // Act
255        let remote = detect_remote(repo_url).expect("GitLab SSH remote should be supported");
256
257        // Assert
258        assert_eq!(remote.forge_kind, ForgeKind::GitLab);
259        assert_eq!(
260            remote.repo_url,
261            "gitlab.com:agentty-xyz/agentty.git".to_string()
262        );
263        assert!(!remote.repo_url.contains("test-user"));
264    }
265
266    #[test]
267    fn detect_remote_returns_github_remote_for_ssh_origin() {
268        // Arrange
269        let repo_url = "git@github.com:agentty-xyz/agentty.git";
270
271        // Act
272        let remote = detect_remote(repo_url).expect("github ssh remote should be supported");
273
274        // Assert
275        assert_eq!(remote.forge_kind, ForgeKind::GitHub);
276        assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
277        assert_eq!(remote.project_path(), "agentty-xyz/agentty");
278    }
279
280    #[test]
281    fn detect_remote_returns_unsupported_remote_error_for_non_forge_origin() {
282        // Arrange
283        let repo_url = "https://example.com/team/project.git";
284
285        // Act
286        let error = detect_remote(repo_url).expect_err("non-forge remote should be rejected");
287
288        // Assert
289        assert_eq!(
290            error,
291            ReviewRequestError::UnsupportedRemote {
292                repo_url: repo_url.to_string(),
293            }
294        );
295        assert!(error.detail_message().contains("GitHub and GitLab remotes"));
296        assert!(error.detail_message().contains("example.com"));
297    }
298
299    #[test]
300    fn detect_remote_returns_gitlab_remote_for_https_origin() {
301        // Arrange
302        let repo_url = "https://gitlab.com/agentty-xyz/agentty.git";
303
304        // Act
305        let remote = detect_remote(repo_url).expect("gitlab remote should be supported");
306
307        // Assert
308        assert_eq!(
309            remote,
310            ForgeRemote {
311                command_working_directory: None,
312                forge_kind: ForgeKind::GitLab,
313                host: "gitlab.com".to_string(),
314                namespace: "agentty-xyz".to_string(),
315                project: "agentty".to_string(),
316                repo_url: repo_url.to_string(),
317                web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
318            }
319        );
320    }
321
322    #[test]
323    fn detect_remote_returns_gitlab_remote_for_gitlab_subdomain_origin() {
324        // Arrange
325        let repo_url = "git@gitlab.company.org:team/agentty.git";
326
327        // Act
328        let remote = detect_remote(repo_url).expect("gitlab subdomain remote should be supported");
329
330        // Assert
331        assert_eq!(remote.forge_kind, ForgeKind::GitLab);
332        assert_eq!(remote.host, "gitlab.company.org");
333        assert_eq!(remote.project_path(), "team/agentty");
334        assert_eq!(remote.web_url, "https://gitlab.company.org/team/agentty");
335    }
336}