1use std::collections::BTreeMap;
7
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12pub type WebhookConfig = shipper_webhook::WebhookConfig;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "event", rename_all = "snake_case")]
18pub enum WebhookEvent {
19 PublishStarted {
21 plan_id: String,
22 package_count: usize,
23 registry: String,
24 },
25 PublishSucceeded {
27 plan_id: String,
28 package_name: String,
29 package_version: String,
30 duration_ms: u64,
31 },
32 PublishFailed {
34 plan_id: String,
35 package_name: String,
36 package_version: String,
37 error_class: String,
38 message: String,
39 },
40 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#[derive(Debug, Serialize, Deserialize)]
53pub struct WebhookPayload {
54 pub timestamp: DateTime<Utc>,
56 pub event: WebhookEvent,
58}
59
60#[derive(Clone)]
62pub struct WebhookClient {
63 config: WebhookConfig,
64}
65
66impl WebhookClient {
67 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 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
95pub 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}