feat: Implement Gate 3 timeline pipeline and Gate 4 migration scaffolding
Complete 5 beads for the Phase B temporal intelligence feature: - bd-1oo: Register migration 015 (commit SHAs, closes watermark) and create migration 016 (mr_file_changes table with 4 indexes for Gate 4 file-history) - bd-20e: Define TimelineEvent model with 9 event type variants, EntityRef, ExpandedEntityRef, UnresolvedRef, and TimelineResult types. Ord impl for chronological sorting with stable tiebreak. - bd-32q: Implement timeline seed phase - FTS5 keyword search to entity IDs with discussion-to-parent resolution, entity dedup, and evidence note extraction with snippet truncation. - bd-ypa: Implement timeline expand phase - BFS cross-reference expansion over entity_references with bidirectional traversal, depth limiting, mention filtering, provenance tracking, and unresolved reference collection. - bd-3as: Implement timeline event collection - gathers Created, StateChanged, LabelAdded/Removed, MilestoneSet/Removed, Merged, and NoteEvidence events. Merged dedup (state=merged -> Merged variant only). NULL label/milestone fallbacks. Chronological interleaving with since filter and limit. 38 new tests, all 445 tests pass. All quality gates clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
253
src/core/timeline.rs
Normal file
253
src/core/timeline.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// 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: 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>,
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user