refactor(timeline): harden pipeline stages with shared resolver and exhaustive error handling
Follows up on the resolve_entity_ref extraction by updating all three pipeline stages to consume the shared helper and removing their local duplicates (~75 lines of dead code eliminated). timeline_seed.rs: - Switch from local resolve_entity to shared resolve_entity_ref with explicit Some(proj_id) scoping - Add tracing::debug for orphaned discussion parents instead of silently skipping them, aiding debugging when evidence notes go missing - Use saturating_mul for the over-fetch multiplier to prevent overflow on pathological max_seeds values timeline_expand.rs: - Switch from local resolve_entity_ref to shared version with None project scoping (cross-project traversal) - Pass Option<i64> for target_iid in UnresolvedRef construction instead of unwrap_or(0) sentinel - Update test assertion to compare against Some(42) timeline_collect.rs: - Make entity_id_column return Result instead of silently defaulting to issue_id for unknown entity types. The previous fallback could produce incorrect SQL queries that return wrong results rather than failing - Replace if-let chains in collect_merged_event with exhaustive match blocks that propagate real DB errors while gracefully handling expected missing-data cases (QueryReturnedNoRows, NULL merged_at) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::core::error::Result;
|
use crate::core::error::{LoreError, Result};
|
||||||
use crate::core::timeline::{EntityRef, ExpandedEntityRef, TimelineEvent, TimelineEventType};
|
use crate::core::timeline::{EntityRef, ExpandedEntityRef, TimelineEvent, TimelineEventType};
|
||||||
|
|
||||||
/// Collect all events for seed and expanded entities, interleave chronologically.
|
/// Collect all events for seed and expanded entities, interleave chronologically.
|
||||||
@@ -118,7 +118,7 @@ fn collect_state_events(
|
|||||||
is_seed: bool,
|
is_seed: bool,
|
||||||
events: &mut Vec<TimelineEvent>,
|
events: &mut Vec<TimelineEvent>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (id_col, id_val) = entity_id_column(entity);
|
let (id_col, id_val) = entity_id_column(entity)?;
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT state, actor_username, created_at FROM resource_state_events
|
"SELECT state, actor_username, created_at FROM resource_state_events
|
||||||
@@ -169,7 +169,7 @@ fn collect_label_events(
|
|||||||
is_seed: bool,
|
is_seed: bool,
|
||||||
events: &mut Vec<TimelineEvent>,
|
events: &mut Vec<TimelineEvent>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (id_col, id_val) = entity_id_column(entity);
|
let (id_col, id_val) = entity_id_column(entity)?;
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT action, label_name, actor_username, created_at FROM resource_label_events
|
"SELECT action, label_name, actor_username, created_at FROM resource_label_events
|
||||||
@@ -231,7 +231,7 @@ fn collect_milestone_events(
|
|||||||
is_seed: bool,
|
is_seed: bool,
|
||||||
events: &mut Vec<TimelineEvent>,
|
events: &mut Vec<TimelineEvent>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (id_col, id_val) = entity_id_column(entity);
|
let (id_col, id_val) = entity_id_column(entity)?;
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT action, milestone_title, actor_username, created_at FROM resource_milestone_events
|
"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 {
|
match mr_result {
|
||||||
events.push(TimelineEvent {
|
Ok((Some(merged_at), merge_user, url)) => {
|
||||||
timestamp: merged_at,
|
events.push(TimelineEvent {
|
||||||
entity_type: entity.entity_type.clone(),
|
timestamp: merged_at,
|
||||||
entity_id: entity.entity_id,
|
entity_type: entity.entity_type.clone(),
|
||||||
entity_iid: entity.entity_iid,
|
entity_id: entity.entity_id,
|
||||||
project_path: entity.project_path.clone(),
|
entity_iid: entity.entity_iid,
|
||||||
event_type: TimelineEventType::Merged,
|
project_path: entity.project_path.clone(),
|
||||||
summary: format!("MR !{} merged", entity.entity_iid),
|
event_type: TimelineEventType::Merged,
|
||||||
actor: merge_user,
|
summary: format!("MR !{} merged", entity.entity_iid),
|
||||||
url,
|
actor: merge_user,
|
||||||
is_seed,
|
url,
|
||||||
});
|
is_seed,
|
||||||
return Ok(());
|
});
|
||||||
|
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'
|
// Fallback: check resource_state_events for state='merged'
|
||||||
@@ -336,30 +341,37 @@ fn collect_merged_event(
|
|||||||
|row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, i64>(1)?)),
|
|row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, i64>(1)?)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Ok((actor, created_at)) = fallback_result {
|
match fallback_result {
|
||||||
events.push(TimelineEvent {
|
Ok((actor, created_at)) => {
|
||||||
timestamp: created_at,
|
events.push(TimelineEvent {
|
||||||
entity_type: entity.entity_type.clone(),
|
timestamp: created_at,
|
||||||
entity_id: entity.entity_id,
|
entity_type: entity.entity_type.clone(),
|
||||||
entity_iid: entity.entity_iid,
|
entity_id: entity.entity_id,
|
||||||
project_path: entity.project_path.clone(),
|
entity_iid: entity.entity_iid,
|
||||||
event_type: TimelineEventType::Merged,
|
project_path: entity.project_path.clone(),
|
||||||
summary: format!("MR !{} merged", entity.entity_iid),
|
event_type: TimelineEventType::Merged,
|
||||||
actor,
|
summary: format!("MR !{} merged", entity.entity_iid),
|
||||||
url: None,
|
actor,
|
||||||
is_seed,
|
url: None,
|
||||||
});
|
is_seed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {} // no merged state event, MR wasn't merged
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the correct column name and value for querying resource event tables.
|
/// 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() {
|
match entity.entity_type.as_str() {
|
||||||
"issue" => ("issue_id", entity.entity_id),
|
"issue" => Ok(("issue_id", entity.entity_id)),
|
||||||
"merge_request" => ("merge_request_id", entity.entity_id),
|
"merge_request" => Ok(("merge_request_id", entity.entity_id)),
|
||||||
_ => ("issue_id", entity.entity_id), // shouldn't happen
|
_ => Err(LoreError::Other(format!(
|
||||||
|
"Unknown entity type for event collection: {}",
|
||||||
|
entity.entity_type
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::collections::{HashSet, VecDeque};
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::core::error::Result;
|
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.
|
/// Result of the expand phase.
|
||||||
pub struct ExpandResult {
|
pub struct ExpandResult {
|
||||||
@@ -167,7 +167,7 @@ fn find_outgoing(
|
|||||||
|
|
||||||
match target_id {
|
match target_id {
|
||||||
Some(tid) => {
|
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 {
|
neighbors.push(Neighbor::Resolved {
|
||||||
entity_ref: resolved,
|
entity_ref: resolved,
|
||||||
reference_type: ref_type,
|
reference_type: ref_type,
|
||||||
@@ -180,7 +180,7 @@ fn find_outgoing(
|
|||||||
source: entity.clone(),
|
source: entity.clone(),
|
||||||
target_project: target_project_path,
|
target_project: target_project_path,
|
||||||
target_type,
|
target_type,
|
||||||
target_iid: target_iid.unwrap_or(0),
|
target_iid,
|
||||||
reference_type: ref_type,
|
reference_type: ref_type,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ fn find_incoming(
|
|||||||
for row_result in rows {
|
for row_result in rows {
|
||||||
let (source_type, source_id, ref_type, source_method) = row_result?;
|
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 {
|
neighbors.push(Neighbor::Resolved {
|
||||||
entity_ref: resolved,
|
entity_ref: resolved,
|
||||||
reference_type: ref_type,
|
reference_type: ref_type,
|
||||||
@@ -247,41 +247,6 @@ fn find_incoming(
|
|||||||
Ok(())
|
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<Option<EntityRef>> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -515,7 +480,7 @@ mod tests {
|
|||||||
result.unresolved_references[0].target_project,
|
result.unresolved_references[0].target_project,
|
||||||
Some("other/repo".to_owned())
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::core::error::Result;
|
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};
|
use crate::search::{FtsQueryMode, to_fts_query};
|
||||||
|
|
||||||
/// Result of the seed + hydrate phases.
|
/// Result of the seed + hydrate phases.
|
||||||
@@ -67,7 +68,12 @@ fn find_seed_entities(
|
|||||||
|
|
||||||
let mut stmt = conn.prepare(sql)?;
|
let mut stmt = conn.prepare(sql)?;
|
||||||
let rows = stmt.query_map(
|
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| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get::<_, String>(0)?,
|
row.get::<_, String>(0)?,
|
||||||
@@ -105,7 +111,8 @@ fn find_seed_entities(
|
|||||||
continue;
|
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);
|
entities.push(entity_ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,42 +124,6 @@ fn find_seed_entities(
|
|||||||
Ok(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<Option<EntityRef>> {
|
|
||||||
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.
|
/// Find evidence notes: FTS5-matched discussion notes that provide context.
|
||||||
fn find_evidence_notes(
|
fn find_evidence_notes(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -211,10 +182,18 @@ fn find_evidence_notes(
|
|||||||
|
|
||||||
let snippet = truncate_to_chars(body.as_deref().unwrap_or(""), 200);
|
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 {
|
let (iid, project_path) = match entity_ref {
|
||||||
Some(ref e) => (e.entity_iid, e.project_path.clone()),
|
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 {
|
events.push(TimelineEvent {
|
||||||
|
|||||||
Reference in New Issue
Block a user