ricecoder_github/managers/
webhook_handler.rs

1//! Webhook Handler - Processes GitHub webhooks and triggers workflows
2
3use crate::errors::{GitHubError, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::sync::Arc;
8use tokio::sync::RwLock;
9use tracing::{debug, info};
10
11/// Webhook event type
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum WebhookEventType {
15    /// Push event
16    Push,
17    /// Pull request event
18    PullRequest,
19    /// Issues event
20    Issues,
21    /// Discussion event
22    Discussion,
23    /// Release event
24    Release,
25    /// Workflow run event
26    WorkflowRun,
27    /// Repository event
28    Repository,
29    /// Unknown event
30    #[serde(other)]
31    Unknown,
32}
33
34impl std::fmt::Display for WebhookEventType {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            WebhookEventType::Push => write!(f, "push"),
38            WebhookEventType::PullRequest => write!(f, "pull_request"),
39            WebhookEventType::Issues => write!(f, "issues"),
40            WebhookEventType::Discussion => write!(f, "discussion"),
41            WebhookEventType::Release => write!(f, "release"),
42            WebhookEventType::WorkflowRun => write!(f, "workflow_run"),
43            WebhookEventType::Repository => write!(f, "repository"),
44            WebhookEventType::Unknown => write!(f, "unknown"),
45        }
46    }
47}
48
49/// Webhook action
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct WebhookAction {
52    /// Action type (e.g., "opened", "closed", "synchronize")
53    pub action: String,
54}
55
56/// Webhook event
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct WebhookEvent {
59    /// Event type
60    pub event_type: WebhookEventType,
61    /// Event action
62    pub action: Option<String>,
63    /// Event payload
64    pub payload: Value,
65    /// Timestamp
66    pub timestamp: chrono::DateTime<chrono::Utc>,
67}
68
69impl WebhookEvent {
70    /// Create a new webhook event
71    pub fn new(event_type: WebhookEventType, payload: Value) -> Self {
72        let action = payload
73            .get("action")
74            .and_then(|v| v.as_str())
75            .map(|s| s.to_string());
76
77        Self {
78            event_type,
79            action,
80            payload,
81            timestamp: chrono::Utc::now(),
82        }
83    }
84
85    /// Get the repository name from the payload
86    pub fn repository_name(&self) -> Option<String> {
87        self.payload
88            .get("repository")
89            .and_then(|r| r.get("name"))
90            .and_then(|n| n.as_str())
91            .map(|s| s.to_string())
92    }
93
94    /// Get the repository owner from the payload
95    pub fn repository_owner(&self) -> Option<String> {
96        self.payload
97            .get("repository")
98            .and_then(|r| r.get("owner"))
99            .and_then(|o| o.get("login"))
100            .and_then(|l| l.as_str())
101            .map(|s| s.to_string())
102    }
103}
104
105/// Event filter configuration
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct EventFilter {
108    /// Event types to process
109    pub event_types: Vec<WebhookEventType>,
110    /// Actions to process (if empty, all actions are processed)
111    pub actions: Vec<String>,
112    /// Repository filter (if empty, all repositories are processed)
113    pub repositories: Vec<String>,
114}
115
116impl EventFilter {
117    /// Create a new event filter
118    pub fn new() -> Self {
119        Self {
120            event_types: vec![],
121            actions: vec![],
122            repositories: vec![],
123        }
124    }
125
126    /// Add an event type to the filter
127    pub fn with_event_type(mut self, event_type: WebhookEventType) -> Self {
128        self.event_types.push(event_type);
129        self
130    }
131
132    /// Add an action to the filter
133    pub fn with_action(mut self, action: impl Into<String>) -> Self {
134        self.actions.push(action.into());
135        self
136    }
137
138    /// Add a repository to the filter
139    pub fn with_repository(mut self, repo: impl Into<String>) -> Self {
140        self.repositories.push(repo.into());
141        self
142    }
143
144    /// Check if an event matches the filter
145    pub fn matches(&self, event: &WebhookEvent) -> bool {
146        // Check event type
147        if !self.event_types.is_empty() && !self.event_types.contains(&event.event_type) {
148            return false;
149        }
150
151        // Check action
152        if !self.actions.is_empty() {
153            if let Some(action) = &event.action {
154                if !self.actions.contains(action) {
155                    return false;
156                }
157            } else if !self.actions.is_empty() {
158                return false;
159            }
160        }
161
162        // Check repository
163        if !self.repositories.is_empty() {
164            if let Some(repo) = event.repository_name() {
165                if !self.repositories.contains(&repo) {
166                    return false;
167                }
168            } else {
169                return false;
170            }
171        }
172
173        true
174    }
175}
176
177impl Default for EventFilter {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183/// Workflow trigger configuration
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct WorkflowTrigger {
186    /// Workflow name or ID
187    pub workflow_id: String,
188    /// Event filter
189    pub filter: EventFilter,
190    /// Workflow input parameters
191    pub inputs: HashMap<String, String>,
192}
193
194impl WorkflowTrigger {
195    /// Create a new workflow trigger
196    pub fn new(workflow_id: impl Into<String>) -> Self {
197        Self {
198            workflow_id: workflow_id.into(),
199            filter: EventFilter::new(),
200            inputs: HashMap::new(),
201        }
202    }
203
204    /// Set the filter
205    pub fn with_filter(mut self, filter: EventFilter) -> Self {
206        self.filter = filter;
207        self
208    }
209
210    /// Add an input parameter
211    pub fn with_input(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
212        self.inputs.insert(key.into(), value.into());
213        self
214    }
215}
216
217/// Webhook processing result
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct WebhookProcessingResult {
220    /// Whether the event was processed
221    pub processed: bool,
222    /// Whether the event matched any filters
223    pub matched_filter: bool,
224    /// Workflows triggered
225    pub workflows_triggered: Vec<String>,
226    /// Error message if any
227    pub error: Option<String>,
228}
229
230impl WebhookProcessingResult {
231    /// Create a new processing result
232    pub fn new() -> Self {
233        Self {
234            processed: false,
235            matched_filter: false,
236            workflows_triggered: vec![],
237            error: None,
238        }
239    }
240
241    /// Mark as processed
242    pub fn with_processed(mut self, processed: bool) -> Self {
243        self.processed = processed;
244        self
245    }
246
247    /// Mark as matched
248    pub fn with_matched(mut self, matched: bool) -> Self {
249        self.matched_filter = matched;
250        self
251    }
252
253    /// Add a triggered workflow
254    pub fn add_workflow(mut self, workflow: impl Into<String>) -> Self {
255        self.workflows_triggered.push(workflow.into());
256        self
257    }
258
259    /// Set error
260    pub fn with_error(mut self, error: impl Into<String>) -> Self {
261        self.error = Some(error.into());
262        self
263    }
264}
265
266impl Default for WebhookProcessingResult {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272/// Webhook handler configuration
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct WebhookHandlerConfig {
275    /// Webhook secret for signature verification
276    pub secret: Option<String>,
277    /// Workflow triggers
278    pub triggers: Vec<WorkflowTrigger>,
279    /// Enable event logging
280    pub log_events: bool,
281    /// Enable event filtering
282    pub enable_filtering: bool,
283}
284
285impl WebhookHandlerConfig {
286    /// Create a new webhook handler configuration
287    pub fn new() -> Self {
288        Self {
289            secret: None,
290            triggers: vec![],
291            log_events: true,
292            enable_filtering: true,
293        }
294    }
295
296    /// Set the webhook secret
297    pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
298        self.secret = Some(secret.into());
299        self
300    }
301
302    /// Add a workflow trigger
303    pub fn add_trigger(mut self, trigger: WorkflowTrigger) -> Self {
304        self.triggers.push(trigger);
305        self
306    }
307
308    /// Enable or disable event logging
309    pub fn with_logging(mut self, enabled: bool) -> Self {
310        self.log_events = enabled;
311        self
312    }
313
314    /// Enable or disable event filtering
315    pub fn with_filtering(mut self, enabled: bool) -> Self {
316        self.enable_filtering = enabled;
317        self
318    }
319}
320
321impl Default for WebhookHandlerConfig {
322    fn default() -> Self {
323        Self::new()
324    }
325}
326
327/// Webhook handler
328pub struct WebhookHandler {
329    config: Arc<RwLock<WebhookHandlerConfig>>,
330    event_log: Arc<RwLock<Vec<WebhookEvent>>>,
331}
332
333impl WebhookHandler {
334    /// Create a new webhook handler
335    pub fn new(config: WebhookHandlerConfig) -> Self {
336        Self {
337            config: Arc::new(RwLock::new(config)),
338            event_log: Arc::new(RwLock::new(Vec::new())),
339        }
340    }
341
342    /// Process a webhook event
343    pub async fn process_event(&self, event: WebhookEvent) -> Result<WebhookProcessingResult> {
344        let config = self.config.read().await;
345
346        // Log the event if enabled
347        if config.log_events {
348            info!(
349                event_type = %event.event_type,
350                action = ?event.action,
351                timestamp = %event.timestamp,
352                "Webhook event received"
353            );
354        }
355
356        let mut result = WebhookProcessingResult::new();
357
358        // Check if filtering is enabled
359        if config.enable_filtering {
360            // Find matching triggers
361            let mut matched = false;
362            for trigger in &config.triggers {
363                if trigger.filter.matches(&event) {
364                    matched = true;
365                    result = result.add_workflow(trigger.workflow_id.clone());
366                    debug!(
367                        workflow_id = %trigger.workflow_id,
368                        "Workflow trigger matched"
369                    );
370                }
371            }
372            result = result.with_matched(matched);
373        } else {
374            // If filtering is disabled, trigger all workflows
375            for trigger in &config.triggers {
376                result = result.add_workflow(trigger.workflow_id.clone());
377            }
378            result = result.with_matched(true);
379        }
380
381        result = result.with_processed(true);
382
383        // Store event in log
384        let mut log = self.event_log.write().await;
385        log.push(event);
386
387        Ok(result)
388    }
389
390    /// Get the event log
391    pub async fn get_event_log(&self) -> Vec<WebhookEvent> {
392        self.event_log.read().await.clone()
393    }
394
395    /// Clear the event log
396    pub async fn clear_event_log(&self) {
397        self.event_log.write().await.clear();
398    }
399
400    /// Get the number of events in the log
401    pub async fn event_log_size(&self) -> usize {
402        self.event_log.read().await.len()
403    }
404
405    /// Update the configuration
406    pub async fn update_config(&self, config: WebhookHandlerConfig) {
407        let mut cfg = self.config.write().await;
408        *cfg = config;
409    }
410
411    /// Get the current configuration
412    pub async fn get_config(&self) -> WebhookHandlerConfig {
413        self.config.read().await.clone()
414    }
415
416    /// Verify webhook signature
417    pub fn verify_signature(
418        &self,
419        payload: &[u8],
420        signature: &str,
421        secret: &str,
422    ) -> Result<bool> {
423        use hmac::{Hmac, Mac};
424        use sha2::Sha256;
425
426        type HmacSha256 = Hmac<Sha256>;
427
428        let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
429            .map_err(|_| GitHubError::invalid_input("Invalid secret"))?;
430        mac.update(payload);
431
432        let computed = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
433        Ok(computed == signature)
434    }
435}
436
437impl Clone for WebhookHandler {
438    fn clone(&self) -> Self {
439        Self {
440            config: Arc::clone(&self.config),
441            event_log: Arc::clone(&self.event_log),
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use serde_json::json;
450
451    #[test]
452    fn test_event_filter_matches_event_type() {
453        let filter = EventFilter::new().with_event_type(WebhookEventType::Push);
454        let event = WebhookEvent::new(WebhookEventType::Push, json!({}));
455        assert!(filter.matches(&event));
456
457        let event = WebhookEvent::new(WebhookEventType::PullRequest, json!({}));
458        assert!(!filter.matches(&event));
459    }
460
461    #[test]
462    fn test_event_filter_matches_action() {
463        let filter = EventFilter::new()
464            .with_event_type(WebhookEventType::PullRequest)
465            .with_action("opened");
466
467        let event = WebhookEvent::new(
468            WebhookEventType::PullRequest,
469            json!({"action": "opened"}),
470        );
471        assert!(filter.matches(&event));
472
473        let event = WebhookEvent::new(
474            WebhookEventType::PullRequest,
475            json!({"action": "closed"}),
476        );
477        assert!(!filter.matches(&event));
478    }
479
480    #[test]
481    fn test_event_filter_matches_repository() {
482        let filter = EventFilter::new().with_repository("my-repo");
483
484        let event = WebhookEvent::new(
485            WebhookEventType::Push,
486            json!({"repository": {"name": "my-repo"}}),
487        );
488        assert!(filter.matches(&event));
489
490        let event = WebhookEvent::new(
491            WebhookEventType::Push,
492            json!({"repository": {"name": "other-repo"}}),
493        );
494        assert!(!filter.matches(&event));
495    }
496
497    #[test]
498    fn test_webhook_event_extraction() {
499        let payload = json!({
500            "action": "opened",
501            "repository": {
502                "name": "test-repo",
503                "owner": {
504                    "login": "test-owner"
505                }
506            }
507        });
508
509        let event = WebhookEvent::new(WebhookEventType::PullRequest, payload);
510        assert_eq!(event.action, Some("opened".to_string()));
511        assert_eq!(event.repository_name(), Some("test-repo".to_string()));
512        assert_eq!(event.repository_owner(), Some("test-owner".to_string()));
513    }
514
515    #[tokio::test]
516    async fn test_webhook_handler_processes_event() {
517        let config = WebhookHandlerConfig::new();
518        let handler = WebhookHandler::new(config);
519
520        let event = WebhookEvent::new(WebhookEventType::Push, json!({}));
521        let result = handler.process_event(event.clone()).await.unwrap();
522
523        assert!(result.processed);
524        assert_eq!(handler.event_log_size().await, 1);
525    }
526
527    #[tokio::test]
528    async fn test_webhook_handler_filters_events() {
529        let trigger = WorkflowTrigger::new("test-workflow")
530            .with_filter(EventFilter::new().with_event_type(WebhookEventType::Push));
531
532        let config = WebhookHandlerConfig::new().add_trigger(trigger);
533        let handler = WebhookHandler::new(config);
534
535        let event = WebhookEvent::new(WebhookEventType::Push, json!({}));
536        let result = handler.process_event(event).await.unwrap();
537
538        assert!(result.matched_filter);
539        assert_eq!(result.workflows_triggered.len(), 1);
540    }
541
542    #[tokio::test]
543    async fn test_webhook_handler_no_match() {
544        let trigger = WorkflowTrigger::new("test-workflow")
545            .with_filter(EventFilter::new().with_event_type(WebhookEventType::Push));
546
547        let config = WebhookHandlerConfig::new().add_trigger(trigger);
548        let handler = WebhookHandler::new(config);
549
550        let event = WebhookEvent::new(WebhookEventType::PullRequest, json!({}));
551        let result = handler.process_event(event).await.unwrap();
552
553        assert!(!result.matched_filter);
554        assert_eq!(result.workflows_triggered.len(), 0);
555    }
556}