Skip to main content

orch/channels/
github.rs

1//! GitHub webhook server — receives events from GitHub and dispatches them.
2//!
3//! Provides `start_webhook_server` and `check_webhook_health` for the engine.
4
5use 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
31/// Default deduplication window: 2 hours (GitHub retries within ~1 hour).
32const DEFAULT_DEDUP_WINDOW_SECS: u64 = 2 * 60 * 60;
33/// Default cap on deduplication map to avoid unbounded memory growth.
34const DEFAULT_MAX_SEEN_DELIVERIES: usize = 50_000;
35
36// ---------------------------------------------------------------------------
37// DedupStore — pluggable delivery deduplication
38// ---------------------------------------------------------------------------
39
40/// Metrics snapshot from the dedup store.
41#[derive(serde::Serialize, Clone, Copy)]
42pub struct DedupMetrics {
43    /// Number of entries currently in the store.
44    pub seen_count: usize,
45    /// Total duplicate deliveries rejected since process start.
46    pub duplicate_count: u64,
47    /// Total new deliveries inserted since process start.
48    pub inserted_count: u64,
49    /// Total entries evicted due to TTL or size cap.
50    pub evicted_count: u64,
51}
52
53struct DedupInner {
54    /// delivery_id → unix timestamp (secs) when first seen.
55    entries: HashMap<String, u64>,
56    duplicate_count: u64,
57    inserted_count: u64,
58    evicted_count: u64,
59}
60
61/// Deduplication store — hides the backing implementation behind a clean API.
62///
63/// Two variants are supported:
64/// - **In-memory** (`new_in_memory`): default, fastest, state lost on restart.
65/// - **File-backed** (`new_filebacked`): persists to a file so dedup state
66///   survives process restarts (prevents re-processing of GitHub retries after a
67///   restart within the dedup window).
68#[derive(Clone)]
69pub struct DedupStore {
70    inner: Arc<tokio::sync::Mutex<DedupInner>>,
71    /// `Some(path)` enables file-backed persistence.
72    file_path: Option<PathBuf>,
73    window_secs: u64,
74    max_size: usize,
75    metrics_enabled: bool,
76}
77
78impl DedupStore {
79    /// Create an in-memory dedup store with the given window and size cap.
80    #[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    /// Create an in-memory dedup store with optional metrics.
86    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    /// Create a file-backed dedup store.
106    ///
107    /// On construction the file is loaded (if it exists) and expired entries are
108    /// filtered out. On each new insertion the state is flushed to disk so it
109    /// survives process restarts within the dedup window.
110    #[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    /// Create a file-backed dedup store with optional metrics.
116    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    /// Check whether `delivery_id` is new and, if so, record it.
143    ///
144    /// Returns `true` if the delivery is new (should be processed) or
145    /// `false` if it is a duplicate (should be silently ACKed).
146    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            // Evict time-expired entries first.
155            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                // Enforce size cap after insertion so the store never exceeds max_size.
177                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                // Snapshot for file flush only when file-backed.
183                let snap = self.file_path.is_some().then(|| inner.entries.clone());
184                (true, snap)
185            }
186        };
187
188        // Flush outside the mutex to keep the critical section short.
189        // Use spawn_blocking so that the blocking std::fs calls (File::create,
190        // write_all, sync_all, rename) do not occupy a Tokio async worker thread.
191        // Retry up to 3 times with exponential backoff (100ms, 200ms) to handle
192        // transient I/O failures without permanently losing dedup state.
193        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    /// Return a metrics snapshot (non-blocking).
239    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
266/// Evict the oldest entries until the map is at most `max_size`.
267fn 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/// Evict expired entries and enforce the size cap (oldest evicted first).
281/// Used directly in tests; `check_and_insert` uses inline expiry + `enforce_size_cap`.
282#[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
289/// Load dedup state from a JSON file, filtering out entries older than `window_secs`.
290fn 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
325/// Persist the dedup entries to a JSON file.
326fn 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// ---------------------------------------------------------------------------
358// Webhook server
359// ---------------------------------------------------------------------------
360
361#[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    // Use constant-time comparison to prevent timing side-channel attacks
428    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    // Deduplicate by x-github-delivery header — GitHub retries unacknowledged
553    // deliveries and allows manual re-delivery from the App settings UI.
554    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
597/// Health endpoint — returns JSON with dedup store status.
598///
599/// Response: `{"ok": true, "seen_count": N, "duplicate_count": M}`
600async 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
609/// Check if the webhook server is healthy by pinging its local health endpoint.
610///
611/// This only verifies the local HTTP listener is running. It does NOT verify
612/// that GitHub can reach the endpoint (NAT/firewall) or that the webhook
613/// secret is valid.
614///
615/// Returns `(healthy, failure_reason)`.  `failure_reason` is `Some` when
616/// unhealthy and cleared (i.e. `None`) when the check succeeds.
617///
618/// Logs dedup store metrics (seen_count, duplicate_count) at `debug` level for observability.
619pub 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            // Log dedup metrics from the health endpoint response.
634            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
656/// Return `true` when `e` represents a transient TCP bind error (e.g. port
657/// already in use) that is worth retrying.
658pub 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
670/// Compute an exponential-backoff delay for webhook startup retries.
671///
672/// Uses subsecond wall-clock bits as a cheap jitter source — no `rand` crate
673/// needed.  Delay is capped at 30 s.
674pub 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
687/// Start the webhook HTTP server.
688///
689/// Dedup configuration is read from the global config:
690/// - `webhook.dedup_window_seconds` (default: 7200)
691/// - `webhook.max_seen_deliveries` (default: 50000)
692/// - `webhook.dedup_store`: `"memory"` (default) or `"file"` — file-backed
693///   variant persists dedup state across restarts to `{data_dir}/webhook_dedup.db`.
694/// - `webhook.dedup_path`: override the file-backed path (optional).
695/// - `webhook.dedup_metrics`: `"true"` (default) to enable metrics; `"false"` to disable.
696pub 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        // Initialization may perform blocking filesystem I/O (reading the
732        // dedup file). Run the constructor on a blocking thread so we don't
733        // block the Tokio async worker thread that is running
734        // start_webhook_server.
735        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        // Compute the correct HMAC-SHA256
1071        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    // ---------------------------------------------------------------------------
1083    // DedupStore unit tests
1084    // ---------------------------------------------------------------------------
1085
1086    #[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; // duplicate
1118
1119        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        // Insert max + 5 entries; each with a unique ID.
1131        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        // First instance — insert an entry.
1150        {
1151            let store = DedupStore::new_filebacked(path.clone(), 7200, 50_000);
1152            store.check_and_insert("delivery-persisted").await;
1153        }
1154
1155        // Second instance — load from same file; should still see the entry as duplicate.
1156        {
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        // Write a file with an entry that is already expired (timestamp = 0).
1171        let content = r#"{"entries":{"old-delivery":0}}"#;
1172        std::fs::write(&path, content).unwrap();
1173
1174        // Load with a 1-second window; the entry (ts=0) is long expired.
1175        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        // Use a real-world-scale timestamp so subtracting offsets doesn't underflow.
1191        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        // Use a window large enough so none of the test entries expire (they span
1200        // DEFAULT_MAX_SEEN_DELIVERIES + 10 seconds from now_secs).
1201        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    /// Integration test: exercise the webhook HTTP handler end-to-end.
1223    /// Sends a POST to /webhook with an issues.opened payload and verifies
1224    /// the IncomingMessage arrives on the mpsc channel.
1225    #[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; // for oneshot()
1230
1231        let (tx, mut rx) = tokio::sync::mpsc::channel::<IncomingMessage>(16);
1232
1233        let state = WebhookState {
1234            secret: String::new(), // empty secret = skip verification
1235            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        // The handler should have sent an IncomingMessage to the channel
1265        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    /// Integration test: verify that issue_comment events include the correct thread_id.
1279    #[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    /// Integration test: verify pull_request_review.submitted events are handled.
1331    #[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    /// Integration test: verify that invalid signatures are rejected.
1385    #[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        // No message should have been sent
1426        assert!(
1427            rx.try_recv().is_err(),
1428            "no message should be sent for invalid signature"
1429        );
1430    }
1431
1432    /// Deduplication test: same x-github-delivery ID sent twice → only one message
1433    /// processed by the engine. Also verifies metrics are recorded correctly.
1434    #[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        // Shared dedup store — both requests use the same state.
1443        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        // First delivery — should be processed.
1456        {
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        // Should have received exactly one message.
1479        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        // Second delivery with the SAME delivery ID — should be silently ACKed.
1486        {
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            // Must still return 200 OK to prevent GitHub from retrying again.
1506            assert_eq!(response.status(), StatusCode::OK);
1507        }
1508
1509        // No second message should have been sent.
1510        assert!(
1511            rx.try_recv().is_err(),
1512            "duplicate delivery should not produce a second message"
1513        );
1514
1515        // Verify metrics: 1 inserted, 1 duplicate.
1516        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        // Third delivery with a DIFFERENT delivery ID — should be processed normally.
1522        {
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        // Final metrics check.
1550        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    /// Health endpoint test: verify it returns JSON with dedup status.
1556    #[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        // Pre-populate with one entry and one duplicate for metrics.
1566        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    /// Verify that `is_transient_bind_error` correctly classifies EADDRINUSE.
1600    #[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    /// Verify that non-bind errors are not classified as transient.
1608    #[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    /// Verify that `webhook_backoff_delay` respects the 30 s cap.
1616    #[test]
1617    fn test_webhook_backoff_delay_cap() {
1618        // At attempt 10 (well past the exponent cap) the base delay should be
1619        // capped at 30 000 ms plus up to 499 ms of jitter.
1620        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    /// Integration test: a double-bind on the same `0.0.0.0` address fails
1634    /// with EADDRINUSE, and `is_transient_bind_error` correctly classifies it.
1635    ///
1636    /// We test the bind step directly (not the full `start_webhook_server`)
1637    /// because `axum::serve` runs until shutdown and would hang the test
1638    /// suite if the first bind accidentally succeeded.
1639    #[tokio::test]
1640    async fn test_start_webhook_server_port_in_use() {
1641        // Hold a listener on 0.0.0.0 (the same bind address used by start_webhook_server).
1642        let holder = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
1643        let port = holder.local_addr().unwrap().port();
1644
1645        // A second bind on the same address+port must fail.
1646        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}