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<i64>. 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 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-06 08:38:12 -05:00
parent e8845380e9
commit a324fa26e1

View File

@@ -1,7 +1,10 @@
use std::cmp::Ordering; use std::cmp::Ordering;
use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use super::error::Result;
/// The core timeline event. All pipeline stages produce or consume these. /// The core timeline event. All pipeline stages produce or consume these.
/// Spec ref: Section 3.3 "Event Model" /// Spec ref: Section 3.3 "Event Model"
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -121,7 +124,7 @@ pub struct UnresolvedRef {
pub source: EntityRef, pub source: EntityRef,
pub target_project: Option<String>, pub target_project: Option<String>,
pub target_type: String, pub target_type: String,
pub target_iid: i64, pub target_iid: Option<i64>,
pub reference_type: String, pub reference_type: String,
} }
@@ -135,6 +138,45 @@ pub struct TimelineResult {
pub unresolved_references: Vec<UnresolvedRef>, pub unresolved_references: Vec<UnresolvedRef>,
} }
/// 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<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 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;