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>
296 lines
8.8 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|