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;