[F] Visibility sync
This commit is contained in:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user