ricecoder_github/managers/
webhook_operations.rs

1//! Webhook Operations - Advanced webhook operations and utilities
2
3use crate::errors::{GitHubError, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7
8/// Webhook retry configuration
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct WebhookRetryConfig {
11    /// Maximum number of retries
12    pub max_retries: u32,
13    /// Initial retry delay in milliseconds
14    pub initial_delay_ms: u64,
15    /// Maximum retry delay in milliseconds
16    pub max_delay_ms: u64,
17    /// Backoff multiplier
18    pub backoff_multiplier: f64,
19}
20
21impl WebhookRetryConfig {
22    /// Create a new retry configuration
23    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    /// Calculate the delay for a given retry attempt
33    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/// Webhook error details
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct WebhookErrorDetails {
49    /// Error code
50    pub code: String,
51    /// Error message
52    pub message: String,
53    /// Error context
54    pub context: HashMap<String, String>,
55    /// Timestamp
56    pub timestamp: chrono::DateTime<chrono::Utc>,
57}
58
59impl WebhookErrorDetails {
60    /// Create a new error details
61    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    /// Add context information
71    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/// Webhook error handling result
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct WebhookErrorHandlingResult {
80    /// Whether the error was handled
81    pub handled: bool,
82    /// Whether to retry
83    pub should_retry: bool,
84    /// Error details
85    pub error: WebhookErrorDetails,
86    /// Retry attempt number
87    pub retry_attempt: u32,
88}
89
90impl WebhookErrorHandlingResult {
91    /// Create a new error handling result
92    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    /// Mark as handled
102    pub fn with_handled(mut self, handled: bool) -> Self {
103        self.handled = handled;
104        self
105    }
106
107    /// Mark for retry
108    pub fn with_retry(mut self, should_retry: bool) -> Self {
109        self.should_retry = should_retry;
110        self
111    }
112
113    /// Set retry attempt
114    pub fn with_attempt(mut self, attempt: u32) -> Self {
115        self.retry_attempt = attempt;
116        self
117    }
118}
119
120/// Webhook event log entry
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct WebhookEventLogEntry {
123    /// Event ID
124    pub event_id: String,
125    /// Event type
126    pub event_type: String,
127    /// Event action
128    pub action: Option<String>,
129    /// Processing status
130    pub status: String,
131    /// Timestamp
132    pub timestamp: chrono::DateTime<chrono::Utc>,
133    /// Error if any
134    pub error: Option<String>,
135    /// Processing duration in milliseconds
136    pub duration_ms: u64,
137}
138
139impl WebhookEventLogEntry {
140    /// Create a new log entry
141    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    /// Set the action
154    pub fn with_action(mut self, action: impl Into<String>) -> Self {
155        self.action = Some(action.into());
156        self
157    }
158
159    /// Mark as processed
160    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    /// Mark as failed
167    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    /// Mark as filtered
174    pub fn with_filtered(mut self) -> Self {
175        self.status = "filtered".to_string();
176        self
177    }
178}
179
180/// Webhook event logger
181pub struct WebhookEventLogger {
182    entries: Vec<WebhookEventLogEntry>,
183    max_entries: usize,
184}
185
186impl WebhookEventLogger {
187    /// Create a new event logger
188    pub fn new(max_entries: usize) -> Self {
189        Self {
190            entries: Vec::new(),
191            max_entries,
192        }
193    }
194
195    /// Log an event
196    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    /// Get all entries
204    pub fn entries(&self) -> &[WebhookEventLogEntry] {
205        &self.entries
206    }
207
208    /// Get entries by status
209    pub fn entries_by_status(&self, status: &str) -> Vec<&WebhookEventLogEntry> {
210        self.entries.iter().filter(|e| e.status == status).collect()
211    }
212
213    /// Get entries by event type
214    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    /// Clear all entries
222    pub fn clear(&mut self) {
223        self.entries.clear();
224    }
225
226    /// Get statistics
227    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/// Webhook event statistics
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct WebhookEventStatistics {
254    /// Total events
255    pub total_events: usize,
256    /// Processed events
257    pub processed_events: usize,
258    /// Failed events
259    pub failed_events: usize,
260    /// Filtered events
261    pub filtered_events: usize,
262}
263
264impl WebhookEventStatistics {
265    /// Get the success rate
266    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    /// Get the failure rate
274    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
282/// Webhook operations
283pub struct WebhookOperations;
284
285impl WebhookOperations {
286    /// Handle a webhook error
287    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    /// Validate webhook payload
323    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        // Check for required fields
329        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    /// Extract event metadata
339    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); // Capped at max_delay_ms
387    }
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}