Skip to main content

shipper_core/
webhook.rs

1//! Webhook notifications backed by the `shipper-webhook` microcrate.
2//!
3//! This module keeps `shipper`'s public webhook API stable while delegating the
4//! HTTP transport behavior to the dedicated microcrate.
5
6use std::collections::BTreeMap;
7
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// Webhook configuration type provided by the `shipper-webhook` microcrate.
13pub type WebhookConfig = shipper_webhook::WebhookConfig;
14
15/// Webhook events published during a publish run.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "event", rename_all = "snake_case")]
18pub enum WebhookEvent {
19    /// Publish workflow started.
20    PublishStarted {
21        plan_id: String,
22        package_count: usize,
23        registry: String,
24    },
25    /// A crate publish succeeded.
26    PublishSucceeded {
27        plan_id: String,
28        package_name: String,
29        package_version: String,
30        duration_ms: u64,
31    },
32    /// A crate publish failed.
33    PublishFailed {
34        plan_id: String,
35        package_name: String,
36        package_version: String,
37        error_class: String,
38        message: String,
39    },
40    /// All publish operations completed.
41    PublishCompleted {
42        plan_id: String,
43        total_packages: usize,
44        success_count: usize,
45        failure_count: usize,
46        skipped_count: usize,
47        result: String,
48    },
49}
50
51/// Typed webhook payload payload.
52#[derive(Debug, Serialize, Deserialize)]
53pub struct WebhookPayload {
54    /// Timestamp of the event (ISO 8601).
55    pub timestamp: DateTime<Utc>,
56    /// Event details.
57    pub event: WebhookEvent,
58}
59
60/// Webhook client for dispatching publish events.
61#[derive(Clone)]
62pub struct WebhookClient {
63    config: WebhookConfig,
64}
65
66impl WebhookClient {
67    /// Create a webhook client with the given configuration.
68    pub fn new(config: &WebhookConfig) -> Result<Self> {
69        if config.url.trim().is_empty() {
70            anyhow::bail!("webhook URL is required when webhooks are enabled");
71        }
72        Ok(Self {
73            config: config.clone(),
74        })
75    }
76
77    /// Send a webhook event asynchronously.
78    pub fn send_event(&self, event: WebhookEvent) {
79        let payload = WebhookPayload {
80            timestamp: Utc::now(),
81            event,
82        };
83
84        let client = self.clone();
85        let _ = std::thread::spawn(move || {
86            if let Err(e) =
87                shipper_webhook::send_webhook(&client.config, &to_micro_payload(&payload))
88            {
89                eprintln!("[warn] webhook delivery failed (non-blocking): {:#}", e);
90            }
91        });
92    }
93}
94
95/// Send a webhook event if webhooks are configured.
96pub fn maybe_send_event(config: &WebhookConfig, event: WebhookEvent) {
97    if config.url.trim().is_empty() {
98        return;
99    }
100
101    let client = match WebhookClient::new(config) {
102        Ok(client) => client,
103        Err(e) => {
104            eprintln!("[warn] failed to build webhook client: {:#}", e);
105            return;
106        }
107    };
108
109    let _ = std::thread::spawn(move || {
110        let payload = WebhookPayload {
111            timestamp: Utc::now(),
112            event,
113        };
114
115        if let Err(e) = shipper_webhook::send_webhook(&client.config, &to_micro_payload(&payload)) {
116            eprintln!("[warn] webhook delivery failed (non-blocking): {:#}", e);
117        }
118    });
119}
120
121fn to_micro_payload(payload: &WebhookPayload) -> shipper_webhook::WebhookPayload {
122    let (message, title, success, package, version, registry, error, extra) = match &payload.event {
123        WebhookEvent::PublishStarted {
124            plan_id,
125            package_count,
126            registry,
127        } => (
128            format!("publish started for plan {plan_id} ({package_count} packages) on {registry}"),
129            Some("Publish Started".to_string()),
130            true,
131            None,
132            None,
133            Some(registry.clone()),
134            None,
135            serde_json::json!({
136                "event": "publish_started",
137                "plan_id": plan_id,
138                "package_count": package_count,
139                "registry": registry,
140            }),
141        ),
142        WebhookEvent::PublishSucceeded {
143            plan_id,
144            package_name,
145            package_version,
146            duration_ms,
147            ..
148        } => (
149            format!(
150                "publish succeeded for package {package_name} version {package_version} in {duration_ms}ms (plan {plan_id})"
151            ),
152            Some("Publish Succeeded".to_string()),
153            true,
154            Some(package_name.clone()),
155            Some(package_version.clone()),
156            None,
157            None,
158            serde_json::json!({
159                "event": "publish_succeeded",
160                "plan_id": plan_id,
161                "duration_ms": duration_ms,
162            }),
163        ),
164        WebhookEvent::PublishFailed {
165            plan_id,
166            package_name,
167            package_version,
168            error_class,
169            message,
170            ..
171        } => (
172            format!(
173                "publish failed for package {package_name} version {package_version} ({error_class}): {message}"
174            ),
175            Some("Publish Failed".to_string()),
176            false,
177            Some(package_name.clone()),
178            Some(package_version.clone()),
179            None,
180            Some(message.clone()),
181            serde_json::json!({
182                "event": "publish_failed",
183                "plan_id": plan_id,
184                "error_class": error_class,
185            }),
186        ),
187        WebhookEvent::PublishCompleted {
188            plan_id,
189            total_packages,
190            success_count,
191            failure_count,
192            skipped_count,
193            result,
194        } => (
195            format!(
196                "publish completed: {success_count}/{total_packages} succeeded, {failure_count} failed, {skipped_count} skipped (plan {plan_id}, result: {result})"
197            ),
198            Some("Publish Completed".to_string()),
199            *failure_count == 0,
200            None,
201            None,
202            None,
203            None,
204            serde_json::json!({
205                "event": "publish_completed",
206                "plan_id": plan_id,
207                "total_packages": total_packages,
208                "success_count": success_count,
209                "failure_count": failure_count,
210            "skipped_count": skipped_count,
211            "result": result,
212            }),
213        ),
214    };
215
216    let mut extra_fields = BTreeMap::new();
217    extra_fields.insert("legacy".to_string(), extra);
218
219    shipper_webhook::WebhookPayload {
220        message,
221        title,
222        success,
223        package,
224        version,
225        registry,
226        error,
227        extra: extra_fields,
228    }
229}