diff --git a/src/core/timeline_collect.rs b/src/core/timeline_collect.rs index ad9ff1e..86ad386 100644 --- a/src/core/timeline_collect.rs +++ b/src/core/timeline_collect.rs @@ -1,6 +1,6 @@ use rusqlite::Connection; -use crate::core::error::Result; +use crate::core::error::{LoreError, Result}; use crate::core::timeline::{EntityRef, ExpandedEntityRef, TimelineEvent, TimelineEventType}; /// Collect all events for seed and expanded entities, interleave chronologically. @@ -118,7 +118,7 @@ fn collect_state_events( is_seed: bool, events: &mut Vec, ) -> Result<()> { - let (id_col, id_val) = entity_id_column(entity); + let (id_col, id_val) = entity_id_column(entity)?; let sql = format!( "SELECT state, actor_username, created_at FROM resource_state_events @@ -169,7 +169,7 @@ fn collect_label_events( is_seed: bool, events: &mut Vec, ) -> Result<()> { - let (id_col, id_val) = entity_id_column(entity); + let (id_col, id_val) = entity_id_column(entity)?; let sql = format!( "SELECT action, label_name, actor_username, created_at FROM resource_label_events @@ -231,7 +231,7 @@ fn collect_milestone_events( is_seed: bool, events: &mut Vec, ) -> Result<()> { - let (id_col, id_val) = entity_id_column(entity); + let (id_col, id_val) = entity_id_column(entity)?; let sql = format!( "SELECT action, milestone_title, actor_username, created_at FROM resource_milestone_events @@ -311,20 +311,25 @@ fn collect_merged_event( }, ); - if let Ok((Some(merged_at), merge_user, url)) = mr_result { - events.push(TimelineEvent { - timestamp: merged_at, - entity_type: entity.entity_type.clone(), - entity_id: entity.entity_id, - entity_iid: entity.entity_iid, - project_path: entity.project_path.clone(), - event_type: TimelineEventType::Merged, - summary: format!("MR !{} merged", entity.entity_iid), - actor: merge_user, - url, - is_seed, - }); - return Ok(()); + match mr_result { + Ok((Some(merged_at), merge_user, url)) => { + events.push(TimelineEvent { + timestamp: merged_at, + entity_type: entity.entity_type.clone(), + entity_id: entity.entity_id, + entity_iid: entity.entity_iid, + project_path: entity.project_path.clone(), + event_type: TimelineEventType::Merged, + summary: format!("MR !{} merged", entity.entity_iid), + actor: merge_user, + url, + is_seed, + }); + return Ok(()); + } + Ok((None, _, _)) => {} // merged_at is NULL, try fallback + Err(rusqlite::Error::QueryReturnedNoRows) => {} // entity not found, try fallback + Err(e) => return Err(e.into()), } // Fallback: check resource_state_events for state='merged' @@ -336,30 +341,37 @@ fn collect_merged_event( |row| Ok((row.get::<_, Option>(0)?, row.get::<_, i64>(1)?)), ); - if let Ok((actor, created_at)) = fallback_result { - events.push(TimelineEvent { - timestamp: created_at, - entity_type: entity.entity_type.clone(), - entity_id: entity.entity_id, - entity_iid: entity.entity_iid, - project_path: entity.project_path.clone(), - event_type: TimelineEventType::Merged, - summary: format!("MR !{} merged", entity.entity_iid), - actor, - url: None, - is_seed, - }); + match fallback_result { + Ok((actor, created_at)) => { + events.push(TimelineEvent { + timestamp: created_at, + entity_type: entity.entity_type.clone(), + entity_id: entity.entity_id, + entity_iid: entity.entity_iid, + project_path: entity.project_path.clone(), + event_type: TimelineEventType::Merged, + summary: format!("MR !{} merged", entity.entity_iid), + actor, + url: None, + is_seed, + }); + } + Err(rusqlite::Error::QueryReturnedNoRows) => {} // no merged state event, MR wasn't merged + Err(e) => return Err(e.into()), } Ok(()) } /// Return the correct column name and value for querying resource event tables. -fn entity_id_column(entity: &EntityRef) -> (&'static str, i64) { +fn entity_id_column(entity: &EntityRef) -> Result<(&'static str, i64)> { match entity.entity_type.as_str() { - "issue" => ("issue_id", entity.entity_id), - "merge_request" => ("merge_request_id", entity.entity_id), - _ => ("issue_id", entity.entity_id), // shouldn't happen + "issue" => Ok(("issue_id", entity.entity_id)), + "merge_request" => Ok(("merge_request_id", entity.entity_id)), + _ => Err(LoreError::Other(format!( + "Unknown entity type for event collection: {}", + entity.entity_type + ))), } } diff --git a/src/core/timeline_expand.rs b/src/core/timeline_expand.rs index 8e228d6..f22bc13 100644 --- a/src/core/timeline_expand.rs +++ b/src/core/timeline_expand.rs @@ -3,7 +3,7 @@ use std::collections::{HashSet, VecDeque}; use rusqlite::Connection; use crate::core::error::Result; -use crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef}; +use crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef, resolve_entity_ref}; /// Result of the expand phase. pub struct ExpandResult { @@ -167,7 +167,7 @@ fn find_outgoing( match target_id { Some(tid) => { - if let Some(resolved) = resolve_entity_ref(conn, &target_type, tid)? { + if let Some(resolved) = resolve_entity_ref(conn, &target_type, tid, None)? { neighbors.push(Neighbor::Resolved { entity_ref: resolved, reference_type: ref_type, @@ -180,7 +180,7 @@ fn find_outgoing( source: entity.clone(), target_project: target_project_path, target_type, - target_iid: target_iid.unwrap_or(0), + target_iid, reference_type: ref_type, })); } @@ -235,7 +235,7 @@ fn find_incoming( for row_result in rows { let (source_type, source_id, ref_type, source_method) = row_result?; - if let Some(resolved) = resolve_entity_ref(conn, &source_type, source_id)? { + if let Some(resolved) = resolve_entity_ref(conn, &source_type, source_id, None)? { neighbors.push(Neighbor::Resolved { entity_ref: resolved, reference_type: ref_type, @@ -247,41 +247,6 @@ fn find_incoming( Ok(()) } -/// Resolve an entity ID to a full EntityRef with iid and project_path. -fn resolve_entity_ref( - conn: &Connection, - entity_type: &str, - entity_id: i64, -) -> 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" - ); - - let result = conn.query_row(&sql, rusqlite::params![entity_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::*; @@ -515,7 +480,7 @@ mod tests { result.unresolved_references[0].target_project, Some("other/repo".to_owned()) ); - assert_eq!(result.unresolved_references[0].target_iid, 42); + assert_eq!(result.unresolved_references[0].target_iid, Some(42)); } #[test] diff --git a/src/core/timeline_seed.rs b/src/core/timeline_seed.rs index 45f1907..dd022a3 100644 --- a/src/core/timeline_seed.rs +++ b/src/core/timeline_seed.rs @@ -1,9 +1,10 @@ use std::collections::HashSet; use rusqlite::Connection; +use tracing::debug; use crate::core::error::Result; -use crate::core::timeline::{EntityRef, TimelineEvent, TimelineEventType}; +use crate::core::timeline::{EntityRef, TimelineEvent, TimelineEventType, resolve_entity_ref}; use crate::search::{FtsQueryMode, to_fts_query}; /// Result of the seed + hydrate phases. @@ -67,7 +68,12 @@ fn find_seed_entities( let mut stmt = conn.prepare(sql)?; let rows = stmt.query_map( - rusqlite::params![fts_query, project_id, since_ms, (max_seeds * 3) as i64], + rusqlite::params![ + fts_query, + project_id, + since_ms, + max_seeds.saturating_mul(3) as i64 + ], |row| { Ok(( row.get::<_, String>(0)?, @@ -105,7 +111,8 @@ fn find_seed_entities( continue; } - if let Some(entity_ref) = resolve_entity(conn, &entity_type, entity_id, proj_id)? { + if let Some(entity_ref) = resolve_entity_ref(conn, &entity_type, entity_id, Some(proj_id))? + { entities.push(entity_ref); } @@ -117,42 +124,6 @@ fn find_seed_entities( Ok(entities) } -/// Resolve an entity ID to a full EntityRef with iid and project_path. -fn resolve_entity( - conn: &Connection, - entity_type: &str, - entity_id: i64, - project_id: i64, -) -> Result> { - let (table, id_col) = match entity_type { - "issue" => ("issues", "id"), - "merge_request" => ("merge_requests", "id"), - _ => 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_col} = ?1 AND 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()), - } -} - /// Find evidence notes: FTS5-matched discussion notes that provide context. fn find_evidence_notes( conn: &Connection, @@ -211,10 +182,18 @@ fn find_evidence_notes( let snippet = truncate_to_chars(body.as_deref().unwrap_or(""), 200); - let entity_ref = resolve_entity(conn, &parent_type, parent_entity_id, proj_id)?; + let entity_ref = resolve_entity_ref(conn, &parent_type, parent_entity_id, Some(proj_id))?; let (iid, project_path) = match entity_ref { Some(ref e) => (e.entity_iid, e.project_path.clone()), - None => continue, + None => { + debug!( + parent_type, + parent_entity_id, + proj_id, + "Skipping evidence note: parent entity not found (orphaned discussion)" + ); + continue; + } }; events.push(TimelineEvent {