Skip to main content

ag_forge/
remote.rs

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