[F] Name validation
This commit is contained in:
@@ -12,13 +12,15 @@ Created becasue github is so unusable and [unreliable](https://red-squares.cian.
|
|||||||
- **read-write mirrors**: Make changes from any provider, and the changes will sync to the others
|
- **read-write mirrors**: Make changes from any provider, and the changes will sync to the others
|
||||||
- **webhook support**: Sync right after push, reduce potential divergence window
|
- **webhook support**: Sync right after push, reduce potential divergence window
|
||||||
- **conflict handling**: Rebase or open pull requests when two platforms diverge
|
- **conflict handling**: Rebase or open pull requests when two platforms diverge
|
||||||
- **tracks deletions**: Delete branches/repos across platforms when they are deleted from one platform
|
- **tracks deletions**: Branches/repo deletions sync across platforms (with backup)
|
||||||
- **selective sync**: Sync subset of repos by regex white/black list, or by private/public visibility
|
- **selective sync**: Sync subset of repos by regex white/black list, or by private/public visibility
|
||||||
|
- **multithreaded**: Process multiple repos simultaneously!
|
||||||
|
|
||||||
Supported platforms: GitHub, GitLab, Gitea, Forgejo
|
Supported platforms: GitHub, GitLab, Gitea, Forgejo
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Meow
|
> My cat made this codebase, meow
|
||||||
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -177,6 +179,10 @@ To move installed hooks to a new public URL, use `webhook update`. It removes ho
|
|||||||
refray webhook update https://new.example.com/webhook
|
refray webhook update https://new.example.com/webhook
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Issues and Pull Requests
|
||||||
|
|
||||||
|
Issues and pull requests are not mirrored.
|
||||||
|
|
||||||
## Sync Semantics
|
## Sync Semantics
|
||||||
|
|
||||||
Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints.
|
Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints.
|
||||||
@@ -245,6 +251,3 @@ REFRAY_E2E_ALLOW_DESTRUCTIVE=1 \
|
|||||||
|
|
||||||
By default cleanup only deletes repositories named `refray-e2e-*`. To start by deleting every owned repository visible to the configured accounts, set `REFRAY_E2E_CLEAR_ALL_REPOS=DELETE_ALL_OWNED_REPOS`. Provider skips (`REFRAY_E2E_SKIP_GITHUB`, `REFRAY_E2E_SKIP_GITLAB`, `REFRAY_E2E_SKIP_GITEA`, `REFRAY_E2E_SKIP_FORGEJO`) and `REFRAY_E2E_ALLOW_PARTIAL=1` are available for local debugging, but the full support check should run with all four providers.
|
By default cleanup only deletes repositories named `refray-e2e-*`. To start by deleting every owned repository visible to the configured accounts, set `REFRAY_E2E_CLEAR_ALL_REPOS=DELETE_ALL_OWNED_REPOS`. Provider skips (`REFRAY_E2E_SKIP_GITHUB`, `REFRAY_E2E_SKIP_GITLAB`, `REFRAY_E2E_SKIP_GITEA`, `REFRAY_E2E_SKIP_FORGEJO`) and `REFRAY_E2E_ALLOW_PARTIAL=1` are available for local debugging, but the full support check should run with all four providers.
|
||||||
|
|
||||||
## Issues and Pull Requests
|
|
||||||
|
|
||||||
Issues and pull requests are not mirrored.
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 433 KiB |
+75
-13
@@ -11,7 +11,7 @@ use regex::Regex;
|
|||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
Config, ConflictResolutionStrategy, DEFAULT_JOBS, EndpointConfig, MirrorConfig, NamespaceKind,
|
Config, ConflictResolutionStrategy, DEFAULT_JOBS, EndpointConfig, MirrorConfig, NamespaceKind,
|
||||||
RepoNameFilter, SyncVisibility, Visibility, default_work_dir, validate_config,
|
ProviderKind, RepoNameFilter, SyncVisibility, Visibility, default_work_dir, validate_config,
|
||||||
};
|
};
|
||||||
use crate::git::{
|
use crate::git::{
|
||||||
BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RefBackup, RemoteSpec,
|
BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RefBackup, RemoteSpec,
|
||||||
@@ -530,7 +530,13 @@ fn ensure_missing_repos(
|
|||||||
let description = template.and_then(|repo| repo.description);
|
let description = template.and_then(|repo| repo.description);
|
||||||
let expected_private = matches!(create_visibility, Visibility::Private);
|
let expected_private = matches!(create_visibility, Visibility::Private);
|
||||||
let create_jobs = missing.into_iter().enumerate().collect::<Vec<_>>();
|
let create_jobs = missing.into_iter().enumerate().collect::<Vec<_>>();
|
||||||
let mut created = crate::parallel::map(create_jobs, context.jobs, |(index, endpoint)| {
|
let created = crate::parallel::map(create_jobs, context.jobs, |(index, endpoint)| {
|
||||||
|
let site = context.config.site(&endpoint.site).unwrap();
|
||||||
|
if site.provider == ProviderKind::Gitlab && !is_supported_gitlab_project_path(repo_name) {
|
||||||
|
log_invalid_gitlab_project_name_skip(repo_name, &endpoint);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
crate::logln!(
|
crate::logln!(
|
||||||
" {} {} {}",
|
" {} {} {}",
|
||||||
style("create").green().bold(),
|
style("create").green().bold(),
|
||||||
@@ -538,16 +544,27 @@ fn ensure_missing_repos(
|
|||||||
style(format!("on {}", endpoint.label())).dim()
|
style(format!("on {}", endpoint.label())).dim()
|
||||||
);
|
);
|
||||||
|
|
||||||
let site = context.config.site(&endpoint.site).unwrap();
|
|
||||||
let client = ProviderClient::new(site)?;
|
let client = ProviderClient::new(site)?;
|
||||||
let created = client
|
let created = match client.create_repo(
|
||||||
.create_repo(
|
&endpoint,
|
||||||
&endpoint,
|
repo_name,
|
||||||
repo_name,
|
&create_visibility,
|
||||||
&create_visibility,
|
description.as_deref(),
|
||||||
description.as_deref(),
|
) {
|
||||||
)
|
Ok(created) => created,
|
||||||
.with_context(|| format!("failed to create {} on {}", repo_name, endpoint.label()))?;
|
Err(error)
|
||||||
|
if site.provider == ProviderKind::Gitlab
|
||||||
|
&& is_gitlab_invalid_project_name_error(&error) =>
|
||||||
|
{
|
||||||
|
log_invalid_gitlab_project_name_skip(repo_name, &endpoint);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error).with_context(|| {
|
||||||
|
format!("failed to create {} on {}", repo_name, endpoint.label())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
if created.private != expected_private {
|
if created.private != expected_private {
|
||||||
crate::logln!(
|
crate::logln!(
|
||||||
" {} created {} on {}, but provider reported a different visibility than requested",
|
" {} created {} on {}, but provider reported a different visibility than requested",
|
||||||
@@ -556,14 +573,15 @@ fn ensure_missing_repos(
|
|||||||
style(endpoint.label()).dim()
|
style(endpoint.label()).dim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok((
|
Ok(Some((
|
||||||
index,
|
index,
|
||||||
EndpointRepo {
|
EndpointRepo {
|
||||||
endpoint,
|
endpoint,
|
||||||
repo: created,
|
repo: created,
|
||||||
},
|
},
|
||||||
))
|
)))
|
||||||
})?;
|
})?;
|
||||||
|
let mut created = created.into_iter().flatten().collect::<Vec<_>>();
|
||||||
created.sort_by_key(|(index, _)| *index);
|
created.sort_by_key(|(index, _)| *index);
|
||||||
let created = created
|
let created = created
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -586,6 +604,50 @@ fn visibility_for_created_repo(mirror: &MirrorConfig, template: Option<&RemoteRe
|
|||||||
.unwrap_or_else(|| mirror.visibility.clone())
|
.unwrap_or_else(|| mirror.visibility.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_supported_gitlab_project_path(name: &str) -> bool {
|
||||||
|
if name.is_empty()
|
||||||
|
|| matches!(name.chars().next(), Some('-' | '_' | '.'))
|
||||||
|
|| matches!(name.chars().last(), Some('-' | '_' | '.'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
if lower.ends_with(".git") || lower.ends_with(".atom") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
name.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_invalid_gitlab_project_name_skip(repo_name: &str, endpoint: &EndpointConfig) {
|
||||||
|
crate::logln!(
|
||||||
|
" {} {} {}",
|
||||||
|
style("skip").yellow().bold(),
|
||||||
|
style(repo_name).cyan(),
|
||||||
|
style(format!(
|
||||||
|
"on {}: invalid GitLab project name/path",
|
||||||
|
endpoint.label()
|
||||||
|
))
|
||||||
|
.dim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_gitlab_invalid_project_name_error(error: &anyhow::Error) -> bool {
|
||||||
|
let text = error
|
||||||
|
.chain()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
text.contains("400 bad request")
|
||||||
|
&& (text.contains("project_namespace.path")
|
||||||
|
|| text.contains("can only include non-accented letters")
|
||||||
|
|| text.contains("must not start with")
|
||||||
|
|| text.contains("must start with a letter"))
|
||||||
|
}
|
||||||
|
|
||||||
struct RepoSyncContext<'a> {
|
struct RepoSyncContext<'a> {
|
||||||
config: &'a Config,
|
config: &'a Config,
|
||||||
mirror: &'a MirrorConfig,
|
mirror: &'a MirrorConfig,
|
||||||
|
|||||||
@@ -532,6 +532,39 @@ fn created_repo_visibility_falls_back_to_config_without_template() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_invalid_project_name_errors_are_skippable() {
|
||||||
|
let error = anyhow::Error::msg(
|
||||||
|
r#"POST https://gitlab.com/api/v4/projects returned 400 Bad Request: {"message":{"project_namespace.path":["can only include non-accented letters, digits, '_', '-' and '.'. It must not start with '-', '_', or '.'."],"name":["can contain only letters, digits, emoji, '_', '.', '+', dashes, or spaces. It must start with a letter, digit, emoji, or '_'."]}}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(is_gitlab_invalid_project_name_error(&error));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitlab_project_path_validation_matches_create_constraints() {
|
||||||
|
for name in ["Kairos", "needLe", "amaoke.app", "repo_1", "repo-1"] {
|
||||||
|
assert!(is_supported_gitlab_project_path(name), "{name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in [
|
||||||
|
"",
|
||||||
|
".github",
|
||||||
|
"_private",
|
||||||
|
"-draft",
|
||||||
|
"repo.",
|
||||||
|
"repo_",
|
||||||
|
"repo-",
|
||||||
|
"repo.git",
|
||||||
|
"repo.atom",
|
||||||
|
"has space",
|
||||||
|
"has+plus",
|
||||||
|
"荞麦main",
|
||||||
|
] {
|
||||||
|
assert!(!is_supported_gitlab_project_path(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState {
|
fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState {
|
||||||
RemoteRefState {
|
RemoteRefState {
|
||||||
hash: hash.to_string(),
|
hash: hash.to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user