1use super::{
4 ForgeKind, ForgeRemote, GitHubReviewRequestAdapter, GitLabReviewRequestAdapter,
5 ReviewRequestError,
6};
7
8#[derive(Clone, Debug, Eq, PartialEq)]
10pub(crate) struct ParsedRemote {
11 pub(crate) host: String,
16 pub(crate) namespace: String,
18 pub(crate) project: String,
20 pub(crate) repo_url: String,
22 pub(crate) web_url: String,
24}
25
26impl ParsedRemote {
27 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
41pub 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
60pub(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
87pub(crate) fn strip_port(host: &str) -> &str {
89 host.split(':').next().unwrap_or(host)
90}
91
92fn 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
127fn 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
150fn 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 let repo_url = "https://github.com/agentty-xyz/agentty.git";
165
166 let remote = detect_remote(repo_url).expect("github remote should be supported");
168
169 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 let repo_url = "https://test-user:placeholder@github.com/agentty-xyz/agentty.git";
188
189 let remote =
191 detect_remote(repo_url).expect("github remote with https credentials should work");
192
193 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 let repo_url = "https://test-user:placeholder@example.com/team/project.git";
209
210 let error = detect_remote(repo_url).expect_err("unsupported remote should fail");
212 let detail = error.detail_message();
213
214 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 let repo_url = "https://test-user:placeholder@example.com";
229
230 let sanitized = display_safe_remote_url(repo_url);
232
233 assert_eq!(sanitized, "https://example.com");
235 }
236
237 #[test]
238 fn display_safe_remote_url_preserves_scp_style_remote_without_userinfo() {
239 let repo_url = "github.com:agentty-xyz/agentty.git";
241
242 let sanitized = display_safe_remote_url(repo_url);
244
245 assert_eq!(sanitized, repo_url);
247 }
248
249 #[test]
250 fn detect_remote_redacts_scp_style_ssh_userinfo() {
251 let repo_url = "test-user@gitlab.com:agentty-xyz/agentty.git";
253
254 let remote = detect_remote(repo_url).expect("GitLab SSH remote should be supported");
256
257 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 let repo_url = "git@github.com:agentty-xyz/agentty.git";
270
271 let remote = detect_remote(repo_url).expect("github ssh remote should be supported");
273
274 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 let repo_url = "https://example.com/team/project.git";
284
285 let error = detect_remote(repo_url).expect_err("non-forge remote should be rejected");
287
288 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 let repo_url = "https://gitlab.com/agentty-xyz/agentty.git";
303
304 let remote = detect_remote(repo_url).expect("gitlab remote should be supported");
306
307 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 let repo_url = "git@gitlab.company.org:team/agentty.git";
326
327 let remote = detect_remote(repo_url).expect("gitlab subdomain remote should be supported");
329
330 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}