#![allow(dead_code)] use anyhow::{Context, Result}; use rusqlite::Connection; use crate::message::{EntityKey, EntityKind, TimelineEvent, TimelineEventKind}; use crate::state::timeline::TimelineScope; /// Internal filter resolved from a [`TimelineScope`]. /// /// Translates the user-facing scope (which uses `EntityKey` with project_id + iid) /// into internal DB ids for efficient querying. enum TimelineFilter { /// No filtering — return all events. All, /// Filter to events for a specific issue (internal DB id). Issue(i64), /// Filter to events for a specific MR (internal DB id). MergeRequest(i64), /// Filter to events by a specific actor. Actor(String), } /// Resolve a [`TimelineScope`] into a concrete [`TimelineFilter`]. fn resolve_timeline_scope(conn: &Connection, scope: &TimelineScope) -> Result { match scope { TimelineScope::All => Ok(TimelineFilter::All), TimelineScope::Entity(key) => { let (table, kind_label) = match key.kind { EntityKind::Issue => ("issues", "issue"), EntityKind::MergeRequest => ("merge_requests", "merge request"), }; let sql = format!("SELECT id FROM {table} WHERE project_id = ?1 AND iid = ?2"); let id: i64 = conn .query_row(&sql, rusqlite::params![key.project_id, key.iid], |r| { r.get(0) }) .with_context(|| { format!( "resolving {kind_label} #{} in project {}", key.iid, key.project_id ) })?; match key.kind { EntityKind::Issue => Ok(TimelineFilter::Issue(id)), EntityKind::MergeRequest => Ok(TimelineFilter::MergeRequest(id)), } } TimelineScope::Author(name) => Ok(TimelineFilter::Actor(name.clone())), } } /// Fetch timeline events from raw resource event tables. /// /// Queries `issues`/`merge_requests` for Created events, plus /// `resource_state_events`, `resource_label_events`, and /// `resource_milestone_events` for lifecycle events. Results are sorted /// by timestamp descending (most recent first) and truncated to `limit`. pub fn fetch_timeline_events( conn: &Connection, scope: &TimelineScope, limit: usize, ) -> Result> { let filter = resolve_timeline_scope(conn, scope)?; let mut events = Vec::new(); // Each collector is given the full limit. After merge-sorting, we truncate // to `limit`. Worst case we hold 4*limit events in memory (bounded). collect_tl_created_events(conn, &filter, limit, &mut events)?; collect_tl_state_events(conn, &filter, limit, &mut events)?; collect_tl_label_events(conn, &filter, limit, &mut events)?; collect_tl_milestone_events(conn, &filter, limit, &mut events)?; // Sort by timestamp descending (most recent first), with stable tiebreak. events.sort_by(|a, b| { b.timestamp_ms .cmp(&a.timestamp_ms) .then_with(|| a.entity_key.kind.cmp(&b.entity_key.kind)) .then_with(|| a.entity_key.iid.cmp(&b.entity_key.iid)) }); events.truncate(limit); Ok(events) } /// Collect Created events from issues and merge_requests tables. fn collect_tl_created_events( conn: &Connection, filter: &TimelineFilter, limit: usize, events: &mut Vec, ) -> Result<()> { // Issue created events. if !matches!(filter, TimelineFilter::MergeRequest(_)) { let (where_clause, mut params) = match filter { TimelineFilter::All => ( "1=1".to_string(), Vec::>::new(), ), TimelineFilter::Issue(id) => ( "i.id = ?1".to_string(), vec![Box::new(*id) as Box], ), TimelineFilter::Actor(name) => ( "i.author_username = ?1".to_string(), vec![Box::new(name.clone()) as Box], ), TimelineFilter::MergeRequest(_) => unreachable!(), }; let limit_param = params.len() + 1; let sql = format!( "SELECT i.created_at, i.iid, i.title, i.author_username, i.project_id, p.path_with_namespace FROM issues i JOIN projects p ON p.id = i.project_id WHERE {where_clause} ORDER BY i.created_at DESC LIMIT ?{limit_param}" ); params.push(Box::new(limit as i64)); let mut stmt = conn .prepare(&sql) .context("preparing issue created query")?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(AsRef::as_ref).collect(); let rows = stmt .query_map(param_refs.as_slice(), |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, row.get::<_, i64>(4)?, row.get::<_, String>(5)?, )) }) .context("querying issue created events")?; for row in rows { let (created_at, iid, title, author, project_id, project_path) = row.context("reading issue created row")?; let title_str = title.as_deref().unwrap_or("(untitled)"); events.push(TimelineEvent { timestamp_ms: created_at, entity_key: EntityKey::issue(project_id, iid), event_kind: TimelineEventKind::Created, summary: format!("Issue #{iid} created: {title_str}"), detail: title, actor: author, project_path, }); } } // MR created events. if !matches!(filter, TimelineFilter::Issue(_)) { let (where_clause, mut params) = match filter { TimelineFilter::All => ( "1=1".to_string(), Vec::>::new(), ), TimelineFilter::MergeRequest(id) => ( "mr.id = ?1".to_string(), vec![Box::new(*id) as Box], ), TimelineFilter::Actor(name) => ( "mr.author_username = ?1".to_string(), vec![Box::new(name.clone()) as Box], ), TimelineFilter::Issue(_) => unreachable!(), }; let limit_param = params.len() + 1; let sql = format!( "SELECT mr.created_at, mr.iid, mr.title, mr.author_username, mr.project_id, p.path_with_namespace FROM merge_requests mr JOIN projects p ON p.id = mr.project_id WHERE {where_clause} ORDER BY mr.created_at DESC LIMIT ?{limit_param}" ); params.push(Box::new(limit as i64)); let mut stmt = conn.prepare(&sql).context("preparing MR created query")?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(AsRef::as_ref).collect(); let rows = stmt .query_map(param_refs.as_slice(), |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, row.get::<_, i64>(4)?, row.get::<_, String>(5)?, )) }) .context("querying MR created events")?; for row in rows { let (created_at, iid, title, author, project_id, project_path) = row.context("reading MR created row")?; let title_str = title.as_deref().unwrap_or("(untitled)"); events.push(TimelineEvent { timestamp_ms: created_at, entity_key: EntityKey::mr(project_id, iid), event_kind: TimelineEventKind::Created, summary: format!("MR !{iid} created: {title_str}"), detail: title, actor: author, project_path, }); } } Ok(()) } /// Helper: build WHERE clause and params for resource event tables. /// /// Resource event tables have `issue_id` and `merge_request_id` columns /// (exactly one is non-NULL per row), plus `actor_username`. fn resource_event_where(filter: &TimelineFilter) -> (String, Vec>) { match filter { TimelineFilter::All => ("1=1".to_string(), Vec::new()), TimelineFilter::Issue(id) => ( "e.issue_id = ?1".to_string(), vec![Box::new(*id) as Box], ), TimelineFilter::MergeRequest(id) => ( "e.merge_request_id = ?1".to_string(), vec![Box::new(*id) as Box], ), TimelineFilter::Actor(name) => ( "e.actor_username = ?1".to_string(), vec![Box::new(name.clone()) as Box], ), } } /// Resolve a resource event row's entity to an EntityKey. fn resolve_event_entity( issue_id: Option, mr_id: Option, issue_iid: Option, mr_iid: Option, issue_project_id: Option, mr_project_id: Option, ) -> Option<(EntityKey, i64)> { if let (Some(iid), Some(pid)) = (issue_iid, issue_project_id) { Some((EntityKey::issue(pid, iid), pid)) } else if let (Some(iid), Some(pid)) = (mr_iid, mr_project_id) { Some((EntityKey::mr(pid, iid), pid)) } else { // Orphaned event — entity was deleted. let _ = (issue_id, mr_id); // suppress unused warnings None } } /// Collect state change events from `resource_state_events`. fn collect_tl_state_events( conn: &Connection, filter: &TimelineFilter, limit: usize, events: &mut Vec, ) -> Result<()> { let (where_clause, mut params) = resource_event_where(filter); let limit_param = params.len() + 1; let sql = format!( "SELECT e.created_at, e.state, e.actor_username, e.issue_id, e.merge_request_id, i.iid, mr.iid, i.project_id, mr.project_id, COALESCE(pi.path_with_namespace, pm.path_with_namespace) AS project_path FROM resource_state_events e LEFT JOIN issues i ON i.id = e.issue_id LEFT JOIN merge_requests mr ON mr.id = e.merge_request_id LEFT JOIN projects pi ON pi.id = i.project_id LEFT JOIN projects pm ON pm.id = mr.project_id WHERE {where_clause} ORDER BY e.created_at DESC LIMIT ?{limit_param}" ); params.push(Box::new(limit as i64)); let mut stmt = conn.prepare(&sql).context("preparing state events query")?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(AsRef::as_ref).collect(); let rows = stmt .query_map(param_refs.as_slice(), |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, row.get::<_, Option>(5)?, row.get::<_, Option>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, row.get::<_, Option>(9)?, )) }) .context("querying state events")?; for row in rows { let ( created_at, state, actor, issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid, project_path, ) = row.context("reading state event row")?; let Some((entity_key, _pid)) = resolve_event_entity(issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid) else { continue; }; let (event_kind, summary) = if state == "merged" { ( TimelineEventKind::Merged, format!("MR !{} merged", entity_key.iid), ) } else { ( TimelineEventKind::StateChanged, format!("State changed to {state}"), ) }; events.push(TimelineEvent { timestamp_ms: created_at, entity_key, event_kind, summary, detail: Some(state), actor, project_path: project_path.unwrap_or_default(), }); } Ok(()) } /// Collect label change events from `resource_label_events`. fn collect_tl_label_events( conn: &Connection, filter: &TimelineFilter, limit: usize, events: &mut Vec, ) -> Result<()> { let (where_clause, mut params) = resource_event_where(filter); let limit_param = params.len() + 1; let sql = format!( "SELECT e.created_at, e.action, e.label_name, e.actor_username, e.issue_id, e.merge_request_id, i.iid, mr.iid, i.project_id, mr.project_id, COALESCE(pi.path_with_namespace, pm.path_with_namespace) AS project_path FROM resource_label_events e LEFT JOIN issues i ON i.id = e.issue_id LEFT JOIN merge_requests mr ON mr.id = e.merge_request_id LEFT JOIN projects pi ON pi.id = i.project_id LEFT JOIN projects pm ON pm.id = mr.project_id WHERE {where_clause} ORDER BY e.created_at DESC LIMIT ?{limit_param}" ); params.push(Box::new(limit as i64)); let mut stmt = conn.prepare(&sql).context("preparing label events query")?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(AsRef::as_ref).collect(); let rows = stmt .query_map(param_refs.as_slice(), |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, row.get::<_, Option>(5)?, row.get::<_, Option>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, row.get::<_, Option>(9)?, row.get::<_, Option>(10)?, )) }) .context("querying label events")?; for row in rows { let ( created_at, action, label_name, actor, issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid, project_path, ) = row.context("reading label event row")?; let Some((entity_key, _pid)) = resolve_event_entity(issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid) else { continue; }; let (event_kind, summary) = match action.as_str() { "add" => ( TimelineEventKind::LabelAdded, format!("Label added: {label_name}"), ), "remove" => ( TimelineEventKind::LabelRemoved, format!("Label removed: {label_name}"), ), _ => continue, }; events.push(TimelineEvent { timestamp_ms: created_at, entity_key, event_kind, summary, detail: Some(label_name), actor, project_path: project_path.unwrap_or_default(), }); } Ok(()) } /// Collect milestone change events from `resource_milestone_events`. fn collect_tl_milestone_events( conn: &Connection, filter: &TimelineFilter, limit: usize, events: &mut Vec, ) -> Result<()> { let (where_clause, mut params) = resource_event_where(filter); let limit_param = params.len() + 1; let sql = format!( "SELECT e.created_at, e.action, e.milestone_title, e.actor_username, e.issue_id, e.merge_request_id, i.iid, mr.iid, i.project_id, mr.project_id, COALESCE(pi.path_with_namespace, pm.path_with_namespace) AS project_path FROM resource_milestone_events e LEFT JOIN issues i ON i.id = e.issue_id LEFT JOIN merge_requests mr ON mr.id = e.merge_request_id LEFT JOIN projects pi ON pi.id = i.project_id LEFT JOIN projects pm ON pm.id = mr.project_id WHERE {where_clause} ORDER BY e.created_at DESC LIMIT ?{limit_param}" ); params.push(Box::new(limit as i64)); let mut stmt = conn .prepare(&sql) .context("preparing milestone events query")?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(AsRef::as_ref).collect(); let rows = stmt .query_map(param_refs.as_slice(), |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, row.get::<_, Option>(5)?, row.get::<_, Option>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, row.get::<_, Option>(9)?, row.get::<_, Option>(10)?, )) }) .context("querying milestone events")?; for row in rows { let ( created_at, action, milestone_title, actor, issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid, project_path, ) = row.context("reading milestone event row")?; let Some((entity_key, _pid)) = resolve_event_entity(issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid) else { continue; }; let (event_kind, summary) = match action.as_str() { "add" => ( TimelineEventKind::MilestoneSet, format!("Milestone set: {milestone_title}"), ), "remove" => ( TimelineEventKind::MilestoneRemoved, format!("Milestone removed: {milestone_title}"), ), _ => continue, }; events.push(TimelineEvent { timestamp_ms: created_at, entity_key, event_kind, summary, detail: Some(milestone_title), actor, project_path: project_path.unwrap_or_default(), }); } Ok(()) } #[cfg(test)] mod tests { use super::*; /// Create the minimal schema needed for timeline queries. fn create_dashboard_schema(conn: &Connection) { conn.execute_batch( " CREATE TABLE projects ( id INTEGER PRIMARY KEY, gitlab_project_id INTEGER UNIQUE NOT NULL, path_with_namespace TEXT NOT NULL ); CREATE TABLE issues ( id INTEGER PRIMARY KEY, gitlab_id INTEGER UNIQUE NOT NULL, project_id INTEGER NOT NULL, iid INTEGER NOT NULL, title TEXT, state TEXT NOT NULL, author_username TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL ); CREATE TABLE merge_requests ( id INTEGER PRIMARY KEY, gitlab_id INTEGER UNIQUE NOT NULL, project_id INTEGER NOT NULL, iid INTEGER NOT NULL, title TEXT, state TEXT, author_username TEXT, created_at INTEGER, updated_at INTEGER, last_seen_at INTEGER NOT NULL ); ", ) .expect("create dashboard schema"); } fn insert_issue(conn: &Connection, iid: i64, state: &str, updated_at: i64) { conn.execute( "INSERT INTO issues (gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?5, ?5)", rusqlite::params![iid * 100, iid, format!("Issue {iid}"), state, updated_at], ) .expect("insert issue"); } fn insert_mr(conn: &Connection, iid: i64, state: &str, updated_at: i64) { conn.execute( "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?5, ?5)", rusqlite::params![iid * 100 + 50, iid, format!("MR {iid}"), state, updated_at], ) .expect("insert mr"); } /// Add resource event tables to an existing schema. fn add_resource_event_tables(conn: &Connection) { conn.execute_batch( " CREATE TABLE IF NOT EXISTS resource_state_events ( id INTEGER PRIMARY KEY, gitlab_id INTEGER NOT NULL, project_id INTEGER NOT NULL, issue_id INTEGER, merge_request_id INTEGER, state TEXT NOT NULL, actor_gitlab_id INTEGER, actor_username TEXT, created_at INTEGER NOT NULL, source_commit TEXT, source_merge_request_iid INTEGER ); CREATE TABLE IF NOT EXISTS resource_label_events ( id INTEGER PRIMARY KEY, gitlab_id INTEGER NOT NULL, project_id INTEGER NOT NULL, issue_id INTEGER, merge_request_id INTEGER, action TEXT NOT NULL, label_name TEXT NOT NULL, actor_gitlab_id INTEGER, actor_username TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS resource_milestone_events ( id INTEGER PRIMARY KEY, gitlab_id INTEGER NOT NULL, project_id INTEGER NOT NULL, issue_id INTEGER, merge_request_id INTEGER, action TEXT NOT NULL, milestone_title TEXT NOT NULL, milestone_id INTEGER, actor_gitlab_id INTEGER, actor_username TEXT, created_at INTEGER NOT NULL ); ", ) .expect("create resource event tables"); } /// Create a full timeline test schema (dashboard schema + resource events). fn create_timeline_schema(conn: &Connection) { create_dashboard_schema(conn); add_resource_event_tables(conn); // Insert a project for test entities. conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')", [], ) .expect("insert test project"); } fn insert_state_event( conn: &Connection, gitlab_id: i64, issue_id: Option, mr_id: Option, state: &str, actor: &str, created_at: i64, ) { conn.execute( "INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![gitlab_id, issue_id, mr_id, state, actor, created_at], ) .expect("insert state event"); } #[allow(clippy::too_many_arguments)] fn insert_label_event( conn: &Connection, gitlab_id: i64, issue_id: Option, mr_id: Option, action: &str, label: &str, actor: &str, created_at: i64, ) { conn.execute( "INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![gitlab_id, issue_id, mr_id, action, label, actor, created_at], ) .expect("insert label event"); } #[test] fn test_fetch_timeline_scoped() { let conn = Connection::open_in_memory().unwrap(); create_timeline_schema(&conn); // Create two issues. let now = 1_700_000_000_000_i64; insert_issue(&conn, 1, "opened", now - 100_000); insert_issue(&conn, 2, "opened", now - 50_000); // Get internal IDs. let issue1_id: i64 = conn .query_row("SELECT id FROM issues WHERE iid = 1", [], |r| r.get(0)) .unwrap(); let issue2_id: i64 = conn .query_row("SELECT id FROM issues WHERE iid = 2", [], |r| r.get(0)) .unwrap(); // State events: issue 1 closed, issue 2 label added. insert_state_event( &conn, 1, Some(issue1_id), None, "closed", "alice", now - 80_000, ); insert_label_event( &conn, 2, Some(issue2_id), None, "add", "bug", "bob", now - 30_000, ); // Fetch scoped to issue 1. let scope = TimelineScope::Entity(EntityKey::issue(1, 1)); let events = fetch_timeline_events(&conn, &scope, 100).unwrap(); // Should only have issue 1's events: Created + StateChanged. assert_eq!(events.len(), 2); for event in &events { assert_eq!(event.entity_key.iid, 1, "All events should be for issue #1"); } // Most recent first. assert!(events[0].timestamp_ms >= events[1].timestamp_ms); } #[test] fn test_fetch_timeline_all_scope() { let conn = Connection::open_in_memory().unwrap(); create_timeline_schema(&conn); let now = 1_700_000_000_000_i64; insert_issue(&conn, 1, "opened", now - 100_000); insert_issue(&conn, 2, "opened", now - 50_000); let events = fetch_timeline_events(&conn, &TimelineScope::All, 100).unwrap(); // Should have Created events for both issues. assert_eq!(events.len(), 2); } #[test] fn test_fetch_timeline_author_scope() { let conn = Connection::open_in_memory().unwrap(); create_timeline_schema(&conn); let now = 1_700_000_000_000_i64; insert_issue(&conn, 1, "opened", now - 100_000); // default: no author_username in insert_issue let issue1_id: i64 = conn .query_row("SELECT id FROM issues WHERE iid = 1", [], |r| r.get(0)) .unwrap(); // State events by different actors. insert_state_event( &conn, 1, Some(issue1_id), None, "closed", "alice", now - 80_000, ); insert_state_event( &conn, 2, Some(issue1_id), None, "reopened", "bob", now - 60_000, ); let scope = TimelineScope::Author("alice".into()); let events = fetch_timeline_events(&conn, &scope, 100).unwrap(); // Should only get alice's state event (Created events don't have author set via insert_issue). assert!(events.iter().all(|e| e.actor.as_deref() == Some("alice"))); } #[test] fn test_fetch_timeline_respects_limit() { let conn = Connection::open_in_memory().unwrap(); create_timeline_schema(&conn); let now = 1_700_000_000_000_i64; for i in 1..=10 { insert_issue(&conn, i, "opened", now - (i * 10_000)); } let events = fetch_timeline_events(&conn, &TimelineScope::All, 3).unwrap(); assert_eq!(events.len(), 3); } #[test] fn test_fetch_timeline_sorted_most_recent_first() { let conn = Connection::open_in_memory().unwrap(); create_timeline_schema(&conn); let now = 1_700_000_000_000_i64; insert_issue(&conn, 1, "opened", now - 200_000); insert_issue(&conn, 2, "opened", now - 100_000); insert_issue(&conn, 3, "opened", now - 300_000); let events = fetch_timeline_events(&conn, &TimelineScope::All, 100).unwrap(); for window in events.windows(2) { assert!( window[0].timestamp_ms >= window[1].timestamp_ms, "Events should be sorted most-recent-first" ); } } #[test] fn test_fetch_timeline_state_merged_is_merged_kind() { let conn = Connection::open_in_memory().unwrap(); create_timeline_schema(&conn); let now = 1_700_000_000_000_i64; insert_mr(&conn, 1, "merged", now - 100_000); let mr_id: i64 = conn .query_row("SELECT id FROM merge_requests WHERE iid = 1", [], |r| { r.get(0) }) .unwrap(); insert_state_event(&conn, 1, None, Some(mr_id), "merged", "alice", now - 50_000); let scope = TimelineScope::Entity(EntityKey::mr(1, 1)); let events = fetch_timeline_events(&conn, &scope, 100).unwrap(); let merged_events: Vec<_> = events .iter() .filter(|e| e.event_kind == TimelineEventKind::Merged) .collect(); assert_eq!(merged_events.len(), 1); assert_eq!(merged_events[0].summary, "MR !1 merged"); } #[test] fn test_fetch_timeline_empty_db() { let conn = Connection::open_in_memory().unwrap(); create_timeline_schema(&conn); let events = fetch_timeline_events(&conn, &TimelineScope::All, 100).unwrap(); assert!(events.is_empty()); } }