1use super::{ForgeKind, ForgeRemote, GitHubReviewRequestAdapter, ReviewRequestError};
4
5#[derive(Clone, Debug, Eq, PartialEq)]
7pub(crate) struct ParsedRemote {
8 pub(crate) host: String,
13 pub(crate) namespace: String,
15 pub(crate) project: String,
17 pub(crate) repo_url: String,
19 pub(crate) web_url: String,
21}
22
23impl ParsedRemote {
24 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
37pub 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
52pub(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
77pub(crate) fn strip_port(host: &str) -> &str {
79 host.split(':').next().unwrap_or(host)
80}
81
82fn strip_userinfo(authority: &str) -> &str {
84 authority
85 .rsplit_once('@')
86 .map_or(authority, |(_, host)| host)
87}
88
89fn 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 let repo_url = "https://github.com/agentty-xyz/agentty.git";
132
133 let remote = detect_remote(repo_url).expect("github remote should be supported");
135
136 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 let repo_url = "https://build-bot:token123@github.com/agentty-xyz/agentty.git";
154
155 let remote =
157 detect_remote(repo_url).expect("github remote with https credentials should work");
158
159 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 let repo_url = "git@github.com:agentty-xyz/agentty.git";
172
173 let remote = detect_remote(repo_url).expect("github ssh remote should be supported");
175
176 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 let repo_url = "https://example.com/team/project.git";
186
187 let error = detect_remote(repo_url).expect_err("non-forge remote should be rejected");
189
190 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}