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: repo_url.to_string(),
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(ssh_remote) = trimmed_url.strip_prefix("git@") {
71 let (host, path) = ssh_remote.split_once(':')?;
72
73 return parsed_remote_from_parts(trimmed_url, host, path, true);
74 }
75
76 let (scheme, scheme_rest) = trimmed_url.split_once("://")?;
77 let scheme_rest = scheme_rest.strip_prefix("git@").unwrap_or(scheme_rest);
78 let (authority, path) = scheme_rest.split_once('/')?;
79 let host = strip_userinfo(authority);
80 let strip_transport_port = scheme.eq_ignore_ascii_case("ssh");
81
82 parsed_remote_from_parts(trimmed_url, host, path, strip_transport_port)
83}
84
85pub(crate) fn strip_port(host: &str) -> &str {
87 host.split(':').next().unwrap_or(host)
88}
89
90fn strip_userinfo(authority: &str) -> &str {
92 authority
93 .rsplit_once('@')
94 .map_or(authority, |(_, host)| host)
95}
96
97fn parsed_remote_from_parts(
102 repo_url: &str,
103 host: &str,
104 path: &str,
105 strip_transport_port: bool,
106) -> Option<ParsedRemote> {
107 let host = host.trim().trim_matches('/').to_ascii_lowercase();
108 let host = if strip_transport_port {
109 strip_port(&host).to_string()
110 } else {
111 host
112 };
113 let path = path.trim().trim_matches('/').trim_end_matches(".git");
114 if host.is_empty() || path.is_empty() {
115 return None;
116 }
117
118 let (namespace, project) = path.rsplit_once('/')?;
119 if namespace.is_empty() || project.is_empty() {
120 return None;
121 }
122
123 Some(ParsedRemote {
124 host: host.clone(),
125 namespace: namespace.to_string(),
126 project: project.to_string(),
127 repo_url: repo_url.to_string(),
128 web_url: format!("https://{host}/{path}"),
129 })
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn detect_remote_returns_github_remote_for_https_origin() {
138 let repo_url = "https://github.com/agentty-xyz/agentty.git";
140
141 let remote = detect_remote(repo_url).expect("github remote should be supported");
143
144 assert_eq!(
146 remote,
147 ForgeRemote {
148 command_working_directory: None,
149 forge_kind: ForgeKind::GitHub,
150 host: "github.com".to_string(),
151 namespace: "agentty-xyz".to_string(),
152 project: "agentty".to_string(),
153 repo_url: repo_url.to_string(),
154 web_url: "https://github.com/agentty-xyz/agentty".to_string(),
155 }
156 );
157 }
158
159 #[test]
160 fn detect_remote_ignores_https_userinfo_for_github_origin() {
161 let repo_url = "https://build-bot:token123@github.com/agentty-xyz/agentty.git";
163
164 let remote =
166 detect_remote(repo_url).expect("github remote with https credentials should work");
167
168 assert_eq!(remote.forge_kind, ForgeKind::GitHub);
170 assert_eq!(remote.host, "github.com");
171 assert_eq!(remote.namespace, "agentty-xyz");
172 assert_eq!(remote.project, "agentty");
173 assert_eq!(remote.repo_url, repo_url);
174 assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
175 }
176
177 #[test]
178 fn detect_remote_returns_github_remote_for_ssh_origin() {
179 let repo_url = "git@github.com:agentty-xyz/agentty.git";
181
182 let remote = detect_remote(repo_url).expect("github ssh remote should be supported");
184
185 assert_eq!(remote.forge_kind, ForgeKind::GitHub);
187 assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
188 assert_eq!(remote.project_path(), "agentty-xyz/agentty");
189 }
190
191 #[test]
192 fn detect_remote_returns_unsupported_remote_error_for_non_forge_origin() {
193 let repo_url = "https://example.com/team/project.git";
195
196 let error = detect_remote(repo_url).expect_err("non-forge remote should be rejected");
198
199 assert_eq!(
201 error,
202 ReviewRequestError::UnsupportedRemote {
203 repo_url: repo_url.to_string(),
204 }
205 );
206 assert!(error.detail_message().contains("GitHub and GitLab remotes"));
207 assert!(error.detail_message().contains("example.com"));
208 }
209
210 #[test]
211 fn detect_remote_returns_gitlab_remote_for_https_origin() {
212 let repo_url = "https://gitlab.com/agentty-xyz/agentty.git";
214
215 let remote = detect_remote(repo_url).expect("gitlab remote should be supported");
217
218 assert_eq!(
220 remote,
221 ForgeRemote {
222 command_working_directory: None,
223 forge_kind: ForgeKind::GitLab,
224 host: "gitlab.com".to_string(),
225 namespace: "agentty-xyz".to_string(),
226 project: "agentty".to_string(),
227 repo_url: repo_url.to_string(),
228 web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
229 }
230 );
231 }
232
233 #[test]
234 fn detect_remote_returns_gitlab_remote_for_gitlab_subdomain_origin() {
235 let repo_url = "git@gitlab.company.org:team/agentty.git";
237
238 let remote = detect_remote(repo_url).expect("gitlab subdomain remote should be supported");
240
241 assert_eq!(remote.forge_kind, ForgeKind::GitLab);
243 assert_eq!(remote.host, "gitlab.company.org");
244 assert_eq!(remote.project_path(), "team/agentty");
245 assert_eq!(remote.web_url, "https://gitlab.company.org/team/agentty");
246 }
247}