diff --git a/README.md b/README.md index c3cd33e..67a6748 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ refray config
Example Config ```toml +jobs = 8 + [[sites]] name = "github" provider = "github" @@ -123,10 +125,10 @@ Retry only repositories that failed during the previous non-dry-run sync: refray sync --retry-failed ``` -Control repo-level parallelism: +Control parallelism for sync, serve, and webhook commands in config: -```sh -refray sync --jobs 8 +```toml +jobs = 8 ```
@@ -139,18 +141,17 @@ You can run `refray` as a service that listens for webhook events and runs full > 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 service: +Start the service (to sync on push and also do full sync periodically): ```sh refray serve ``` -Install webhooks on all repos: +Install webhooks on all repos (with the URL in config): ```sh refray webhook install ``` -Webhook install uses `[webhook].url` and `[webhook].secret` from your config. To uninstall webhooks previously installed by `refray`: @@ -161,20 +162,18 @@ To uninstall webhooks previously installed by `refray`: refray webhook uninstall ``` +By default, uninstall uses `[webhook].url` from your config. To remove hooks for a previous URL, pass it explicitly: + +```sh +refray webhook uninstall https://old.example.com/webhook +``` + 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 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: - -```sh -refray serve --full-sync-interval-minutes 30 -``` - -If `[webhook].reachability_check_interval_minutes` is configured, `serve` periodically checks that the public webhook URL is still reachable and logs a warning when it is not. - ## Sync Semantics Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints. diff --git a/src/config.rs b/src/config.rs index d3a6f03..8c6c012 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,9 +9,12 @@ use regex::Regex; use serde::{Deserialize, Serialize}; const APP_NAME: &str = "refray"; +pub const DEFAULT_JOBS: usize = 4; -#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Config { + #[serde(default = "default_jobs", skip_serializing_if = "is_default_jobs")] + pub jobs: usize, #[serde(default)] pub sites: Vec, #[serde(default)] @@ -90,6 +93,17 @@ pub struct WebhookConfig { pub reachability_check_interval_minutes: Option, } +impl Default for Config { + fn default() -> Self { + Self { + jobs: DEFAULT_JOBS, + sites: Vec::new(), + mirrors: Vec::new(), + webhook: None, + } + } +} + #[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] pub struct EndpointConfig { pub site: String, @@ -176,6 +190,14 @@ fn default_true() -> bool { true } +fn default_jobs() -> usize { + DEFAULT_JOBS +} + +fn is_default_jobs(jobs: &usize) -> bool { + *jobs == DEFAULT_JOBS +} + impl Config { pub fn load(path: &Path) -> Result { let contents = fs::read_to_string(path) @@ -321,6 +343,9 @@ fn protect_file(_path: &Path) -> Result<()> { } pub fn validate_config(config: &Config) -> Result<()> { + if config.jobs == 0 { + bail!("jobs must be at least 1"); + } if config.sites.is_empty() { bail!("no sites configured"); } diff --git a/src/main.rs b/src/main.rs index 06c3ca3..643334d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,14 +7,13 @@ mod state; mod sync; mod webhook; -use std::env; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand}; use crate::config::{Config, default_config_path}; -use crate::sync::{DEFAULT_JOBS, SyncOptions, sync_all}; +use crate::sync::{SyncOptions, sync_all}; use crate::webhook::{ ServeOptions, WebhookInstallOptions, WebhookUninstallOptions, WebhookUpdateOptions, install_webhooks, serve, uninstall_webhooks, update_webhooks, @@ -60,24 +59,14 @@ struct SyncCommand { retry_failed: 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 ServeCommand { #[arg(long, default_value = "127.0.0.1:8787", value_name = "HOST:PORT")] listen: String, - #[arg(long, conflicts_with = "secret_env")] - secret: Option, - #[arg(long, value_name = "ENV", conflicts_with = "secret")] - secret_env: Option, - #[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")] - jobs: usize, #[arg(long, value_name = "PATH")] work_dir: Option, - #[arg(long, value_name = "MINUTES")] - full_sync_interval_minutes: Option, } #[derive(Subcommand, Debug)] @@ -91,16 +80,14 @@ enum WebhookCommand { struct WebhookInstallCommand { #[arg(long)] dry_run: bool, - #[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")] - jobs: usize, } #[derive(Args, Debug)] struct WebhookUninstallCommand { + #[arg(value_name = "URL", value_parser = parse_webhook_url)] + url: Option, #[arg(long)] dry_run: bool, - #[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")] - jobs: usize, } #[derive(Args, Debug)] @@ -111,8 +98,6 @@ struct WebhookUpdateCommand { dry_run: bool, #[arg(long, value_name = "PATH")] work_dir: Option, - #[arg(long, default_value_t = DEFAULT_JOBS, value_name = "N")] - jobs: usize, } fn main() -> Result<()> { @@ -123,7 +108,13 @@ fn main() -> Result<()> { Command::Config => { let outcome = interactive::run_config_wizard(&config_path)?; if outcome.run_full_sync_now { - sync_all(&outcome.config, SyncOptions::default()) + sync_all( + &outcome.config, + SyncOptions { + jobs: outcome.config.jobs, + ..SyncOptions::default() + }, + ) } else { Ok(()) } @@ -140,30 +131,29 @@ fn main() -> Result<()> { repo_pattern: command.repo_pattern, retry_failed: command.retry_failed, work_dir: command.work_dir, - jobs: command.jobs, + jobs: config.jobs, }, ) } Command::Serve(command) => { let config = load_config(&config_path)?; - let full_sync_interval_minutes = command.full_sync_interval_minutes.or_else(|| { - config - .webhook - .as_ref() - .and_then(|webhook| webhook.full_sync_interval_minutes) - }); + let full_sync_interval_minutes = config + .webhook + .as_ref() + .and_then(|webhook| webhook.full_sync_interval_minutes); let reachability_url = config.webhook.as_ref().map(|webhook| webhook.url.clone()); let reachability_check_interval_minutes = config .webhook .as_ref() .and_then(|webhook| webhook.reachability_check_interval_minutes); - let secret = resolve_webhook_secret(&config, command.secret, command.secret_env)?; + let secret = resolve_config_webhook_secret(&config)?; + let workers = config.jobs; serve( config, ServeOptions { listen: command.listen, secret, - workers: command.jobs, + workers, work_dir: command.work_dir, full_sync_interval_minutes, reachability_url, @@ -182,20 +172,20 @@ fn main() -> Result<()> { secret, dry_run: command.dry_run, work_dir: None, - jobs: command.jobs, + jobs: config.jobs, }, ) } Command::Webhook(WebhookCommand::Uninstall(command)) => { let config = load_config(&config_path)?; - let url = resolve_config_webhook_url(&config)?; + let url = resolve_uninstall_webhook_url(&config, command.url)?; uninstall_webhooks( &config, WebhookUninstallOptions { url, dry_run: command.dry_run, work_dir: None, - jobs: command.jobs, + jobs: config.jobs, }, ) } @@ -217,7 +207,7 @@ fn main() -> Result<()> { secret, dry_run: command.dry_run, work_dir: command.work_dir, - jobs: command.jobs, + jobs: config.jobs, }, )?; if !command.dry_run { @@ -246,29 +236,10 @@ fn resolve_config_webhook_secret(config: &Config) -> Result { .map(|webhook| webhook.secret()) .transpose()? .ok_or_else(|| { - anyhow::anyhow!("configure [webhook].secret before running webhook commands") + anyhow::anyhow!("configure [webhook].secret before running serve or webhook commands") }) } -fn resolve_webhook_secret( - config: &Config, - value: Option, - env_name: Option, -) -> Result { - match (value, env_name) { - (Some(value), None) => Ok(value), - (None, Some(env_name)) => env::var(&env_name) - .with_context(|| format!("environment variable {env_name} is not set")), - (None, None) => config - .webhook - .as_ref() - .map(|webhook| webhook.secret()) - .transpose()? - .ok_or_else(|| anyhow::anyhow!("pass either --secret or --secret-env")), - (Some(_), Some(_)) => unreachable!("clap enforces secret conflicts"), - } -} - fn resolve_config_webhook_url(config: &Config) -> Result { config .webhook @@ -277,6 +248,24 @@ fn resolve_config_webhook_url(config: &Config) -> Result { .ok_or_else(|| anyhow::anyhow!("configure [webhook].url before running webhook commands")) } +fn resolve_uninstall_webhook_url(config: &Config, url: Option) -> Result { + match url { + Some(url) => Ok(url), + None => resolve_config_webhook_url(config), + } +} + +fn parse_webhook_url(value: &str) -> std::result::Result { + if value.trim().is_empty() { + return Err("A value is required".to_string()); + } + let url = url::Url::parse(value).map_err(|error| format!("Invalid URL: {error}"))?; + match url.scheme() { + "http" | "https" => Ok(value.to_string()), + _ => Err("URL must start with http:// or https://".to_string()), + } +} + fn set_config_webhook_url(config: &mut Config, url: String) { let Some(webhook) = &mut config.webhook else { unreachable!("caller verifies webhook config exists before saving update") diff --git a/src/sync.rs b/src/sync.rs index 280c642..002accf 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -9,7 +9,7 @@ use console::style; use regex::Regex; use crate::config::{ - Config, ConflictResolutionStrategy, EndpointConfig, MirrorConfig, RepoNameFilter, + Config, ConflictResolutionStrategy, DEFAULT_JOBS, EndpointConfig, MirrorConfig, RepoNameFilter, SyncVisibility, default_work_dir, validate_config, }; use crate::git::{ @@ -34,7 +34,6 @@ use self::state::{ save_failure_state, save_ref_state, }; -pub const DEFAULT_JOBS: usize = 4; const CONFLICT_BRANCH_ROOT: &str = "refray/conflicts/"; #[derive(Clone, Debug)] @@ -67,7 +66,7 @@ impl Default for SyncOptions { pub fn sync_all(config: &Config, options: SyncOptions) -> Result<()> { validate_config(config)?; if options.jobs == 0 { - bail!("--jobs must be at least 1"); + bail!("jobs must be at least 1"); } let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir); fs::create_dir_all(&work_dir) diff --git a/src/webhook.rs b/src/webhook.rs index 1034687..ec91baa 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -77,7 +77,7 @@ struct JobQueue { pub fn serve(config: Config, options: ServeOptions) -> Result<()> { validate_config(&config)?; if options.workers == 0 { - bail!("--jobs must be at least 1"); + bail!("jobs must be at least 1"); } let server = Server::http(&options.listen) .map_err(|error| anyhow::anyhow!("failed to listen on {}: {error}", options.listen))?; @@ -174,7 +174,7 @@ fn reachability_timer_loop(url: String, minutes: u64) { pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Result<()> { validate_config(config)?; if options.jobs == 0 { - bail!("--jobs must be at least 1"); + bail!("jobs must be at least 1"); } let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir); let state = Arc::new(Mutex::new(load_webhook_state(&work_dir)?)); @@ -229,7 +229,7 @@ pub fn install_webhooks(config: &Config, options: WebhookInstallOptions) -> Resu pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) -> Result<()> { validate_config(config)?; if options.jobs == 0 { - bail!("--jobs must be at least 1"); + bail!("jobs must be at least 1"); } let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir); let mut state = load_webhook_state(&work_dir)?; @@ -267,10 +267,7 @@ pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) -> let removed_keys = run_uninstall_tasks(tasks, options.jobs)?; if !options.dry_run { - for key in removed_keys { - state.installations.remove(&key); - state.skipped.remove(&key); - } + remove_webhook_state_keys(&mut state, removed_keys, &options.url); save_webhook_state(&work_dir, &state)?; } Ok(()) @@ -279,7 +276,7 @@ pub fn uninstall_webhooks(config: &Config, options: WebhookUninstallOptions) -> pub fn update_webhooks(config: &Config, options: WebhookUpdateOptions) -> Result<()> { validate_config(config)?; if options.jobs == 0 { - bail!("--jobs must be at least 1"); + bail!("jobs must be at least 1"); } if options.old_url != options.new_url { crate::logln!( @@ -325,7 +322,7 @@ pub fn ensure_configured_webhooks( return Ok(()); } if jobs == 0 { - bail!("--jobs must be at least 1"); + bail!("jobs must be at least 1"); } let secret = webhook.secret()?; let state = Arc::new(Mutex::new(load_webhook_state(work_dir)?)); @@ -696,6 +693,25 @@ fn record_webhook_installation( ); } +fn remove_webhook_state_keys(state: &mut WebhookState, keys: Vec, url: &str) { + for key in keys { + if state + .installations + .get(&key) + .is_some_and(|installation| installation.url == url) + { + state.installations.remove(&key); + } + if state + .skipped + .get(&key) + .is_some_and(|skipped| skipped.url == url) + { + state.skipped.remove(&key); + } + } +} + fn uninstall_webhook_task(task: WebhookUninstallTask) -> Result> { let key = webhook_installation_key(&task.group, &task.endpoint, &task.repo.name); crate::logln!( diff --git a/tests/e2e/sequential.rs b/tests/e2e/sequential.rs index 7a0d31a..0ceab4c 100644 --- a/tests/e2e/sequential.rs +++ b/tests/e2e/sequential.rs @@ -313,7 +313,7 @@ impl E2eRun { ) -> Result<()> { let default_whitelist = format!("^{REPO_PREFIX}{}-", self.run_id); let whitelist = repo_pattern.unwrap_or(&default_whitelist); - let mut contents = String::new(); + let mut contents = "jobs = 1\n\n".to_string(); for provider in &self.settings.providers { contents.push_str(&format!( r#"[[sites]] @@ -658,15 +658,7 @@ namespace = "{}" let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?; drop(listener); - let mut server = self.spawn_refray([ - "serve", - "--listen", - &addr.to_string(), - "--secret", - WEBHOOK_SECRET, - "--jobs", - "1", - ])?; + let mut server = self.spawn_refray(["serve", "--listen", &addr.to_string()])?; thread::sleep(Duration::from_millis(750)); let (body, headers) = source.webhook_payload(&repo, WEBHOOK_SECRET); @@ -794,25 +786,13 @@ namespace = "{}" } fn sync(&self, args: [&str; N]) -> Result<()> { - let mut command = vec![ - "sync", - "--work-dir", - self.work_dir.to_str().unwrap(), - "--jobs", - "1", - ]; + let mut command = vec!["sync", "--work-dir", self.work_dir.to_str().unwrap()]; command.extend(args); self.refray(command) } fn sync_expect_failure(&self, args: [&str; N]) -> Result<()> { - let mut command = vec![ - "sync", - "--work-dir", - self.work_dir.to_str().unwrap(), - "--jobs", - "1", - ]; + let mut command = vec!["sync", "--work-dir", self.work_dir.to_str().unwrap()]; command.extend(args); let output = self.refray_output(command)?; if output.status.success() { diff --git a/tests/unit/cli.rs b/tests/unit/cli.rs index 02b8cd2..dec718e 100644 --- a/tests/unit/cli.rs +++ b/tests/unit/cli.rs @@ -48,49 +48,56 @@ fn cli_accepts_sync_retry_failed() { assert!(args.retry_failed); } -#[test] -fn cli_accepts_sync_jobs() { - let cli = Cli::try_parse_from(["refray", "sync", "--jobs", "8"]).unwrap(); - - let Command::Sync(args) = cli.command else { - panic!("parsed unexpected command"); - }; - assert_eq!(args.jobs, 8); -} - #[test] fn cli_accepts_webhook_serve() { - let cli = Cli::try_parse_from([ - "refray", - "serve", - "--listen", - "127.0.0.1:9000", - "--secret-env", - "WEBHOOK_SECRET", - "--jobs", - "2", - "--full-sync-interval-minutes", - "30", - ]) - .unwrap(); + let cli = Cli::try_parse_from(["refray", "serve", "--listen", "127.0.0.1:9000"]).unwrap(); let Command::Serve(args) = cli.command else { panic!("parsed unexpected command"); }; assert_eq!(args.listen, "127.0.0.1:9000"); - assert_eq!(args.secret_env, Some("WEBHOOK_SECRET".to_string())); - assert_eq!(args.jobs, 2); - assert_eq!(args.full_sync_interval_minutes, Some(30)); +} + +#[test] +fn cli_rejects_removed_jobs_args() { + for args in [ + ["refray", "sync", "--jobs", "8"].as_slice(), + ["refray", "serve", "--jobs", "2"].as_slice(), + ["refray", "webhook", "install", "--jobs", "6"].as_slice(), + ["refray", "webhook", "uninstall", "--jobs", "3"].as_slice(), + [ + "refray", + "webhook", + "update", + "https://new.example.test/webhook", + "--jobs", + "5", + ] + .as_slice(), + ] { + assert!(Cli::try_parse_from(args).is_err()); + } +} + +#[test] +fn cli_rejects_removed_serve_args() { + for args in [ + ["refray", "serve", "--secret", "secret"].as_slice(), + ["refray", "serve", "--secret-env", "WEBHOOK_SECRET"].as_slice(), + ["refray", "serve", "--full-sync-interval-minutes", "30"].as_slice(), + ] { + assert!(Cli::try_parse_from(args).is_err()); + } } #[test] fn cli_accepts_webhook_install() { - let cli = Cli::try_parse_from(["refray", "webhook", "install", "--jobs", "6"]).unwrap(); + let cli = Cli::try_parse_from(["refray", "webhook", "install"]).unwrap(); let Command::Webhook(WebhookCommand::Install(args)) = cli.command else { panic!("parsed unexpected command"); }; - assert_eq!(args.jobs, 6); + assert!(!args.dry_run); } #[test] @@ -124,20 +131,37 @@ fn cli_rejects_removed_webhook_install_args() { #[test] fn cli_accepts_webhook_uninstall() { - let cli = Cli::try_parse_from(["refray", "webhook", "uninstall", "--dry-run", "--jobs", "3"]) - .unwrap(); + let cli = Cli::try_parse_from(["refray", "webhook", "uninstall", "--dry-run"]).unwrap(); let Command::Webhook(WebhookCommand::Uninstall(args)) = cli.command else { panic!("parsed unexpected command"); }; + assert_eq!(args.url, None); assert!(args.dry_run); - assert_eq!(args.jobs, 3); +} + +#[test] +fn cli_accepts_webhook_uninstall_url() { + let cli = Cli::try_parse_from([ + "refray", + "webhook", + "uninstall", + "https://old.example.test/webhook", + ]) + .unwrap(); + + let Command::Webhook(WebhookCommand::Uninstall(args)) = cli.command else { + panic!("parsed unexpected command"); + }; + assert_eq!( + args.url, + Some("https://old.example.test/webhook".to_string()) + ); } #[test] fn cli_rejects_removed_webhook_uninstall_args() { for args in [ - ["refray", "webhook", "uninstall", "repo-one"].as_slice(), [ "refray", "webhook", @@ -160,6 +184,39 @@ fn cli_rejects_removed_webhook_uninstall_args() { } } +#[test] +fn cli_rejects_invalid_webhook_uninstall_url() { + for args in [ + ["refray", "webhook", "uninstall", "repo-one"].as_slice(), + [ + "refray", + "webhook", + "uninstall", + "ftp://example.test/webhook", + ] + .as_slice(), + ] { + assert!(Cli::try_parse_from(args).is_err()); + } +} + +#[test] +fn webhook_uninstall_url_uses_arg_before_config() { + let config = Config { + jobs: crate::config::DEFAULT_JOBS, + webhook: None, + ..Config::default() + }; + + let url = resolve_uninstall_webhook_url( + &config, + Some("https://old.example.test/webhook".to_string()), + ) + .unwrap(); + + assert_eq!(url, "https://old.example.test/webhook"); +} + #[test] fn cli_accepts_webhook_update() { let cli = Cli::try_parse_from([ @@ -167,8 +224,6 @@ fn cli_accepts_webhook_update() { "webhook", "update", "https://new.example.test/webhook", - "--jobs", - "5", ]) .unwrap(); @@ -176,7 +231,6 @@ fn cli_accepts_webhook_update() { panic!("parsed unexpected command"); }; assert_eq!(args.url, "https://new.example.test/webhook"); - assert_eq!(args.jobs, 5); } #[test] diff --git a/tests/unit/config.rs b/tests/unit/config.rs index 3c5b0e9..21fa269 100644 --- a/tests/unit/config.rs +++ b/tests/unit/config.rs @@ -4,6 +4,8 @@ use super::*; fn parses_token_forms() { let config: Config = toml::from_str( r#" + jobs = 8 + [webhook] install = true url = "https://mirror.example.test/webhook" @@ -40,6 +42,7 @@ fn parses_token_forms() { ) .unwrap(); + assert_eq!(config.jobs, 8); assert_eq!(config.sites.len(), 1); assert_eq!(config.mirrors[0].endpoints.len(), 2); assert_eq!( @@ -65,9 +68,17 @@ fn parses_token_forms() { assert_eq!(webhook.full_sync_interval_minutes, Some(60)); } +#[test] +fn config_defaults_jobs() { + let config: Config = toml::from_str("").unwrap(); + + assert_eq!(config.jobs, DEFAULT_JOBS); +} + #[test] fn validation_rejects_unknown_sites_and_single_endpoint_groups() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![site("github", ProviderKind::Github)], mirrors: vec![MirrorConfig { name: "broken".to_string(), @@ -90,6 +101,7 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() { assert!(err.contains("at least two endpoints")); let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![site("github", ProviderKind::Github)], mirrors: vec![MirrorConfig { name: "broken".to_string(), @@ -185,6 +197,7 @@ fn repo_name_filter_applies_whitelist_then_blacklist() { #[test] fn validation_rejects_invalid_repo_filter_regex() { let mut config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![site("github", ProviderKind::Github)], mirrors: vec![mirror_config()], webhook: None, @@ -196,6 +209,22 @@ fn validation_rejects_invalid_repo_filter_regex() { assert!(err.contains("invalid repo_whitelist regex")); } +#[test] +fn validation_rejects_zero_jobs() { + let mut config = Config { + jobs: 0, + sites: vec![site("github", ProviderKind::Github)], + mirrors: vec![mirror_config()], + webhook: None, + }; + + let err = validate_config(&config).unwrap_err().to_string(); + + assert!(err.contains("jobs must be at least 1")); + config.jobs = DEFAULT_JOBS; + validate_config(&config).unwrap(); +} + fn mirror_config() -> MirrorConfig { MirrorConfig { name: "personal".to_string(), diff --git a/tests/unit/interactive.rs b/tests/unit/interactive.rs index 9e3109a..9c9fdfb 100644 --- a/tests/unit/interactive.rs +++ b/tests/unit/interactive.rs @@ -139,6 +139,7 @@ fn wizard_can_enable_webhooks() { #[test] fn wizard_reuses_existing_credentials_for_same_instance() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![SiteConfig { name: "github".to_string(), provider: ProviderKind::Github, @@ -177,6 +178,7 @@ fn wizard_reuses_existing_credentials_for_same_instance() { #[test] fn wizard_starts_existing_config_at_sync_group_menu() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![ SiteConfig { name: "github".to_string(), @@ -234,6 +236,7 @@ fn wizard_starts_existing_config_at_sync_group_menu() { #[test] fn wizard_can_ask_to_run_full_sync_after_config() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: Vec::new(), mirrors: vec![MirrorConfig { name: "sync-1".to_string(), @@ -274,6 +277,7 @@ fn wizard_skips_full_sync_prompt_without_sync_groups() { #[test] fn wizard_edits_existing_sync_group_from_menu() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![ SiteConfig { name: "github".to_string(), @@ -371,6 +375,7 @@ fn wizard_edits_existing_sync_group_from_menu() { #[test] fn wizard_prefills_existing_sync_group_when_editing() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![ SiteConfig { name: "github".to_string(), @@ -435,6 +440,7 @@ fn wizard_prefills_existing_sync_group_when_editing() { #[test] fn wizard_deletes_existing_sync_group_from_menu() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![ SiteConfig { name: "github".to_string(), @@ -494,6 +500,7 @@ fn wizard_deletes_existing_sync_group_from_menu() { #[test] fn wizard_can_go_back_from_delete_menu() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![ SiteConfig { name: "github".to_string(), diff --git a/tests/unit/webhook.rs b/tests/unit/webhook.rs index 8c44042..777bc2d 100644 --- a/tests/unit/webhook.rs +++ b/tests/unit/webhook.rs @@ -100,6 +100,7 @@ fn parses_gitlab_push_payload() { #[test] fn matches_jobs_by_provider_and_namespace() { let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![ site("github", ProviderKind::Github), site("gitea", ProviderKind::Gitea), @@ -147,6 +148,7 @@ fn matching_jobs_respects_repo_name_filters() { conflict_resolution: ConflictResolutionStrategy::Fail, }; let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![site("github", ProviderKind::Github)], mirrors: vec![mirror.clone()], webhook: None, @@ -161,6 +163,7 @@ fn matching_jobs_respects_repo_name_filters() { mirror.repo_whitelist.clear(); let config = Config { + jobs: crate::config::DEFAULT_JOBS, sites: vec![site("github", ProviderKind::Github)], mirrors: vec![mirror], webhook: None, @@ -358,6 +361,50 @@ fn uninstall_task_removes_state_even_when_hook_is_missing() { handle.join().unwrap(); } +#[test] +fn uninstall_state_cleanup_only_removes_matching_url() { + let endpoint = endpoint("github", NamespaceKind::User, "alice"); + let key = webhook_installation_key("sync-1", &endpoint, "repo"); + let mut state = WebhookState::default(); + state.installations.insert( + key.clone(), + WebhookInstallation { + group: "sync-1".to_string(), + endpoint: endpoint.clone(), + repo: "repo".to_string(), + url: "https://current.example.test/webhook".to_string(), + }, + ); + state.skipped.insert( + key.clone(), + SkippedWebhookInstallation { + group: "sync-1".to_string(), + endpoint, + repo: "repo".to_string(), + url: "https://current.example.test/webhook".to_string(), + reason: "provider blocked access".to_string(), + }, + ); + + remove_webhook_state_keys( + &mut state, + vec![key.clone()], + "https://old.example.test/webhook", + ); + + assert!(state.installations.contains_key(&key)); + assert!(state.skipped.contains_key(&key)); + + remove_webhook_state_keys( + &mut state, + vec![key.clone()], + "https://current.example.test/webhook", + ); + + assert!(!state.installations.contains_key(&key)); + assert!(!state.skipped.contains_key(&key)); +} + fn site(name: &str, provider: ProviderKind) -> SiteConfig { SiteConfig { name: name.to_string(),