fix(timeline): report true total_events in robot JSON meta

The robot JSON envelope's meta.total_events field was incorrectly
reporting events.len() (the post-limit count), making it identical
to meta.showing. This defeated the purpose of having both fields.

Changes across the pipeline to fix this:

- collect_events now returns (Vec<TimelineEvent>, usize) where the
  second element is the total event count before truncation
- TimelineResult gains a total_events_before_limit field (serde-skipped)
  so the value flows cleanly from collect through to the renderer
- main.rs passes the real total instead of the events.len() workaround

Additional cleanup in this pass:

- Derive PartialEq/Eq/PartialOrd/Ord on TimelineEventType, replacing
  the hand-rolled event_type_discriminant() function. Variant declaration
  order now defines sort tiebreak, documented in a doc comment.
- Validate --since input with a proper LoreError::Other instead of
  silently treating invalid values as None
- Fix ANSI-aware tag column padding with console::pad_str (colored tags
  like "[merged]" were misaligned because ANSI escapes consumed width)
- Remove dead print_timeline_json and infer_max_depth functions that
  were superseded by print_timeline_json_with_meta

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-06 09:35:02 -05:00
parent f1cb45a168
commit 32783080f1
5 changed files with 47 additions and 73 deletions

View File

@@ -26,7 +26,7 @@ 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()
&& self.event_type == other.event_type
}
}
@@ -43,31 +43,15 @@ impl Ord for TimelineEvent {
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,
}
.then_with(|| self.event_type.cmp(&other.event_type))
}
}
/// Per spec Section 3.3. Serde tagged enum for JSON output.
#[derive(Debug, Clone, Serialize)]
///
/// Variant declaration order defines the sort order within a timestamp+entity
/// tiebreak (Created < StateChanged < LabelAdded < ... < CrossReferenced).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TimelineEventType {
Created,
@@ -133,6 +117,9 @@ pub struct UnresolvedRef {
pub struct TimelineResult {
pub query: String,
pub events: Vec<TimelineEvent>,
/// Total events before the `--limit` was applied (for meta.total_events vs meta.showing).
#[serde(skip)]
pub total_events_before_limit: usize,
pub seed_entities: Vec<EntityRef>,
pub expanded_entities: Vec<ExpandedEntityRef>,
pub unresolved_references: Vec<UnresolvedRef>,

View File

@@ -17,7 +17,7 @@ pub fn collect_events(
evidence_notes: &[TimelineEvent],
since_ms: Option<i64>,
limit: usize,
) -> Result<Vec<TimelineEvent>> {
) -> Result<(Vec<TimelineEvent>, usize)> {
let mut all_events: Vec<TimelineEvent> = Vec::new();
// Collect events for seed entities
@@ -41,10 +41,13 @@ pub fn collect_events(
all_events.retain(|e| e.timestamp >= since);
}
// Capture total before applying limit (for meta.total_events vs meta.showing)
let total_before_limit = all_events.len();
// Apply limit
all_events.truncate(limit);
Ok(all_events)
Ok((all_events, total_before_limit))
}
/// Collect all events for a single entity.
@@ -480,7 +483,7 @@ mod tests {
let issue_id = insert_issue(&conn, project_id, 1);
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
let events = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0].event_type, TimelineEventType::Created));
assert_eq!(events[0].timestamp, 1000);
@@ -498,7 +501,7 @@ mod tests {
insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 4000);
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
let events = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
// Created + 2 state changes = 3
assert_eq!(events.len(), 3);
@@ -523,7 +526,7 @@ mod tests {
insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000);
let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
let events = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
// Should have Created + Merged (not Created + StateChanged{merged} + Merged)
let merged_count = events
@@ -548,7 +551,7 @@ mod tests {
insert_label_event(&conn, project_id, Some(issue_id), None, "add", None, 2000);
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
let events = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let label_event = events.iter().find(|e| {
matches!(&e.event_type, TimelineEventType::LabelAdded { label } if label == "[deleted label]")
@@ -565,7 +568,7 @@ mod tests {
insert_milestone_event(&conn, project_id, Some(issue_id), None, "add", None, 2000);
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
let events = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let ms_event = events.iter().find(|e| {
matches!(&e.event_type, TimelineEventType::MilestoneSet { milestone } if milestone == "[deleted milestone]")
@@ -585,7 +588,7 @@ mod tests {
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
// Since 4000: should exclude Created (1000) and closed (3000)
let events = collect_events(&conn, &seeds, &[], &[], Some(4000), 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &[], Some(4000), 100).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].timestamp, 5000);
}
@@ -612,7 +615,7 @@ mod tests {
make_entity_ref("issue", issue_id, 1),
make_entity_ref("merge_request", mr_id, 10),
];
let events = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
// Verify chronological order
for window in events.windows(2) {
@@ -638,8 +641,10 @@ mod tests {
}
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
let events = collect_events(&conn, &seeds, &[], &[], None, 5).unwrap();
let (events, total) = collect_events(&conn, &seeds, &[], &[], None, 5).unwrap();
assert_eq!(events.len(), 5);
// 20 state changes + 1 created = 21 total before limit
assert_eq!(total, 21);
}
#[test]
@@ -666,7 +671,7 @@ mod tests {
}];
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
let events = collect_events(&conn, &seeds, &[], &evidence, None, 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &evidence, None, 100).unwrap();
let note_event = events.iter().find(|e| {
matches!(
@@ -688,7 +693,7 @@ mod tests {
insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000);
let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
let events = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
let merged = events
.iter()