use std::collections::HashMap; use anyhow::{Context, Result, anyhow, bail}; use console::style; use reqwest::blocking::{Client, Response}; use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT}; use serde::Deserialize; use serde_json::json; use url::Url; use crate::config::{ Config, EndpointConfig, MirrorConfig, NamespaceKind, ProviderKind, RepoNameFilter, SiteConfig, Visibility, }; #[derive(Clone, Debug)] pub struct RemoteRepo { pub name: String, pub clone_url: String, pub private: bool, pub description: Option, } #[derive(Clone, Debug)] pub struct EndpointRepo { pub endpoint: EndpointConfig, pub repo: RemoteRepo, } #[derive(Clone, Debug)] pub struct PullRequestRequest { pub title: String, pub body: String, pub head_branch: String, pub base_branch: String, } #[derive(Clone, Debug)] pub struct PullRequestInfo { pub url: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WebhookInstallOutcome { Created, Existing, } pub fn list_mirror_repos( config: &Config, mirror: &MirrorConfig, repo_filter: &RepoNameFilter, jobs: usize, ) -> Result> { let endpoint_jobs = mirror .endpoints .iter() .cloned() .enumerate() .collect::>(); let worker_count = jobs.min(endpoint_jobs.len()); if worker_count > 1 { crate::logln!( " {} listing repositories with {} workers", style("jobs").cyan().bold(), worker_count ); } let mut listed = crate::parallel::map(endpoint_jobs, jobs, |(index, endpoint)| { let site = config.site(&endpoint.site).unwrap(); let client = ProviderClient::new(site)?; crate::logln!( " {} {}", style("list").cyan().bold(), style(endpoint.label()).dim() ); let repos = client .list_repos(&endpoint) .with_context(|| format!("failed to list repos for {}", endpoint.label()))?; let repos = repos .into_iter() .filter(|repo| mirror.sync_visibility.matches_private(repo.private)) .filter(|repo| repo_filter.matches(&repo.name)) .map(|repo| EndpointRepo { endpoint: endpoint.clone(), repo, }) .collect::>(); Ok((index, repos)) })?; listed.sort_by_key(|(index, _)| *index); Ok(listed.into_iter().flat_map(|(_, repos)| repos).collect()) } pub struct ProviderClient<'a> { site: &'a SiteConfig, token: String, http: Client, } macro_rules! dispatch_provider { ($provider:expr, github => $github:expr, gitlab => $gitlab:expr, gitea_like => $gitea_like:expr $(,)?) => { match $provider { ProviderKind::Github => $github, ProviderKind::Gitlab => $gitlab, ProviderKind::Gitea | ProviderKind::Forgejo => $gitea_like, } }; } macro_rules! owned_repos { ($client:expr, $repo:ty, $url:expr, $namespace:expr) => {{ Ok($client .paged_get::<$repo>($url)? .into_iter() .filter(|repo: &$repo| repo.owner.login.eq_ignore_ascii_case($namespace)) .map(Into::into) .collect()) }}; } macro_rules! json_method { ($name:ident, $method:literal, $request:ident) => { fn $name(&self, url: &str, body: &serde_json::Value) -> Result where T: for<'de> Deserialize<'de>, { self.send_json($method, self.http.$request(url), url, body) } }; } impl<'a> ProviderClient<'a> { pub fn new(site: &'a SiteConfig) -> Result { let token = site.token()?; Ok(Self { site, token, http: Client::builder().build()?, }) } pub fn list_repos(&self, endpoint: &EndpointConfig) -> Result> { dispatch_provider!(self.site.provider, github => self.github_list_repos(endpoint), gitlab => self.gitlab_list_repos(endpoint), gitea_like => self.gitea_list_repos(endpoint), ) } pub fn create_repo( &self, endpoint: &EndpointConfig, name: &str, visibility: &Visibility, description: Option<&str>, ) -> Result { dispatch_provider!(self.site.provider, github => self.github_create_repo(endpoint, name, visibility, description), gitlab => self.gitlab_create_repo(endpoint, name, visibility, description), gitea_like => self.gitea_create_repo(endpoint, name, visibility, description), ) } pub fn delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> { dispatch_provider!(self.site.provider, github => self.github_delete_repo(endpoint, repo_name), gitlab => self.gitlab_delete_repo(endpoint, repo_name), gitea_like => self.gitea_delete_repo(endpoint, repo_name), ) } pub fn install_webhook( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, url: &str, secret: &str, ) -> Result { dispatch_provider!(self.site.provider, github => self.github_install_webhook(endpoint, repo, url, secret), gitlab => self.gitlab_install_webhook(endpoint, repo, url, secret), gitea_like => self.gitea_install_webhook(endpoint, repo, url, secret), ) } pub fn uninstall_webhook( &self, endpoint: &EndpointConfig, repo_name: &str, url: &str, ) -> Result { dispatch_provider!(self.site.provider, github => self.github_uninstall_webhook(endpoint, repo_name, url), gitlab => self.gitlab_uninstall_webhook(endpoint, repo_name, url), gitea_like => self.gitea_uninstall_webhook(endpoint, repo_name, url), ) } pub fn open_pull_request( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, request: &PullRequestRequest, ) -> Result { dispatch_provider!(self.site.provider, github => self.github_open_pull_request(endpoint, repo, request), gitlab => self.gitlab_open_pull_request(endpoint, repo, request), gitea_like => self.gitea_open_pull_request(endpoint, repo, request), ) } pub fn close_pull_requests_by_head_prefix( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, base_branch: &str, head_prefix: &str, ) -> Result { dispatch_provider!(self.site.provider, github => self.github_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix), gitlab => self.gitlab_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix), gitea_like => self.gitea_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix), ) } pub fn validate_token(&self) -> Result<()> { let url = format!("{}/user", self.site.api_base()); self.get(&url).map(|_| ()) } pub fn detect_namespace_kind(&self, namespace: &str) -> Result> { dispatch_provider!(self.site.provider, github => self.github_detect_namespace_kind(namespace), gitlab => self.gitlab_detect_namespace_kind(namespace), gitea_like => self.gitea_detect_namespace_kind(namespace), ) } pub fn authenticated_clone_url(&self, clone_url: &str) -> Result { let mut url = Url::parse(clone_url) .or_else(|_| Url::parse(&format!("{}/{}", self.site.base_url, clone_url))) .with_context(|| format!("failed to parse clone URL '{clone_url}'"))?; if url.scheme() != "https" && url.scheme() != "http" { bail!("only HTTP(S) clone URLs are supported, got '{clone_url}'"); } let username = self .site .git_username .clone() .unwrap_or_else(|| default_git_username(&self.site.provider).to_string()); url.set_username(&username) .map_err(|_| anyhow!("failed to set username on clone URL"))?; url.set_password(Some(&self.token)) .map_err(|_| anyhow!("failed to set token on clone URL"))?; Ok(url.to_string()) } fn github_list_repos(&self, endpoint: &EndpointConfig) -> Result> { match endpoint.kind { NamespaceKind::User => { let url = format!( "{}/user/repos?affiliation=owner&visibility=all&per_page=100", self.site.api_base() ); owned_repos!(self, GithubRepo, &url, &endpoint.namespace) } NamespaceKind::Org => { let url = format!( "{}/orgs/{}/repos?type=all&per_page=100", self.site.api_base(), endpoint.namespace ); self.paged_remote_repos::(&url) } NamespaceKind::Group => bail!("GitHub endpoints use kind 'user' or 'org'"), } } fn github_create_repo( &self, endpoint: &EndpointConfig, name: &str, visibility: &Visibility, description: Option<&str>, ) -> Result { let url = match endpoint.kind { NamespaceKind::User => format!("{}/user/repos", self.site.api_base()), NamespaceKind::Org => { format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace) } NamespaceKind::Group => bail!("GitHub endpoints use kind 'user' or 'org'"), }; let body = json!({ "name": name, "private": matches!(visibility, Visibility::Private), "description": description.unwrap_or(""), }); self.post_json::(&url, &body).map(Into::into) } fn github_detect_namespace_kind(&self, namespace: &str) -> Result> { let url = format!("{}/users/{namespace}", self.site.api_base()); let value: serde_json::Value = self.get_json(&url)?; Ok(match value.get("type").and_then(|value| value.as_str()) { Some("Organization") => Some(NamespaceKind::Org), Some("User") => Some(NamespaceKind::User), _ => None, }) } fn github_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> { let url = self.repo_url(endpoint, repo_name, "GitHub")?; self.delete(&url).map(|_| ()) } fn github_install_webhook( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, url: &str, secret: &str, ) -> Result { let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "GitHub")?; let body = json!({ "name": "web", "active": true, "events": ["push"], "config": { "url": url, "content_type": "json", "secret": secret, "insecure_ssl": "0", }, }); self.upsert_hook(&hooks_url, url, &body, false) } fn github_uninstall_webhook( &self, endpoint: &EndpointConfig, repo_name: &str, url: &str, ) -> Result { let hooks_url = self.repo_hooks_url(endpoint, repo_name, "GitHub")?; self.delete_matching_hook(&hooks_url, url) } fn github_open_pull_request( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, request: &PullRequestRequest, ) -> Result { let url = self.repo_pulls_url(endpoint, &repo.name, "GitHub")?; if let Some(existing) = self.github_find_open_pull_request(&url, &request.head_branch, &request.base_branch)? { return Ok(PullRequestInfo { url: existing.url(), }); } let body = json!({ "title": request.title, "body": request.body, "head": request.head_branch, "base": request.base_branch, }); let created: ProviderPullRequest = self.post_json(&url, &body)?; Ok(PullRequestInfo { url: created.url() }) } fn github_find_open_pull_request( &self, pulls_url: &str, head_branch: &str, base_branch: &str, ) -> Result> { let url = format!( "{pulls_url}?state=open&base={}&per_page=100", urlencoding(base_branch) ); let pulls: Vec = self.paged_get(&url)?; Ok(pulls .into_iter() .find(|pull| pull.head_ref() == Some(head_branch))) } fn github_close_pull_requests_by_head_prefix( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, base_branch: &str, head_prefix: &str, ) -> Result { let pulls_url = self.repo_pulls_url(endpoint, &repo.name, "GitHub")?; let url = format!( "{pulls_url}?state=open&base={}&per_page=100", urlencoding(base_branch) ); let pulls: Vec = match self.paged_get(&url) { Ok(pulls) => pulls, Err(error) if is_not_found_error(&error) => return Ok(0), Err(error) => return Err(error), }; let mut closed = 0; for pull in pulls.into_iter().filter(|pull| { pull.head_ref() .is_some_and(|head| head.starts_with(head_prefix)) }) { let Some(number) = pull.number else { continue; }; let update_url = format!("{pulls_url}/{number}"); self.patch_json::(&update_url, &json!({ "state": "closed" }))?; closed += 1; } Ok(closed) } fn gitlab_list_repos(&self, endpoint: &EndpointConfig) -> Result> { match endpoint.kind { NamespaceKind::User => { let url = format!( "{}/users/{}/projects?simple=true&per_page=100&owned=true", self.site.api_base(), endpoint.namespace ); 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); let url = format!( "{}/groups/{}/projects?simple=true&include_subgroups=false&per_page=100", self.site.api_base(), encoded ); self.paged_remote_repos::(&url) } } } fn gitlab_create_repo( &self, endpoint: &EndpointConfig, name: &str, visibility: &Visibility, description: Option<&str>, ) -> Result { let mut body = serde_json::Map::from_iter([ ("name".to_string(), json!(name)), ("path".to_string(), json!(name)), ( "visibility".to_string(), json!(match visibility { Visibility::Private => "private", Visibility::Public => "public", }), ), ("description".to_string(), json!(description.unwrap_or(""))), ]); if matches!(endpoint.kind, NamespaceKind::Org | NamespaceKind::Group) { let group = self.gitlab_group(&endpoint.namespace)?; body.insert("namespace_id".to_string(), json!(group.id)); } let url = format!("{}/projects", self.site.api_base()); 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<()> { let url = self.gitlab_project_url(endpoint, repo_name); self.delete(&url).map(|_| ()) } fn gitlab_group(&self, namespace: &str) -> Result { let url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace)); self.get_json(&url) } fn gitlab_detect_namespace_kind(&self, namespace: &str) -> Result> { let group_url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace)); if self.get(&group_url).is_ok() { return Ok(Some(NamespaceKind::Group)); } let username = namespace.rsplit('/').next().unwrap_or(namespace); let user_url = format!( "{}/users?username={}", self.site.api_base(), urlencoding(username) ); let users: serde_json::Value = self.get_json(&user_url)?; Ok(users .as_array() .is_some_and(|items| !items.is_empty()) .then_some(NamespaceKind::User)) } fn gitlab_install_webhook( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, url: &str, secret: &str, ) -> Result { let hooks_url = self.gitlab_hooks_url(endpoint, &repo.name); let body = json!({ "url": url, "push_events": true, "tag_push_events": true, "token": secret, "enable_ssl_verification": true, }); self.upsert_hook(&hooks_url, url, &body, true) } fn gitlab_uninstall_webhook( &self, endpoint: &EndpointConfig, repo_name: &str, url: &str, ) -> Result { let hooks_url = self.gitlab_hooks_url(endpoint, repo_name); self.delete_matching_hook(&hooks_url, url) } fn gitlab_open_pull_request( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, request: &PullRequestRequest, ) -> Result { let url = self.gitlab_merge_requests_url(endpoint, &repo.name); if let Some(existing) = self.gitlab_find_open_merge_request(&url, &request.head_branch, &request.base_branch)? { return Ok(PullRequestInfo { url: existing.url(), }); } let body = json!({ "title": request.title, "description": request.body, "source_branch": request.head_branch, "target_branch": request.base_branch, }); let created: ProviderPullRequest = self.post_json(&url, &body)?; Ok(PullRequestInfo { url: created.url() }) } fn gitlab_find_open_merge_request( &self, merge_requests_url: &str, source_branch: &str, target_branch: &str, ) -> Result> { let url = format!( "{merge_requests_url}?state=opened&source_branch={}&target_branch={}&per_page=100", urlencoding(source_branch), urlencoding(target_branch) ); let pulls: Vec = self.paged_get(&url)?; Ok(pulls .into_iter() .find(|pull| pull.head_ref() == Some(source_branch))) } fn gitlab_close_pull_requests_by_head_prefix( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, base_branch: &str, head_prefix: &str, ) -> Result { let merge_requests_url = self.gitlab_merge_requests_url(endpoint, &repo.name); let url = format!( "{merge_requests_url}?state=opened&target_branch={}&per_page=100", urlencoding(base_branch) ); let pulls: Vec = self.paged_get(&url)?; let mut closed = 0; for pull in pulls.into_iter().filter(|pull| { pull.head_ref() .is_some_and(|head| head.starts_with(head_prefix)) }) { let Some(number) = pull.iid.or(pull.number) else { continue; }; let update_url = format!("{merge_requests_url}/{number}"); self.put_json::(&update_url, &json!({ "state_event": "close" }))?; closed += 1; } Ok(closed) } fn gitea_list_repos(&self, endpoint: &EndpointConfig) -> Result> { match endpoint.kind { NamespaceKind::User => { 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", 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'"), } } fn gitea_create_repo( &self, endpoint: &EndpointConfig, name: &str, visibility: &Visibility, description: Option<&str>, ) -> Result { let url = match endpoint.kind { NamespaceKind::User => format!("{}/user/repos", self.site.api_base()), NamespaceKind::Org => { format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace) } NamespaceKind::Group => bail!("Gitea/Forgejo endpoints use kind 'user' or 'org'"), }; let body = json!({ "name": name, "private": matches!(visibility, Visibility::Private), "description": description.unwrap_or(""), "auto_init": false, }); 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> { let org_url = format!("{}/orgs/{namespace}", self.site.api_base()); if self.get(&org_url).is_ok() { return Ok(Some(NamespaceKind::Org)); } let user_url = format!("{}/users/{namespace}", self.site.api_base()); if self.get(&user_url).is_ok() { return Ok(Some(NamespaceKind::User)); } Ok(None) } fn gitea_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> { let url = self.repo_url(endpoint, repo_name, "Gitea/Forgejo")?; self.delete(&url).map(|_| ()) } fn gitea_install_webhook( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, url: &str, secret: &str, ) -> Result { let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "Gitea/Forgejo")?; let body = json!({ "type": "gitea", "active": true, "events": ["push"], "config": { "url": url, "content_type": "json", "secret": secret, }, }); self.upsert_hook(&hooks_url, url, &body, false) } fn gitea_uninstall_webhook( &self, endpoint: &EndpointConfig, repo_name: &str, url: &str, ) -> Result { let hooks_url = self.repo_hooks_url(endpoint, repo_name, "Gitea/Forgejo")?; self.delete_matching_hook(&hooks_url, url) } fn gitea_open_pull_request( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, request: &PullRequestRequest, ) -> Result { let url = self.repo_pulls_url(endpoint, &repo.name, "Gitea/Forgejo")?; if let Some(existing) = self.gitea_find_open_pull_request(&url, &request.head_branch, &request.base_branch)? { return Ok(PullRequestInfo { url: existing.url(), }); } let body = json!({ "title": request.title, "body": request.body, "head": request.head_branch, "base": request.base_branch, }); let created: ProviderPullRequest = self.post_json(&url, &body)?; Ok(PullRequestInfo { url: created.url() }) } fn gitea_find_open_pull_request( &self, pulls_url: &str, head_branch: &str, base_branch: &str, ) -> Result> { let url = format!( "{pulls_url}?state=open&base={}&head={}&limit=50", urlencoding(base_branch), urlencoding(head_branch) ); let pulls: Vec = self.paged_get(&url)?; Ok(pulls .into_iter() .find(|pull| pull.head_ref() == Some(head_branch))) } fn gitea_close_pull_requests_by_head_prefix( &self, endpoint: &EndpointConfig, repo: &RemoteRepo, base_branch: &str, head_prefix: &str, ) -> Result { let pulls_url = self.repo_pulls_url(endpoint, &repo.name, "Gitea/Forgejo")?; let url = format!( "{pulls_url}?state=open&base={}&limit=50", urlencoding(base_branch) ); let pulls: Vec = match self.paged_get(&url) { Ok(pulls) => pulls, Err(error) if is_not_found_error(&error) => return Ok(0), Err(error) => return Err(error), }; let mut closed = 0; for pull in pulls.into_iter().filter(|pull| { pull.head_ref() .is_some_and(|head| head.starts_with(head_prefix)) }) { let Some(number) = pull.number.or(pull.index) else { continue; }; let update_url = format!("{pulls_url}/{number}"); self.patch_json::(&update_url, &json!({ "state": "closed" }))?; closed += 1; } Ok(closed) } fn repo_url( &self, endpoint: &EndpointConfig, repo_name: &str, provider: &str, ) -> Result { if matches!(endpoint.kind, NamespaceKind::Group) { bail!("{provider} endpoints use kind 'user' or 'org'"); } Ok(format!( "{}/repos/{}/{repo_name}", self.site.api_base(), endpoint.namespace )) } fn repo_hooks_url( &self, endpoint: &EndpointConfig, repo_name: &str, provider: &str, ) -> Result { if matches!(endpoint.kind, NamespaceKind::Group) { bail!("{provider} endpoints use kind 'user' or 'org'"); } Ok(format!( "{}/repos/{}/{repo_name}/hooks", self.site.api_base(), endpoint.namespace )) } fn repo_pulls_url( &self, endpoint: &EndpointConfig, repo_name: &str, provider: &str, ) -> Result { if matches!(endpoint.kind, NamespaceKind::Group) { bail!("{provider} endpoints use kind 'user' or 'org'"); } Ok(format!( "{}/repos/{}/{repo_name}/pulls", self.site.api_base(), endpoint.namespace )) } fn gitlab_hooks_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String { format!("{}/hooks", self.gitlab_project_url(endpoint, repo_name)) } fn gitlab_merge_requests_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String { format!( "{}/merge_requests", self.gitlab_project_url(endpoint, repo_name) ) } fn gitlab_project_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String { let project = format!("{}/{repo_name}", endpoint.namespace); format!( "{}/projects/{}", self.site.api_base(), urlencoding(&project) ) } fn find_existing_hook(&self, hooks_url: &str, target_url: &str) -> Result> { let hooks: Vec = self.paged_get(hooks_url)?; Ok(hooks.into_iter().find(|hook| { hook.url() .is_some_and(|hook_url| webhook_urls_match(hook_url, target_url)) })) } fn upsert_hook( &self, hooks_url: &str, target_url: &str, body: &serde_json::Value, put_on_update: bool, ) -> Result { let Some(hook) = self.find_existing_hook(hooks_url, target_url)? else { self.post_json::(hooks_url, body)?; return Ok(WebhookInstallOutcome::Created); }; let update_url = format!("{hooks_url}/{}", hook.id); if put_on_update { self.put_json::(&update_url, body)?; } else { self.patch_json::(&update_url, body)?; } Ok(WebhookInstallOutcome::Existing) } fn delete_matching_hook(&self, hooks_url: &str, target_url: &str) -> Result { let Some(hook) = self.find_existing_hook(hooks_url, target_url)? else { return Ok(false); }; let delete_url = format!("{hooks_url}/{}", hook.id); self.delete(&delete_url)?; Ok(true) } fn paged_get(&self, first_url: &str) -> Result> where T: for<'de> Deserialize<'de>, { let mut output = Vec::new(); let mut next_url = Some(first_url.to_string()); while let Some(url) = next_url.take() { let response = self.get(&url)?; next_url = next_link(response.headers()); let mut page: Vec = response .json() .with_context(|| format!("invalid JSON from {url}"))?; output.append(&mut page); } Ok(output) } fn paged_remote_repos(&self, url: &str) -> Result> where T: for<'de> Deserialize<'de> + Into, { Ok(self .paged_get::(url)? .into_iter() .map(Into::into) .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>, { self.get(url)? .json() .with_context(|| format!("invalid JSON from {url}")) } json_method!(post_json, "POST", post); json_method!(put_json, "PUT", put); json_method!(patch_json, "PATCH", patch); fn send_json( &self, method: &str, request: reqwest::blocking::RequestBuilder, url: &str, body: &serde_json::Value, ) -> Result where T: for<'de> Deserialize<'de>, { self.request_headers(request)? .json(body) .send() .with_context(|| format!("{method} {url} failed")) .and_then(|response| check_response(method, url, response))? .json() .with_context(|| format!("invalid JSON from {url}")) } fn get(&self, url: &str) -> Result { self.send("GET", self.http.get(url), url) } fn delete(&self, url: &str) -> Result { self.send("DELETE", self.http.delete(url), url) } fn send( &self, method: &str, request: reqwest::blocking::RequestBuilder, url: &str, ) -> Result { self.request_headers(request)? .send() .with_context(|| format!("{method} {url} failed")) .and_then(|response| check_response(method, url, response)) } fn request_headers( &self, request: reqwest::blocking::RequestBuilder, ) -> Result { let mut headers = HeaderMap::new(); headers.insert(USER_AGENT, HeaderValue::from_static("refray/0.1")); headers.insert(ACCEPT, HeaderValue::from_static("application/json")); match self.site.provider { ProviderKind::Github => { headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", self.token)) .context("PAT contains invalid header characters")?, ); headers.insert( "X-GitHub-Api-Version", HeaderValue::from_static("2022-11-28"), ); } ProviderKind::Gitlab => { headers.insert( "PRIVATE-TOKEN", HeaderValue::from_str(&self.token) .context("PAT contains invalid header characters")?, ); } ProviderKind::Gitea | ProviderKind::Forgejo => { headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("token {}", self.token)) .context("PAT contains invalid header characters")?, ); } } Ok(request.headers(headers)) } } fn check_response(method: &str, url: &str, response: Response) -> Result { if response.status().is_success() { return Ok(response); } let status = response.status(); let body = response.text().unwrap_or_default(); bail!("{method} {url} returned {status}: {body}"); } 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 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; } match (normalize_webhook_url(left), normalize_webhook_url(right)) { (Some(left), Some(right)) => left == right, _ => false, } } fn normalize_webhook_url(value: &str) -> Option { let url = Url::parse(value).ok()?; let scheme = url.scheme().to_ascii_lowercase(); if scheme != "http" && scheme != "https" { return None; } let host = url.host_str()?.to_ascii_lowercase(); let port = url.port_or_known_default()?; let username = url.username(); let password = url.password().unwrap_or_default(); let path = url.path().trim_end_matches('/'); let path = if path.is_empty() { "/" } else { path }; let query = url.query().unwrap_or_default(); Some(format!( "{scheme}://{username}:{password}@{host}:{port}{path}?{query}" )) } fn next_link(headers: &HeaderMap) -> Option { let header = headers.get("link")?.to_str().ok()?; for part in header.split(',') { let mut sections = part.trim().split(';'); let url = sections.next()?.trim(); let rel = sections.any(|section| section.trim() == "rel=\"next\""); if rel { return url .strip_prefix('<') .and_then(|value| value.strip_suffix('>')) .map(ToString::to_string); } } None } fn urlencoding(value: &str) -> String { url::form_urlencoded::byte_serialize(value.as_bytes()).collect() } fn default_git_username(provider: &ProviderKind) -> &'static str { match provider { ProviderKind::Github => "x-access-token", ProviderKind::Gitlab | ProviderKind::Gitea | ProviderKind::Forgejo => "oauth2", } } #[derive(Deserialize)] struct GithubRepo { name: String, clone_url: String, private: bool, description: Option, owner: GithubOwner, } #[derive(Deserialize)] struct GithubOwner { login: String, } impl From for RemoteRepo { fn from(value: GithubRepo) -> Self { Self { name: value.name, clone_url: value.clone_url, private: value.private, description: value.description, } } } #[derive(Deserialize)] 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 { name: value.path.unwrap_or(value.name), clone_url: value.http_url_to_repo, private: value.visibility != "public", description: value.description, } } } #[derive(Deserialize)] struct GitlabGroup { id: u64, } #[derive(Deserialize)] struct GiteaRepo { name: String, clone_url: String, private: bool, description: Option, owner: GiteaOwner, } #[derive(Deserialize)] struct GiteaOwner { login: String, } impl From for RemoteRepo { fn from(value: GiteaRepo) -> Self { Self { name: value.name, clone_url: value.clone_url, private: value.private, description: value.description, } } } #[derive(Deserialize)] struct RepoHook { id: u64, #[serde(default)] url: Option, #[serde(default)] config: HashMap, } #[derive(Deserialize)] struct ProviderPullRequest { #[serde(default)] number: Option, #[serde(default)] iid: Option, #[serde(default)] index: Option, #[serde(default)] html_url: Option, #[serde(default)] web_url: Option, #[serde(default)] head: Option, #[serde(default)] source_branch: Option, } impl ProviderPullRequest { fn url(&self) -> Option { self.html_url.clone().or_else(|| self.web_url.clone()) } fn head_ref(&self) -> Option<&str> { self.head .as_ref() .map(|head| head.reference.as_str()) .or(self.source_branch.as_deref()) } } #[derive(Deserialize)] struct PullRequestHead { #[serde(rename = "ref")] reference: String, } impl RepoHook { fn url(&self) -> Option<&str> { self.config .get("url") .map(String::as_str) .or(self.url.as_deref()) } } pub fn repos_by_name(repos: Vec) -> HashMap> { let mut output: HashMap> = HashMap::new(); for repo in repos { output.entry(repo.repo.name.clone()).or_default().push(repo); } output } #[cfg(test)] #[path = "../tests/unit/provider.rs"] mod tests;