From 915a63a955b77d84ee502a3a1c660dae913b2909 Mon Sep 17 00:00:00 2001 From: Azalea Date: Sun, 10 May 2026 01:07:47 +0000 Subject: [PATCH] [F] Fix gitlab project listing --- src/provider.rs | 81 ++++++++++++++++++++++++++++++++-- tests/unit/provider.rs | 98 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/provider.rs b/src/provider.rs index 4f6ab34..bc3d1a9 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -371,7 +371,19 @@ impl<'a> ProviderClient<'a> { self.site.api_base(), endpoint.namespace ); - self.paged_remote_repos::(&url) + let mut projects = self.paged_get::(&url)?; + let owned_url = format!( + "{}/projects?owned=true&simple=true&per_page=100", + self.site.api_base() + ); + for project in self.paged_get::(&owned_url)? { + if project.is_in_namespace(&endpoint.namespace) + && !projects.iter().any(|existing| existing.same_path(&project)) + { + projects.push(project); + } + } + Ok(projects.into_iter().map(Into::into).collect()) } NamespaceKind::Org | NamespaceKind::Group => { let encoded = urlencoding(&endpoint.namespace); @@ -411,8 +423,20 @@ impl<'a> ProviderClient<'a> { } let url = format!("{}/projects", self.site.api_base()); - self.post_json::(&url, &serde_json::Value::Object(body)) - .map(Into::into) + match self.post_json::(&url, &serde_json::Value::Object(body)) { + Ok(project) => Ok(project.into()), + Err(error) if is_already_taken_error(&error) => { + let existing_url = self.gitlab_project_url(endpoint, name); + self.get_json::(&existing_url) + .map(Into::into) + .with_context(|| { + format!( + "GitLab reported that {name} already exists, but failed to fetch {existing_url}: {error:#}" + ) + }) + } + Err(error) => Err(error), + } } fn gitlab_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> { @@ -982,6 +1006,16 @@ fn is_conflict_error(error: &anyhow::Error) -> bool { error.to_string().contains("409 Conflict") } +fn is_already_taken_error(error: &anyhow::Error) -> bool { + let text = error + .chain() + .map(ToString::to_string) + .collect::>() + .join("\n") + .to_ascii_lowercase(); + text.contains("has already been taken") +} + fn webhook_urls_match(left: &str, right: &str) -> bool { if left == right { return true; @@ -1066,11 +1100,52 @@ impl From for RemoteRepo { struct GitlabProject { name: String, path: Option, + path_with_namespace: Option, + namespace: Option, http_url_to_repo: String, visibility: String, description: Option, } +impl GitlabProject { + fn project_path(&self) -> &str { + self.path.as_deref().unwrap_or(&self.name) + } + + fn is_in_namespace(&self, namespace: &str) -> bool { + self.namespace + .as_ref() + .and_then(GitlabNamespace::full_path) + .is_some_and(|path| path.eq_ignore_ascii_case(namespace)) + || self + .path_with_namespace + .as_deref() + .and_then(|path| path.rsplit_once('/').map(|(namespace, _)| namespace)) + .is_some_and(|path| path.eq_ignore_ascii_case(namespace)) + } + + fn same_path(&self, other: &Self) -> bool { + match (&self.path_with_namespace, &other.path_with_namespace) { + (Some(left), Some(right)) => left.eq_ignore_ascii_case(right), + _ => self + .project_path() + .eq_ignore_ascii_case(other.project_path()), + } + } +} + +#[derive(Deserialize)] +struct GitlabNamespace { + path: Option, + full_path: Option, +} + +impl GitlabNamespace { + fn full_path(&self) -> Option<&str> { + self.full_path.as_deref().or(self.path.as_deref()) + } +} + impl From for RemoteRepo { fn from(value: GitlabProject) -> Self { Self { diff --git a/tests/unit/provider.rs b/tests/unit/provider.rs index 41d001d..29e82fa 100644 --- a/tests/unit/provider.rs +++ b/tests/unit/provider.rs @@ -184,6 +184,104 @@ fn detect_namespace_kind_uses_authenticated_gitea_api() { handle.join().unwrap(); } +#[test] +fn list_gitlab_user_repos_merges_authenticated_owned_projects() { + let owned_projects = r#"[ + {"name":"repo","path":"repo","path_with_namespace":"alice/repo","http_url_to_repo":"https://gitlab.example.test/alice/repo.git","visibility":"private","description":null,"namespace":{"path":"alice","full_path":"alice"}}, + {"name":"other","path":"other","path_with_namespace":"bob/other","http_url_to_repo":"https://gitlab.example.test/bob/other.git","visibility":"public","description":null,"namespace":{"path":"bob","full_path":"bob"}} + ]"#; + let (api_url, handle) = request_server( + vec![("200 OK", "[]"), ("200 OK", owned_projects)], + |index, request| match index { + 0 => assert!( + request + .starts_with("GET /users/alice/projects?simple=true&per_page=100&owned=true "), + "request was {request}" + ), + 1 => assert!( + request.starts_with("GET /projects?owned=true&simple=true&per_page=100 "), + "request was {request}" + ), + _ => unreachable!(), + }, + ); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Gitlab, None) + }; + + let repos = ProviderClient::new(&site) + .unwrap() + .list_repos(&EndpointConfig { + site: "gitlab".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }) + .unwrap(); + + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].name, "repo"); + assert!(repos[0].private); + handle.join().unwrap(); +} + +#[test] +fn create_gitlab_repo_returns_existing_repo_when_path_is_taken() { + let existing = r#"{"name":"repo","path":"repo","path_with_namespace":"alice/repo","http_url_to_repo":"https://gitlab.example.test/alice/repo.git","visibility":"public","description":"existing","namespace":{"path":"alice","full_path":"alice"}}"#; + let (api_url, handle) = request_server( + vec![ + ( + "400 Bad Request", + r#"{"message":{"name":["has already been taken"],"path":["has already been taken"]}}"#, + ), + ("200 OK", existing), + ], + |index, request| match index { + 0 => { + assert!( + request.starts_with("POST /projects "), + "request was {request}" + ); + assert!( + request.contains(r#""name":"repo""#), + "request was {request}" + ); + assert!( + request.contains(r#""path":"repo""#), + "request was {request}" + ); + } + 1 => assert!( + request.starts_with("GET /projects/alice%2Frepo "), + "request was {request}" + ), + _ => unreachable!(), + }, + ); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Gitlab, None) + }; + + let repo = ProviderClient::new(&site) + .unwrap() + .create_repo( + &EndpointConfig { + site: "gitlab".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + "repo", + &Visibility::Public, + Some("description"), + ) + .unwrap(); + + assert_eq!(repo.name, "repo"); + assert_eq!(repo.clone_url, "https://gitlab.example.test/alice/repo.git"); + handle.join().unwrap(); +} + #[test] fn install_webhook_posts_github_hook_when_missing() { let (api_url, handle) = request_server(