Files
gitlore/src/core/timeline.rs
Taylor Eernisse a324fa26e1 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>
2026-02-06 08:38:12 -05:00

296 lines
8.8 KiB
Rust

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)]
pub struct TimelineEvent {
pub timestamp: i64,
pub entity_type: String,
#[serde(skip)]
pub entity_id: i64,
pub entity_iid: i64,
pub project_path: String,
pub event_type: TimelineEventType,
pub summary: String,
pub actor: Option<String>,
pub url: Option<String>,
pub is_seed: bool,
}
impl PartialEq for TimelineEvent {
fn eq(&self, other: &Self) -> bool {
self.timestamp == other.timestamp
&& self.entity_id == other.entity_id
&& self.event_type_discriminant() == other.event_type_discriminant()
}
}
impl Eq for TimelineEvent {}
impl PartialOrd for TimelineEvent {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TimelineEvent {
fn cmp(&self, other: &Self) -> Ordering {
self.timestamp
.cmp(&other.timestamp)
.then_with(|| self.entity_id.cmp(&other.entity_id))
.then_with(|| {
self.event_type_discriminant()
.cmp(&other.event_type_discriminant())
})
}
}
impl TimelineEvent {
fn event_type_discriminant(&self) -> u8 {
match &self.event_type {
TimelineEventType::Created => 0,
TimelineEventType::StateChanged { .. } => 1,
TimelineEventType::LabelAdded { .. } => 2,
TimelineEventType::LabelRemoved { .. } => 3,
TimelineEventType::MilestoneSet { .. } => 4,
TimelineEventType::MilestoneRemoved { .. } => 5,
TimelineEventType::Merged => 6,
TimelineEventType::NoteEvidence { .. } => 7,
TimelineEventType::CrossReferenced { .. } => 8,
}
}
}
/// Per spec Section 3.3. Serde tagged enum for JSON output.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TimelineEventType {
Created,
StateChanged {
state: String,
},
LabelAdded {
label: String,
},
LabelRemoved {
label: String,
},
MilestoneSet {
milestone: String,
},
MilestoneRemoved {
milestone: String,
},
Merged,
NoteEvidence {
note_id: i64,
snippet: String,
discussion_id: Option<i64>,
},
CrossReferenced {
target: String,
},
}
/// Internal entity reference used across pipeline stages.
#[derive(Debug, Clone, Serialize)]
pub struct EntityRef {
pub entity_type: String,
pub entity_id: i64,
pub entity_iid: i64,
pub project_path: String,
}
/// An entity discovered via BFS expansion.
/// Spec ref: Section 3.5 "expanded_entities" JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct ExpandedEntityRef {
pub entity_ref: EntityRef,
pub depth: u32,
pub via_from: EntityRef,
pub via_reference_type: String,
pub via_source_method: String,
}
/// Reference to an unsynced external entity.
/// Spec ref: Section 3.5 "unresolved_references" JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct UnresolvedRef {
pub source: EntityRef,
pub target_project: Option<String>,
pub target_type: String,
pub target_iid: Option<i64>,
pub reference_type: String,
}
/// Complete result from the timeline pipeline.
#[derive(Debug, Clone, Serialize)]
pub struct TimelineResult {
pub query: String,
pub events: Vec<TimelineEvent>,
pub seed_entities: Vec<EntityRef>,
pub expanded_entities: Vec<ExpandedEntityRef>,
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)]
mod tests {
use super::*;
fn make_event(timestamp: i64, entity_id: i64, event_type: TimelineEventType) -> TimelineEvent {
TimelineEvent {
timestamp,
entity_type: "issue".to_owned(),
entity_id,
entity_iid: 1,
project_path: "group/project".to_owned(),
event_type,
summary: "test".to_owned(),
actor: None,
url: None,
is_seed: true,
}
}
#[test]
fn test_timeline_event_sort_by_timestamp() {
let mut events = [
make_event(3000, 1, TimelineEventType::Created),
make_event(1000, 2, TimelineEventType::Created),
make_event(2000, 3, TimelineEventType::Merged),
];
events.sort();
assert_eq!(events[0].timestamp, 1000);
assert_eq!(events[1].timestamp, 2000);
assert_eq!(events[2].timestamp, 3000);
}
#[test]
fn test_timeline_event_sort_tiebreak() {
let mut events = [
make_event(1000, 5, TimelineEventType::Created),
make_event(1000, 2, TimelineEventType::Merged),
make_event(1000, 2, TimelineEventType::Created),
];
events.sort();
// Same timestamp: sort by entity_id first, then event_type discriminant
assert_eq!(events[0].entity_id, 2);
assert!(matches!(events[0].event_type, TimelineEventType::Created));
assert_eq!(events[1].entity_id, 2);
assert!(matches!(events[1].event_type, TimelineEventType::Merged));
assert_eq!(events[2].entity_id, 5);
}
#[test]
fn test_timeline_event_type_serializes_tagged() {
let event_type = TimelineEventType::StateChanged {
state: "closed".to_owned(),
};
let json = serde_json::to_value(&event_type).unwrap();
assert_eq!(json["kind"], "state_changed");
assert_eq!(json["state"], "closed");
}
#[test]
fn test_note_evidence_has_note_id() {
let event_type = TimelineEventType::NoteEvidence {
note_id: 42,
snippet: "some text".to_owned(),
discussion_id: Some(7),
};
let json = serde_json::to_value(&event_type).unwrap();
assert_eq!(json["kind"], "note_evidence");
assert_eq!(json["note_id"], 42);
assert_eq!(json["snippet"], "some text");
assert_eq!(json["discussion_id"], 7);
}
#[test]
fn test_entity_id_skipped_in_serialization() {
let event = make_event(1000, 99, TimelineEventType::Created);
let json = serde_json::to_value(&event).unwrap();
assert!(json.get("entity_id").is_none());
assert_eq!(json["entity_iid"], 1);
}
#[test]
fn test_timeline_event_type_variant_count() {
// Verify all 9 variants serialize without panic
let variants: Vec<TimelineEventType> = vec![
TimelineEventType::Created,
TimelineEventType::StateChanged {
state: "closed".to_owned(),
},
TimelineEventType::LabelAdded {
label: "bug".to_owned(),
},
TimelineEventType::LabelRemoved {
label: "bug".to_owned(),
},
TimelineEventType::MilestoneSet {
milestone: "v1".to_owned(),
},
TimelineEventType::MilestoneRemoved {
milestone: "v1".to_owned(),
},
TimelineEventType::Merged,
TimelineEventType::NoteEvidence {
note_id: 1,
snippet: "text".to_owned(),
discussion_id: None,
},
TimelineEventType::CrossReferenced {
target: "!567".to_owned(),
},
];
assert_eq!(variants.len(), 9);
for v in &variants {
serde_json::to_value(v).unwrap();
}
}
}