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            forge_kind,
31            host: self.host,
32            namespace: self.namespace,
33            project: self.project,
34            repo_url: self.repo_url,
35            web_url: self.web_url,
36        }
37    }
38
39    /// Returns whether the hostname clearly identifies a GitLab instance.
40    pub(crate) fn host_is_gitlab(&self) -> bool {
41        strip_port(&self.host)
42            .split('.')
43            .any(|segment| segment == "gitlab")
44    }
45}
46
47/// Detects one supported forge remote from `repo_url`.
48///
49/// # Errors
50/// Returns [`ReviewRequestError::UnsupportedRemote`] when the repository
51/// remote does not map to GitHub or GitLab.
52pub fn detect_remote(repo_url: &str) -> Result<ForgeRemote, ReviewRequestError> {
53    if let Some(remote) = GitHubReviewRequestAdapter::detect_remote(repo_url) {
54        return Ok(remote);
55    }
56
57    if let Some(remote) = GitLabReviewRequestAdapter::detect_remote(repo_url) {
58        return Ok(remote);
59    }
60
61    Err(ReviewRequestError::UnsupportedRemote {
62        repo_url: repo_url.to_string(),
63    })
64}
65
66/// Parses a git remote URL into normalized hostname and repository components.
67///
68/// HTTPS remotes may include `username[:password]@` userinfo, which is ignored
69/// when deriving the forge host and browser-openable repository URL.
70pub(crate) fn parse_remote_url(repo_url: &str) -> Option<ParsedRemote> {
71    let trimmed_url = repo_url.trim().trim_end_matches('/');
72    if trimmed_url.is_empty() {
73        return None;
74    }
75
76    if let Some(ssh_remote) = trimmed_url.strip_prefix("git@") {
77        let (host, path) = ssh_remote.split_once(':')?;
78
79        return parsed_remote_from_parts(trimmed_url, host, path, true);
80    }
81
82    let (scheme, scheme_rest) = trimmed_url.split_once("://")?;
83    let scheme_rest = scheme_rest.strip_prefix("git@").unwrap_or(scheme_rest);
84    let (authority, path) = scheme_rest.split_once('/')?;
85    let host = strip_userinfo(authority);
86    let strip_transport_port = scheme.eq_ignore_ascii_case("ssh");
87
88    parsed_remote_from_parts(trimmed_url, host, path, strip_transport_port)
89}
90
91/// Removes any `:port` suffix from `host`.
92pub(crate) fn strip_port(host: &str) -> &str {
93    host.split(':').next().unwrap_or(host)
94}
95
96/// Removes any `username[:password]@` prefix from one URL authority segment.
97fn strip_userinfo(authority: &str) -> &str {
98    authority
99        .rsplit_once('@')
100        .map_or(authority, |(_, host)| host)
101}
102
103/// Builds one parsed remote from extracted host and path components.
104///
105/// When `strip_transport_port` is `true`, the parsed host is normalized for
106/// browser and API access by dropping any SSH transport port.
107fn parsed_remote_from_parts(
108    repo_url: &str,
109    host: &str,
110    path: &str,
111    strip_transport_port: bool,
112) -> Option<ParsedRemote> {
113    let host = host.trim().trim_matches('/').to_ascii_lowercase();
114    let host = if strip_transport_port {
115        strip_port(&host).to_string()
116    } else {
117        host
118    };
119    let path = path.trim().trim_matches('/').trim_end_matches(".git");
120    if host.is_empty() || path.is_empty() {
121        return None;
122    }
123
124    let (namespace, project) = path.rsplit_once('/')?;
125    if namespace.is_empty() || project.is_empty() {
126        return None;
127    }
128
129    Some(ParsedRemote {
130        host: host.clone(),
131        namespace: namespace.to_string(),
132        project: project.to_string(),
133        repo_url: repo_url.to_string(),
134        web_url: format!("https://{host}/{path}"),
135    })
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn detect_remote_returns_github_remote_for_https_origin() {
144        // Arrange
145        let repo_url = "https://github.com/agentty-xyz/agentty.git";
146
147        // Act
148        let remote = detect_remote(repo_url).expect("github remote should be supported");
149
150        // Assert
151        assert_eq!(
152            remote,
153            ForgeRemote {
154                forge_kind: ForgeKind::GitHub,
155                host: "github.com".to_string(),
156                namespace: "agentty-xyz".to_string(),
157                project: "agentty".to_string(),
158                repo_url: repo_url.to_string(),
159                web_url: "https://github.com/agentty-xyz/agentty".to_string(),
160            }
161        );
162    }
163
164    #[test]
165    fn detect_remote_ignores_https_userinfo_for_github_origin() {
166        // Arrange
167        let repo_url = "https://build-bot:token123@github.com/agentty-xyz/agentty.git";
168
169        // Act
170        let remote =
171            detect_remote(repo_url).expect("github remote with https credentials should work");
172
173        // Assert
174        assert_eq!(remote.forge_kind, ForgeKind::GitHub);
175        assert_eq!(remote.host, "github.com");
176        assert_eq!(remote.namespace, "agentty-xyz");
177        assert_eq!(remote.project, "agentty");
178        assert_eq!(remote.repo_url, repo_url);
179        assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
180    }
181
182    #[test]
183    fn detect_remote_returns_github_remote_for_ssh_origin() {
184        // Arrange
185        let repo_url = "git@github.com:agentty-xyz/agentty.git";
186
187        // Act
188        let remote = detect_remote(repo_url).expect("github ssh remote should be supported");
189
190        // Assert
191        assert_eq!(remote.forge_kind, ForgeKind::GitHub);
192        assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
193        assert_eq!(remote.project_path(), "agentty-xyz/agentty");
194    }
195
196    #[test]
197    fn detect_remote_ignores_https_userinfo_for_gitlab_origin() {
198        // Arrange
199        let repo_url = "https://reviewer:token123@gitlab.com/group/subgroup/project.git";
200
201        // Act
202        let remote =
203            detect_remote(repo_url).expect("gitlab remote with https credentials should work");
204
205        // Assert
206        assert_eq!(remote.forge_kind, ForgeKind::GitLab);
207        assert_eq!(remote.host, "gitlab.com");
208        assert_eq!(remote.namespace, "group/subgroup");
209        assert_eq!(remote.project, "project");
210        assert_eq!(remote.repo_url, repo_url);
211        assert_eq!(remote.web_url, "https://gitlab.com/group/subgroup/project");
212    }
213
214    #[test]
215    fn detect_remote_returns_gitlab_remote_for_https_origin() {
216        // Arrange
217        let repo_url = "https://gitlab.com/group/subgroup/project.git";
218
219        // Act
220        let remote = detect_remote(repo_url).expect("gitlab remote should be supported");
221
222        // Assert
223        assert_eq!(remote.forge_kind, ForgeKind::GitLab);
224        assert_eq!(remote.host, "gitlab.com");
225        assert_eq!(remote.namespace, "group/subgroup");
226        assert_eq!(remote.project, "project");
227    }
228
229    #[test]
230    fn detect_remote_returns_gitlab_remote_for_self_hosted_ssh_origin() {
231        // Arrange
232        let repo_url = "git@gitlab.example.com:team/project.git";
233
234        // Act
235        let remote =
236            detect_remote(repo_url).expect("self-hosted gitlab remote should be supported");
237
238        // Assert
239        assert_eq!(remote.forge_kind, ForgeKind::GitLab);
240        assert_eq!(remote.host, "gitlab.example.com");
241        assert_eq!(remote.web_url, "https://gitlab.example.com/team/project");
242    }
243
244    #[test]
245    fn detect_remote_strips_ssh_transport_port_from_gitlab_host() {
246        // Arrange
247        let repo_url = "ssh://git@gitlab.example.com:2222/team/project.git";
248
249        // Act
250        let remote =
251            detect_remote(repo_url).expect("gitlab ssh remote with transport port should work");
252
253        // Assert
254        assert_eq!(remote.forge_kind, ForgeKind::GitLab);
255        assert_eq!(remote.host, "gitlab.example.com");
256        assert_eq!(remote.web_url, "https://gitlab.example.com/team/project");
257    }
258
259    #[test]
260    fn detect_remote_preserves_https_port_for_gitlab_origin() {
261        // Arrange
262        let repo_url = "https://gitlab.example.com:8443/team/project.git";
263
264        // Act
265        let remote =
266            detect_remote(repo_url).expect("gitlab https remote with api port should work");
267
268        // Assert
269        assert_eq!(remote.forge_kind, ForgeKind::GitLab);
270        assert_eq!(remote.host, "gitlab.example.com:8443");
271        assert_eq!(
272            remote.web_url,
273            "https://gitlab.example.com:8443/team/project"
274        );
275    }
276
277    #[test]
278    fn detect_remote_returns_unsupported_remote_error_for_non_forge_origin() {
279        // Arrange
280        let repo_url = "https://example.com/team/project.git";
281
282        // Act
283        let error = detect_remote(repo_url).expect_err("non-forge remote should be rejected");
284
285        // Assert
286        assert_eq!(
287            error,
288            ReviewRequestError::UnsupportedRemote {
289                repo_url: repo_url.to_string(),
290            }
291        );
292        assert!(error.detail_message().contains("GitHub and GitLab"));
293        assert!(error.detail_message().contains("example.com"));
294    }
295}