ricecoder_github/managers/
webhook_handler.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum WebhookEventType {
15 Push,
17 PullRequest,
19 Issues,
21 Discussion,
23 Release,
25 WorkflowRun,
27 Repository,
29 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct WebhookAction {
52 pub action: String,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct WebhookEvent {
59 pub event_type: WebhookEventType,
61 pub action: Option<String>,
63 pub payload: Value,
65 pub timestamp: chrono::DateTime<chrono::Utc>,
67}
68
69impl WebhookEvent {
70 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct EventFilter {
108 pub event_types: Vec<WebhookEventType>,
110 pub actions: Vec<String>,
112 pub repositories: Vec<String>,
114}
115
116impl EventFilter {
117 pub fn new() -> Self {
119 Self {
120 event_types: vec![],
121 actions: vec![],
122 repositories: vec![],
123 }
124 }
125
126 pub fn with_event_type(mut self, event_type: WebhookEventType) -> Self {
128 self.event_types.push(event_type);
129 self
130 }
131
132 pub fn with_action(mut self, action: impl Into<String>) -> Self {
134 self.actions.push(action.into());
135 self
136 }
137
138 pub fn with_repository(mut self, repo: impl Into<String>) -> Self {
140 self.repositories.push(repo.into());
141 self
142 }
143
144 pub fn matches(&self, event: &WebhookEvent) -> bool {
146 if !self.event_types.is_empty() && !self.event_types.contains(&event.event_type) {
148 return false;
149 }
150
151 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct WorkflowTrigger {
186 pub workflow_id: String,
188 pub filter: EventFilter,
190 pub inputs: HashMap<String, String>,
192}
193
194impl WorkflowTrigger {
195 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 pub fn with_filter(mut self, filter: EventFilter) -> Self {
206 self.filter = filter;
207 self
208 }
209
210 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#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct WebhookProcessingResult {
220 pub processed: bool,
222 pub matched_filter: bool,
224 pub workflows_triggered: Vec<String>,
226 pub error: Option<String>,
228}
229
230impl WebhookProcessingResult {
231 pub fn new() -> Self {
233 Self {
234 processed: false,
235 matched_filter: false,
236 workflows_triggered: vec![],
237 error: None,
238 }
239 }
240
241 pub fn with_processed(mut self, processed: bool) -> Self {
243 self.processed = processed;
244 self
245 }
246
247 pub fn with_matched(mut self, matched: bool) -> Self {
249 self.matched_filter = matched;
250 self
251 }
252
253 pub fn add_workflow(mut self, workflow: impl Into<String>) -> Self {
255 self.workflows_triggered.push(workflow.into());
256 self
257 }
258
259 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#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct WebhookHandlerConfig {
275 pub secret: Option<String>,
277 pub triggers: Vec<WorkflowTrigger>,
279 pub log_events: bool,
281 pub enable_filtering: bool,
283}
284
285impl WebhookHandlerConfig {
286 pub fn new() -> Self {
288 Self {
289 secret: None,
290 triggers: vec![],
291 log_events: true,
292 enable_filtering: true,
293 }
294 }
295
296 pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
298 self.secret = Some(secret.into());
299 self
300 }
301
302 pub fn add_trigger(mut self, trigger: WorkflowTrigger) -> Self {
304 self.triggers.push(trigger);
305 self
306 }
307
308 pub fn with_logging(mut self, enabled: bool) -> Self {
310 self.log_events = enabled;
311 self
312 }
313
314 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
327pub struct WebhookHandler {
329 config: Arc<RwLock<WebhookHandlerConfig>>,
330 event_log: Arc<RwLock<Vec<WebhookEvent>>>,
331}
332
333impl WebhookHandler {
334 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 pub async fn process_event(&self, event: WebhookEvent) -> Result<WebhookProcessingResult> {
344 let config = self.config.read().await;
345
346 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 if config.enable_filtering {
360 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 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 let mut log = self.event_log.write().await;
385 log.push(event);
386
387 Ok(result)
388 }
389
390 pub async fn get_event_log(&self) -> Vec<WebhookEvent> {
392 self.event_log.read().await.clone()
393 }
394
395 pub async fn clear_event_log(&self) {
397 self.event_log.write().await.clear();
398 }
399
400 pub async fn event_log_size(&self) -> usize {
402 self.event_log.read().await.len()
403 }
404
405 pub async fn update_config(&self, config: WebhookHandlerConfig) {
407 let mut cfg = self.config.write().await;
408 *cfg = config;
409 }
410
411 pub async fn get_config(&self) -> WebhookHandlerConfig {
413 self.config.read().await.clone()
414 }
415
416 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}