diff --git a/README.md b/README.md index 28d8ba6..f49c8b3 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ refray webhook update https://new.example.com/webhook Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints. -Set `sync_visibility = "all"`, `"private"`, or `"public"` on a mirror group to choose which repository visibility is included in that group. `visibility` still controls the visibility used when `refray` creates a missing repository. +Set `sync_visibility = "all"`, `"private"`, or `"public"` on a mirror group to choose which repository visibility is included in that group. When `refray` creates a missing repository, it mirrors the visibility of the existing repository it is syncing from; `visibility` is only a fallback when no source visibility is available. Set `repo_whitelist = ["..."]` and/or `repo_blacklist = ["..."]` on a mirror group to filter repository names with regular expressions. An empty whitelist includes all repository names, and blacklist matches are excluded after whitelist matches. These name filters are independent from `sync_visibility`; both must match for a repository to be synced. diff --git a/src/sync.rs b/src/sync.rs index 7930167..7feb5ba 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -10,7 +10,7 @@ use regex::Regex; use crate::config::{ Config, ConflictResolutionStrategy, DEFAULT_JOBS, EndpointConfig, MirrorConfig, NamespaceKind, - RepoNameFilter, SyncVisibility, default_work_dir, validate_config, + RepoNameFilter, SyncVisibility, Visibility, default_work_dir, validate_config, }; use crate::git::{ BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RemoteSpec, @@ -18,7 +18,7 @@ use crate::git::{ }; use crate::logging; use crate::provider::{ - EndpointRepo, ProviderClient, PullRequestRequest, list_mirror_repos, repos_by_name, + EndpointRepo, ProviderClient, PullRequestRequest, RemoteRepo, list_mirror_repos, repos_by_name, }; use crate::webhook; @@ -425,6 +425,7 @@ fn ensure_missing_repos( .filter(|endpoint| !present.contains(*endpoint)) .cloned() .collect::>(); + let create_visibility = visibility_for_created_repo(context.mirror, template.as_ref()); if !create_missing || context.dry_run { for endpoint in &missing { @@ -449,10 +450,7 @@ fn ensure_missing_repos( } let description = template.and_then(|repo| repo.description); - let expected_private = matches!( - &context.mirror.visibility, - crate::config::Visibility::Private - ); + let expected_private = matches!(create_visibility, Visibility::Private); let create_jobs = missing.into_iter().enumerate().collect::>(); let mut created = crate::parallel::map(create_jobs, context.jobs, |(index, endpoint)| { crate::logln!( @@ -468,7 +466,7 @@ fn ensure_missing_repos( .create_repo( &endpoint, repo_name, - &context.mirror.visibility, + &create_visibility, description.as_deref(), ) .with_context(|| format!("failed to create {} on {}", repo_name, endpoint.label()))?; @@ -494,6 +492,18 @@ fn ensure_missing_repos( Ok(()) } +fn visibility_for_created_repo(mirror: &MirrorConfig, template: Option<&RemoteRepo>) -> Visibility { + template + .map(|repo| { + if repo.private { + Visibility::Private + } else { + Visibility::Public + } + }) + .unwrap_or_else(|| mirror.visibility.clone()) +} + struct RepoSyncContext<'a> { config: &'a Config, mirror: &'a MirrorConfig, diff --git a/tests/e2e/sequential.rs b/tests/e2e/sequential.rs index 02f87ac..5499dfd 100644 --- a/tests/e2e/sequential.rs +++ b/tests/e2e/sequential.rs @@ -38,6 +38,8 @@ fn sequential_live_e2e_all_supported_features() -> Result<()> { run.creation_branch_tag_and_read_write_sync()?; eprintln!("e2e phase: dry-run/no-create"); run.dry_run_and_no_create_do_not_write()?; + eprintln!("e2e phase: visibility sync"); + run.repository_visibility_is_mirrored()?; eprintln!("e2e phase: retry failed"); run.failed_sync_can_retry_only_failed_repo()?; eprintln!("e2e phase: auto rebase"); @@ -427,6 +429,26 @@ namespace = "{}" Ok(()) } + fn repository_visibility_is_mirrored(&self) -> Result<()> { + let source = self.primary_provider(); + + let public_repo = self.repo_name("visibility-public"); + source.create_repo_with_visibility(&public_repo, false)?; + self.seed_main(source, &public_repo, "visibility public", 1_700_000_251)?; + self.set_mirror_visibility("private")?; + self.sync_repo(&public_repo, [])?; + self.assert_repo_visibility_all(&public_repo, false)?; + + let private_repo = self.repo_name("visibility-private"); + source.create_repo_with_visibility(&private_repo, true)?; + self.seed_main(source, &private_repo, "visibility private", 1_700_000_252)?; + self.set_mirror_visibility("public")?; + self.sync_repo(&private_repo, [])?; + self.assert_repo_visibility_all(&private_repo, true)?; + + Ok(()) + } + fn failed_sync_can_retry_only_failed_repo(&self) -> Result<()> { let repo = self.repo_name("retry"); let (source, peer) = self.provider_pair(); @@ -790,6 +812,33 @@ namespace = "{}" .with_context(|| format!("failed to write {}", self.config_path.display())) } + fn set_mirror_visibility(&self, visibility: &str) -> Result<()> { + let contents = fs::read_to_string(&self.config_path) + .with_context(|| format!("failed to read {}", self.config_path.display()))?; + let replacement = format!("visibility = \"{visibility}\""); + let mut replaced = false; + let mut updated = contents + .lines() + .map(|line| { + if line.starts_with("visibility = ") { + replaced = true; + replacement.clone() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + if contents.ends_with('\n') { + updated.push('\n'); + } + if !replaced { + bail!("config is missing mirror visibility"); + } + fs::write(&self.config_path, updated) + .with_context(|| format!("failed to write {}", self.config_path.display())) + } + fn set_webhook_url(&self, url: &str) -> Result<()> { let contents = fs::read_to_string(&self.config_path) .with_context(|| format!("failed to read {}", self.config_path.display()))?; @@ -963,6 +1012,21 @@ namespace = "{}" Ok(()) } + fn assert_repo_visibility_all(&self, repo: &str, private: bool) -> Result<()> { + retry("repo visibility", || { + for provider in &self.settings.providers { + let actual = provider.repo_private(repo)?; + if actual != private { + bail!( + "expected {repo} private={private} on {}, got private={actual}", + provider.site_name + ); + } + } + Ok(()) + }) + } + fn assert_branch_all_equal(&self, repo: &str, branch: &str) -> Result<()> { retry("branch convergence", || { let refs = self.refs_by_provider(repo)?; @@ -1274,6 +1338,29 @@ impl ProviderAccount { } } + fn repo_private(&self, repo: &str) -> Result { + let url = self.repo_api_url(repo); + let response = self.auth(self.http.get(url.clone())).send()?; + if !response.status().is_success() { + check_response("GET", &url, response)?; + unreachable!("check_response returns Err for unsuccessful responses") + } + let value: Value = response + .json() + .with_context(|| format!("invalid JSON from {url}"))?; + match self.kind { + ProviderKind::Github | ProviderKind::Gitea | ProviderKind::Forgejo => value + .get("private") + .and_then(Value::as_bool) + .ok_or_else(|| anyhow!("{} repo response missing private", self.site_name)), + ProviderKind::Gitlab => value + .get("visibility") + .and_then(Value::as_str) + .map(|visibility| visibility != "public") + .ok_or_else(|| anyhow!("{} repo response missing visibility", self.site_name)), + } + } + fn wait_repo_present(&self, repo: &str) -> Result<()> { retry("repo present", || { if self.repo_exists(repo)? { @@ -1329,15 +1416,23 @@ impl ProviderAccount { } fn create_repo(&self, name: &str) -> Result<()> { + self.create_repo_with_visibility(name, false) + } + + fn create_repo_with_visibility(&self, name: &str, private: bool) -> Result<()> { if self.repo_exists(name)? { self.wait_repo_listed(name)?; return Ok(()); } let body = match self.kind { - ProviderKind::Github => json!({ "name": name, "private": false, "auto_init": false }), - ProviderKind::Gitlab => json!({ "name": name, "path": name, "visibility": "public" }), + ProviderKind::Github => json!({ "name": name, "private": private, "auto_init": false }), + ProviderKind::Gitlab => json!({ + "name": name, + "path": name, + "visibility": if private { "private" } else { "public" }, + }), ProviderKind::Gitea | ProviderKind::Forgejo => { - json!({ "name": name, "private": false, "auto_init": false }) + json!({ "name": name, "private": private, "auto_init": false }) } }; let url = match self.kind { diff --git a/tests/unit/sync.rs b/tests/unit/sync.rs index f1aad56..e5f7f63 100644 --- a/tests/unit/sync.rs +++ b/tests/unit/sync.rs @@ -396,6 +396,50 @@ fn endpoint_remote_names_do_not_slug_collide() { ); } +#[test] +fn created_repo_visibility_follows_existing_public_repo() { + let mirror = test_mirror(); + let repo = crate::provider::RemoteRepo { + name: "repo".to_string(), + clone_url: "https://github.invalid/alice/repo.git".to_string(), + private: false, + description: None, + }; + + assert_eq!( + visibility_for_created_repo(&mirror, Some(&repo)), + crate::config::Visibility::Public + ); +} + +#[test] +fn created_repo_visibility_follows_existing_private_repo() { + let mut mirror = test_mirror(); + mirror.visibility = crate::config::Visibility::Public; + let repo = crate::provider::RemoteRepo { + name: "repo".to_string(), + clone_url: "https://github.invalid/alice/repo.git".to_string(), + private: true, + description: None, + }; + + assert_eq!( + visibility_for_created_repo(&mirror, Some(&repo)), + crate::config::Visibility::Private + ); +} + +#[test] +fn created_repo_visibility_falls_back_to_config_without_template() { + let mut mirror = test_mirror(); + mirror.visibility = crate::config::Visibility::Public; + + assert_eq!( + visibility_for_created_repo(&mirror, None), + crate::config::Visibility::Public + ); +} + fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState { RemoteRefState { hash: hash.to_string(),