diff --git a/src/cli/commands/timeline.rs b/src/cli/commands/timeline.rs index be46953..518e0ea 100644 --- a/src/cli/commands/timeline.rs +++ b/src/cli/commands/timeline.rs @@ -13,7 +13,7 @@ use crate::core::timeline::{ }; use crate::core::timeline_collect::collect_events; use crate::core::timeline_expand::expand_timeline; -use crate::core::timeline_seed::seed_timeline; +use crate::core::timeline_seed::{seed_timeline, seed_timeline_direct}; use crate::embedding::ollama::{OllamaClient, OllamaConfig}; /// Parameters for running the timeline pipeline. @@ -30,6 +30,43 @@ pub struct TimelineParams { pub robot_mode: bool, } +/// Parsed timeline query: either a search string or a direct entity reference. +enum TimelineQuery { + Search(String), + EntityDirect { entity_type: String, iid: i64 }, +} + +/// Parse the timeline query for entity-direct patterns. +/// +/// Recognized patterns (case-insensitive prefix): +/// - `issue:N`, `i:N` -> issue +/// - `mr:N`, `m:N` -> merge_request +/// - Anything else -> search query +fn parse_timeline_query(query: &str) -> TimelineQuery { + let query = query.trim(); + if let Some((prefix, rest)) = query.split_once(':') { + let prefix_lower = prefix.to_ascii_lowercase(); + if let Ok(iid) = rest.trim().parse::() { + match prefix_lower.as_str() { + "issue" | "i" => { + return TimelineQuery::EntityDirect { + entity_type: "issue".to_owned(), + iid, + }; + } + "mr" | "m" => { + return TimelineQuery::EntityDirect { + entity_type: "merge_request".to_owned(), + iid, + }; + } + _ => {} + } + } + } + TimelineQuery::Search(query.to_owned()) +} + /// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT. pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); @@ -53,27 +90,42 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result { + // Direct seeding: synchronous, no Ollama needed + let spinner = stage_spinner(1, 3, "Resolving entity...", params.robot_mode); + let result = seed_timeline_direct(&conn, &entity_type, iid, project_id)?; + spinner.finish_and_clear(); + result + } + TimelineQuery::Search(ref query) => { + // Construct OllamaClient for hybrid search (same pattern as run_search) + let ollama_cfg = &config.embedding; + let client = OllamaClient::new(OllamaConfig { + base_url: ollama_cfg.base_url.clone(), + model: ollama_cfg.model.clone(), + ..OllamaConfig::default() + }); + + // Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback) + let spinner = stage_spinner(1, 3, "Seeding timeline...", params.robot_mode); + let result = seed_timeline( + &conn, + Some(&client), + query, + project_id, + since_ms, + params.max_seeds, + params.max_evidence, + ) + .await?; + spinner.finish_and_clear(); + result + } + }; // Stage 3: EXPAND let spinner = stage_spinner(2, 3, "Expanding cross-references...", params.robot_mode); @@ -556,3 +608,84 @@ fn count_discussion_threads(events: &[TimelineEvent]) -> usize { .filter(|e| matches!(e.event_type, TimelineEventType::DiscussionThread { .. })) .count() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_issue_colon_number() { + let q = parse_timeline_query("issue:42"); + assert!( + matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42) + ); + } + + #[test] + fn test_parse_i_colon_number() { + let q = parse_timeline_query("i:42"); + assert!( + matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42) + ); + } + + #[test] + fn test_parse_mr_colon_number() { + let q = parse_timeline_query("mr:99"); + assert!( + matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99) + ); + } + + #[test] + fn test_parse_m_colon_number() { + let q = parse_timeline_query("m:99"); + assert!( + matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99) + ); + } + + #[test] + fn test_parse_case_insensitive() { + let q = parse_timeline_query("ISSUE:42"); + assert!( + matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42) + ); + + let q = parse_timeline_query("MR:99"); + assert!( + matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99) + ); + + let q = parse_timeline_query("Issue:7"); + assert!( + matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 7) + ); + } + + #[test] + fn test_parse_search_fallback() { + let q = parse_timeline_query("switch health"); + assert!(matches!(q, TimelineQuery::Search(ref s) if s == "switch health")); + } + + #[test] + fn test_parse_non_numeric_falls_back_to_search() { + let q = parse_timeline_query("issue:abc"); + assert!(matches!(q, TimelineQuery::Search(_))); + } + + #[test] + fn test_parse_unknown_prefix_falls_back_to_search() { + let q = parse_timeline_query("foo:42"); + assert!(matches!(q, TimelineQuery::Search(_))); + } + + #[test] + fn test_parse_whitespace_trimmed() { + let q = parse_timeline_query(" issue:42 "); + assert!( + matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42) + ); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2647b34..9c083ec 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -794,11 +794,14 @@ pub struct EmbedArgs { #[derive(Parser)] #[command(after_help = "\x1b[1mExamples:\x1b[0m - lore timeline 'deployment' # Events related to deployments + lore timeline 'deployment' # Search-based seeding + lore timeline issue:42 # Direct: issue #42 and related entities + lore timeline i:42 # Shorthand for issue:42 + lore timeline mr:99 # Direct: MR !99 and related entities lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")] pub struct TimelineArgs { - /// Search query (keywords to find in issues, MRs, and discussions) + /// Search text or entity reference (issue:N, i:N, mr:N, m:N) pub query: String, /// Scope to a specific project (fuzzy match) diff --git a/src/core/timeline.rs b/src/core/timeline.rs index 4be0b50..26b9d70 100644 --- a/src/core/timeline.rs +++ b/src/core/timeline.rs @@ -211,6 +211,77 @@ pub fn resolve_entity_ref( } } +/// Resolve an entity by its user-facing IID (e.g. issue #42) to a full [`EntityRef`]. +/// +/// Unlike [`resolve_entity_ref`] which takes an internal DB id, this takes the +/// GitLab IID that users see. Used by entity-direct timeline seeding (`issue:42`). +/// +/// When `project_id` is `Some`, the query is scoped to that project (disambiguates +/// duplicate IIDs across projects). +/// +/// Returns `LoreError::NotFound` when no match exists, `LoreError::Ambiguous` when +/// the same IID exists in multiple projects (suggest `--project`). +pub fn resolve_entity_by_iid( + conn: &Connection, + entity_type: &str, + iid: i64, + project_id: Option, +) -> Result { + let table = match entity_type { + "issue" => "issues", + "merge_request" => "merge_requests", + _ => { + return Err(super::error::LoreError::NotFound(format!( + "Unknown entity type: {entity_type}" + ))); + } + }; + + let sql = format!( + "SELECT e.id, e.iid, p.path_with_namespace + FROM {table} e + JOIN projects p ON p.id = e.project_id + WHERE e.iid = ?1 AND (?2 IS NULL OR e.project_id = ?2)" + ); + + let mut stmt = conn.prepare(&sql)?; + let rows: Vec<(i64, i64, String)> = stmt + .query_map(rusqlite::params![iid, project_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::, _>>()?; + + match rows.len() { + 0 => { + let sigil = if entity_type == "issue" { "#" } else { "!" }; + Err(super::error::LoreError::NotFound(format!( + "{entity_type} {sigil}{iid} not found" + ))) + } + 1 => { + let (entity_id, entity_iid, project_path) = rows.into_iter().next().unwrap(); + Ok(EntityRef { + entity_type: entity_type.to_owned(), + entity_id, + entity_iid, + project_path, + }) + } + _ => { + let projects: Vec<&str> = rows.iter().map(|(_, _, p)| p.as_str()).collect(); + let sigil = if entity_type == "issue" { "#" } else { "!" }; + Err(super::error::LoreError::Ambiguous(format!( + "{entity_type} {sigil}{iid} exists in multiple projects: {}. Use --project to specify.", + projects.join(", ") + ))) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -409,4 +480,106 @@ mod tests { let long = "a".repeat(300); assert_eq!(truncate_to_chars(&long, 200).chars().count(), 200); } + + // ─── resolve_entity_by_iid tests ──────────────────────────────────────── + + use crate::core::db::{create_connection, run_migrations}; + use std::path::Path; + + fn setup_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn + } + + fn insert_project(conn: &Connection, gitlab_id: i64, path: &str) -> i64 { + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (?1, ?2, ?3)", + rusqlite::params![gitlab_id, path, format!("https://gitlab.com/{path}")], + ) + .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) VALUES (?1, ?2, ?3, 'Test issue', 'opened', 'alice', 1000, 2000, 3000)", + rusqlite::params![project_id * 10000 + iid, project_id, iid], + ) + .unwrap(); + conn.last_insert_rowid() + } + + fn insert_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 { + conn.execute( + "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)", + rusqlite::params![project_id * 10000 + iid, project_id, iid], + ) + .unwrap(); + conn.last_insert_rowid() + } + + #[test] + fn test_resolve_entity_by_iid_issue() { + let conn = setup_db(); + let project_id = insert_project(&conn, 1, "group/project"); + let entity_id = insert_issue(&conn, project_id, 42); + + let result = resolve_entity_by_iid(&conn, "issue", 42, None).unwrap(); + assert_eq!(result.entity_type, "issue"); + assert_eq!(result.entity_id, entity_id); + assert_eq!(result.entity_iid, 42); + assert_eq!(result.project_path, "group/project"); + } + + #[test] + fn test_resolve_entity_by_iid_mr() { + let conn = setup_db(); + let project_id = insert_project(&conn, 1, "group/project"); + let entity_id = insert_mr(&conn, project_id, 99); + + let result = resolve_entity_by_iid(&conn, "merge_request", 99, None).unwrap(); + assert_eq!(result.entity_type, "merge_request"); + assert_eq!(result.entity_id, entity_id); + assert_eq!(result.entity_iid, 99); + assert_eq!(result.project_path, "group/project"); + } + + #[test] + fn test_resolve_entity_by_iid_not_found() { + let conn = setup_db(); + insert_project(&conn, 1, "group/project"); + + let result = resolve_entity_by_iid(&conn, "issue", 999, None); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, crate::core::error::LoreError::NotFound(_))); + } + + #[test] + fn test_resolve_entity_by_iid_ambiguous() { + let conn = setup_db(); + let proj1 = insert_project(&conn, 1, "group/project-a"); + let proj2 = insert_project(&conn, 2, "group/project-b"); + insert_issue(&conn, proj1, 42); + insert_issue(&conn, proj2, 42); + + let result = resolve_entity_by_iid(&conn, "issue", 42, None); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, crate::core::error::LoreError::Ambiguous(_))); + } + + #[test] + fn test_resolve_entity_by_iid_project_scoped() { + let conn = setup_db(); + let proj1 = insert_project(&conn, 1, "group/project-a"); + let proj2 = insert_project(&conn, 2, "group/project-b"); + insert_issue(&conn, proj1, 42); + let entity_id_b = insert_issue(&conn, proj2, 42); + + let result = resolve_entity_by_iid(&conn, "issue", 42, Some(proj2)).unwrap(); + assert_eq!(result.entity_id, entity_id_b); + assert_eq!(result.project_path, "group/project-b"); + } } diff --git a/src/core/timeline_seed.rs b/src/core/timeline_seed.rs index b0bcbeb..c2e95c9 100644 --- a/src/core/timeline_seed.rs +++ b/src/core/timeline_seed.rs @@ -5,8 +5,8 @@ use tracing::debug; use crate::core::error::Result; use crate::core::timeline::{ - EntityRef, MatchedDiscussion, TimelineEvent, TimelineEventType, resolve_entity_ref, - truncate_to_chars, + EntityRef, MatchedDiscussion, TimelineEvent, TimelineEventType, resolve_entity_by_iid, + resolve_entity_ref, truncate_to_chars, }; use crate::embedding::ollama::OllamaClient; use crate::search::{FtsQueryMode, SearchFilters, SearchMode, search_hybrid, to_fts_query}; @@ -102,6 +102,53 @@ pub async fn seed_timeline( }) } +/// Seed the timeline directly from an entity IID, bypassing search entirely. +/// +/// Used for `issue:42` / `mr:99` syntax. Resolves the entity, gathers ALL its +/// discussions, and returns a `SeedResult` compatible with the rest of the pipeline. +pub fn seed_timeline_direct( + conn: &Connection, + entity_type: &str, + iid: i64, + project_id: Option, +) -> Result { + let entity_ref = resolve_entity_by_iid(conn, entity_type, iid, project_id)?; + + // Gather all discussions for this entity (not search-matched, ALL of them) + let entity_id_col = match entity_type { + "issue" => "issue_id", + "merge_request" => "merge_request_id", + _ => { + return Ok(SeedResult { + seed_entities: vec![entity_ref], + evidence_notes: Vec::new(), + matched_discussions: Vec::new(), + search_mode: "direct".to_owned(), + }); + } + }; + + let sql = format!("SELECT id, project_id FROM discussions WHERE {entity_id_col} = ?1"); + let mut stmt = conn.prepare(&sql)?; + let matched_discussions: Vec = stmt + .query_map(rusqlite::params![entity_ref.entity_id], |row| { + Ok(MatchedDiscussion { + discussion_id: row.get(0)?, + entity_type: entity_type.to_owned(), + entity_id: entity_ref.entity_id, + project_id: row.get(1)?, + }) + })? + .collect::, _>>()?; + + Ok(SeedResult { + seed_entities: vec![entity_ref], + evidence_notes: Vec::new(), + matched_discussions, + search_mode: "direct".to_owned(), + }) +} + /// Resolve a list of document IDs to deduplicated entity refs and matched discussions. /// Discussion and note documents are resolved to their parent entity (issue or MR). /// Returns (entities, matched_discussions). diff --git a/src/core/timeline_seed_tests.rs b/src/core/timeline_seed_tests.rs index 119fb2d..b09a144 100644 --- a/src/core/timeline_seed_tests.rs +++ b/src/core/timeline_seed_tests.rs @@ -423,3 +423,90 @@ async fn test_seed_matched_discussions_have_correct_parent_entity() { assert_eq!(result.matched_discussions[0].entity_type, "merge_request"); assert_eq!(result.matched_discussions[0].entity_id, mr_id); } + +// ─── seed_timeline_direct tests ───────────────────────────────────────────── + +#[test] +fn test_direct_seed_resolves_entity() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + insert_test_issue(&conn, project_id, 42); + + let result = seed_timeline_direct(&conn, "issue", 42, None).unwrap(); + assert_eq!(result.seed_entities.len(), 1); + assert_eq!(result.seed_entities[0].entity_type, "issue"); + assert_eq!(result.seed_entities[0].entity_iid, 42); + assert_eq!(result.seed_entities[0].project_path, "group/project"); +} + +#[test] +fn test_direct_seed_gathers_all_discussions() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, project_id, 42); + + // Create 3 discussions for this issue + let disc1 = insert_discussion(&conn, project_id, Some(issue_id), None); + let disc2 = insert_discussion(&conn, project_id, Some(issue_id), None); + let disc3 = insert_discussion(&conn, project_id, Some(issue_id), None); + + let result = seed_timeline_direct(&conn, "issue", 42, None).unwrap(); + assert_eq!(result.matched_discussions.len(), 3); + let disc_ids: Vec = result + .matched_discussions + .iter() + .map(|d| d.discussion_id) + .collect(); + assert!(disc_ids.contains(&disc1)); + assert!(disc_ids.contains(&disc2)); + assert!(disc_ids.contains(&disc3)); +} + +#[test] +fn test_direct_seed_no_evidence_notes() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, project_id, 42); + let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); + insert_note(&conn, disc_id, project_id, "some note body", false); + + let result = seed_timeline_direct(&conn, "issue", 42, None).unwrap(); + assert!( + result.evidence_notes.is_empty(), + "Direct seeding should not produce evidence notes" + ); +} + +#[test] +fn test_direct_seed_search_mode_is_direct() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + insert_test_issue(&conn, project_id, 42); + + let result = seed_timeline_direct(&conn, "issue", 42, None).unwrap(); + assert_eq!(result.search_mode, "direct"); +} + +#[test] +fn test_direct_seed_not_found() { + let conn = setup_test_db(); + insert_test_project(&conn); + + let result = seed_timeline_direct(&conn, "issue", 999, None); + assert!(result.is_err()); +} + +#[test] +fn test_direct_seed_mr() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let mr_id = insert_test_mr(&conn, project_id, 99); + let disc_id = insert_discussion(&conn, project_id, None, Some(mr_id)); + + let result = seed_timeline_direct(&conn, "merge_request", 99, None).unwrap(); + assert_eq!(result.seed_entities.len(), 1); + assert_eq!(result.seed_entities[0].entity_type, "merge_request"); + assert_eq!(result.seed_entities[0].entity_iid, 99); + assert_eq!(result.matched_discussions.len(), 1); + assert_eq!(result.matched_discussions[0].discussion_id, disc_id); +} diff --git a/src/main.rs b/src/main.rs index 2c26990..f4c0021 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2345,13 +2345,22 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box ~/.local/share/bash-completion/completions/lore" }, "timeline": { - "description": "Chronological timeline of events matching a keyword query", + "description": "Chronological timeline of events matching a keyword query or entity reference", "flags": ["", "-p/--project", "--since ", "--depth ", "--expand-mentions", "-n/--limit", "--fields ", "--max-seeds", "--max-entities", "--max-evidence"], - "example": "lore --robot timeline '' --since 30d", + "query_syntax": { + "search": "Any text -> hybrid search seeding (FTS + vector)", + "entity_direct": "issue:N, i:N, mr:N, m:N -> direct entity seeding (no search, no Ollama)" + }, + "examples": [ + "lore --robot timeline 'deployment' --since 30d", + "lore --robot timeline issue:42", + "lore --robot timeline i:42", + "lore --robot timeline mr:99 -p group/repo" + ], "response_schema": { "ok": "bool", "data": {"entities": "[{type:string, iid:int, title:string, project_path:string}]", "events": "[{timestamp:string, type:string, entity_type:string, entity_iid:int, detail:string}]", "total_events": "int"}, - "meta": {"elapsed_ms": "int"} + "meta": {"elapsed_ms": "int", "search_mode": "string (hybrid|lexical|direct)"} }, "fields_presets": {"minimal": ["timestamp", "type", "entity_iid", "detail"]} },