From f94a0f11b526ef11d7fdbe25e91569bdb4f20161 Mon Sep 17 00:00:00 2001 From: Azalea Date: Sat, 9 May 2026 22:24:13 +0000 Subject: [PATCH] [F] Fix gitea pagination --- src/provider.rs | 56 ++++++++++++++++--- tests/e2e/sequential.rs | 32 ++++++++++- tests/unit/provider.rs | 119 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 10 deletions(-) diff --git a/src/provider.rs b/src/provider.rs index 4bd10e5..62c40cf 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -544,16 +544,21 @@ impl<'a> ProviderClient<'a> { fn gitea_list_repos(&self, endpoint: &EndpointConfig) -> Result> { match endpoint.kind { NamespaceKind::User => { - let url = format!("{}/user/repos?limit=50", self.site.api_base()); - owned_repos!(self, GiteaRepo, &url, &endpoint.namespace) + let url = format!("{}/user/repos", self.site.api_base()); + Ok(self + .gitea_paged_get::(&url)? + .into_iter() + .filter(|repo| repo.owner.login.eq_ignore_ascii_case(&endpoint.namespace)) + .map(Into::into) + .collect()) } NamespaceKind::Org => { - let url = format!( - "{}/orgs/{}/repos?limit=50", - self.site.api_base(), - endpoint.namespace - ); - self.paged_remote_repos::(&url) + let url = format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace); + Ok(self + .gitea_paged_get::(&url)? + .into_iter() + .map(Into::into) + .collect()) } NamespaceKind::Group => bail!("Gitea/Forgejo endpoints use kind 'user' or 'org'"), } @@ -579,7 +584,14 @@ impl<'a> ProviderClient<'a> { "description": description.unwrap_or(""), "auto_init": false, }); - self.post_json::(&url, &body).map(Into::into) + match self.post_json::(&url, &body) { + Ok(repo) => Ok(repo.into()), + Err(error) if is_conflict_error(&error) => { + let repo_url = self.repo_url(endpoint, name, "Gitea/Forgejo")?; + self.get_json::(&repo_url).map(Into::into) + } + Err(error) => Err(error), + } } fn gitea_detect_namespace_kind(&self, namespace: &str) -> Result> { @@ -840,6 +852,28 @@ impl<'a> ProviderClient<'a> { .collect()) } + fn gitea_paged_get(&self, base_url: &str) -> Result> + where + T: for<'de> Deserialize<'de>, + { + const LIMIT: usize = 50; + let mut output = Vec::new(); + for page in 1.. { + let separator = if base_url.contains('?') { '&' } else { '?' }; + let url = format!("{base_url}{separator}page={page}&limit={LIMIT}"); + let mut items: Vec = self + .get(&url)? + .json() + .with_context(|| format!("invalid JSON from {url}"))?; + let count = items.len(); + output.append(&mut items); + if count < LIMIT { + break; + } + } + Ok(output) + } + fn get_json(&self, url: &str) -> Result where T: for<'de> Deserialize<'de>, @@ -943,6 +977,10 @@ fn is_not_found_error(error: &anyhow::Error) -> bool { error.to_string().contains("404 Not Found") } +fn is_conflict_error(error: &anyhow::Error) -> bool { + error.to_string().contains("409 Conflict") +} + fn next_link(headers: &HeaderMap) -> Option { let header = headers.get("link")?.to_str().ok()?; for part in header.split(',') { diff --git a/tests/e2e/sequential.rs b/tests/e2e/sequential.rs index 987fd7b..d23d10b 100644 --- a/tests/e2e/sequential.rs +++ b/tests/e2e/sequential.rs @@ -1079,10 +1079,25 @@ impl ProviderAccount { username: String, token: String, ) -> Self { + let mut base_url = trim_url(&base_url).to_string(); + let mut username = username; + if let Ok(url) = Url::parse(&username) { + if let Some(host) = url.host_str() { + let path = url.path().trim_matches('/'); + if !path.is_empty() { + let mut profile_base_url = format!("{}://{}", url.scheme(), host); + if let Some(port) = url.port() { + profile_base_url.push_str(&format!(":{port}")); + } + base_url = profile_base_url; + username = path.to_string(); + } + } + } Self { site_name: site_name.into(), kind, - base_url: trim_url(&base_url).to_string(), + base_url, username, token, http: Client::builder() @@ -1503,6 +1518,21 @@ impl ProviderAccount { } } +#[test] +fn provider_account_derives_base_url_from_profile_url_username() { + let account = ProviderAccount::new( + "gitea", + ProviderKind::Gitea, + "https://gitea.com".to_string(), + "https://gitea.aza.moe/refray-test".to_string(), + "secret".to_string(), + ); + + assert_eq!(account.base_url, "https://gitea.aza.moe"); + assert_eq!(account.username, "refray-test"); + assert_eq!(account.api_base(), "https://gitea.aza.moe/api/v1"); +} + #[derive(Clone, Copy)] enum ProviderKind { Github, diff --git a/tests/unit/provider.rs b/tests/unit/provider.rs index 98ead83..68a630e 100644 --- a/tests/unit/provider.rs +++ b/tests/unit/provider.rs @@ -354,6 +354,109 @@ fn delete_repo_deletes_gitea_repo() { handle.join().unwrap(); } +#[test] +fn list_gitea_repos_uses_page_and_limit_pagination() { + let mut first_page = Vec::new(); + for index in 0..50 { + first_page.push(format!( + r#"{{"name":"repo-{index}","clone_url":"https://gitea.example.test/alice/repo-{index}.git","private":false,"description":null,"owner":{{"login":"alice"}}}}"# + )); + } + let first_page = format!("[{}]", first_page.join(",")); + let second_page = r#"[ + {"name":"repo-50","clone_url":"https://gitea.example.test/alice/repo-50.git","private":false,"description":null,"owner":{"login":"Alice"}}, + {"name":"other-owner","clone_url":"https://gitea.example.test/bob/other-owner.git","private":false,"description":null,"owner":{"login":"bob"}} + ]"# + .to_string(); + let (api_url, handle) = request_server_owned( + vec![("200 OK", first_page), ("200 OK", second_page)], + |index, request| match index { + 0 => assert!( + request.starts_with("GET /user/repos?page=1&limit=50 "), + "request was {request}" + ), + 1 => assert!( + request.starts_with("GET /user/repos?page=2&limit=50 "), + "request was {request}" + ), + _ => unreachable!(), + }, + ); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Gitea, None) + }; + + let repos = ProviderClient::new(&site) + .unwrap() + .list_repos(&EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }) + .unwrap(); + + assert_eq!(repos.len(), 51); + assert!(repos.iter().any(|repo| repo.name == "repo-50")); + assert!(!repos.iter().any(|repo| repo.name == "other-owner")); + handle.join().unwrap(); +} + +#[test] +fn create_gitea_repo_returns_existing_repo_on_conflict() { + let (api_url, handle) = request_server( + vec![ + ( + "409 Conflict", + r#"{"message":"The repository with the same name already exists."}"#, + ), + ( + "200 OK", + r#"{"name":"repo","clone_url":"https://gitea.example.test/alice/repo.git","private":false,"description":"existing","owner":{"login":"alice"}}"#, + ), + ], + |index, request| match index { + 0 => { + assert!( + request.starts_with("POST /user/repos "), + "request was {request}" + ); + assert!( + request.contains(r#""name":"repo""#), + "request was {request}" + ); + } + 1 => assert!( + request.starts_with("GET /repos/alice/repo "), + "request was {request}" + ), + _ => unreachable!(), + }, + ); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Gitea, None) + }; + + let repo = ProviderClient::new(&site) + .unwrap() + .create_repo( + &EndpointConfig { + site: "gitea".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://gitea.example.test/alice/repo.git"); + handle.join().unwrap(); +} + #[test] fn open_pull_request_posts_github_pull_when_missing() { let (api_url, handle) = request_server( @@ -508,6 +611,22 @@ where fn request_server( responses: Vec<(&'static str, &'static str)>, + assert_request: F, +) -> (String, thread::JoinHandle<()>) +where + F: FnMut(usize, &str) + Send + 'static, +{ + request_server_owned( + responses + .into_iter() + .map(|(status, body)| (status, body.to_string())) + .collect(), + assert_request, + ) +} + +fn request_server_owned( + responses: Vec<(&'static str, String)>, mut assert_request: F, ) -> (String, thread::JoinHandle<()>) where