ricecoder_github/managers/
webhook_operations.rs1use crate::errors::{GitHubError, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct WebhookRetryConfig {
11 pub max_retries: u32,
13 pub initial_delay_ms: u64,
15 pub max_delay_ms: u64,
17 pub backoff_multiplier: f64,
19}
20
21impl WebhookRetryConfig {
22 pub fn new() -> Self {
24 Self {
25 max_retries: 3,
26 initial_delay_ms: 100,
27 max_delay_ms: 10000,
28 backoff_multiplier: 2.0,
29 }
30 }
31
32 pub fn calculate_delay(&self, attempt: u32) -> u64 {
34 let delay = (self.initial_delay_ms as f64
35 * self.backoff_multiplier.powi(attempt as i32)) as u64;
36 delay.min(self.max_delay_ms)
37 }
38}
39
40impl Default for WebhookRetryConfig {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct WebhookErrorDetails {
49 pub code: String,
51 pub message: String,
53 pub context: HashMap<String, String>,
55 pub timestamp: chrono::DateTime<chrono::Utc>,
57}
58
59impl WebhookErrorDetails {
60 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
62 Self {
63 code: code.into(),
64 message: message.into(),
65 context: HashMap::new(),
66 timestamp: chrono::Utc::now(),
67 }
68 }
69
70 pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
72 self.context.insert(key.into(), value.into());
73 self
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct WebhookErrorHandlingResult {
80 pub handled: bool,
82 pub should_retry: bool,
84 pub error: WebhookErrorDetails,
86 pub retry_attempt: u32,
88}
89
90impl WebhookErrorHandlingResult {
91 pub fn new(error: WebhookErrorDetails) -> Self {
93 Self {
94 handled: false,
95 should_retry: false,
96 error,
97 retry_attempt: 0,
98 }
99 }
100
101 pub fn with_handled(mut self, handled: bool) -> Self {
103 self.handled = handled;
104 self
105 }
106
107 pub fn with_retry(mut self, should_retry: bool) -> Self {
109 self.should_retry = should_retry;
110 self
111 }
112
113 pub fn with_attempt(mut self, attempt: u32) -> Self {
115 self.retry_attempt = attempt;
116 self
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct WebhookEventLogEntry {
123 pub event_id: String,
125 pub event_type: String,
127 pub action: Option<String>,
129 pub status: String,
131 pub timestamp: chrono::DateTime<chrono::Utc>,
133 pub error: Option<String>,
135 pub duration_ms: u64,
137}
138
139impl WebhookEventLogEntry {
140 pub fn new(event_id: impl Into<String>, event_type: impl Into<String>) -> Self {
142 Self {
143 event_id: event_id.into(),
144 event_type: event_type.into(),
145 action: None,
146 status: "pending".to_string(),
147 timestamp: chrono::Utc::now(),
148 error: None,
149 duration_ms: 0,
150 }
151 }
152
153 pub fn with_action(mut self, action: impl Into<String>) -> Self {
155 self.action = Some(action.into());
156 self
157 }
158
159 pub fn with_processed(mut self, duration_ms: u64) -> Self {
161 self.status = "processed".to_string();
162 self.duration_ms = duration_ms;
163 self
164 }
165
166 pub fn with_error(mut self, error: impl Into<String>) -> Self {
168 self.status = "failed".to_string();
169 self.error = Some(error.into());
170 self
171 }
172
173 pub fn with_filtered(mut self) -> Self {
175 self.status = "filtered".to_string();
176 self
177 }
178}
179
180pub struct WebhookEventLogger {
182 entries: Vec<WebhookEventLogEntry>,
183 max_entries: usize,
184}
185
186impl WebhookEventLogger {
187 pub fn new(max_entries: usize) -> Self {
189 Self {
190 entries: Vec::new(),
191 max_entries,
192 }
193 }
194
195 pub fn log(&mut self, entry: WebhookEventLogEntry) {
197 self.entries.push(entry);
198 if self.entries.len() > self.max_entries {
199 self.entries.remove(0);
200 }
201 }
202
203 pub fn entries(&self) -> &[WebhookEventLogEntry] {
205 &self.entries
206 }
207
208 pub fn entries_by_status(&self, status: &str) -> Vec<&WebhookEventLogEntry> {
210 self.entries.iter().filter(|e| e.status == status).collect()
211 }
212
213 pub fn entries_by_type(&self, event_type: &str) -> Vec<&WebhookEventLogEntry> {
215 self.entries
216 .iter()
217 .filter(|e| e.event_type == event_type)
218 .collect()
219 }
220
221 pub fn clear(&mut self) {
223 self.entries.clear();
224 }
225
226 pub fn statistics(&self) -> WebhookEventStatistics {
228 let total = self.entries.len();
229 let processed = self.entries_by_status("processed").len();
230 let failed = self.entries_by_status("failed").len();
231 let filtered = self.entries_by_status("filtered").len();
232
233 WebhookEventStatistics {
234 total_events: total,
235 processed_events: processed,
236 failed_events: failed,
237 filtered_events: filtered,
238 }
239 }
240}
241
242impl Clone for WebhookEventLogger {
243 fn clone(&self) -> Self {
244 Self {
245 entries: self.entries.clone(),
246 max_entries: self.max_entries,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct WebhookEventStatistics {
254 pub total_events: usize,
256 pub processed_events: usize,
258 pub failed_events: usize,
260 pub filtered_events: usize,
262}
263
264impl WebhookEventStatistics {
265 pub fn success_rate(&self) -> f64 {
267 if self.total_events == 0 {
268 return 0.0;
269 }
270 (self.processed_events as f64) / (self.total_events as f64)
271 }
272
273 pub fn failure_rate(&self) -> f64 {
275 if self.total_events == 0 {
276 return 0.0;
277 }
278 (self.failed_events as f64) / (self.total_events as f64)
279 }
280}
281
282pub struct WebhookOperations;
284
285impl WebhookOperations {
286 pub fn handle_error(
288 error: &GitHubError,
289 retry_config: &WebhookRetryConfig,
290 attempt: u32,
291 ) -> WebhookErrorHandlingResult {
292 let error_details = match error {
293 GitHubError::RateLimitExceeded => {
294 WebhookErrorDetails::new("RATE_LIMIT", "GitHub API rate limit exceeded")
295 }
296 GitHubError::AuthError(msg) => {
297 WebhookErrorDetails::new("AUTH_ERROR", format!("Authentication failed: {}", msg))
298 }
299 GitHubError::NetworkError(msg) => {
300 WebhookErrorDetails::new("NETWORK_ERROR", format!("Network error: {}", msg))
301 }
302 GitHubError::Timeout => {
303 WebhookErrorDetails::new("TIMEOUT", "Operation timed out")
304 }
305 _ => WebhookErrorDetails::new("UNKNOWN_ERROR", error.to_string()),
306 };
307
308 let should_retry = attempt < retry_config.max_retries
309 && matches!(
310 error,
311 GitHubError::NetworkError(_)
312 | GitHubError::Timeout
313 | GitHubError::RateLimitExceeded
314 );
315
316 WebhookErrorHandlingResult::new(error_details)
317 .with_handled(true)
318 .with_retry(should_retry)
319 .with_attempt(attempt)
320 }
321
322 pub fn validate_payload(payload: &Value) -> Result<()> {
324 if !payload.is_object() {
325 return Err(GitHubError::invalid_input("Webhook payload must be a JSON object"));
326 }
327
328 if payload.get("action").is_none() && payload.get("repository").is_none() {
330 return Err(GitHubError::invalid_input(
331 "Webhook payload must contain 'action' or 'repository' field",
332 ));
333 }
334
335 Ok(())
336 }
337
338 pub fn extract_metadata(payload: &Value) -> HashMap<String, String> {
340 let mut metadata = HashMap::new();
341
342 if let Some(repo) = payload.get("repository") {
343 if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
344 metadata.insert("repository".to_string(), name.to_string());
345 }
346 if let Some(owner) = repo.get("owner").and_then(|o| o.get("login")).and_then(|l| l.as_str()) {
347 metadata.insert("owner".to_string(), owner.to_string());
348 }
349 }
350
351 if let Some(action) = payload.get("action").and_then(|a| a.as_str()) {
352 metadata.insert("action".to_string(), action.to_string());
353 }
354
355 if let Some(sender) = payload.get("sender").and_then(|s| s.get("login")).and_then(|l| l.as_str()) {
356 metadata.insert("sender".to_string(), sender.to_string());
357 }
358
359 metadata
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn test_retry_config_calculate_delay() {
369 let config = WebhookRetryConfig::new();
370 assert_eq!(config.calculate_delay(0), 100);
371 assert_eq!(config.calculate_delay(1), 200);
372 assert_eq!(config.calculate_delay(2), 400);
373 }
374
375 #[test]
376 fn test_retry_config_max_delay() {
377 let config = WebhookRetryConfig {
378 max_retries: 3,
379 initial_delay_ms: 100,
380 max_delay_ms: 500,
381 backoff_multiplier: 2.0,
382 };
383 assert_eq!(config.calculate_delay(0), 100);
384 assert_eq!(config.calculate_delay(1), 200);
385 assert_eq!(config.calculate_delay(2), 400);
386 assert_eq!(config.calculate_delay(3), 500); }
388
389 #[test]
390 fn test_event_logger_log_entry() {
391 let mut logger = WebhookEventLogger::new(10);
392 let entry = WebhookEventLogEntry::new("event-1", "push");
393 logger.log(entry);
394
395 assert_eq!(logger.entries().len(), 1);
396 }
397
398 #[test]
399 fn test_event_logger_max_entries() {
400 let mut logger = WebhookEventLogger::new(2);
401 logger.log(WebhookEventLogEntry::new("event-1", "push"));
402 logger.log(WebhookEventLogEntry::new("event-2", "push"));
403 logger.log(WebhookEventLogEntry::new("event-3", "push"));
404
405 assert_eq!(logger.entries().len(), 2);
406 assert_eq!(logger.entries()[0].event_id, "event-2");
407 assert_eq!(logger.entries()[1].event_id, "event-3");
408 }
409
410 #[test]
411 fn test_event_logger_statistics() {
412 let mut logger = WebhookEventLogger::new(10);
413 let mut entry1 = WebhookEventLogEntry::new("event-1", "push");
414 entry1.status = "processed".to_string();
415 logger.log(entry1);
416
417 let mut entry2 = WebhookEventLogEntry::new("event-2", "push");
418 entry2.status = "failed".to_string();
419 logger.log(entry2);
420
421 let stats = logger.statistics();
422 assert_eq!(stats.total_events, 2);
423 assert_eq!(stats.processed_events, 1);
424 assert_eq!(stats.failed_events, 1);
425 }
426
427 #[test]
428 fn test_event_statistics_success_rate() {
429 let stats = WebhookEventStatistics {
430 total_events: 10,
431 processed_events: 8,
432 failed_events: 2,
433 filtered_events: 0,
434 };
435 assert_eq!(stats.success_rate(), 0.8);
436 assert_eq!(stats.failure_rate(), 0.2);
437 }
438
439 #[test]
440 fn test_webhook_operations_validate_payload() {
441 let valid_payload = serde_json::json!({"action": "opened"});
442 assert!(WebhookOperations::validate_payload(&valid_payload).is_ok());
443
444 let invalid_payload = serde_json::json!("not an object");
445 assert!(WebhookOperations::validate_payload(&invalid_payload).is_err());
446 }
447
448 #[test]
449 fn test_webhook_operations_extract_metadata() {
450 let payload = serde_json::json!({
451 "action": "opened",
452 "repository": {
453 "name": "test-repo",
454 "owner": {
455 "login": "test-owner"
456 }
457 },
458 "sender": {
459 "login": "test-user"
460 }
461 });
462
463 let metadata = WebhookOperations::extract_metadata(&payload);
464 assert_eq!(metadata.get("action"), Some(&"opened".to_string()));
465 assert_eq!(metadata.get("repository"), Some(&"test-repo".to_string()));
466 assert_eq!(metadata.get("owner"), Some(&"test-owner".to_string()));
467 assert_eq!(metadata.get("sender"), Some(&"test-user".to_string()));
468 }
469}