1use super::IncomingMessage;
6use chrono::{DateTime, Utc};
7
8use axum::{
9 extract::State,
10 http::StatusCode,
11 response::IntoResponse,
12 routing::{get, post},
13 Router,
14};
15use hmac::{Hmac, Mac};
16use sha2::Sha256;
17use std::collections::HashMap;
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::{SystemTime, UNIX_EPOCH};
22use subtle::ConstantTimeEq;
23
24#[derive(serde::Deserialize, serde::Serialize)]
25struct GhUser {
26 login: String,
27}
28
29type HmacSha256 = Hmac<Sha256>;
30
31const DEFAULT_DEDUP_WINDOW_SECS: u64 = 2 * 60 * 60;
33const DEFAULT_MAX_SEEN_DELIVERIES: usize = 50_000;
35
36#[derive(serde::Serialize, Clone, Copy)]
42pub struct DedupMetrics {
43 pub seen_count: usize,
45 pub duplicate_count: u64,
47 pub inserted_count: u64,
49 pub evicted_count: u64,
51}
52
53struct DedupInner {
54 entries: HashMap<String, u64>,
56 duplicate_count: u64,
57 inserted_count: u64,
58 evicted_count: u64,
59}
60
61#[derive(Clone)]
69pub struct DedupStore {
70 inner: Arc<tokio::sync::Mutex<DedupInner>>,
71 file_path: Option<PathBuf>,
73 window_secs: u64,
74 max_size: usize,
75 metrics_enabled: bool,
76}
77
78impl DedupStore {
79 #[cfg(test)]
81 pub fn new_in_memory(window_secs: u64, max_size: usize) -> Self {
82 Self::new_in_memory_with_metrics(window_secs, max_size, true)
83 }
84
85 pub fn new_in_memory_with_metrics(
87 window_secs: u64,
88 max_size: usize,
89 metrics_enabled: bool,
90 ) -> Self {
91 Self {
92 inner: Arc::new(tokio::sync::Mutex::new(DedupInner {
93 entries: HashMap::new(),
94 duplicate_count: 0,
95 inserted_count: 0,
96 evicted_count: 0,
97 })),
98 file_path: None,
99 window_secs,
100 max_size,
101 metrics_enabled,
102 }
103 }
104
105 #[cfg(test)]
111 pub fn new_filebacked(path: PathBuf, window_secs: u64, max_size: usize) -> Self {
112 Self::new_filebacked_with_metrics(path, window_secs, max_size, true)
113 }
114
115 pub fn new_filebacked_with_metrics(
117 path: PathBuf,
118 window_secs: u64,
119 max_size: usize,
120 metrics_enabled: bool,
121 ) -> Self {
122 let entries = load_dedup_file(&path, window_secs);
123 tracing::info!(
124 path = %path.display(),
125 loaded = entries.len(),
126 "loaded webhook dedup state from file"
127 );
128 Self {
129 inner: Arc::new(tokio::sync::Mutex::new(DedupInner {
130 entries,
131 duplicate_count: 0,
132 inserted_count: 0,
133 evicted_count: 0,
134 })),
135 file_path: Some(path),
136 window_secs,
137 max_size,
138 metrics_enabled,
139 }
140 }
141
142 pub async fn check_and_insert(&self, delivery_id: &str) -> bool {
147 let now_secs = SystemTime::now()
148 .duration_since(UNIX_EPOCH)
149 .unwrap_or_default()
150 .as_secs();
151
152 let (is_new, flush_entries) = {
153 let mut inner = self.inner.lock().await;
154 let cutoff = now_secs.saturating_sub(self.window_secs);
156 let mut expired = 0usize;
157 if self.metrics_enabled {
158 let before = inner.entries.len();
159 inner.entries.retain(|_, ts| *ts > cutoff);
160 expired = before.saturating_sub(inner.entries.len());
161 } else {
162 inner.entries.retain(|_, ts| *ts > cutoff);
163 }
164
165 if inner.entries.contains_key(delivery_id) {
166 if self.metrics_enabled {
167 inner.duplicate_count += 1;
168 inner.evicted_count += expired as u64;
169 }
170 (false, None)
171 } else {
172 inner.entries.insert(delivery_id.to_string(), now_secs);
173 if self.metrics_enabled {
174 inner.inserted_count += 1;
175 }
176 let evicted_size = enforce_size_cap(&mut inner.entries, self.max_size);
178 if self.metrics_enabled {
179 inner.evicted_count += expired as u64;
180 inner.evicted_count += evicted_size as u64;
181 }
182 let snap = self.file_path.is_some().then(|| inner.entries.clone());
184 (true, snap)
185 }
186 };
187
188 if let (Some(path), Some(entries)) = (&self.file_path, flush_entries) {
194 let path = path.clone();
195 let path_for_log = path.clone();
196 let entries = Arc::new(entries);
197 const MAX_FLUSH_ATTEMPTS: u32 = 3;
198 let mut attempt = 0u32;
199 loop {
200 attempt += 1;
201 let path_clone = path.clone();
202 let entries_clone = Arc::clone(&entries);
203 match tokio::task::spawn_blocking(move || {
204 flush_dedup_file(&path_clone, &entries_clone)
205 })
206 .await
207 {
208 Ok(Ok(())) => break,
209 Ok(Err(e)) if attempt < MAX_FLUSH_ATTEMPTS => {
210 let delay = std::time::Duration::from_millis(100u64 << (attempt - 1));
211 tracing::warn!(
212 ?e,
213 attempt,
214 path = %path_for_log.display(),
215 "failed to flush webhook dedup state, retrying"
216 );
217 tokio::time::sleep(delay).await;
218 }
219 Ok(Err(e)) => {
220 tracing::warn!(
221 ?e,
222 path = %path_for_log.display(),
223 "failed to flush webhook dedup state after {MAX_FLUSH_ATTEMPTS} attempts"
224 );
225 break;
226 }
227 Err(e) => {
228 tracing::warn!(?e, "spawn_blocking failed for flush_dedup_file");
229 break;
230 }
231 }
232 }
233 }
234
235 is_new
236 }
237
238 pub async fn metrics(&self) -> DedupMetrics {
240 let inner = self.inner.lock().await;
241 DedupMetrics {
242 seen_count: inner.entries.len(),
243 duplicate_count: if self.metrics_enabled {
244 inner.duplicate_count
245 } else {
246 0
247 },
248 inserted_count: if self.metrics_enabled {
249 inner.inserted_count
250 } else {
251 0
252 },
253 evicted_count: if self.metrics_enabled {
254 inner.evicted_count
255 } else {
256 0
257 },
258 }
259 }
260
261 pub fn metrics_enabled(&self) -> bool {
262 self.metrics_enabled
263 }
264}
265
266fn enforce_size_cap(entries: &mut HashMap<String, u64>, max_size: usize) -> usize {
268 if entries.len() <= max_size {
269 return 0;
270 }
271 let mut sorted: Vec<(String, u64)> = entries.iter().map(|(k, v)| (k.clone(), *v)).collect();
272 sorted.sort_by_key(|(_, t)| *t);
273 let overflow = sorted.len().saturating_sub(max_size);
274 for (key, _) in sorted.into_iter().take(overflow) {
275 entries.remove(&key);
276 }
277 overflow
278}
279
280#[cfg(test)]
283fn prune_dedup_inner(inner: &mut DedupInner, now_secs: u64, window_secs: u64, max_size: usize) {
284 let cutoff = now_secs.saturating_sub(window_secs);
285 inner.entries.retain(|_, ts| *ts > cutoff);
286 enforce_size_cap(&mut inner.entries, max_size);
287}
288
289fn load_dedup_file(path: &Path, window_secs: u64) -> HashMap<String, u64> {
291 let content = match std::fs::read_to_string(path) {
292 Ok(c) => c,
293 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
294 Err(e) => {
295 tracing::warn!(?e, path = %path.display(), "failed to read webhook dedup file, starting fresh");
296 return HashMap::new();
297 }
298 };
299
300 #[derive(serde::Deserialize)]
301 struct FileFormat {
302 entries: HashMap<String, u64>,
303 }
304
305 let parsed: FileFormat = match serde_json::from_str(&content) {
306 Ok(p) => p,
307 Err(e) => {
308 tracing::warn!(?e, path = %path.display(), "failed to parse webhook dedup file, starting fresh");
309 return HashMap::new();
310 }
311 };
312
313 let now_secs = SystemTime::now()
314 .duration_since(UNIX_EPOCH)
315 .unwrap_or_default()
316 .as_secs();
317 let cutoff = now_secs.saturating_sub(window_secs);
318 parsed
319 .entries
320 .into_iter()
321 .filter(|(_, ts)| *ts > cutoff)
322 .collect()
323}
324
325fn flush_dedup_file(path: &Path, entries: &HashMap<String, u64>) -> std::io::Result<()> {
327 #[derive(serde::Serialize)]
328 struct FileFormat<'a> {
329 entries: &'a HashMap<String, u64>,
330 }
331
332 let content = serde_json::to_string(&FileFormat { entries })
333 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
334 atomic_write(path, &content)
335}
336
337fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
338 if let Some(parent) = path.parent() {
339 std::fs::create_dir_all(parent)?;
340 }
341
342 let tmp_path = path.with_extension(format!("db.tmp.{}", uuid::Uuid::new_v4()));
343 let mut file = std::fs::File::create(&tmp_path)?;
344 file.write_all(content.as_bytes())?;
345 file.sync_all()?;
346 std::fs::rename(&tmp_path, path)?;
347 if let Some(parent) = path.parent() {
348 if let Ok(dir) = std::fs::File::open(parent) {
349 if let Err(e) = dir.sync_all() {
350 tracing::warn!(?e, path = %parent.display(), "failed to sync parent directory after dedup file rename");
351 }
352 }
353 }
354 Ok(())
355}
356
357#[derive(Clone)]
362struct WebhookState {
363 secret: String,
364 repo: String,
365 tx: tokio::sync::mpsc::Sender<IncomingMessage>,
366 dedup_store: DedupStore,
367}
368
369#[derive(serde::Deserialize, serde::Serialize)]
370struct WebhookPayload {
371 action: Option<String>,
372 issue: Option<IssuePayload>,
373 #[serde(rename = "pull_request")]
374 pr: Option<PullRequestPayload>,
375 comment: Option<CommentPayload>,
376 review: Option<ReviewPayload>,
377}
378
379#[derive(serde::Deserialize, serde::Serialize)]
380struct IssuePayload {
381 number: u64,
382 title: String,
383 labels: Vec<LabelPayload>,
384}
385
386#[derive(serde::Deserialize, serde::Serialize)]
387struct LabelPayload {
388 name: String,
389}
390
391#[derive(serde::Deserialize, serde::Serialize)]
392struct PullRequestPayload {
393 number: u64,
394 title: String,
395 body: Option<String>,
396 action: Option<String>,
397}
398
399#[derive(serde::Deserialize, serde::Serialize)]
400struct CommentPayload {
401 id: u64,
402 body: String,
403 user: GhUser,
404 created_at: Option<DateTime<Utc>>,
405}
406
407#[derive(serde::Deserialize, serde::Serialize)]
408struct ReviewPayload {
409 id: u64,
410 state: String,
411 body: Option<String>,
412 user: GhUser,
413}
414
415fn verify_signature(secret: &str, payload: &[u8], signature: &str) -> bool {
416 let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
417 Ok(m) => m,
418 Err(_) => return false,
419 };
420 mac.update(payload);
421 let result = mac.finalize().into_bytes();
422
423 let expected = format!("sha256={:x}", result);
424 if expected.len() != signature.len() {
425 return false;
426 }
427 bool::from(expected.as_bytes().ct_eq(signature.as_bytes()))
429}
430
431fn parse_github_event(
432 payload: WebhookPayload,
433 event_type: &str,
434 _repo: &str,
435) -> Option<IncomingMessage> {
436 let timestamp = chrono::Utc::now();
437
438 match event_type {
439 "issues" => {
440 if let (Some(action), Some(issue)) = (&payload.action, &payload.issue) {
441 if action == "opened" || action == "labeled" {
442 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
443 return Some(IncomingMessage {
444 channel: "github".to_string(),
445 id: format!("issue-{}-{}", issue.number, action),
446 thread_id: issue.number.to_string(),
447 author: "github".to_string(),
448 body: format!("Issue {}: {}", action, issue.title),
449 timestamp,
450 metadata: serde_json::json!({
451 "event": "issues",
452 "action": action,
453 "issue_number": issue.number,
454 "labels": labels
455 }),
456 topic_id: None,
457 });
458 }
459 }
460 }
461 "pull_request" => {
462 if let (Some(action), Some(pr)) = (&payload.action, &payload.pr) {
463 let relevant_actions = ["opened", "synchronize", "labeled", "ready_for_review"];
464 if relevant_actions.contains(&action.as_str()) {
465 return Some(IncomingMessage {
466 channel: "github".to_string(),
467 id: format!("pr-{}-{}", pr.number, action),
468 thread_id: pr.number.to_string(),
469 author: "github".to_string(),
470 body: format!("PR {}: {}", action, pr.title),
471 timestamp,
472 metadata: serde_json::json!({
473 "event": "pull_request",
474 "action": action,
475 "pr_number": pr.number,
476 "pr_title": pr.title
477 }),
478 topic_id: None,
479 });
480 }
481 }
482 }
483 "issue_comment" => {
484 if let (Some(action), Some(comment), Some(issue)) =
485 (&payload.action, &payload.comment, &payload.issue)
486 {
487 if action == "created" {
488 return Some(IncomingMessage {
489 channel: "github".to_string(),
490 id: format!("comment-{}", comment.id),
491 thread_id: issue.number.to_string(),
492 author: comment.user.login.clone(),
493 body: comment.body.clone(),
494 timestamp: comment.created_at.unwrap_or(timestamp),
495 metadata: serde_json::json!({
496 "event": "issue_comment",
497 "action": action,
498 "issue_number": issue.number
499 }),
500 topic_id: None,
501 });
502 }
503 }
504 }
505 "pull_request_review" => {
506 if let (Some(action), Some(review), Some(pr)) =
507 (&payload.action, &payload.review, &payload.pr)
508 {
509 if action == "submitted" {
510 return Some(IncomingMessage {
511 channel: "github".to_string(),
512 id: format!("review-{}", review.id),
513 thread_id: pr.number.to_string(),
514 author: review.user.login.clone(),
515 body: review
516 .body
517 .clone()
518 .unwrap_or_else(|| format!("Review {}", review.state)),
519 timestamp,
520 metadata: serde_json::json!({
521 "event": "pull_request_review",
522 "action": action,
523 "pr_number": pr.number,
524 "review_state": review.state
525 }),
526 topic_id: None,
527 });
528 }
529 }
530 }
531 _ => {}
532 }
533
534 None
535}
536
537async fn handle_webhook(
538 State(state): State<WebhookState>,
539 headers: axum::http::HeaderMap,
540 body: axum::body::Bytes,
541) -> impl IntoResponse {
542 let signature = headers
543 .get("x-hub-signature-256")
544 .and_then(|v| v.to_str().ok())
545 .unwrap_or("");
546
547 if !state.secret.is_empty() && !verify_signature(&state.secret, &body, signature) {
548 tracing::warn!("webhook signature verification failed");
549 return (StatusCode::UNAUTHORIZED, "Invalid signature");
550 }
551
552 let delivery_id = headers
555 .get("x-github-delivery")
556 .and_then(|v| v.to_str().ok())
557 .unwrap_or("")
558 .to_string();
559
560 if !delivery_id.is_empty() && !state.dedup_store.check_and_insert(&delivery_id).await {
561 if state.dedup_store.metrics_enabled() {
562 let metrics = state.dedup_store.metrics().await;
563 tracing::debug!(
564 delivery_id = %delivery_id,
565 seen_count = metrics.seen_count,
566 duplicate_count = metrics.duplicate_count,
567 "duplicate webhook delivery, skipping"
568 );
569 } else {
570 tracing::debug!(delivery_id = %delivery_id, "duplicate webhook delivery, skipping");
571 }
572 return (StatusCode::OK, "OK");
573 }
574
575 let payload: WebhookPayload = match serde_json::from_slice(&body) {
576 Ok(p) => p,
577 Err(e) => {
578 tracing::warn!(?e, "failed to parse webhook payload");
579 return (StatusCode::BAD_REQUEST, "Invalid JSON");
580 }
581 };
582
583 let event_type = headers
584 .get("x-github-event")
585 .and_then(|v| v.to_str().ok())
586 .unwrap_or("");
587
588 if let Some(msg) = parse_github_event(payload, event_type, &state.repo) {
589 if state.tx.send(msg).await.is_err() {
590 tracing::warn!("webhook event channel receiver dropped");
591 }
592 }
593
594 (StatusCode::OK, "OK")
595}
596
597async fn webhook_health(State(state): State<WebhookState>) -> impl IntoResponse {
601 let metrics = state.dedup_store.metrics().await;
602 axum::Json(serde_json::json!({
603 "ok": true,
604 "seen_count": metrics.seen_count,
605 "duplicate_count": metrics.duplicate_count
606 }))
607}
608
609pub async fn check_webhook_health(port: u16) -> (bool, Option<String>) {
620 let client = match reqwest::Client::builder()
621 .timeout(std::time::Duration::from_secs(5))
622 .build()
623 {
624 Ok(c) => c,
625 Err(e) => {
626 tracing::warn!(?e, "failed to build HTTP client for webhook health check");
627 return (false, Some(format!("failed to build HTTP client: {e}")));
628 }
629 };
630 let url = format!("http://localhost:{}/health", port);
631 match client.get(&url).send().await {
632 Ok(response) if response.status().is_success() => {
633 if let Ok(body) = response.json::<serde_json::Value>().await {
635 let seen = body.get("seen_count").and_then(|v| v.as_u64()).unwrap_or(0);
636 let dupes = body
637 .get("duplicate_count")
638 .and_then(|v| v.as_u64())
639 .unwrap_or(0);
640 tracing::debug!(
641 seen_count = seen,
642 duplicate_count = dupes,
643 "webhook health ok"
644 );
645 }
646 (true, None)
647 }
648 Ok(response) => {
649 let reason = format!("health endpoint returned {}", response.status());
650 (false, Some(reason))
651 }
652 Err(e) => (false, Some(format!("health check request failed: {e}"))),
653 }
654}
655
656pub fn is_transient_bind_error(e: &anyhow::Error) -> bool {
659 for cause in e.chain() {
660 if let Some(io_err) = cause.downcast_ref::<std::io::Error>() {
661 return matches!(
662 io_err.kind(),
663 std::io::ErrorKind::AddrInUse | std::io::ErrorKind::AddrNotAvailable
664 );
665 }
666 }
667 false
668}
669
670pub fn webhook_backoff_delay(attempt: u32) -> std::time::Duration {
675 let base_ms: u64 = 500;
676 let max_ms: u64 = 30_000;
677 let exp_ms = base_ms.saturating_mul(1u64 << (attempt.saturating_sub(1)).min(6));
678 let bounded_ms = exp_ms.min(max_ms);
679 let jitter_ms = std::time::SystemTime::now()
680 .duration_since(std::time::UNIX_EPOCH)
681 .unwrap_or_default()
682 .subsec_millis() as u64
683 % 500;
684 std::time::Duration::from_millis(bounded_ms + jitter_ms)
685}
686
687pub async fn start_webhook_server(
697 port: u16,
698 secret: String,
699 repo: String,
700 tx: tokio::sync::mpsc::Sender<IncomingMessage>,
701) -> anyhow::Result<()> {
702 let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
703
704 let window_secs = crate::config::get("webhook.dedup_window_seconds")
705 .ok()
706 .and_then(|v| v.parse::<u64>().ok())
707 .unwrap_or(DEFAULT_DEDUP_WINDOW_SECS);
708
709 let max_size = crate::config::get("webhook.max_seen_deliveries")
710 .ok()
711 .and_then(|v| v.parse::<usize>().ok())
712 .unwrap_or(DEFAULT_MAX_SEEN_DELIVERIES);
713
714 let store_type =
715 crate::config::get("webhook.dedup_store").unwrap_or_else(|_| "memory".to_string());
716
717 let metrics_enabled = crate::config::get("webhook.dedup_metrics")
718 .map(|v| v == "true")
719 .unwrap_or(true);
720
721 let dedup_store = if store_type == "file" {
722 let path = crate::config::get("webhook.dedup_path")
723 .ok()
724 .map(PathBuf::from)
725 .unwrap_or_else(|| {
726 crate::home::orch_home()
727 .unwrap_or_else(|_| PathBuf::from("/tmp"))
728 .join("webhook_dedup.db")
729 });
730
731 match tokio::task::spawn_blocking(move || {
736 DedupStore::new_filebacked_with_metrics(path, window_secs, max_size, metrics_enabled)
737 })
738 .await
739 {
740 Ok(store) => store,
741 Err(e) => {
742 return Err(anyhow::anyhow!(
743 "failed to initialize file-backed dedup store: {e}"
744 ))
745 }
746 }
747 } else {
748 DedupStore::new_in_memory_with_metrics(window_secs, max_size, metrics_enabled)
749 };
750
751 tracing::info!(
752 port,
753 dedup_store = %store_type,
754 dedup_window_secs = window_secs,
755 max_seen_deliveries = max_size,
756 dedup_metrics = metrics_enabled,
757 "starting webhook server"
758 );
759
760 let state = WebhookState {
761 secret,
762 repo,
763 tx,
764 dedup_store,
765 };
766
767 let app = Router::new()
768 .route("/health", get(webhook_health))
769 .route("/webhook", post(handle_webhook))
770 .with_state(state);
771
772 axum::serve(listener, app).await?;
773
774 Ok(())
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780
781 #[test]
782 fn test_verify_signature_valid() {
783 let secret = "test-secret";
784 let payload = br#"{"action":"opened"}"#;
785 let signature = "sha256=e7f446e3b1c1c8e7b8c8e7b8c8e7b8c8e7b8c8e7b8c8e7b8c8e7b8c8e7b8c8";
786
787 let result = verify_signature(secret, payload, signature);
788 assert!(!result, "Invalid signature format should fail");
789 }
790
791 #[test]
792 fn test_verify_signature_invalid() {
793 let secret = "test-secret";
794 let payload = br#"{"action":"opened"}"#;
795 let signature = "sha256=invalidsignature";
796
797 let result = verify_signature(secret, payload, signature);
798 assert!(!result, "Invalid signature should return false");
799 }
800
801 #[test]
802 fn test_verify_signature_empty_secret() {
803 let secret = "";
804 let payload = br#"{"action":"opened"}"#;
805 let signature = "sha256=anysignature";
806
807 let result = verify_signature(secret, payload, signature);
808 assert!(!result, "Empty secret should return false");
809 }
810
811 #[test]
812 fn test_parse_github_event_issue_opened() {
813 let payload = WebhookPayload {
814 action: Some("opened".to_string()),
815 issue: Some(IssuePayload {
816 number: 42,
817 title: "Test issue".to_string(),
818 labels: vec![],
819 }),
820 pr: None,
821 comment: None,
822 review: None,
823 };
824
825 let msg = parse_github_event(payload, "issues", "owner/repo");
826 assert!(msg.is_some());
827
828 let msg = msg.unwrap();
829 assert_eq!(msg.channel, "github");
830 assert_eq!(msg.thread_id, "42");
831 assert!(msg.body.contains("opened"));
832 assert!(msg.body.contains("Test issue"));
833 }
834
835 #[test]
836 fn test_parse_github_event_issue_labeled() {
837 let payload = WebhookPayload {
838 action: Some("labeled".to_string()),
839 issue: Some(IssuePayload {
840 number: 42,
841 title: "Test issue".to_string(),
842 labels: vec![
843 LabelPayload {
844 name: "bug".to_string(),
845 },
846 LabelPayload {
847 name: "urgent".to_string(),
848 },
849 ],
850 }),
851 pr: None,
852 comment: None,
853 review: None,
854 };
855
856 let msg = parse_github_event(payload, "issues", "owner/repo");
857 assert!(msg.is_some());
858
859 let msg = msg.unwrap();
860 assert_eq!(msg.thread_id, "42");
861 let labels = msg.metadata.get("labels").unwrap().as_array().unwrap();
862 assert_eq!(labels.len(), 2);
863 }
864
865 #[test]
866 fn test_parse_github_event_pr_opened() {
867 let payload = WebhookPayload {
868 action: Some("opened".to_string()),
869 issue: None,
870 pr: Some(PullRequestPayload {
871 number: 101,
872 title: "New feature".to_string(),
873 body: Some("Description".to_string()),
874 action: Some("opened".to_string()),
875 }),
876 comment: None,
877 review: None,
878 };
879
880 let msg = parse_github_event(payload, "pull_request", "owner/repo");
881 assert!(msg.is_some());
882
883 let msg = msg.unwrap();
884 assert_eq!(msg.thread_id, "101");
885 assert!(msg.body.contains("opened"));
886 assert!(msg.body.contains("New feature"));
887 }
888
889 #[test]
890 fn test_parse_github_event_pr_synchronize() {
891 let payload = WebhookPayload {
892 action: Some("synchronize".to_string()),
893 issue: None,
894 pr: Some(PullRequestPayload {
895 number: 101,
896 title: "New feature".to_string(),
897 body: Some("Description".to_string()),
898 action: Some("synchronize".to_string()),
899 }),
900 comment: None,
901 review: None,
902 };
903
904 let msg = parse_github_event(payload, "pull_request", "owner/repo");
905 assert!(msg.is_some());
906
907 let msg = msg.unwrap();
908 assert!(msg.body.contains("synchronize"));
909 }
910
911 #[test]
912 fn test_parse_github_event_ignored_action() {
913 let payload = WebhookPayload {
914 action: Some("closed".to_string()),
915 issue: Some(IssuePayload {
916 number: 42,
917 title: "Test issue".to_string(),
918 labels: vec![],
919 }),
920 pr: None,
921 comment: None,
922 review: None,
923 };
924
925 let msg = parse_github_event(payload, "issues", "owner/repo");
926 assert!(msg.is_none(), "closed action should be ignored");
927 }
928
929 #[test]
930 fn test_parse_github_event_unknown_type() {
931 let payload = WebhookPayload {
932 action: Some("created".to_string()),
933 issue: None,
934 pr: None,
935 comment: None,
936 review: None,
937 };
938
939 let msg = parse_github_event(payload, "unknown_event", "owner/repo");
940 assert!(msg.is_none());
941 }
942
943 #[test]
944 fn test_parse_github_event_issue_comment() {
945 let payload = WebhookPayload {
946 action: Some("created".to_string()),
947 issue: Some(IssuePayload {
948 number: 42,
949 title: "Test issue".to_string(),
950 labels: vec![],
951 }),
952 pr: None,
953 comment: Some(CommentPayload {
954 id: 12345,
955 body: "This is a comment".to_string(),
956 user: GhUser {
957 login: "testuser".to_string(),
958 },
959 created_at: Some(
960 chrono::DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
961 .unwrap()
962 .with_timezone(&chrono::Utc),
963 ),
964 }),
965 review: None,
966 };
967
968 let msg = parse_github_event(payload, "issue_comment", "owner/repo");
969 assert!(msg.is_some());
970
971 let msg = msg.unwrap();
972 assert_eq!(msg.id, "comment-12345", "ID should use actual comment ID");
973 assert_eq!(msg.author, "testuser");
974 assert_eq!(msg.body, "This is a comment");
975 assert_eq!(msg.thread_id, "42");
976 assert_eq!(
977 msg.metadata.get("issue_number").unwrap().as_u64().unwrap(),
978 42
979 );
980 }
981
982 #[test]
983 fn test_parse_github_event_issue_comment_without_issue_ignored() {
984 let payload = WebhookPayload {
985 action: Some("created".to_string()),
986 issue: None,
987 pr: None,
988 comment: Some(CommentPayload {
989 id: 12345,
990 body: "Orphan comment".to_string(),
991 user: GhUser {
992 login: "testuser".to_string(),
993 },
994 created_at: None,
995 }),
996 review: None,
997 };
998
999 let msg = parse_github_event(payload, "issue_comment", "owner/repo");
1000 assert!(msg.is_none(), "comment without issue should be ignored");
1001 }
1002
1003 #[test]
1004 fn test_parse_github_event_pr_review_submitted() {
1005 let payload = WebhookPayload {
1006 action: Some("submitted".to_string()),
1007 issue: None,
1008 pr: Some(PullRequestPayload {
1009 number: 55,
1010 title: "Add feature".to_string(),
1011 body: Some("Description".to_string()),
1012 action: None,
1013 }),
1014 comment: None,
1015 review: Some(ReviewPayload {
1016 id: 98765,
1017 state: "changes_requested".to_string(),
1018 body: Some("Please fix the tests".to_string()),
1019 user: GhUser {
1020 login: "reviewer".to_string(),
1021 },
1022 }),
1023 };
1024
1025 let msg = parse_github_event(payload, "pull_request_review", "owner/repo");
1026 assert!(msg.is_some());
1027
1028 let msg = msg.unwrap();
1029 assert_eq!(msg.id, "review-98765", "ID should use actual review ID");
1030 assert_eq!(msg.thread_id, "55");
1031 assert_eq!(msg.author, "reviewer");
1032 assert_eq!(msg.body, "Please fix the tests");
1033 assert_eq!(
1034 msg.metadata.get("review_state").unwrap().as_str().unwrap(),
1035 "changes_requested"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_parse_github_event_pr_review_non_submitted_ignored() {
1041 let payload = WebhookPayload {
1042 action: Some("dismissed".to_string()),
1043 issue: None,
1044 pr: Some(PullRequestPayload {
1045 number: 55,
1046 title: "Add feature".to_string(),
1047 body: None,
1048 action: None,
1049 }),
1050 comment: None,
1051 review: Some(ReviewPayload {
1052 id: 98765,
1053 state: "dismissed".to_string(),
1054 body: None,
1055 user: GhUser {
1056 login: "reviewer".to_string(),
1057 },
1058 }),
1059 };
1060
1061 let msg = parse_github_event(payload, "pull_request_review", "owner/repo");
1062 assert!(msg.is_none(), "dismissed review should be ignored");
1063 }
1064
1065 #[test]
1066 fn test_verify_signature_correct() {
1067 let secret = "mysecret";
1068 let payload = br#"{"action":"opened"}"#;
1069
1070 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
1072 mac.update(payload);
1073 let result = mac.finalize().into_bytes();
1074 let signature = format!("sha256={:x}", result);
1075
1076 assert!(
1077 verify_signature(secret, payload, &signature),
1078 "correct signature should verify"
1079 );
1080 }
1081
1082 #[test]
1087 fn test_dedup_store_is_send_sync() {
1088 fn assert_send_sync<T: Send + Sync>() {}
1089 assert_send_sync::<DedupStore>();
1090 }
1091
1092 #[tokio::test]
1093 async fn test_dedup_store_new_entry_is_accepted() {
1094 let store = DedupStore::new_in_memory(7200, 50_000);
1095 assert!(
1096 store.check_and_insert("delivery-1").await,
1097 "first insertion should return true"
1098 );
1099 }
1100
1101 #[tokio::test]
1102 async fn test_dedup_store_duplicate_is_rejected() {
1103 let store = DedupStore::new_in_memory(7200, 50_000);
1104 assert!(store.check_and_insert("delivery-1").await);
1105 assert!(
1106 !store.check_and_insert("delivery-1").await,
1107 "duplicate should return false"
1108 );
1109 }
1110
1111 #[tokio::test]
1112 async fn test_dedup_store_metrics_track_counts() {
1113 let store = DedupStore::new_in_memory(7200, 50_000);
1114
1115 store.check_and_insert("d1").await;
1116 store.check_and_insert("d2").await;
1117 store.check_and_insert("d1").await; let m = store.metrics().await;
1120 assert_eq!(m.seen_count, 2, "two unique IDs stored");
1121 assert_eq!(m.inserted_count, 2, "two insertions");
1122 assert_eq!(m.duplicate_count, 1, "one duplicate");
1123 }
1124
1125 #[tokio::test]
1126 async fn test_dedup_store_caps_size() {
1127 let max = 10usize;
1128 let store = DedupStore::new_in_memory(7200, max);
1129
1130 for i in 0..(max + 5) {
1132 store.check_and_insert(&format!("d-{}", i)).await;
1133 }
1134
1135 let m = store.metrics().await;
1136 assert!(
1137 m.seen_count <= max,
1138 "seen_count {} should not exceed max {}",
1139 m.seen_count,
1140 max
1141 );
1142 }
1143
1144 #[tokio::test]
1145 async fn test_dedup_store_filebacked_persists_across_instances() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let path = dir.path().join("dedup.json");
1148
1149 {
1151 let store = DedupStore::new_filebacked(path.clone(), 7200, 50_000);
1152 store.check_and_insert("delivery-persisted").await;
1153 }
1154
1155 {
1157 let store = DedupStore::new_filebacked(path.clone(), 7200, 50_000);
1158 assert!(
1159 !store.check_and_insert("delivery-persisted").await,
1160 "entry loaded from file should be treated as duplicate"
1161 );
1162 }
1163 }
1164
1165 #[tokio::test]
1166 async fn test_dedup_store_filebacked_expired_entries_not_loaded() {
1167 let dir = tempfile::tempdir().unwrap();
1168 let path = dir.path().join("dedup.json");
1169
1170 let content = r#"{"entries":{"old-delivery":0}}"#;
1172 std::fs::write(&path, content).unwrap();
1173
1174 let store = DedupStore::new_filebacked(path, 1, 50_000);
1176 assert!(
1177 store.check_and_insert("old-delivery").await,
1178 "expired entry should not block new delivery with same ID"
1179 );
1180 }
1181
1182 #[test]
1183 fn test_prune_dedup_inner_caps_size() {
1184 let mut inner = DedupInner {
1185 entries: HashMap::new(),
1186 duplicate_count: 0,
1187 inserted_count: 0,
1188 evicted_count: 0,
1189 };
1190 let now_secs: u64 = 1_700_000_000;
1192
1193 for i in 0..(DEFAULT_MAX_SEEN_DELIVERIES + 10) {
1194 inner
1195 .entries
1196 .insert(format!("id-{}", i), now_secs - i as u64);
1197 }
1198
1199 let large_window = (DEFAULT_MAX_SEEN_DELIVERIES + 100) as u64;
1202 prune_dedup_inner(
1203 &mut inner,
1204 now_secs,
1205 large_window,
1206 DEFAULT_MAX_SEEN_DELIVERIES,
1207 );
1208
1209 assert_eq!(inner.entries.len(), DEFAULT_MAX_SEEN_DELIVERIES);
1210 assert!(
1211 inner.entries.contains_key("id-0"),
1212 "newest entry should remain"
1213 );
1214 assert!(
1215 !inner
1216 .entries
1217 .contains_key(&format!("id-{}", DEFAULT_MAX_SEEN_DELIVERIES + 9)),
1218 "oldest entries should be evicted"
1219 );
1220 }
1221
1222 #[tokio::test]
1226 async fn test_webhook_handler_issues_opened() {
1227 use axum::body::Body;
1228 use axum::http::Request;
1229 use tower::util::ServiceExt; let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1232
1233 let state = WebhookState {
1234 secret: String::new(), repo: "owner/repo".to_string(),
1236 tx,
1237 dedup_store: DedupStore::new_in_memory(7200, 50_000),
1238 };
1239
1240 let app = Router::new()
1241 .route("/webhook", post(handle_webhook))
1242 .with_state(state);
1243
1244 let body = serde_json::json!({
1245 "action": "opened",
1246 "issue": {
1247 "number": 99,
1248 "title": "New bug report",
1249 "labels": [{"name": "bug"}]
1250 }
1251 });
1252
1253 let request = Request::builder()
1254 .method("POST")
1255 .uri("/webhook")
1256 .header("content-type", "application/json")
1257 .header("x-github-event", "issues")
1258 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1259 .unwrap();
1260
1261 let response = app.oneshot(request).await.unwrap();
1262 assert_eq!(response.status(), StatusCode::OK);
1263
1264 let msg = rx.try_recv().expect("should receive webhook message");
1266 assert_eq!(msg.channel, "github");
1267 assert_eq!(msg.thread_id, "99");
1268 assert_eq!(
1269 msg.metadata.get("event").unwrap().as_str().unwrap(),
1270 "issues"
1271 );
1272 assert_eq!(
1273 msg.metadata.get("action").unwrap().as_str().unwrap(),
1274 "opened"
1275 );
1276 }
1277
1278 #[tokio::test]
1280 async fn test_webhook_handler_issue_comment() {
1281 use axum::body::Body;
1282 use axum::http::Request;
1283 use tower::ServiceExt;
1284
1285 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1286
1287 let state = WebhookState {
1288 secret: String::new(),
1289 repo: "owner/repo".to_string(),
1290 tx,
1291 dedup_store: DedupStore::new_in_memory(7200, 50_000),
1292 };
1293
1294 let app = Router::new()
1295 .route("/webhook", post(handle_webhook))
1296 .with_state(state);
1297
1298 let body = serde_json::json!({
1299 "action": "created",
1300 "issue": {
1301 "number": 42,
1302 "title": "Existing issue",
1303 "labels": []
1304 },
1305 "comment": {
1306 "id": 12345,
1307 "body": "@orch please fix this",
1308 "user": {"login": "contributor"},
1309 "created_at": "2024-06-15T12:00:00Z"
1310 }
1311 });
1312
1313 let request = Request::builder()
1314 .method("POST")
1315 .uri("/webhook")
1316 .header("content-type", "application/json")
1317 .header("x-github-event", "issue_comment")
1318 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1319 .unwrap();
1320
1321 let response = app.oneshot(request).await.unwrap();
1322 assert_eq!(response.status(), StatusCode::OK);
1323
1324 let msg = rx.try_recv().expect("should receive comment message");
1325 assert_eq!(msg.thread_id, "42", "thread_id should be the issue number");
1326 assert_eq!(msg.author, "contributor");
1327 assert!(msg.body.contains("@orch"));
1328 }
1329
1330 #[tokio::test]
1332 async fn test_webhook_handler_pr_review_submitted() {
1333 use axum::body::Body;
1334 use axum::http::Request;
1335 use tower::ServiceExt;
1336
1337 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1338
1339 let state = WebhookState {
1340 secret: String::new(),
1341 repo: "owner/repo".to_string(),
1342 tx,
1343 dedup_store: DedupStore::new_in_memory(7200, 50_000),
1344 };
1345
1346 let app = Router::new()
1347 .route("/webhook", post(handle_webhook))
1348 .with_state(state);
1349
1350 let body = serde_json::json!({
1351 "action": "submitted",
1352 "pull_request": {
1353 "number": 77,
1354 "title": "Add feature X"
1355 },
1356 "review": {
1357 "id": 98765,
1358 "state": "changes_requested",
1359 "body": "Fix the failing tests",
1360 "user": {"login": "maintainer"}
1361 }
1362 });
1363
1364 let request = Request::builder()
1365 .method("POST")
1366 .uri("/webhook")
1367 .header("content-type", "application/json")
1368 .header("x-github-event", "pull_request_review")
1369 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1370 .unwrap();
1371
1372 let response = app.oneshot(request).await.unwrap();
1373 assert_eq!(response.status(), StatusCode::OK);
1374
1375 let msg = rx.try_recv().expect("should receive review message");
1376 assert_eq!(msg.thread_id, "77");
1377 assert_eq!(msg.author, "maintainer");
1378 assert_eq!(
1379 msg.metadata.get("review_state").unwrap().as_str().unwrap(),
1380 "changes_requested"
1381 );
1382 }
1383
1384 #[tokio::test]
1386 async fn test_webhook_handler_rejects_invalid_signature() {
1387 use axum::body::Body;
1388 use axum::http::Request;
1389 use tower::ServiceExt;
1390
1391 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1392
1393 let state = WebhookState {
1394 secret: "real-secret".to_string(),
1395 repo: "owner/repo".to_string(),
1396 tx,
1397 dedup_store: DedupStore::new_in_memory(7200, 50_000),
1398 };
1399
1400 let app = Router::new()
1401 .route("/webhook", post(handle_webhook))
1402 .with_state(state);
1403
1404 let body = serde_json::json!({
1405 "action": "opened",
1406 "issue": {
1407 "number": 1,
1408 "title": "Should be rejected",
1409 "labels": []
1410 }
1411 });
1412
1413 let request = Request::builder()
1414 .method("POST")
1415 .uri("/webhook")
1416 .header("content-type", "application/json")
1417 .header("x-github-event", "issues")
1418 .header("x-hub-signature-256", "sha256=invalid")
1419 .body(Body::from(serde_json::to_vec(&body).unwrap()))
1420 .unwrap();
1421
1422 let response = app.oneshot(request).await.unwrap();
1423 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1424
1425 assert!(
1427 rx.try_recv().is_err(),
1428 "no message should be sent for invalid signature"
1429 );
1430 }
1431
1432 #[tokio::test]
1435 async fn test_webhook_deduplication_same_delivery_id() {
1436 use axum::body::Body;
1437 use axum::http::Request;
1438 use tower::ServiceExt;
1439
1440 let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1441
1442 let dedup_store = DedupStore::new_in_memory(7200, 50_000);
1444
1445 let body_json = serde_json::json!({
1446 "action": "opened",
1447 "issue": {
1448 "number": 200,
1449 "title": "Deduplicated issue",
1450 "labels": []
1451 }
1452 });
1453 let body_bytes = serde_json::to_vec(&body_json).unwrap();
1454
1455 {
1457 let state = WebhookState {
1458 secret: String::new(),
1459 repo: "owner/repo".to_string(),
1460 tx: tx.clone(),
1461 dedup_store: dedup_store.clone(),
1462 };
1463 let app = Router::new()
1464 .route("/webhook", post(handle_webhook))
1465 .with_state(state);
1466 let request = Request::builder()
1467 .method("POST")
1468 .uri("/webhook")
1469 .header("content-type", "application/json")
1470 .header("x-github-event", "issues")
1471 .header("x-github-delivery", "abc-123-unique-id")
1472 .body(Body::from(body_bytes.clone()))
1473 .unwrap();
1474 let response = app.oneshot(request).await.unwrap();
1475 assert_eq!(response.status(), StatusCode::OK);
1476 }
1477
1478 let msg = rx
1480 .try_recv()
1481 .expect("first delivery should produce a message");
1482 assert_eq!(msg.thread_id, "200");
1483 assert!(rx.try_recv().is_err(), "no second message yet");
1484
1485 {
1487 let state = WebhookState {
1488 secret: String::new(),
1489 repo: "owner/repo".to_string(),
1490 tx: tx.clone(),
1491 dedup_store: dedup_store.clone(),
1492 };
1493 let app = Router::new()
1494 .route("/webhook", post(handle_webhook))
1495 .with_state(state);
1496 let request = Request::builder()
1497 .method("POST")
1498 .uri("/webhook")
1499 .header("content-type", "application/json")
1500 .header("x-github-event", "issues")
1501 .header("x-github-delivery", "abc-123-unique-id")
1502 .body(Body::from(body_bytes.clone()))
1503 .unwrap();
1504 let response = app.oneshot(request).await.unwrap();
1505 assert_eq!(response.status(), StatusCode::OK);
1507 }
1508
1509 assert!(
1511 rx.try_recv().is_err(),
1512 "duplicate delivery should not produce a second message"
1513 );
1514
1515 let m = dedup_store.metrics().await;
1517 assert_eq!(m.inserted_count, 1, "one unique delivery inserted");
1518 assert_eq!(m.duplicate_count, 1, "one duplicate rejected");
1519 assert_eq!(m.seen_count, 1, "one entry in store");
1520
1521 {
1523 let state = WebhookState {
1524 secret: String::new(),
1525 repo: "owner/repo".to_string(),
1526 tx: tx.clone(),
1527 dedup_store: dedup_store.clone(),
1528 };
1529 let app = Router::new()
1530 .route("/webhook", post(handle_webhook))
1531 .with_state(state);
1532 let request = Request::builder()
1533 .method("POST")
1534 .uri("/webhook")
1535 .header("content-type", "application/json")
1536 .header("x-github-event", "issues")
1537 .header("x-github-delivery", "xyz-456-different-id")
1538 .body(Body::from(body_bytes))
1539 .unwrap();
1540 let response = app.oneshot(request).await.unwrap();
1541 assert_eq!(response.status(), StatusCode::OK);
1542 }
1543
1544 let msg2 = rx
1545 .try_recv()
1546 .expect("different delivery ID should produce a message");
1547 assert_eq!(msg2.thread_id, "200");
1548
1549 let m = dedup_store.metrics().await;
1551 assert_eq!(m.inserted_count, 2, "two unique deliveries inserted total");
1552 assert_eq!(m.duplicate_count, 1, "still one duplicate");
1553 }
1554
1555 #[tokio::test]
1557 async fn test_health_endpoint_returns_json_metrics() {
1558 use axum::body::Body;
1559 use axum::http::Request;
1560 use tower::ServiceExt;
1561
1562 let (tx, _rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1563 let dedup_store = DedupStore::new_in_memory(7200, 50_000);
1564
1565 dedup_store.check_and_insert("health-test-1").await;
1567 dedup_store.check_and_insert("health-test-1").await;
1568
1569 let state = WebhookState {
1570 secret: String::new(),
1571 repo: "owner/repo".to_string(),
1572 tx,
1573 dedup_store,
1574 };
1575
1576 let app = Router::new()
1577 .route("/health", get(webhook_health))
1578 .with_state(state);
1579
1580 let request = Request::builder()
1581 .method("GET")
1582 .uri("/health")
1583 .body(Body::empty())
1584 .unwrap();
1585
1586 let response = app.oneshot(request).await.unwrap();
1587 assert_eq!(response.status(), StatusCode::OK);
1588
1589 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1590 .await
1591 .unwrap();
1592 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
1593
1594 assert_eq!(json["ok"], true);
1595 assert_eq!(json["seen_count"], 1);
1596 assert_eq!(json["duplicate_count"], 1);
1597 }
1598
1599 #[test]
1601 fn test_is_transient_bind_error_addr_in_use() {
1602 let io_err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
1603 let err = anyhow::anyhow!(io_err);
1604 assert!(is_transient_bind_error(&err));
1605 }
1606
1607 #[test]
1609 fn test_is_transient_bind_error_other_error() {
1610 let io_err = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
1611 let err = anyhow::anyhow!(io_err);
1612 assert!(!is_transient_bind_error(&err));
1613 }
1614
1615 #[test]
1617 fn test_webhook_backoff_delay_cap() {
1618 let delay = webhook_backoff_delay(10);
1621 assert!(
1622 delay.as_millis() <= 30_500,
1623 "delay exceeded 30 s cap + jitter: {:?}",
1624 delay
1625 );
1626 assert!(
1627 delay.as_millis() >= 500,
1628 "delay should be at least base: {:?}",
1629 delay
1630 );
1631 }
1632
1633 #[tokio::test]
1640 async fn test_start_webhook_server_port_in_use() {
1641 let holder = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
1643 let port = holder.local_addr().unwrap().port();
1644
1645 let result = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await;
1647 drop(holder);
1648
1649 let io_err = result.expect_err("expected EADDRINUSE on duplicate bind");
1650 let anyhow_err = anyhow::anyhow!(io_err);
1651 assert!(
1652 is_transient_bind_error(&anyhow_err),
1653 "EADDRINUSE should be classified as transient: {anyhow_err}"
1654 );
1655 }
1656}