use std::cmp::Ordering; use rusqlite::Connection; use serde::Serialize; use super::error::Result; /// The core timeline event. All pipeline stages produce or consume these. /// Spec ref: Section 3.3 "Event Model" #[derive(Debug, Clone, Serialize)] pub struct TimelineEvent { pub timestamp: i64, pub entity_type: String, #[serde(skip)] pub entity_id: i64, pub entity_iid: i64, pub project_path: String, pub event_type: TimelineEventType, pub summary: String, pub actor: Option, pub url: Option, pub is_seed: bool, } impl PartialEq for TimelineEvent { fn eq(&self, other: &Self) -> bool { self.timestamp == other.timestamp && self.entity_id == other.entity_id && self.event_type_discriminant() == other.event_type_discriminant() } } impl Eq for TimelineEvent {} impl PartialOrd for TimelineEvent { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for TimelineEvent { fn cmp(&self, other: &Self) -> Ordering { self.timestamp .cmp(&other.timestamp) .then_with(|| self.entity_id.cmp(&other.entity_id)) .then_with(|| { self.event_type_discriminant() .cmp(&other.event_type_discriminant()) }) } } impl TimelineEvent { fn event_type_discriminant(&self) -> u8 { match &self.event_type { TimelineEventType::Created => 0, TimelineEventType::StateChanged { .. } => 1, TimelineEventType::LabelAdded { .. } => 2, TimelineEventType::LabelRemoved { .. } => 3, TimelineEventType::MilestoneSet { .. } => 4, TimelineEventType::MilestoneRemoved { .. } => 5, TimelineEventType::Merged => 6, TimelineEventType::NoteEvidence { .. } => 7, TimelineEventType::CrossReferenced { .. } => 8, } } } /// Per spec Section 3.3. Serde tagged enum for JSON output. #[derive(Debug, Clone, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum TimelineEventType { Created, StateChanged { state: String, }, LabelAdded { label: String, }, LabelRemoved { label: String, }, MilestoneSet { milestone: String, }, MilestoneRemoved { milestone: String, }, Merged, NoteEvidence { note_id: i64, snippet: String, discussion_id: Option, }, CrossReferenced { target: String, }, } /// Internal entity reference used across pipeline stages. #[derive(Debug, Clone, Serialize)] pub struct EntityRef { pub entity_type: String, pub entity_id: i64, pub entity_iid: i64, pub project_path: String, } /// An entity discovered via BFS expansion. /// Spec ref: Section 3.5 "expanded_entities" JSON structure. #[derive(Debug, Clone, Serialize)] pub struct ExpandedEntityRef { pub entity_ref: EntityRef, pub depth: u32, pub via_from: EntityRef, pub via_reference_type: String, pub via_source_method: String, } /// Reference to an unsynced external entity. /// Spec ref: Section 3.5 "unresolved_references" JSON structure. #[derive(Debug, Clone, Serialize)] pub struct UnresolvedRef { pub source: EntityRef, pub target_project: Option, pub target_type: String, pub target_iid: Option, pub reference_type: String, } /// Complete result from the timeline pipeline. #[derive(Debug, Clone, Serialize)] pub struct TimelineResult { pub query: String, pub events: Vec, pub seed_entities: Vec, pub expanded_entities: Vec, pub unresolved_references: Vec, } /// Resolve an entity's internal DB id to a full [`EntityRef`] with iid and project path. /// /// When `project_id` is `Some`, the query is scoped to that project. /// Returns `Ok(None)` for unknown entity types or when no matching row exists. pub fn resolve_entity_ref( conn: &Connection, entity_type: &str, entity_id: i64, project_id: Option, ) -> Result> { let table = match entity_type { "issue" => "issues", "merge_request" => "merge_requests", _ => return Ok(None), }; let sql = format!( "SELECT e.iid, p.path_with_namespace FROM {table} e JOIN projects p ON p.id = e.project_id WHERE e.id = ?1 AND (?2 IS NULL OR e.project_id = ?2)" ); let result = conn.query_row(&sql, rusqlite::params![entity_id, project_id], |row| { Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) }); match result { Ok((iid, project_path)) => Ok(Some(EntityRef { entity_type: entity_type.to_owned(), entity_id, entity_iid: iid, project_path, })), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.into()), } } #[cfg(test)] mod tests { use super::*; fn make_event(timestamp: i64, entity_id: i64, event_type: TimelineEventType) -> TimelineEvent { TimelineEvent { timestamp, entity_type: "issue".to_owned(), entity_id, entity_iid: 1, project_path: "group/project".to_owned(), event_type, summary: "test".to_owned(), actor: None, url: None, is_seed: true, } } #[test] fn test_timeline_event_sort_by_timestamp() { let mut events = [ make_event(3000, 1, TimelineEventType::Created), make_event(1000, 2, TimelineEventType::Created), make_event(2000, 3, TimelineEventType::Merged), ]; events.sort(); assert_eq!(events[0].timestamp, 1000); assert_eq!(events[1].timestamp, 2000); assert_eq!(events[2].timestamp, 3000); } #[test] fn test_timeline_event_sort_tiebreak() { let mut events = [ make_event(1000, 5, TimelineEventType::Created), make_event(1000, 2, TimelineEventType::Merged), make_event(1000, 2, TimelineEventType::Created), ]; events.sort(); // Same timestamp: sort by entity_id first, then event_type discriminant assert_eq!(events[0].entity_id, 2); assert!(matches!(events[0].event_type, TimelineEventType::Created)); assert_eq!(events[1].entity_id, 2); assert!(matches!(events[1].event_type, TimelineEventType::Merged)); assert_eq!(events[2].entity_id, 5); } #[test] fn test_timeline_event_type_serializes_tagged() { let event_type = TimelineEventType::StateChanged { state: "closed".to_owned(), }; let json = serde_json::to_value(&event_type).unwrap(); assert_eq!(json["kind"], "state_changed"); assert_eq!(json["state"], "closed"); } #[test] fn test_note_evidence_has_note_id() { let event_type = TimelineEventType::NoteEvidence { note_id: 42, snippet: "some text".to_owned(), discussion_id: Some(7), }; let json = serde_json::to_value(&event_type).unwrap(); assert_eq!(json["kind"], "note_evidence"); assert_eq!(json["note_id"], 42); assert_eq!(json["snippet"], "some text"); assert_eq!(json["discussion_id"], 7); } #[test] fn test_entity_id_skipped_in_serialization() { let event = make_event(1000, 99, TimelineEventType::Created); let json = serde_json::to_value(&event).unwrap(); assert!(json.get("entity_id").is_none()); assert_eq!(json["entity_iid"], 1); } #[test] fn test_timeline_event_type_variant_count() { // Verify all 9 variants serialize without panic let variants: Vec = vec![ TimelineEventType::Created, TimelineEventType::StateChanged { state: "closed".to_owned(), }, TimelineEventType::LabelAdded { label: "bug".to_owned(), }, TimelineEventType::LabelRemoved { label: "bug".to_owned(), }, TimelineEventType::MilestoneSet { milestone: "v1".to_owned(), }, TimelineEventType::MilestoneRemoved { milestone: "v1".to_owned(), }, TimelineEventType::Merged, TimelineEventType::NoteEvidence { note_id: 1, snippet: "text".to_owned(), discussion_id: None, }, TimelineEventType::CrossReferenced { target: "!567".to_owned(), }, ]; assert_eq!(variants.len(), 9); for v in &variants { serde_json::to_value(v).unwrap(); } } }