diff --git a/src/provider.rs b/src/provider.rs index 3ca2856..e9350a0 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -40,6 +40,12 @@ pub struct PullRequestInfo { pub url: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WebhookInstallOutcome { + Created, + Existing, +} + pub fn list_mirror_repos( config: &Config, mirror: &MirrorConfig, @@ -172,7 +178,7 @@ impl<'a> ProviderClient<'a> { repo: &RemoteRepo, url: &str, secret: &str, - ) -> Result<()> { + ) -> Result { dispatch_provider!(self.site.provider, github => self.github_install_webhook(endpoint, repo, url, secret), gitlab => self.gitlab_install_webhook(endpoint, repo, url, secret), @@ -317,7 +323,7 @@ impl<'a> ProviderClient<'a> { repo: &RemoteRepo, url: &str, secret: &str, - ) -> Result<()> { + ) -> Result { let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "GitHub")?; let body = json!({ "name": "web", @@ -526,7 +532,7 @@ impl<'a> ProviderClient<'a> { repo: &RemoteRepo, url: &str, secret: &str, - ) -> Result<()> { + ) -> Result { let hooks_url = self.gitlab_hooks_url(endpoint, &repo.name); let body = json!({ "url": url, @@ -695,7 +701,7 @@ impl<'a> ProviderClient<'a> { repo: &RemoteRepo, url: &str, secret: &str, - ) -> Result<()> { + ) -> Result { let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "Gitea/Forgejo")?; let body = json!({ "type": "gitea", @@ -875,10 +881,10 @@ impl<'a> ProviderClient<'a> { target_url: &str, body: &serde_json::Value, put_on_update: bool, - ) -> Result<()> { + ) -> Result { let Some(hook) = self.find_existing_hook(hooks_url, target_url)? else { self.post_json::(hooks_url, body)?; - return Ok(()); + return Ok(WebhookInstallOutcome::Created); }; let update_url = format!("{hooks_url}/{}", hook.id); @@ -887,7 +893,7 @@ impl<'a> ProviderClient<'a> { } else { self.patch_json::(&update_url, body)?; } - Ok(()) + Ok(WebhookInstallOutcome::Existing) } fn delete_matching_hook(&self, hooks_url: &str, target_url: &str) -> Result { diff --git a/src/webhook.rs b/src/webhook.rs index fd28b30..6d6e087 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -18,7 +18,9 @@ use crate::config::{ Config, EndpointConfig, MirrorConfig, ProviderKind, RepoNameFilter, default_work_dir, validate_config, }; -use crate::provider::{EndpointRepo, ProviderClient, RemoteRepo, list_mirror_repos}; +use crate::provider::{ + EndpointRepo, ProviderClient, RemoteRepo, WebhookInstallOutcome, list_mirror_repos, +}; use crate::state::{load_toml_or_default, save_toml}; use crate::sync::{SyncOptions, sync_all}; @@ -575,66 +577,73 @@ fn run_uninstall_tasks(tasks: Vec, jobs: usize) -> Result< fn install_webhook_task(task: WebhookInstallTask, state: &Arc>) -> Result<()> { let key = webhook_installation_key(&task.group, &task.endpoint, &task.repo.name); - crate::logln!( - " {} {} {}", - style(if task.dry_run { - "would install" - } else { - "install" - }) - .green() - .bold(), - style(&task.repo.name).cyan(), - style(format!("webhook on {}", task.endpoint.label())).dim() - ); if task.dry_run { + crate::logln!( + " {} {} {}", + style("would install").green().bold(), + style(&task.repo.name).cyan(), + style(format!("webhook on {}", task.endpoint.label())).dim() + ); return Ok(()); } let client = ProviderClient::new(&task.site)?; - if let Err(error) = client.install_webhook(&task.endpoint, &task.repo, &task.url, &task.secret) - { - if is_duplicate_webhook_error(&error) { + match client.install_webhook(&task.endpoint, &task.repo, &task.url, &task.secret) { + Ok(outcome) => { + let action = match outcome { + WebhookInstallOutcome::Created => "install", + WebhookInstallOutcome::Existing => "exists", + }; crate::logln!( " {} {} {}", - style("exists").green().bold(), + style(action).green().bold(), style(&task.repo.name).cyan(), style(format!("webhook on {}", task.endpoint.label())).dim() ); record_webhook_installation(state, key, task); - return Ok(()); + Ok(()) } - if let Some(reason) = non_actionable_webhook_failure_reason(&error) { - crate::logln!( - " {} {} {}", - style("skip").yellow().bold(), - style(&task.repo.name).cyan(), - style(format!("webhook on {}: {reason}", task.endpoint.label())).dim() - ); - let mut state = state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.skipped.insert( - key, - SkippedWebhookInstallation { - group: task.group, - endpoint: task.endpoint, - repo: task.repo.name, - url: task.url, - reason, - }, - ); - return Ok(()); + Err(error) => { + if is_duplicate_webhook_error(&error) { + crate::logln!( + " {} {} {}", + style("exists").green().bold(), + style(&task.repo.name).cyan(), + style(format!("webhook on {}", task.endpoint.label())).dim() + ); + record_webhook_installation(state, key, task); + return Ok(()); + } + if let Some(reason) = non_actionable_webhook_failure_reason(&error) { + crate::logln!( + " {} {} {}", + style("skip").yellow().bold(), + style(&task.repo.name).cyan(), + style(format!("webhook on {}: {reason}", task.endpoint.label())).dim() + ); + let mut state = state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.skipped.insert( + key, + SkippedWebhookInstallation { + group: task.group, + endpoint: task.endpoint, + repo: task.repo.name, + url: task.url, + reason, + }, + ); + return Ok(()); + } + Err(error).with_context(|| { + format!( + "failed to install webhook for {} on {}", + task.repo.name, + task.endpoint.label() + ) + }) } - return Err(error).with_context(|| { - format!( - "failed to install webhook for {} on {}", - task.repo.name, - task.endpoint.label() - ) - }); } - record_webhook_installation(state, key, task); - Ok(()) } fn record_webhook_installation( diff --git a/tests/unit/provider.rs b/tests/unit/provider.rs index 29e82fa..105eb1b 100644 --- a/tests/unit/provider.rs +++ b/tests/unit/provider.rs @@ -309,7 +309,7 @@ fn install_webhook_posts_github_hook_when_missing() { }; let client = ProviderClient::new(&site).unwrap(); - client + let outcome = client .install_webhook( &EndpointConfig { site: "github".to_string(), @@ -326,6 +326,63 @@ fn install_webhook_posts_github_hook_when_missing() { "secret", ) .unwrap(); + + assert_eq!(outcome, WebhookInstallOutcome::Created); + handle.join().unwrap(); +} + +#[test] +fn install_webhook_reports_existing_forgejo_hook() { + let (api_url, handle) = request_server( + vec![ + ( + "200 OK", + r#"[{"id":42,"config":{"url":"https://mirror.example.test/webhook/"}}]"#, + ), + ("200 OK", r#"{"id":42}"#), + ], + |index, request| match index { + 0 => assert!( + request.starts_with("GET /repos/alice/repo/hooks "), + "request was {request}" + ), + 1 => { + assert!( + request.starts_with("PATCH /repos/alice/repo/hooks/42 "), + "request was {request}" + ); + assert!(request.contains("https://mirror.example.test/webhook")); + assert!(request.contains("secret")); + assert!(request.contains("push")); + } + _ => unreachable!(), + }, + ); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Forgejo, None) + }; + let client = ProviderClient::new(&site).unwrap(); + + let outcome = client + .install_webhook( + &EndpointConfig { + site: "forgejo".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + &RemoteRepo { + name: "repo".to_string(), + clone_url: "https://codeberg.org/alice/repo.git".to_string(), + private: true, + description: None, + }, + "https://mirror.example.test/webhook", + "secret", + ) + .unwrap(); + + assert_eq!(outcome, WebhookInstallOutcome::Existing); handle.join().unwrap(); }