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