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:
Taylor Eernisse
2026-02-05 16:54:28 -05:00
parent d1b2b5fa7d
commit 3767c33c28
9 changed files with 2143 additions and 6 deletions

253
src/core/timeline.rs Normal file
View 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();
}
}
}