[F] Visibility sync

This commit is contained in:
2026-05-10 01:36:06 +00:00
parent 6f11eeea30
commit d0aed91490
4 changed files with 160 additions and 11 deletions
+1 -1
View File
@@ -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.
+17 -7
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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,
+98 -3
View File
@@ -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::<Vec<_>>()
.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<bool> {
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 {
+44
View File
@@ -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(),