Move inline #[cfg(test)] mod tests { ... } blocks from 22 source files
into dedicated _tests.rs companion files, wired via:
#[cfg(test)]
#[path = "module_tests.rs"]
mod tests;
This keeps implementation-focused source files leaner and more scannable
while preserving full access to private items through `use super::*;`.
Modules extracted:
core: db, note_parser, payloads, project, references, sync_run,
timeline_collect, timeline_expand, timeline_seed
cli: list (55 tests), who (75 tests)
documents: extractor (43 tests), regenerator
embedding: change_detector, chunking
gitlab: graphql (wiremock async tests), transformers/issue
ingestion: dirty_tracker, discussions, issues, mr_diffs
Also adds conflicts_with("explain_score") to the --detail flag in the
who command to prevent mutually exclusive flags from being combined.
All 629 unit tests pass. No behavior changes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
375 lines
12 KiB
Rust
375 lines
12 KiB
Rust
use rusqlite::Connection;
|
|
|
|
use crate::core::error::{LoreError, 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>, usize)> {
|
|
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);
|
|
}
|
|
|
|
// 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, total_before_limit))
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
let summary = format!("State changed to {state}");
|
|
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 },
|
|
summary,
|
|
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" => {
|
|
let summary = format!("Label added: {label}");
|
|
(TimelineEventType::LabelAdded { label }, summary)
|
|
}
|
|
"remove" => {
|
|
let summary = format!("Label removed: {label}");
|
|
(TimelineEventType::LabelRemoved { label }, summary)
|
|
}
|
|
_ => 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" => {
|
|
let summary = format!("Milestone set: {milestone}");
|
|
(TimelineEventType::MilestoneSet { milestone }, summary)
|
|
}
|
|
"remove" => {
|
|
let summary = format!("Milestone removed: {milestone}");
|
|
(TimelineEventType::MilestoneRemoved { milestone }, summary)
|
|
}
|
|
_ => 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)?,
|
|
))
|
|
},
|
|
);
|
|
|
|
match mr_result {
|
|
Ok((Some(merged_at), merge_user, url)) => {
|
|
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(());
|
|
}
|
|
Ok((None, _, _)) => {} // merged_at is NULL, try fallback
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {} // entity not found, try fallback
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
|
|
// 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)?)),
|
|
);
|
|
|
|
match fallback_result {
|
|
Ok((actor, created_at)) => {
|
|
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,
|
|
});
|
|
}
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => {} // no merged state event, MR wasn't merged
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Return the correct column name and value for querying resource event tables.
|
|
fn entity_id_column(entity: &EntityRef) -> Result<(&'static str, i64)> {
|
|
match entity.entity_type.as_str() {
|
|
"issue" => Ok(("issue_id", entity.entity_id)),
|
|
"merge_request" => Ok(("merge_request_id", entity.entity_id)),
|
|
_ => Err(LoreError::Other(format!(
|
|
"Unknown entity type for event collection: {}",
|
|
entity.entity_type
|
|
))),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "timeline_collect_tests.rs"]
|
|
mod tests;
|