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