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:
Taylor Eernisse
2026-02-06 08:38:24 -05:00
parent a324fa26e1
commit 9b23d91378
3 changed files with 72 additions and 116 deletions

View File

@@ -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<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.
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 {