Skip to main content

github_bot_sdk/events/
mod.rs

1//! GitHub webhook event types and processing.
2//!
3//! This module provides type-safe event parsing, validation, and normalization
4//! for GitHub webhook events. It bridges the gap between raw GitHub webhook
5//! payloads and the bot processing system.
6//!
7//! # Overview
8//!
9//! The events module defines:
10//! - Event envelope types for normalized event representation
11//! - Typed event structures for different GitHub event types
12//! - Event processing pipeline for webhook conversion
13//! - Session management for ordered event processing
14//!
15//! # Examples
16//!
17//! ## Processing a Webhook Event
18//!
19//! ```rust,no_run
20//! use github_bot_sdk::events::{EventProcessor, ProcessorConfig};
21//!
22//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
23//! let config = ProcessorConfig::default();
24//! let processor = EventProcessor::new(config);
25//!
26//! // Process incoming webhook
27//! let payload_bytes = b"{\"action\": \"opened\", \"repository\": {}}";
28//! let envelope = processor.process_webhook(
29//!     "pull_request",
30//!     payload_bytes,
31//!     Some("12345-67890-abcdef"),
32//! ).await?;
33//!
34//! println!("Event ID: {}", envelope.event_id);
35//! println!("Repository: {}", envelope.repository.full_name);
36//! # Ok(())
37//! # }
38//! ```
39//!
40//! ## Typed Event Handling
41//!
42//! ```rust,no_run
43//! # use github_bot_sdk::events::EventEnvelope;
44//! # fn handle_event(envelope: EventEnvelope) -> Result<(), Box<dyn std::error::Error>> {
45//! match envelope.event_type.as_str() {
46//!     "pull_request" => {
47//!         let pr_event = envelope.payload.parse_pull_request()?;
48//!         println!("PR #{} was {}", pr_event.number, pr_event.action);
49//!     }
50//!     "issues" => {
51//!         let issue_event = envelope.payload.parse_issue()?;
52//!         println!("Issue #{} was {}", issue_event.issue.number, issue_event.action);
53//!     }
54//!     _ => println!("Unhandled event type"),
55//! }
56//! # Ok(())
57//! # }
58//! ```
59
60use chrono::{DateTime, Utc};
61use serde::{Deserialize, Serialize};
62use std::fmt;
63
64use crate::client::Repository;
65use crate::error::EventError;
66
67pub mod github_events;
68pub mod processor;
69pub mod session;
70
71// Re-export GitHub event types
72pub use github_events::*;
73pub use processor::{EventProcessor, ProcessorConfig, SessionIdStrategy};
74pub use session::SessionManager;
75
76// ============================================================================
77// Event Envelope
78// ============================================================================
79
80/// Primary event container that wraps all GitHub events in a normalized format.
81///
82/// The EventEnvelope provides a consistent structure for all webhook events,
83/// regardless of their source event type. It includes metadata for routing,
84/// correlation, and session-based ordering.
85///
86/// # Examples
87///
88/// ```rust
89/// use github_bot_sdk::events::{EventEnvelope, EventPayload, EntityType};
90/// use github_bot_sdk::client::{Repository, RepositoryOwner, OwnerType};
91/// use serde_json::json;
92/// use chrono::Utc;
93///
94/// # let repository = Repository {
95/// #     id: 12345,
96/// #     name: "repo".to_string(),
97/// #     full_name: "owner/repo".to_string(),
98/// #     owner: RepositoryOwner {
99/// #         login: "owner".to_string(),
100/// #         id: 1,
101/// #         avatar_url: "https://example.com/avatar.png".to_string(),
102/// #         owner_type: OwnerType::User,
103/// #     },
104/// #     private: false,
105/// #     description: None,
106/// #     default_branch: "main".to_string(),
107/// #     html_url: "https://github.com/owner/repo".to_string(),
108/// #     clone_url: "https://github.com/owner/repo.git".to_string(),
109/// #     ssh_url: "git@github.com:owner/repo.git".to_string(),
110/// #     created_at: Utc::now(),
111/// #     updated_at: Utc::now(),
112/// # };
113/// let payload = EventPayload::new(json!({"action": "opened"}));
114///
115/// let envelope = EventEnvelope::new(
116///     "pull_request".to_string(),
117///     repository,
118///     payload,
119/// );
120///
121/// assert_eq!(envelope.event_type, "pull_request");
122/// assert_eq!(envelope.entity_type, EntityType::PullRequest);
123/// ```
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct EventEnvelope {
126    /// Unique identifier for this event
127    pub event_id: EventId,
128
129    /// GitHub event type (e.g., "pull_request", "issues", "push")
130    pub event_type: String,
131
132    /// Repository where the event occurred
133    pub repository: Repository,
134
135    /// Primary entity type involved in the event
136    pub entity_type: EntityType,
137
138    /// Identifier of the primary entity (e.g., PR number, issue number)
139    pub entity_id: Option<String>,
140
141    /// Session ID for ordered processing of related events
142    pub session_id: Option<String>,
143
144    /// Raw event payload from GitHub
145    pub payload: EventPayload,
146
147    /// Processing and routing metadata
148    pub metadata: EventMetadata,
149
150    /// Distributed tracing context
151    pub trace_context: Option<TraceContext>,
152}
153
154impl EventEnvelope {
155    /// Create a new event envelope.
156    ///
157    /// # Examples
158    ///
159    /// ```rust
160    /// # use github_bot_sdk::events::{EventEnvelope, EventPayload};
161    /// # use github_bot_sdk::client::{Repository, RepositoryOwner, OwnerType};
162    /// # use serde_json::json;
163    /// # use chrono::Utc;
164    /// # let repository = Repository {
165    /// #     id: 1,
166    /// #     name: "repo".to_string(),
167    /// #     full_name: "owner/repo".to_string(),
168    /// #     owner: RepositoryOwner {
169    /// #         login: "owner".to_string(),
170    /// #         id: 1,
171    /// #         avatar_url: "https://example.com/avatar.png".to_string(),
172    /// #         owner_type: OwnerType::User,
173    /// #     },
174    /// #     private: false,
175    /// #     description: None,
176    /// #     default_branch: "main".to_string(),
177    /// #     html_url: "https://github.com/owner/repo".to_string(),
178    /// #     clone_url: "https://github.com/owner/repo.git".to_string(),
179    /// #     ssh_url: "git@github.com:owner/repo.git".to_string(),
180    /// #     created_at: Utc::now(),
181    /// #     updated_at: Utc::now(),
182    /// # };
183    /// let payload = EventPayload::new(json!({"action": "opened"}));
184    /// let envelope = EventEnvelope::new("pull_request".to_string(), repository, payload);
185    /// ```
186    pub fn new(event_type: String, repository: Repository, payload: EventPayload) -> Self {
187        let entity_type = EntityType::from_event_type(&event_type);
188
189        Self {
190            event_id: EventId::new(),
191            event_type,
192            repository,
193            entity_type,
194            entity_id: None,
195            session_id: None,
196            payload,
197            metadata: EventMetadata::default(),
198            trace_context: None,
199        }
200    }
201
202    /// Add a session ID for ordered processing.
203    pub fn with_session_id(mut self, session_id: String) -> Self {
204        self.session_id = Some(session_id);
205        self
206    }
207
208    /// Add trace context for distributed tracing.
209    pub fn with_trace_context(mut self, context: TraceContext) -> Self {
210        self.trace_context = Some(context);
211        self
212    }
213
214    /// Get a unique key for the primary entity.
215    ///
216    /// Returns a string in the format "repo:owner/name:entity_type:entity_id"
217    /// for entities with IDs, or "repo:owner/name" for repository-level events.
218    pub fn entity_key(&self) -> String {
219        if let Some(ref entity_id) = self.entity_id {
220            format!(
221                "repo:{}:{:?}:{}",
222                self.repository.full_name, self.entity_type, entity_id
223            )
224        } else {
225            format!("repo:{}", self.repository.full_name)
226        }
227    }
228
229    /// Get the correlation ID for this event.
230    ///
231    /// Returns the event ID as a string for correlation across system boundaries.
232    pub fn correlation_id(&self) -> &str {
233        self.event_id.as_str()
234    }
235}
236
237// ============================================================================
238// Event ID
239// ============================================================================
240
241/// Unique identifier for events, ensuring idempotency and deduplication.
242///
243/// Event IDs can be generated from GitHub delivery IDs or created as new UUIDs.
244/// They are used for event deduplication and correlation across the system.
245///
246/// # Examples
247///
248/// ```rust
249/// use github_bot_sdk::events::EventId;
250///
251/// // Create from GitHub delivery ID
252/// let id = EventId::from_github_delivery("12345-67890-abcdef");
253/// assert_eq!(id.as_str(), "gh-12345-67890-abcdef");
254///
255/// // Create new random ID
256/// let id = EventId::new();
257/// ```
258#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
259pub struct EventId(String);
260
261impl EventId {
262    /// Create a new random event ID using UUID v4.
263    pub fn new() -> Self {
264        Self(uuid::Uuid::new_v4().to_string())
265    }
266
267    /// Create an event ID from a GitHub delivery ID.
268    ///
269    /// GitHub delivery IDs are prefixed with "gh-" to distinguish them
270    /// from internally generated IDs.
271    pub fn from_github_delivery(delivery_id: &str) -> Self {
272        Self(format!("gh-{}", delivery_id))
273    }
274
275    /// Get the event ID as a string slice.
276    pub fn as_str(&self) -> &str {
277        &self.0
278    }
279}
280
281impl Default for EventId {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl fmt::Display for EventId {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        write!(f, "{}", self.0)
290    }
291}
292
293// ============================================================================
294// Entity Type
295// ============================================================================
296
297/// Classifies the primary entity involved in the event for session correlation.
298///
299/// Entity types are used to group related events for ordered processing and
300/// to determine session ID generation strategies.
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub enum EntityType {
303    /// Repository-level event
304    Repository,
305    /// Pull request event
306    PullRequest,
307    /// Issue event
308    Issue,
309    /// Branch event (push, create, delete)
310    Branch,
311    /// Release event
312    Release,
313    /// User event
314    User,
315    /// Organization event
316    Organization,
317    /// Check run event
318    CheckRun,
319    /// Check suite event
320    CheckSuite,
321    /// Deployment event
322    Deployment,
323    /// Unknown or unsupported entity type
324    Unknown,
325}
326
327impl EntityType {
328    /// Determine entity type from GitHub event type string.
329    ///
330    /// # Examples
331    ///
332    /// ```rust
333    /// use github_bot_sdk::events::EntityType;
334    ///
335    /// assert_eq!(EntityType::from_event_type("pull_request"), EntityType::PullRequest);
336    /// assert_eq!(EntityType::from_event_type("issues"), EntityType::Issue);
337    /// assert_eq!(EntityType::from_event_type("push"), EntityType::Branch);
338    /// assert_eq!(EntityType::from_event_type("unknown"), EntityType::Unknown);
339    /// ```
340    pub fn from_event_type(event_type: &str) -> Self {
341        match event_type {
342            "pull_request" | "pull_request_review" | "pull_request_review_comment" => {
343                Self::PullRequest
344            }
345            "issues" | "issue_comment" => Self::Issue,
346            "push" | "create" | "delete" => Self::Branch,
347            "release" | "release_published" => Self::Release,
348            "check_run" => Self::CheckRun,
349            "check_suite" => Self::CheckSuite,
350            "deployment" | "deployment_status" => Self::Deployment,
351            "repository" => Self::Repository,
352            "organization" | "member" | "membership" => Self::Organization,
353            _ => Self::Unknown,
354        }
355    }
356
357    /// Check if this entity type supports ordered processing.
358    ///
359    /// Returns true for entity types where event ordering matters
360    /// (pull requests, issues, branches).
361    pub fn supports_ordering(&self) -> bool {
362        matches!(self, Self::PullRequest | Self::Issue | Self::Branch)
363    }
364}
365
366// ============================================================================
367// Event Payload
368// ============================================================================
369
370/// Container for the actual GitHub webhook payload data.
371///
372/// The payload stores the raw JSON value and provides typed parsing methods
373/// for different event types.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct EventPayload {
376    inner: serde_json::Value,
377}
378
379impl EventPayload {
380    /// Create a new event payload from a JSON value.
381    pub fn new(value: serde_json::Value) -> Self {
382        Self { inner: value }
383    }
384
385    /// Get the raw JSON value.
386    pub fn raw(&self) -> &serde_json::Value {
387        &self.inner
388    }
389
390    /// Parse as a pull request event.
391    pub fn parse_pull_request(&self) -> Result<PullRequestEvent, EventError> {
392        Ok(serde_json::from_value(self.inner.clone())?)
393    }
394
395    /// Parse as an issue event.
396    pub fn parse_issue(&self) -> Result<IssueEvent, EventError> {
397        Ok(serde_json::from_value(self.inner.clone())?)
398    }
399
400    /// Parse as a push event.
401    pub fn parse_push(&self) -> Result<PushEvent, EventError> {
402        Ok(serde_json::from_value(self.inner.clone())?)
403    }
404
405    /// Parse as a check run event.
406    pub fn parse_check_run(&self) -> Result<CheckRunEvent, EventError> {
407        Ok(serde_json::from_value(self.inner.clone())?)
408    }
409
410    /// Parse as a check suite event.
411    pub fn parse_check_suite(&self) -> Result<CheckSuiteEvent, EventError> {
412        Ok(serde_json::from_value(self.inner.clone())?)
413    }
414}
415
416// ============================================================================
417// Event Metadata
418// ============================================================================
419
420/// Additional metadata about event processing and routing.
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct EventMetadata {
423    /// When the event was received by our system
424    pub received_at: DateTime<Utc>,
425
426    /// When processing completed (if applicable)
427    pub processed_at: Option<DateTime<Utc>>,
428
429    /// Source of this event
430    pub source: EventSource,
431
432    /// GitHub delivery ID from X-GitHub-Delivery header
433    pub delivery_id: Option<String>,
434
435    /// Whether the webhook signature was valid
436    pub signature_valid: bool,
437
438    /// Number of times this event has been retried
439    pub retry_count: u32,
440
441    /// Names of routing rules that matched this event
442    pub routing_rules: Vec<String>,
443}
444
445impl Default for EventMetadata {
446    fn default() -> Self {
447        Self {
448            received_at: Utc::now(),
449            processed_at: None,
450            source: EventSource::GitHub,
451            delivery_id: None,
452            signature_valid: false,
453            retry_count: 0,
454            routing_rules: Vec::new(),
455        }
456    }
457}
458
459/// Source of an event.
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
461pub enum EventSource {
462    /// Event from GitHub webhook
463    GitHub,
464    /// Event from replay operation
465    Replay,
466    /// Event from test/development
467    Test,
468}
469
470// ============================================================================
471// Trace Context
472// ============================================================================
473
474/// Distributed tracing context for event correlation.
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct TraceContext {
477    /// Trace ID for distributed tracing
478    pub trace_id: String,
479
480    /// Span ID for this specific operation
481    pub span_id: String,
482
483    /// Parent span ID if applicable
484    pub parent_span_id: Option<String>,
485}
486
487#[cfg(test)]
488#[path = "mod_tests.rs"]
489mod tests;