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:
687
src/core/timeline_collect.rs
Normal file
687
src/core/timeline_collect.rs
Normal file
@@ -0,0 +1,687 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::core::error::Result;
|
||||
use crate::core::timeline::{EntityRef, ExpandedEntityRef, TimelineEvent, TimelineEventType};
|
||||
|
||||
/// Collect all events for seed and expanded entities, interleave chronologically.
|
||||
///
|
||||
/// Steps 4-5 of the timeline pipeline:
|
||||
/// 1. For each entity, collect Created, StateChanged, Label, Milestone, Merged events
|
||||
/// 2. Merge in evidence notes from the seed phase
|
||||
/// 3. Sort chronologically with stable tiebreak
|
||||
/// 4. Apply --since filter and --limit
|
||||
pub fn collect_events(
|
||||
conn: &Connection,
|
||||
seed_entities: &[EntityRef],
|
||||
expanded_entities: &[ExpandedEntityRef],
|
||||
evidence_notes: &[TimelineEvent],
|
||||
since_ms: Option<i64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<TimelineEvent>> {
|
||||
let mut all_events: Vec<TimelineEvent> = Vec::new();
|
||||
|
||||
// Collect events for seed entities
|
||||
for entity in seed_entities {
|
||||
collect_entity_events(conn, entity, true, &mut all_events)?;
|
||||
}
|
||||
|
||||
// Collect events for expanded entities
|
||||
for expanded in expanded_entities {
|
||||
collect_entity_events(conn, &expanded.entity_ref, false, &mut all_events)?;
|
||||
}
|
||||
|
||||
// Add evidence notes from seed phase
|
||||
all_events.extend(evidence_notes.iter().cloned());
|
||||
|
||||
// Sort chronologically (uses Ord impl from timeline.rs)
|
||||
all_events.sort();
|
||||
|
||||
// Apply --since filter
|
||||
if let Some(since) = since_ms {
|
||||
all_events.retain(|e| e.timestamp >= since);
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
all_events.truncate(limit);
|
||||
|
||||
Ok(all_events)
|
||||
}
|
||||
|
||||
/// Collect all events for a single entity.
|
||||
fn collect_entity_events(
|
||||
conn: &Connection,
|
||||
entity: &EntityRef,
|
||||
is_seed: bool,
|
||||
events: &mut Vec<TimelineEvent>,
|
||||
) -> Result<()> {
|
||||
collect_creation_event(conn, entity, is_seed, events)?;
|
||||
collect_state_events(conn, entity, is_seed, events)?;
|
||||
collect_label_events(conn, entity, is_seed, events)?;
|
||||
collect_milestone_events(conn, entity, is_seed, events)?;
|
||||
collect_merged_event(conn, entity, is_seed, events)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect the Created event from the entity's own table.
|
||||
fn collect_creation_event(
|
||||
conn: &Connection,
|
||||
entity: &EntityRef,
|
||||
is_seed: bool,
|
||||
events: &mut Vec<TimelineEvent>,
|
||||
) -> Result<()> {
|
||||
let table = match entity.entity_type.as_str() {
|
||||
"issue" => "issues",
|
||||
"merge_request" => "merge_requests",
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let sql =
|
||||
format!("SELECT created_at, author_username, title, web_url FROM {table} WHERE id = ?1");
|
||||
|
||||
let result = conn.query_row(&sql, rusqlite::params![entity.entity_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, Option<i64>>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
))
|
||||
});
|
||||
|
||||
if let Ok((Some(created_at), author, title, url)) = result {
|
||||
let type_label = if entity.entity_type == "issue" {
|
||||
"Issue"
|
||||
} else {
|
||||
"MR"
|
||||
};
|
||||
let title_str = title.as_deref().unwrap_or("(untitled)");
|
||||
events.push(TimelineEvent {
|
||||
timestamp: created_at,
|
||||
entity_type: entity.entity_type.clone(),
|
||||
entity_id: entity.entity_id,
|
||||
entity_iid: entity.entity_iid,
|
||||
project_path: entity.project_path.clone(),
|
||||
event_type: TimelineEventType::Created,
|
||||
summary: format!("{type_label} #{} created: {title_str}", entity.entity_iid),
|
||||
actor: author,
|
||||
url,
|
||||
is_seed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect state change events. State='merged' produces Merged, not StateChanged.
|
||||
fn collect_state_events(
|
||||
conn: &Connection,
|
||||
entity: &EntityRef,
|
||||
is_seed: bool,
|
||||
events: &mut Vec<TimelineEvent>,
|
||||
) -> Result<()> {
|
||||
let (id_col, id_val) = entity_id_column(entity);
|
||||
|
||||
let sql = format!(
|
||||
"SELECT state, actor_username, created_at FROM resource_state_events
|
||||
WHERE {id_col} = ?1
|
||||
ORDER BY created_at ASC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params![id_val], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row_result in rows {
|
||||
let (state, actor, created_at) = row_result?;
|
||||
|
||||
// state='merged' is handled by collect_merged_event — skip here
|
||||
if state == "merged" {
|
||||
continue;
|
||||
}
|
||||
|
||||
events.push(TimelineEvent {
|
||||
timestamp: created_at,
|
||||
entity_type: entity.entity_type.clone(),
|
||||
entity_id: entity.entity_id,
|
||||
entity_iid: entity.entity_iid,
|
||||
project_path: entity.project_path.clone(),
|
||||
event_type: TimelineEventType::StateChanged {
|
||||
state: state.clone(),
|
||||
},
|
||||
summary: format!("State changed to {state}"),
|
||||
actor,
|
||||
url: None,
|
||||
is_seed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect label add/remove events.
|
||||
fn collect_label_events(
|
||||
conn: &Connection,
|
||||
entity: &EntityRef,
|
||||
is_seed: bool,
|
||||
events: &mut Vec<TimelineEvent>,
|
||||
) -> Result<()> {
|
||||
let (id_col, id_val) = entity_id_column(entity);
|
||||
|
||||
let sql = format!(
|
||||
"SELECT action, label_name, actor_username, created_at FROM resource_label_events
|
||||
WHERE {id_col} = ?1
|
||||
ORDER BY created_at ASC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params![id_val], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row_result in rows {
|
||||
let (action, label_name, actor, created_at) = row_result?;
|
||||
let label = label_name.unwrap_or_else(|| "[deleted label]".to_owned());
|
||||
|
||||
let (event_type, summary) = match action.as_str() {
|
||||
"add" => (
|
||||
TimelineEventType::LabelAdded {
|
||||
label: label.clone(),
|
||||
},
|
||||
format!("Label added: {label}"),
|
||||
),
|
||||
"remove" => (
|
||||
TimelineEventType::LabelRemoved {
|
||||
label: label.clone(),
|
||||
},
|
||||
format!("Label removed: {label}"),
|
||||
),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
events.push(TimelineEvent {
|
||||
timestamp: created_at,
|
||||
entity_type: entity.entity_type.clone(),
|
||||
entity_id: entity.entity_id,
|
||||
entity_iid: entity.entity_iid,
|
||||
project_path: entity.project_path.clone(),
|
||||
event_type,
|
||||
summary,
|
||||
actor,
|
||||
url: None,
|
||||
is_seed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect milestone add/remove events.
|
||||
fn collect_milestone_events(
|
||||
conn: &Connection,
|
||||
entity: &EntityRef,
|
||||
is_seed: bool,
|
||||
events: &mut Vec<TimelineEvent>,
|
||||
) -> Result<()> {
|
||||
let (id_col, id_val) = entity_id_column(entity);
|
||||
|
||||
let sql = format!(
|
||||
"SELECT action, milestone_title, actor_username, created_at FROM resource_milestone_events
|
||||
WHERE {id_col} = ?1
|
||||
ORDER BY created_at ASC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params![id_val], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row_result in rows {
|
||||
let (action, milestone_title, actor, created_at) = row_result?;
|
||||
let milestone = milestone_title.unwrap_or_else(|| "[deleted milestone]".to_owned());
|
||||
|
||||
let (event_type, summary) = match action.as_str() {
|
||||
"add" => (
|
||||
TimelineEventType::MilestoneSet {
|
||||
milestone: milestone.clone(),
|
||||
},
|
||||
format!("Milestone set: {milestone}"),
|
||||
),
|
||||
"remove" => (
|
||||
TimelineEventType::MilestoneRemoved {
|
||||
milestone: milestone.clone(),
|
||||
},
|
||||
format!("Milestone removed: {milestone}"),
|
||||
),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
events.push(TimelineEvent {
|
||||
timestamp: created_at,
|
||||
entity_type: entity.entity_type.clone(),
|
||||
entity_id: entity.entity_id,
|
||||
entity_iid: entity.entity_iid,
|
||||
project_path: entity.project_path.clone(),
|
||||
event_type,
|
||||
summary,
|
||||
actor,
|
||||
url: None,
|
||||
is_seed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect Merged event for MRs. Prefers merged_at from the MR table.
|
||||
/// Falls back to resource_state_events WHERE state='merged' if merged_at is NULL.
|
||||
fn collect_merged_event(
|
||||
conn: &Connection,
|
||||
entity: &EntityRef,
|
||||
is_seed: bool,
|
||||
events: &mut Vec<TimelineEvent>,
|
||||
) -> Result<()> {
|
||||
if entity.entity_type != "merge_request" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try merged_at from merge_requests table first
|
||||
let mr_result = conn.query_row(
|
||||
"SELECT merged_at, merge_user_username, web_url FROM merge_requests WHERE id = ?1",
|
||||
rusqlite::params![entity.entity_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, Option<i64>>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
if let Ok((Some(merged_at), merge_user, url)) = mr_result {
|
||||
events.push(TimelineEvent {
|
||||
timestamp: merged_at,
|
||||
entity_type: entity.entity_type.clone(),
|
||||
entity_id: entity.entity_id,
|
||||
entity_iid: entity.entity_iid,
|
||||
project_path: entity.project_path.clone(),
|
||||
event_type: TimelineEventType::Merged,
|
||||
summary: format!("MR !{} merged", entity.entity_iid),
|
||||
actor: merge_user,
|
||||
url,
|
||||
is_seed,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fallback: check resource_state_events for state='merged'
|
||||
let fallback_result = conn.query_row(
|
||||
"SELECT actor_username, created_at FROM resource_state_events
|
||||
WHERE merge_request_id = ?1 AND state = 'merged'
|
||||
ORDER BY created_at DESC LIMIT 1",
|
||||
rusqlite::params![entity.entity_id],
|
||||
|row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, i64>(1)?)),
|
||||
);
|
||||
|
||||
if let Ok((actor, created_at)) = fallback_result {
|
||||
events.push(TimelineEvent {
|
||||
timestamp: created_at,
|
||||
entity_type: entity.entity_type.clone(),
|
||||
entity_id: entity.entity_id,
|
||||
entity_iid: entity.entity_iid,
|
||||
project_path: entity.project_path.clone(),
|
||||
event_type: TimelineEventType::Merged,
|
||||
summary: format!("MR !{} merged", entity.entity_iid),
|
||||
actor,
|
||||
url: None,
|
||||
is_seed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the correct column name and value for querying resource event tables.
|
||||
fn entity_id_column(entity: &EntityRef) -> (&'static str, i64) {
|
||||
match entity.entity_type.as_str() {
|
||||
"issue" => ("issue_id", entity.entity_id),
|
||||
"merge_request" => ("merge_request_id", entity.entity_id),
|
||||
_ => ("issue_id", entity.entity_id), // shouldn't happen
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use std::path::Path;
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_project(conn: &Connection) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, ?3, 'Auth bug', 'opened', 'alice', 1000, 2000, 3000, 'https://gitlab.com/group/project/-/issues/1')",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_mr(conn: &Connection, project_id: i64, iid: i64, merged_at: Option<i64>) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, merged_at, merge_user_username, web_url) VALUES (?1, ?2, ?3, 'Fix auth', 'merged', 'bob', 1000, 5000, 6000, ?4, 'charlie', 'https://gitlab.com/group/project/-/merge_requests/10')",
|
||||
rusqlite::params![iid * 100, project_id, iid, merged_at],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn make_entity_ref(entity_type: &str, entity_id: i64, iid: i64) -> EntityRef {
|
||||
EntityRef {
|
||||
entity_type: entity_type.to_owned(),
|
||||
entity_id,
|
||||
entity_iid: iid,
|
||||
project_path: "group/project".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_state_event(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
issue_id: Option<i64>,
|
||||
mr_id: Option<i64>,
|
||||
state: &str,
|
||||
created_at: i64,
|
||||
) {
|
||||
let gitlab_id: i64 = rand::random::<u32>().into();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'alice', ?6)",
|
||||
rusqlite::params![gitlab_id, project_id, issue_id, mr_id, state, created_at],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_label_event(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
issue_id: Option<i64>,
|
||||
mr_id: Option<i64>,
|
||||
action: &str,
|
||||
label_name: Option<&str>,
|
||||
created_at: i64,
|
||||
) {
|
||||
let gitlab_id: i64 = rand::random::<u32>().into();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)",
|
||||
rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, label_name, created_at],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_milestone_event(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
issue_id: Option<i64>,
|
||||
mr_id: Option<i64>,
|
||||
action: &str,
|
||||
milestone_title: Option<&str>,
|
||||
created_at: i64,
|
||||
) {
|
||||
let gitlab_id: i64 = rand::random::<u32>().into();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_milestone_events (gitlab_id, project_id, issue_id, merge_request_id, action, milestone_title, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)",
|
||||
rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, milestone_title, created_at],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_creation_event() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
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();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0].event_type, TimelineEventType::Created));
|
||||
assert_eq!(events[0].timestamp, 1000);
|
||||
assert_eq!(events[0].actor, Some("alice".to_owned()));
|
||||
assert!(events[0].is_seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_state_events() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
|
||||
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();
|
||||
|
||||
// Created + 2 state changes = 3
|
||||
assert_eq!(events.len(), 3);
|
||||
assert!(matches!(events[0].event_type, TimelineEventType::Created));
|
||||
assert!(matches!(
|
||||
events[1].event_type,
|
||||
TimelineEventType::StateChanged { ref state } if state == "closed"
|
||||
));
|
||||
assert!(matches!(
|
||||
events[2].event_type,
|
||||
TimelineEventType::StateChanged { ref state } if state == "reopened"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_merged_dedup() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let mr_id = insert_mr(&conn, project_id, 10, Some(5000));
|
||||
|
||||
// Also add a state event for 'merged' — this should NOT produce a StateChanged
|
||||
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();
|
||||
|
||||
// Should have Created + Merged (not Created + StateChanged{merged} + Merged)
|
||||
let merged_count = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event_type, TimelineEventType::Merged))
|
||||
.count();
|
||||
let state_merged_count = events
|
||||
.iter()
|
||||
.filter(|e| matches!(&e.event_type, TimelineEventType::StateChanged { state } if state == "merged"))
|
||||
.count();
|
||||
|
||||
assert_eq!(merged_count, 1);
|
||||
assert_eq!(state_merged_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_null_label_fallback() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
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 label_event = events.iter().find(|e| {
|
||||
matches!(&e.event_type, TimelineEventType::LabelAdded { label } if label == "[deleted label]")
|
||||
});
|
||||
assert!(label_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_null_milestone_fallback() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
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 ms_event = events.iter().find(|e| {
|
||||
matches!(&e.event_type, TimelineEventType::MilestoneSet { milestone } if milestone == "[deleted milestone]")
|
||||
});
|
||||
assert!(ms_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_since_filter() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 5000);
|
||||
|
||||
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();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].timestamp, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_chronological_sort() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
let mr_id = insert_mr(&conn, project_id, 10, Some(4000));
|
||||
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
|
||||
insert_label_event(
|
||||
&conn,
|
||||
project_id,
|
||||
None,
|
||||
Some(mr_id),
|
||||
"add",
|
||||
Some("bug"),
|
||||
2000,
|
||||
);
|
||||
|
||||
let seeds = vec![
|
||||
make_entity_ref("issue", issue_id, 1),
|
||||
make_entity_ref("merge_request", mr_id, 10),
|
||||
];
|
||||
let events = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
|
||||
|
||||
// Verify chronological order
|
||||
for window in events.windows(2) {
|
||||
assert!(window[0].timestamp <= window[1].timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_respects_limit() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
for i in 0..20 {
|
||||
insert_state_event(
|
||||
&conn,
|
||||
project_id,
|
||||
Some(issue_id),
|
||||
None,
|
||||
"closed",
|
||||
3000 + i * 100,
|
||||
);
|
||||
}
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let events = collect_events(&conn, &seeds, &[], &[], None, 5).unwrap();
|
||||
assert_eq!(events.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_evidence_notes_included() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
let evidence = vec![TimelineEvent {
|
||||
timestamp: 2500,
|
||||
entity_type: "issue".to_owned(),
|
||||
entity_id: issue_id,
|
||||
entity_iid: 1,
|
||||
project_path: "group/project".to_owned(),
|
||||
event_type: TimelineEventType::NoteEvidence {
|
||||
note_id: 42,
|
||||
snippet: "relevant note".to_owned(),
|
||||
discussion_id: Some(1),
|
||||
},
|
||||
summary: "Note by alice".to_owned(),
|
||||
actor: Some("alice".to_owned()),
|
||||
url: None,
|
||||
is_seed: true,
|
||||
}];
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let events = collect_events(&conn, &seeds, &[], &evidence, None, 100).unwrap();
|
||||
|
||||
let note_event = events.iter().find(|e| {
|
||||
matches!(
|
||||
&e.event_type,
|
||||
TimelineEventType::NoteEvidence { note_id, .. } if *note_id == 42
|
||||
)
|
||||
});
|
||||
assert!(note_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_merged_fallback_to_state_event() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
// MR with merged_at = NULL
|
||||
let mr_id = insert_mr(&conn, project_id, 10, None);
|
||||
|
||||
// But has a state event for 'merged'
|
||||
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 merged = events
|
||||
.iter()
|
||||
.find(|e| matches!(e.event_type, TimelineEventType::Merged));
|
||||
assert!(merged.is_some());
|
||||
assert_eq!(merged.unwrap().timestamp, 5000);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user