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,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<TimelineEvent>,
) -> 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<TimelineEvent>,
) -> 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<TimelineEvent>,
) -> 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<String>>(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
))),
}
}