From 018f1f12d5e7ae6737dbd44f0fbdafca13c03e75 Mon Sep 17 00:00:00 2001 From: Azalea Date: Fri, 8 May 2026 16:24:23 +0000 Subject: [PATCH] [O] UX --- README.md | 119 ++++++++++++++-------------- src/main.rs | 72 ++++++++--------- src/webhook.rs | 42 ---------- tests/e2e/sequential.rs | 24 +----- tests/unit/cli.rs | 167 ++++++++++++++++++++++++---------------- 5 files changed, 188 insertions(+), 236 deletions(-) diff --git a/README.md b/README.md index b17c4c8..c3cd33e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ Created becasue github is so unusable and [unreliable](https://red-squares.cian. Supported platforms: GitHub, GitLab, Gitea, Forgejo +> [!NOTE] +> Meow + ## Install ### Option 1. Install from source @@ -36,7 +39,9 @@ docker compose up -d --build docker compose run --rm --entrypoint nano refray /data/config/refray/config.toml ``` -## Configure +## Usage + +### 1. Configure Run the interactive configuration wizard: @@ -44,7 +49,45 @@ Run the interactive configuration wizard: refray config ``` -## One-time Sync +
Example Config + +```toml +[[sites]] +name = "github" +provider = "github" +base_url = "https://github.com" +token = { env = "GITHUB_TOKEN" } + +[[sites]] +name = "gitea" +provider = "gitea" +base_url = "https://gitea.example.com" +token = { env = "GITEA_TOKEN" } + +[[mirrors]] +name = "personal" +sync_visibility = "all" +repo_whitelist = ["^important-"] +repo_blacklist = ["-archive$"] +create_missing = true +visibility = "private" +allow_force = false +conflict_resolution = "auto_rebase_pull_request" + +[[mirrors.endpoints]] +site = "github" +kind = "user" +namespace = "hykilpikonna" + +[[mirrors.endpoints]] +site = "gitea" +kind = "user" +namespace = "azalea" +``` + +
+ +### 2. One-time Sync Run all configured mirror groups: @@ -88,54 +131,40 @@ refray sync --jobs 8 -## Service & Webhooks +### 3. Service & Webhooks You can run `refray` as a service that listens for webhook events and runs full sync periodically. This is the recommended way to run `refray`. -If you want to use webhooks, you need to expose port 8787 to a public URL that can be accessed by the git provider (e.g. using port forwarding, reverse proxy, or cloudflare tunnel). +> [!NOTE] +> If you want to use webhooks, you need to expose port 8787 to a public URL that can be accessed by the git provider.
+> This can be done using e.g. port forwarding, reverse proxy, cloudflare tunnel, or tailscale funnel. -Start the receiver: +Start the service: ```sh refray serve ``` -Expose that listener with your reverse proxy or tunnel, then install repository webhooks. If `[webhook]` is configured, the URL and secret can come from config: +Install webhooks on all repos: ```sh refray webhook install ``` - -Useful install filters: - -```sh -refray webhook install \ - --url https://mirror.example.com/webhook \ - --secret-env REFRAY_WEBHOOK_SECRET \ - --group personal \ - --repo-pattern '^important-' -``` - -The receiver accepts `POST /` and `POST /webhook`. It verifies GitHub/Gitea HMAC SHA-256 signatures and GitLab webhook tokens, then queues `refray sync --group --repo-pattern '^$'` internally. Duplicate events for the same group/repo are coalesced while a job is queued or running. Sync jobs are serialized inside the receiver so the local ref and failure caches stay consistent. - -When `[webhook].install = true`, normal `refray sync` also checks webhook installation status and installs missing webhooks for repositories that have not been recorded yet. Installation status is stored in `webhook-state.toml` under the work directory. +Webhook install uses `[webhook].url` and `[webhook].secret` from your config. To uninstall webhooks previously installed by `refray`: +> [!WARNING] +> If you want to stop using `refray`, make sure you run this! Otherwise, all of your repos will keep trying to send webhooks to the URL. + ```sh refray webhook uninstall ``` -Manual `webhook uninstall` checks repositories on the provider instead of trusting only local state. To uninstall one repository exactly: - -```sh -refray webhook uninstall important-repo -``` - To move installed hooks to a new public URL, use `webhook update`. It removes hooks matching the current configured `[webhook].url`, installs the new URL, updates `[webhook].url` in the config, and refreshes local webhook state: ```sh -refray webhook update --url https://new.example.com/webhook +refray webhook update https://new.example.com/webhook ``` Serve can also run periodic full syncs. The interval can be configured in `[webhook].full_sync_interval_minutes` or overridden at startup: @@ -183,42 +212,6 @@ Branch deletion follows the same rule at branch scope: if a branch existed on ev Tags are fetched into provider-specific cache refs and pushed only when the tag object agrees across providers or exists on one side. Divergent tags are skipped and reported. Tag deletion is not propagated. -## Example Config - -```toml -[[sites]] -name = "github" -provider = "github" -base_url = "https://github.com" -token = { env = "GITHUB_TOKEN" } - -[[sites]] -name = "gitea" -provider = "gitea" -base_url = "https://gitea.example.com" -token = { env = "GITEA_TOKEN" } - -[[mirrors]] -name = "personal" -sync_visibility = "all" -repo_whitelist = ["^important-"] -repo_blacklist = ["-archive$"] -create_missing = true -visibility = "private" -allow_force = false -conflict_resolution = "auto_rebase_pull_request" - -[[mirrors.endpoints]] -site = "github" -kind = "user" -namespace = "hykilpikonna" - -[[mirrors.endpoints]] -site = "gitea" -kind = "user" -namespace = "azalea" -``` - ## Testing Run the normal, non-destructive test suite: diff --git a/src/main.rs b/src/main.rs index 90f219f..06c3ca3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,50 +89,24 @@ enum WebhookCommand { #[derive(Args, Debug)] struct WebhookInstallCommand { - #[arg(value_name = "REPO", conflicts_with = "repo_pattern")] - repo: Option, - #[arg(long, value_name = "URL")] - url: Option, - #[arg(long, conflicts_with = "secret_env")] - secret: Option, - #[arg(long, value_name = "ENV", conflicts_with = "secret")] - secret_env: Option, - #[arg(long, value_name = "NAME")] - group: Option, - #[arg(long, value_name = "REGEX")] - repo_pattern: Option, #[arg(long)] dry_run: bool, - #[arg(long, value_name = "PATH")] - work_dir: Option, #[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")] jobs: usize, } #[derive(Args, Debug)] struct WebhookUninstallCommand { - #[arg(value_name = "REPO")] - repo: Option, - #[arg(long, value_name = "URL")] - url: Option, - #[arg(long, value_name = "NAME")] - group: Option, #[arg(long)] dry_run: bool, - #[arg(long, value_name = "PATH")] - work_dir: Option, #[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")] jobs: usize, } #[derive(Args, Debug)] struct WebhookUpdateCommand { - #[arg(long, value_name = "URL")] + #[arg(value_name = "URL")] url: String, - #[arg(long, conflicts_with = "secret_env")] - secret: Option, - #[arg(long, value_name = "ENV", conflicts_with = "secret")] - secret_env: Option, #[arg(long)] dry_run: bool, #[arg(long, value_name = "PATH")] @@ -199,33 +173,28 @@ fn main() -> Result<()> { } Command::Webhook(WebhookCommand::Install(command)) => { let config = load_config(&config_path)?; - let secret = resolve_webhook_secret(&config, command.secret, command.secret_env)?; - let url = resolve_webhook_url(&config, command.url)?; + let secret = resolve_config_webhook_secret(&config)?; + let url = resolve_config_webhook_url(&config)?; install_webhooks( &config, WebhookInstallOptions { url, secret, - group: command.group, - repo: command.repo, - repo_pattern: command.repo_pattern, dry_run: command.dry_run, - work_dir: command.work_dir, + work_dir: None, jobs: command.jobs, }, ) } Command::Webhook(WebhookCommand::Uninstall(command)) => { let config = load_config(&config_path)?; - let url = resolve_webhook_url(&config, command.url)?; + let url = resolve_config_webhook_url(&config)?; uninstall_webhooks( &config, WebhookUninstallOptions { url, - group: command.group, - repo: command.repo, dry_run: command.dry_run, - work_dir: command.work_dir, + work_dir: None, jobs: command.jobs, }, ) @@ -239,7 +208,7 @@ fn main() -> Result<()> { .ok_or_else(|| { anyhow::anyhow!("configure [webhook] before running webhook update") })?; - let secret = resolve_webhook_secret(&config, command.secret, command.secret_env)?; + let secret = resolve_config_webhook_secret(&config)?; update_webhooks( &config, WebhookUpdateOptions { @@ -261,9 +230,26 @@ fn main() -> Result<()> { } fn load_config(path: &Path) -> Result { + if !path.exists() { + anyhow::bail!( + "config not found at {}. Run `refray config` first to create one.", + path.display() + ); + } Config::load(path).with_context(|| format!("failed to load config at {}", path.display())) } +fn resolve_config_webhook_secret(config: &Config) -> Result { + config + .webhook + .as_ref() + .map(|webhook| webhook.secret()) + .transpose()? + .ok_or_else(|| { + anyhow::anyhow!("configure [webhook].secret before running webhook commands") + }) +} + fn resolve_webhook_secret( config: &Config, value: Option, @@ -283,10 +269,12 @@ fn resolve_webhook_secret( } } -fn resolve_webhook_url(config: &Config, value: Option) -> Result { - value - .or_else(|| config.webhook.as_ref().map(|webhook| webhook.url.clone())) - .ok_or_else(|| anyhow::anyhow!("pass --url or configure [webhook].url")) +fn resolve_config_webhook_url(config: &Config) -> Result { + config + .webhook + .as_ref() + .map(|webhook| webhook.url.clone()) + .ok_or_else(|| anyhow::anyhow!("configure [webhook].url before running webhook commands")) } fn set_config_webhook_url(config: &mut Config, url: String) { diff --git a/src/webhook.rs b/src/webhook.rs index e1793ad..1034687 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -39,9 +39,6 @@ pub struct ServeOptions { pub struct WebhookInstallOptions { pub url: String, pub secret: String, - pub group: Option, - pub repo: Option, - pub repo_pattern: Option, pub dry_run: bool, pub work_dir: Option, pub jobs: usize, @@ -50,8 +47,6 @@ pub struct WebhookInstallOptions { #[derive(Clone, Debug)] pub struct WebhookUninstallOptions { pub url: String, - pub group: Option, - pub repo: Option, pub dry_run: bool, pub work_dir: Option, pub jobs: usize, @@ -183,21 +178,8 @@ pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Resu } let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir); let state = Arc::new(Mutex::new(load_webhook_state(&work_dir)?)); - let repo_pattern = options - .repo_pattern - .as_deref() - .map(regex::Regex::new) - .transpose() - .with_context(|| "invalid --repo-pattern regex")?; for mirror in &config.mirrors { - if options - .group - .as_ref() - .is_some_and(|group| group != &mirror.name) - { - continue; - } crate::logln!(); crate::logln!( "{} {}", @@ -222,15 +204,6 @@ pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Resu .filter(|repo| mirror.sync_visibility.matches_private(repo.private)) .filter(|repo| repo_filter.matches(&repo.name)) { - if options.repo.as_ref().is_some_and(|name| name != &repo.name) { - continue; - } - if repo_pattern - .as_ref() - .is_some_and(|pattern| !pattern.is_match(&repo.name)) - { - continue; - } tasks.push(WebhookInstallTask { site: site.clone(), group: mirror.name.clone(), @@ -262,13 +235,6 @@ pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) -> let mut state = load_webhook_state(&work_dir)?; let mut tasks = Vec::new(); for mirror in &config.mirrors { - if options - .group - .as_ref() - .is_some_and(|group| group != &mirror.name) - { - continue; - } crate::logln!(); crate::logln!( "{} {}", @@ -287,9 +253,6 @@ pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) -> .list_repos(endpoint) .with_context(|| format!("failed to list repos for {}", endpoint.label()))?; for repo in repos { - if options.repo.as_ref().is_some_and(|name| name != &repo.name) { - continue; - } tasks.push(WebhookUninstallTask { group: mirror.name.clone(), site: site.clone(), @@ -329,8 +292,6 @@ pub fn update_webhooks(config: &Config, options: WebhookUpdateOptions) -> Result config, WebhookUninstallOptions { url: options.old_url.clone(), - group: None, - repo: None, dry_run: options.dry_run, work_dir: options.work_dir.clone(), jobs: options.jobs, @@ -343,9 +304,6 @@ pub fn update_webhooks(config: &Config, options: WebhookUpdateOptions) -> Result WebhookInstallOptions { url: options.new_url, secret: options.secret, - group: None, - repo: None, - repo_pattern: None, dry_run: options.dry_run, work_dir: options.work_dir, jobs: options.jobs, diff --git a/tests/e2e/sequential.rs b/tests/e2e/sequential.rs index 5e1c42b..7a0d31a 100644 --- a/tests/e2e/sequential.rs +++ b/tests/e2e/sequential.rs @@ -646,33 +646,13 @@ namespace = "{}" self.seed_all_main(&repo, "webhook base", 1_700_001_601)?; self.sync(["--repo-pattern", &exact_pattern(&repo)])?; - self.refray([ - "webhook", - "install", - "--dry-run", - "--repo-pattern", - &exact_pattern(&repo), - "--url", - "https://example.invalid/webhook", - "--secret", - WEBHOOK_SECRET, - ])?; - self.refray([ - "webhook", - "uninstall", - &repo, - "--dry-run", - "--url", - "https://example.invalid/webhook", - ])?; + self.refray(["webhook", "install", "--dry-run"])?; + self.refray(["webhook", "uninstall", "--dry-run"])?; self.refray([ "webhook", "update", "--dry-run", - "--url", "https://example.invalid/new-webhook", - "--secret", - WEBHOOK_SECRET, ])?; let listener = TcpListener::bind("127.0.0.1:0")?; diff --git a/tests/unit/cli.rs b/tests/unit/cli.rs index 34fa3a1..02b8cd2 100644 --- a/tests/unit/cli.rs +++ b/tests/unit/cli.rs @@ -85,98 +85,88 @@ fn cli_accepts_webhook_serve() { #[test] fn cli_accepts_webhook_install() { - let cli = Cli::try_parse_from([ - "refray", - "webhook", - "install", - "repo-one", - "--url", - "https://mirror.example.test/webhook", - "--secret", - "secret", - "--group", - "sync-1", - "--jobs", - "6", - ]) - .unwrap(); + let cli = Cli::try_parse_from(["refray", "webhook", "install", "--jobs", "6"]).unwrap(); let Command::Webhook(WebhookCommand::Install(args)) = cli.command else { panic!("parsed unexpected command"); }; - assert_eq!( - args.url, - Some("https://mirror.example.test/webhook".to_string()) - ); - assert_eq!(args.secret, Some("secret".to_string())); - assert_eq!(args.group, Some("sync-1".to_string())); - assert_eq!(args.repo, Some("repo-one".to_string())); - assert_eq!(args.repo_pattern, None); assert_eq!(args.jobs, 6); } #[test] -fn cli_accepts_webhook_install_repo_pattern() { - let cli = Cli::try_parse_from([ - "refray", - "webhook", - "install", - "--url", - "https://mirror.example.test/webhook", - "--secret", - "secret", - "--repo-pattern", - "^repo$", - ]) - .unwrap(); - - let Command::Webhook(WebhookCommand::Install(args)) = cli.command else { - panic!("parsed unexpected command"); - }; - assert_eq!(args.repo, None); - assert_eq!(args.repo_pattern, Some("^repo$".to_string())); +fn cli_rejects_removed_webhook_install_args() { + for args in [ + ["refray", "webhook", "install", "repo-one"].as_slice(), + [ + "refray", + "webhook", + "install", + "--url", + "https://mirror.example.test/webhook", + ] + .as_slice(), + ["refray", "webhook", "install", "--group", "sync-1"].as_slice(), + ["refray", "webhook", "install", "--work-dir", "/tmp/refray"].as_slice(), + ["refray", "webhook", "install", "--secret", "secret"].as_slice(), + [ + "refray", + "webhook", + "install", + "--secret-env", + "WEBHOOK_SECRET", + ] + .as_slice(), + ["refray", "webhook", "install", "--repo-pattern", "^repo$"].as_slice(), + ] { + assert!(Cli::try_parse_from(args).is_err()); + } } #[test] fn cli_accepts_webhook_uninstall() { - let cli = Cli::try_parse_from([ - "refray", - "webhook", - "uninstall", - "repo-one", - "--url", - "https://mirror.example.test/webhook", - "--group", - "sync-1", - "--dry-run", - "--jobs", - "3", - ]) - .unwrap(); + let cli = Cli::try_parse_from(["refray", "webhook", "uninstall", "--dry-run", "--jobs", "3"]) + .unwrap(); let Command::Webhook(WebhookCommand::Uninstall(args)) = cli.command else { panic!("parsed unexpected command"); }; - assert_eq!( - args.url, - Some("https://mirror.example.test/webhook".to_string()) - ); - assert_eq!(args.group, Some("sync-1".to_string())); - assert_eq!(args.repo, Some("repo-one".to_string())); assert!(args.dry_run); assert_eq!(args.jobs, 3); } +#[test] +fn cli_rejects_removed_webhook_uninstall_args() { + for args in [ + ["refray", "webhook", "uninstall", "repo-one"].as_slice(), + [ + "refray", + "webhook", + "uninstall", + "--url", + "https://mirror.example.test/webhook", + ] + .as_slice(), + ["refray", "webhook", "uninstall", "--group", "sync-1"].as_slice(), + [ + "refray", + "webhook", + "uninstall", + "--work-dir", + "/tmp/refray", + ] + .as_slice(), + ] { + assert!(Cli::try_parse_from(args).is_err()); + } +} + #[test] fn cli_accepts_webhook_update() { let cli = Cli::try_parse_from([ "refray", "webhook", "update", - "--url", "https://new.example.test/webhook", - "--secret-env", - "WEBHOOK_SECRET", "--jobs", "5", ]) @@ -186,10 +176,43 @@ fn cli_accepts_webhook_update() { panic!("parsed unexpected command"); }; assert_eq!(args.url, "https://new.example.test/webhook"); - assert_eq!(args.secret_env, Some("WEBHOOK_SECRET".to_string())); assert_eq!(args.jobs, 5); } +#[test] +fn cli_rejects_removed_webhook_update_secret_args() { + for args in [ + [ + "refray", + "webhook", + "update", + "--url", + "https://new.example.test/webhook", + ] + .as_slice(), + [ + "refray", + "webhook", + "update", + "https://new.example.test/webhook", + "--secret", + "secret", + ] + .as_slice(), + [ + "refray", + "webhook", + "update", + "https://new.example.test/webhook", + "--secret-env", + "WEBHOOK_SECRET", + ] + .as_slice(), + ] { + assert!(Cli::try_parse_from(args).is_err()); + } +} + #[test] fn cli_rejects_scoped_webhook_update() { let result = Cli::try_parse_from([ @@ -197,9 +220,19 @@ fn cli_rejects_scoped_webhook_update() { "webhook", "update", "repo-one", - "--url", "https://new.example.test/webhook", ]); assert!(result.is_err()); } + +#[test] +fn missing_config_error_asks_user_to_run_config() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.toml"); + + let error = load_config(&path).unwrap_err().to_string(); + + assert!(error.contains("config not found at")); + assert!(error.contains("Run `refray config` first")); +}