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 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
49/// Default deduplication window: 2 hours (GitHub retries within ~1 hour).
50const DEFAULT_DEDUP_WINDOW_SECS: u64 = 2 * 60 * 60;
51/// Default cap on deduplication map to avoid unbounded memory growth.
52const DEFAULT_MAX_SEEN_DELIVERIES: usize = 50_000;
53
54// ---------------------------------------------------------------------------
55// DedupStore — pluggable delivery deduplication
56// ---------------------------------------------------------------------------
57
58/// Metrics snapshot from the dedup store.
59#[derive(Debug, serde::Serialize, Clone, Copy)]
60pub struct DedupMetrics {
61    /// Number of entries currently in the store.
62    pub seen_count: usize,
63    /// Total duplicate deliveries rejected since process start.
64    pub duplicate_count: u64,
65    /// Total new deliveries inserted since process start.
66    pub inserted_count: u64,
67    /// Total entries evicted due to TTL or size cap.
68    pub evicted_count: u64,
69}
70
71struct DedupInner {
72    /// delivery_id → unix timestamp (secs) when first seen.
73    entries: HashMap<String, u64>,
74    duplicate_count: u64,
75    inserted_count: u64,
76    evicted_count: u64,
77}
78
79/// Deduplication store — hides the backing implementation behind a clean API.
80///
81/// Two variants are supported:
82/// - **In-memory** (`new_in_memory`): default, fastest, state lost on restart.
83/// - **File-backed** (`new_filebacked`): persists to a file so dedup state
84///   survives process restarts (prevents re-processing of GitHub retries after a
85///   restart within the dedup window).
86#[derive(Clone)]
87pub struct DedupStore {
88    inner: Arc<Mutex<DedupInner>>,
89    /// `Some(path)` enables file-backed persistence.
90    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    /// Create an in-memory dedup store with the given window and size cap.
109    #[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    /// Create an in-memory dedup store with optional metrics.
115    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    /// Create a file-backed dedup store.
135    ///
136    /// On construction the file is loaded (if it exists) and expired entries are
137    /// filtered out. On each new insertion the state is flushed to disk so it
138    /// survives process restarts within the dedup window.
139    #[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    /// Create a file-backed dedup store with optional metrics.
145    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    /// Check whether `delivery_id` is new and, if so, record it.
172    ///
173    /// Returns `true` if the delivery is new (should be processed) or
174    /// `false` if it is a duplicate (should be silently ACKed).
175    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            // Evict time-expired entries first.
186            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                // Enforce size cap after insertion so the store never exceeds max_size.
208                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                // Snapshot for file flush only when file-backed.
214                let snap = self.file_path.is_some().then(|| inner.entries.clone());
215                (true, snap)
216            }
217        };
218
219        // Flush outside the mutex to keep the critical section short.
220        // Use spawn_blocking so that the blocking std::fs calls (File::create,
221        // write_all, sync_all, rename) do not occupy a Tokio async worker thread.
222        // Retry up to 3 times with exponential backoff (100ms, 200ms) to handle
223        // transient I/O failures without permanently losing dedup state.
224        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    /// Return a metrics snapshot (non-blocking).
267    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
294/// Evict the oldest entries until the map is at most `max_size`.
295fn 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/// Evict expired entries and enforce the size cap (oldest evicted first).
309/// Used directly in tests; `check_and_insert` uses inline expiry + `enforce_size_cap`.
310#[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
317/// Load dedup state from a JSON file, filtering out entries older than `window_secs`.
318fn 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
353/// Persist the dedup entries to a JSON file.
354fn 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// ---------------------------------------------------------------------------
385// Webhook server
386// ---------------------------------------------------------------------------
387
388#[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    // Use constant-time comparison to prevent timing side-channel attacks
454    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    // Deduplicate by x-github-delivery header — GitHub retries unacknowledged
577    // deliveries and allows manual re-delivery from the App settings UI.
578    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
621/// Health endpoint — returns JSON with dedup store status.
622///
623/// Response: `{"ok": true, "seen_count": N, "duplicate_count": M}`
624async 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
633/// Check if the webhook server is healthy by pinging its local health endpoint.
634///
635/// This only verifies the local HTTP listener is running. It does NOT verify
636/// that GitHub can reach the endpoint (NAT/firewall) or that the webhook
637/// secret is valid.
638///
639/// Returns `(healthy, failure_reason)`.  `failure_reason` is `Some` when
640/// unhealthy and cleared (i.e. `None`) when the check succeeds.
641///
642/// Logs dedup store metrics (seen_count, duplicate_count) at `debug` level for observability.
643pub 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            // Log dedup metrics from the health endpoint response.
658            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
683/// Return `true` when `e` represents a transient TCP bind error (e.g. port
684/// already in use) that is worth retrying.
685pub 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
697/// Compute an exponential-backoff delay for webhook startup retries.
698///
699/// Uses subsecond wall-clock bits as a cheap jitter source — no `rand` crate
700/// needed.  Delay is capped at 30 s.
701pub 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
715/// Start the webhook HTTP server.
716///
717/// Dedup configuration is read from the global config:
718/// - `webhook.dedup_window_seconds` (default: 7200)
719/// - `webhook.max_seen_deliveries` (default: 50000)
720/// - `webhook.dedup_store`: `"memory"` (default) or `"file"` — file-backed
721///   variant persists dedup state across restarts to `{data_dir}/webhook_dedup.db`.
722/// - `webhook.dedup_path`: override the file-backed path (optional).
723/// - `webhook.dedup_metrics`: `"true"` (default) to enable metrics; `"false"` to disable.
724pub 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        // Initialization may perform blocking filesystem I/O (reading the
757        // dedup file). Run the constructor on a blocking thread so we don't
758        // block the Tokio async worker thread that is running
759        // start_webhook_server.
760        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        // Compute the correct HMAC-SHA256
1102        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    // ---------------------------------------------------------------------------
1114    // DedupStore unit tests
1115    // ---------------------------------------------------------------------------
1116
1117    #[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; // duplicate
1149
1150        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        // Insert max + 5 entries; each with a unique ID.
1162        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        // First instance — insert an entry.
1181        {
1182            let store = DedupStore::new_filebacked(path.clone(), 7200, 50_000);
1183            store.check_and_insert("delivery-persisted").await;
1184        }
1185
1186        // Second instance — load from same file; should still see the entry as duplicate.
1187        {
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        // Write a file with an entry that is already expired (timestamp = 0).
1202        let content = r#"{"entries":{"old-delivery":0}}"#;
1203        fs::write(&path, content).unwrap();
1204
1205        // Load with a 1-second window; the entry (ts=0) is long expired.
1206        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        // Use a real-world-scale timestamp so subtracting offsets doesn't underflow.
1222        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        // Use a window large enough so none of the test entries expire (they span
1229        // DEFAULT_MAX_SEEN_DELIVERIES + 10 seconds from now_secs).
1230        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    /// Integration test: exercise the webhook HTTP handler end-to-end.
1252    /// Sends a POST to /webhook with an issues.opened payload and verifies
1253    /// the IncomingMessage arrives on the mpsc channel.
1254    #[tokio::test]
1255    async fn test_webhook_handler_issues_opened() {
1256        // for oneshot()
1257
1258        let (tx, mut rx) = mpsc::channel::<IncomingMessage>(16);
1259
1260        let state = WebhookState {
1261            secret: String::new(), // empty secret = skip verification
1262            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        // The handler should have sent an IncomingMessage to the channel
1292        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    /// Integration test: verify that issue_comment events include the correct thread_id.
1306    #[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    /// Integration test: verify pull_request_review.submitted events are handled.
1354    #[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    /// Integration test: verify that invalid signatures are rejected.
1404    #[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        // No message should have been sent
1441        assert!(
1442            rx.try_recv().is_err(),
1443            "no message should be sent for invalid signature"
1444        );
1445    }
1446
1447    /// Deduplication test: same x-github-delivery ID sent twice → only one message
1448    /// processed by the engine. Also verifies metrics are recorded correctly.
1449    #[tokio::test]
1450    async fn test_webhook_deduplication_same_delivery_id() {
1451        let (tx, mut rx) = mpsc::channel::<IncomingMessage>(16);
1452
1453        // Shared dedup store — both requests use the same state.
1454        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        // First delivery — should be processed.
1467        {
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        // Should have received exactly one message.
1490        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        // Second delivery with the SAME delivery ID — should be silently ACKed.
1497        {
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            // Must still return 200 OK to prevent GitHub from retrying again.
1517            assert_eq!(response.status(), StatusCode::OK);
1518        }
1519
1520        // No second message should have been sent.
1521        assert!(
1522            rx.try_recv().is_err(),
1523            "duplicate delivery should not produce a second message"
1524        );
1525
1526        // Verify metrics: 1 inserted, 1 duplicate.
1527        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        // Third delivery with a DIFFERENT delivery ID — should be processed normally.
1533        {
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        // Final metrics check.
1561        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    /// Health endpoint test: verify it returns JSON with dedup status.
1567    #[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        // Pre-populate with one entry and one duplicate for metrics.
1573        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    /// Verify that `is_transient_bind_error` correctly classifies EADDRINUSE.
1607    #[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    /// Verify that non-bind errors are not classified as transient.
1615    #[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    /// Verify that `webhook_backoff_delay` respects the 30 s cap.
1623    #[test]
1624    fn test_webhook_backoff_delay_cap() {
1625        // At attempt 10 (well past the exponent cap) the base delay should be
1626        // capped at 30 000 ms plus up to 499 ms of jitter.
1627        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    /// Integration test: a double-bind on the same `0.0.0.0` address fails
1639    /// with EADDRINUSE, and `is_transient_bind_error` correctly classifies it.
1640    ///
1641    /// We test the bind step directly (not the full `start_webhook_server`)
1642    /// because `axum::serve` runs until shutdown and would hang the test
1643    /// suite if the first bind accidentally succeeded.
1644    #[tokio::test]
1645    async fn test_start_webhook_server_port_in_use() {
1646        // Hold a listener on 0.0.0.0 (the same bind address used by start_webhook_server).
1647        let holder = TcpListener::bind("0.0.0.0:0").await.unwrap();
1648        let port = holder.local_addr().unwrap().port();
1649
1650        // A second bind on the same address+port must fail.
1651        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}