1use anyhow::{bail, Context, Result};
5use serde::Deserialize;
6
7use crate::worktree::{Auth, NeedsToken};
8
9pub use comindly_api::hosting::{Provider, RemoteRepo, Source};
10
11pub fn parse(url: &str) -> Result<Source> {
13 let url = url.trim().trim_end_matches('/');
14 let clone_url = url.ends_with(".git") || url.starts_with("git@") || url.starts_with("ssh://");
15 let rest = url
16 .trim_start_matches("https://")
17 .trim_start_matches("http://")
18 .trim_start_matches("ssh://")
19 .trim_start_matches("git@");
20 let rest = rest.split(['?', '#']).next().unwrap_or_default();
21 let (host, path) = rest
22 .split_once(['/', ':'])
23 .context("give the organisation, group or repository, e.g. github.com/acme")?;
24 let path = path.trim_end_matches(".git").trim_matches('/');
25 let path = path.split("/-/").next().unwrap_or(path);
26 anyhow::ensure!(
27 !path.is_empty(),
28 "give the organisation, group or repository after the host"
29 );
30 let host = host.to_lowercase();
31 let provider = if host == "github.com" || host.contains("github") {
32 Provider::GitHub
33 } else {
34 Provider::GitLab
35 };
36 let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
37 let (owner, repo) = match provider {
38 Provider::GitHub if parts.first() == Some(&"orgs") => {
40 (parts.get(1).unwrap_or(&"").to_string(), None)
41 }
42 Provider::GitHub => (parts[0].to_string(), parts.get(1).map(|r| r.to_string())),
43 Provider::GitLab if clone_url && parts.len() >= 2 => {
44 let (group, name) = path.rsplit_once('/').unwrap_or_default();
45 (group.to_string(), Some(name.to_string()))
46 }
47 Provider::GitLab => (path.to_string(), None),
48 };
49 anyhow::ensure!(
50 !owner.is_empty(),
51 "give the organisation, group or repository after the host"
52 );
53 Ok(Source {
54 provider,
55 host,
56 owner,
57 repo,
58 })
59}
60
61pub async fn list(source: &Source, auth: Option<&Auth>) -> Result<Vec<RemoteRepo>> {
63 let client = reqwest::Client::builder()
64 .user_agent(concat!("comindly/", env!("CARGO_PKG_VERSION")))
65 .build()?;
66 let mut repos = match source.provider {
67 Provider::GitHub => match &source.repo {
68 Some(name) => {
69 let api = github_api(&source.host);
70 let url = format!("{api}/repos/{}/{name}", source.owner);
71 let one = one(&client, &url, auth, false, &source.host)
72 .await?
73 .with_context(|| {
74 format!("no repository {}/{name} on {}", source.owner, source.host)
75 })?;
76 github_repo(&one).into_iter().collect()
77 }
78 None => github(&client, source, auth).await?,
79 },
80 Provider::GitLab => {
81 let path = match &source.repo {
83 Some(name) => format!("{}/{name}", source.owner),
84 None => source.owner.clone(),
85 };
86 let url = format!(
87 "https://{}/api/v4/projects/{}",
88 source.host,
89 path.replace('/', "%2F")
90 );
91 let project = if path.contains('/') {
92 one(&client, &url, auth, true, &source.host).await?
93 } else {
94 None
95 };
96 match project {
97 Some(p) => gitlab_repo(&p).into_iter().collect(),
98 None if source.repo.is_some() => bail!("no repository {path} on {}", source.host),
99 None => gitlab(&client, source, auth).await?,
100 }
101 }
102 };
103 repos.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
104 Ok(repos)
105}
106
107async fn one(
109 client: &reqwest::Client,
110 url: &str,
111 auth: Option<&Auth>,
112 gitlab: bool,
113 host: &str,
114) -> Result<Option<serde_json::Value>> {
115 let mut req = client.get(url);
116 if let Some(a) = auth {
117 req = if gitlab {
118 req.header("PRIVATE-TOKEN", &a.token)
119 } else {
120 req.bearer_auth(&a.token)
121 };
122 }
123 let resp = req.send().await.with_context(|| format!("reach {host}"))?;
124 match resp.status().as_u16() {
125 404 => Ok(None),
126 401 | 403 => Err(NeedsToken {
127 host: host.to_string(),
128 username: crate::worktree::token_user(host).into(),
129 }
130 .into()),
131 s if !resp.status().is_success() => bail!("{host} answered {s}"),
132 _ => Ok(Some(resp.json().await?)),
133 }
134}
135
136fn github_api(host: &str) -> String {
137 if host == "github.com" {
138 "https://api.github.com".to_string()
139 } else {
140 format!("https://{host}/api/v3")
141 }
142}
143
144async fn pages(
146 client: &reqwest::Client,
147 url: &str,
148 auth: Option<&Auth>,
149 gitlab: bool,
150 host: &str,
151) -> Result<Option<Vec<serde_json::Value>>> {
152 let mut out = Vec::new();
153 for page in 1..=50 {
154 let sep = if url.contains('?') { '&' } else { '?' };
155 let mut req = client.get(format!("{url}{sep}per_page=100&page={page}"));
156 if let Some(a) = auth {
157 req = if gitlab {
158 req.header("PRIVATE-TOKEN", &a.token)
159 } else {
160 req.bearer_auth(&a.token)
161 };
162 }
163 let resp = req.send().await.with_context(|| format!("reach {host}"))?;
164 let status = resp.status().as_u16();
165 if status == 404 && page == 1 {
166 return Ok(None);
167 }
168 if status == 401 || status == 403 {
169 return Err(NeedsToken {
170 host: host.to_string(),
171 username: crate::worktree::token_user(host).into(),
172 }
173 .into());
174 }
175 if !resp.status().is_success() {
176 bail!("{host} answered {status}");
177 }
178 let items: Vec<serde_json::Value> = resp.json().await?;
179 let done = items.len() < 100;
180 out.extend(items);
181 if done {
182 break;
183 }
184 }
185 Ok(Some(out))
186}
187
188async fn github(
189 client: &reqwest::Client,
190 s: &Source,
191 auth: Option<&Auth>,
192) -> Result<Vec<RemoteRepo>> {
193 let api = github_api(&s.host);
194 let org = format!("{api}/orgs/{}/repos?type=all", s.owner);
195 let items = match pages(client, &org, auth, false, &s.host).await? {
196 Some(items) => items,
197 None => {
198 let user = format!("{api}/users/{}/repos?type=owner", s.owner);
199 pages(client, &user, auth, false, &s.host)
200 .await?
201 .with_context(|| format!("no organisation or user {} on {}", s.owner, s.host))?
202 }
203 };
204 Ok(items.iter().filter_map(github_repo).collect())
205}
206
207fn github_repo(v: &serde_json::Value) -> Option<RemoteRepo> {
208 #[derive(Deserialize)]
209 struct R {
210 name: String,
211 full_name: String,
212 clone_url: String,
213 description: Option<String>,
214 default_branch: Option<String>,
215 #[serde(default)]
216 archived: bool,
217 #[serde(default)]
218 fork: bool,
219 pushed_at: Option<String>,
220 size: Option<u64>,
221 }
222 let r: R = serde_json::from_value(v.clone()).ok()?;
223 Some(RemoteRepo {
224 name: r.name,
225 full_path: r.full_name,
226 clone_url: r.clone_url,
227 description: r.description.filter(|d| !d.is_empty()),
228 default_branch: r.default_branch,
229 archived: r.archived,
230 fork: r.fork,
231 last_activity: r.pushed_at,
232 size_kib: r.size,
233 })
234}
235
236async fn gitlab(
237 client: &reqwest::Client,
238 s: &Source,
239 auth: Option<&Auth>,
240) -> Result<Vec<RemoteRepo>> {
241 let id = s.owner.replace('/', "%2F");
242 let group = format!(
243 "https://{}/api/v4/groups/{id}/projects?include_subgroups=true&order_by=last_activity_at",
244 s.host
245 );
246 let items = match pages(client, &group, auth, true, &s.host).await? {
247 Some(items) => items,
248 None => {
249 let user = format!("https://{}/api/v4/users/{id}/projects", s.host);
250 pages(client, &user, auth, true, &s.host)
251 .await?
252 .with_context(|| format!("no group or user {} on {}", s.owner, s.host))?
253 }
254 };
255 Ok(items.iter().filter_map(gitlab_repo).collect())
256}
257
258fn gitlab_repo(v: &serde_json::Value) -> Option<RemoteRepo> {
259 #[derive(Deserialize)]
260 struct R {
261 path: String,
262 path_with_namespace: String,
263 http_url_to_repo: String,
264 description: Option<String>,
265 default_branch: Option<String>,
266 #[serde(default)]
267 archived: bool,
268 forked_from_project: Option<serde_json::Value>,
269 last_activity_at: Option<String>,
270 }
271 let r: R = serde_json::from_value(v.clone()).ok()?;
272 Some(RemoteRepo {
273 name: r.path,
274 full_path: r.path_with_namespace,
275 clone_url: r.http_url_to_repo,
276 description: r.description.filter(|d| !d.is_empty()),
277 default_branch: r.default_branch,
278 archived: r.archived,
279 fork: r.forked_from_project.is_some(),
280 last_activity: r.last_activity_at,
281 size_kib: None,
282 })
283}
284
285pub fn managed_path(data_dir: &std::path::Path, clone_url: &str) -> Result<std::path::PathBuf> {
287 let rest = clone_url
288 .trim_start_matches("https://")
289 .trim_start_matches("http://")
290 .trim_start_matches("ssh://")
291 .trim_start_matches("git@");
292 let rest = rest.split_once('@').map_or(rest, |(_, r)| r);
293 let (host, path) = rest
294 .split_once(['/', ':'])
295 .context("cannot tell the host from the URL")?;
296 let path = path.trim_end_matches(".git").trim_matches('/');
297 anyhow::ensure!(
298 !host.is_empty() && !path.is_empty() && !path.contains(".."),
299 "cannot tell the repository from {clone_url}"
300 );
301 Ok(data_dir
302 .join("repos")
303 .join(host.split(':').next().unwrap_or(host))
304 .join(path))
305}
306
307pub fn file_link(remote: &str, commit: &str, path: &str, line: usize) -> Option<String> {
309 let (provider, base) = web_base(remote)?;
310 let path: String = path
311 .chars()
312 .map(|c| match c {
313 ' ' => "%20".to_string(),
314 '#' => "%23".to_string(),
315 '?' => "%3F".to_string(),
316 c => c.to_string(),
317 })
318 .collect();
319 Some(match provider {
320 Provider::GitLab => format!("{base}/-/blob/{commit}/{path}#L{line}"),
321 Provider::GitHub => format!("{base}/blob/{commit}/{path}#L{line}"),
322 })
323}
324
325pub fn commit_link(remote: &str, commit: &str) -> Option<String> {
327 let (provider, base) = web_base(remote)?;
328 Some(match provider {
329 Provider::GitLab => format!("{base}/-/commit/{commit}"),
330 Provider::GitHub => format!("{base}/commit/{commit}"),
331 })
332}
333
334fn web_base(remote: &str) -> Option<(Provider, String)> {
336 let s = parse(&crate::storage::without_userinfo(remote)).ok()?;
337 let path = match (&s.repo, s.provider) {
338 (Some(r), _) => format!("{}/{r}", s.owner),
339 (None, Provider::GitLab) => s.owner.clone(),
340 (None, Provider::GitHub) => return None,
341 };
342 Some((s.provider, format!("https://{}/{path}", s.host)))
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 const DATA: &str = "/data/comindly";
350
351 #[test]
352 fn reads_org_and_group_urls() {
353 let s = parse("https://github.com/acme").unwrap();
354 assert_eq!(
355 (s.provider, s.host.as_str(), s.owner.as_str()),
356 (Provider::GitHub, "github.com", "acme")
357 );
358 assert_eq!(
359 parse("github.com/orgs/acme/repositories").unwrap().owner,
360 "acme"
361 );
362 let g = parse("https://gitlab.com/acme/platform/-/tree/main").unwrap();
363 assert_eq!(
364 (g.provider, g.owner.as_str()),
365 (Provider::GitLab, "acme/platform")
366 );
367 let own = parse("git.example.com/team/").unwrap();
368 assert_eq!(
369 (own.provider, own.host.as_str(), own.owner.as_str()),
370 (Provider::GitLab, "git.example.com", "team")
371 );
372 assert!(parse("github.com").is_err());
373 let r = parse("https://github.com/acme/orders").unwrap();
374 assert_eq!(
375 (r.owner.as_str(), r.repo.as_deref()),
376 ("acme", Some("orders"))
377 );
378 let ssh = parse("git@gitlab.com:acme/platform/payments.git").unwrap();
379 assert_eq!(
380 (ssh.host.as_str(), ssh.owner.as_str(), ssh.repo.as_deref()),
381 ("gitlab.com", "acme/platform", Some("payments"))
382 );
383 assert_eq!(parse("gitlab.com/acme/platform").unwrap().repo, None);
384 }
385
386 #[test]
387 fn reads_api_answers() {
388 let gh = serde_json::json!({"name": "orders", "full_name": "acme/orders", "clone_url": "https://github.com/acme/orders.git",
389 "description": "", "default_branch": "trunk", "archived": false, "fork": false, "pushed_at": "2026-10-01T00:00:00Z", "size": 2048});
390 let r = github_repo(&gh).unwrap();
391 assert_eq!(
392 (
393 r.full_path.as_str(),
394 r.default_branch.as_deref(),
395 r.description
396 ),
397 ("acme/orders", Some("trunk"), None)
398 );
399 let gl = serde_json::json!({"path": "payments", "path_with_namespace": "acme/platform/payments",
400 "http_url_to_repo": "https://gitlab.com/acme/platform/payments.git", "description": "Charges", "default_branch": "master",
401 "archived": true, "last_activity_at": "2026-09-01T00:00:00Z"});
402 let r = gitlab_repo(&gl).unwrap();
403 assert_eq!(
404 (r.name.as_str(), r.archived, r.fork),
405 ("payments", true, false)
406 );
407 }
408
409 #[test]
410 fn links_to_files_and_commits_without_credentials() {
411 assert_eq!(
412 file_link(
413 "https://oauth2:secret@gitlab.com/acme/team/orders.git",
414 "abc",
415 "src/a b.py",
416 7
417 )
418 .unwrap(),
419 "https://gitlab.com/acme/team/orders/-/blob/abc/src/a%20b.py#L7"
420 );
421 assert_eq!(
422 commit_link("git@github.com:acme/orders.git", "abc").unwrap(),
423 "https://github.com/acme/orders/commit/abc"
424 );
425 assert_eq!(
426 commit_link("https://git.example.com/acme/orders", "abc").unwrap(),
427 "https://git.example.com/acme/orders/-/commit/abc"
428 );
429 assert_eq!(commit_link("https://github.com/acme", "abc"), None);
430 }
431
432 #[test]
433 fn managed_paths_follow_host_and_group() {
434 let p = managed_path(
435 std::path::Path::new(DATA),
436 "https://gitlab.com/acme/platform/payments.git",
437 )
438 .unwrap();
439 assert!(
440 p.ends_with("repos/gitlab.com/acme/platform/payments"),
441 "{}",
442 p.display()
443 );
444 let s = managed_path(std::path::Path::new(DATA), "git@github.com:acme/orders.git").unwrap();
445 assert!(
446 s.ends_with("repos/github.com/acme/orders"),
447 "{}",
448 s.display()
449 );
450 assert!(managed_path(std::path::Path::new(DATA), "https://github.com/../etc").is_err());
451 }
452}