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 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 pub(crate) fn host_is_gitlab(&self) -> bool {
41 strip_port(&self.host)
42 .split('.')
43 .any(|segment| segment == "gitlab")
44 }
45}
46
47pub 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
66pub(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
91pub(crate) fn strip_port(host: &str) -> &str {
93 host.split(':').next().unwrap_or(host)
94}
95
96fn strip_userinfo(authority: &str) -> &str {
98 authority
99 .rsplit_once('@')
100 .map_or(authority, |(_, host)| host)
101}
102
103fn 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 let repo_url = "https://github.com/agentty-xyz/agentty.git";
146
147 let remote = detect_remote(repo_url).expect("github remote should be supported");
149
150 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 let repo_url = "https://build-bot:token123@github.com/agentty-xyz/agentty.git";
168
169 let remote =
171 detect_remote(repo_url).expect("github remote with https credentials should work");
172
173 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 let repo_url = "git@github.com:agentty-xyz/agentty.git";
186
187 let remote = detect_remote(repo_url).expect("github ssh remote should be supported");
189
190 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 let repo_url = "https://reviewer:token123@gitlab.com/group/subgroup/project.git";
200
201 let remote =
203 detect_remote(repo_url).expect("gitlab remote with https credentials should work");
204
205 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 let repo_url = "https://gitlab.com/group/subgroup/project.git";
218
219 let remote = detect_remote(repo_url).expect("gitlab remote should be supported");
221
222 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 let repo_url = "git@gitlab.example.com:team/project.git";
233
234 let remote =
236 detect_remote(repo_url).expect("self-hosted gitlab remote should be supported");
237
238 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 let repo_url = "ssh://git@gitlab.example.com:2222/team/project.git";
248
249 let remote =
251 detect_remote(repo_url).expect("gitlab ssh remote with transport port should work");
252
253 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 let repo_url = "https://gitlab.example.com:8443/team/project.git";
263
264 let remote =
266 detect_remote(repo_url).expect("gitlab https remote with api port should work");
267
268 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 let repo_url = "https://example.com/team/project.git";
281
282 let error = detect_remote(repo_url).expect_err("non-forge remote should be rejected");
284
285 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}