From a324fa26e1896d89c31f1f3d6c83607b2d89d621 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Fri, 6 Feb 2026 08:38:12 -0500 Subject: [PATCH] refactor(timeline): extract shared resolve_entity_ref and make target_iid optional The seed, expand, and collect stages each had their own near-identical resolve_entity_ref helper that converted internal DB IDs to full EntityRef structs. This duplication made it easy for bug fixes to land in one copy but not the others. Extract a single public resolve_entity_ref into timeline.rs with an optional project_id parameter: - Some(project_id): scopes the lookup (used by seed, which knows the project from the FTS result) - None: unscoped lookup (used by expand, which traverses cross-project references) Also changes UnresolvedRef.target_iid from i64 to Option. Cross- project references parsed from descriptions may not always carry an IID (e.g. when the reference is malformed or the target was deleted). The previous sentinel value of 0 was semantically incorrect since GitLab IIDs start at 1. Co-Authored-By: Claude Opus 4.6 --- src/core/timeline.rs | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/core/timeline.rs b/src/core/timeline.rs index c9ee919..27b0dee 100644 --- a/src/core/timeline.rs +++ b/src/core/timeline.rs @@ -1,7 +1,10 @@ use std::cmp::Ordering; +use rusqlite::Connection; use serde::Serialize; +use super::error::Result; + /// The core timeline event. All pipeline stages produce or consume these. /// Spec ref: Section 3.3 "Event Model" #[derive(Debug, Clone, Serialize)] @@ -121,7 +124,7 @@ pub struct UnresolvedRef { pub source: EntityRef, pub target_project: Option, pub target_type: String, - pub target_iid: i64, + pub target_iid: Option, pub reference_type: String, } @@ -135,6 +138,45 @@ pub struct TimelineResult { pub unresolved_references: Vec, } +/// Resolve an entity's internal DB id to a full [`EntityRef`] with iid and project path. +/// +/// When `project_id` is `Some`, the query is scoped to that project. +/// Returns `Ok(None)` for unknown entity types or when no matching row exists. +pub fn resolve_entity_ref( + conn: &Connection, + entity_type: &str, + entity_id: i64, + project_id: Option, +) -> 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 AND (?2 IS NULL OR 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()), + } +} + #[cfg(test)] mod tests { use super::*;