feat(timeline): add entity-direct seeding (issue:N, mr:N syntax)
Adds issue:N / i:N / mr:N / m:N query syntax to bypass hybrid search and seed the timeline directly from a known entity. All discussions for the entity are gathered without needing Ollama. - parse_timeline_query() detects entity-direct patterns - resolve_entity_by_iid() resolves IID to EntityRef with ambiguity handling - seed_timeline_direct() gathers all discussions for the entity - 20 new tests (5 resolve, 6 direct seed, 9 parse) - Updated CLI help text and robot-docs manifest
This commit is contained in:
@@ -13,7 +13,7 @@ use crate::core::timeline::{
|
|||||||
};
|
};
|
||||||
use crate::core::timeline_collect::collect_events;
|
use crate::core::timeline_collect::collect_events;
|
||||||
use crate::core::timeline_expand::expand_timeline;
|
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};
|
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
||||||
|
|
||||||
/// Parameters for running the timeline pipeline.
|
/// Parameters for running the timeline pipeline.
|
||||||
@@ -30,6 +30,43 @@ pub struct TimelineParams {
|
|||||||
pub robot_mode: bool,
|
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::<i64>() {
|
||||||
|
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.
|
/// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT.
|
||||||
pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<TimelineResult> {
|
pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<TimelineResult> {
|
||||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||||
@@ -53,6 +90,18 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
|||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
|
||||||
|
// Parse query for entity-direct syntax (issue:N, mr:N, i:N, m:N)
|
||||||
|
let parsed_query = parse_timeline_query(¶ms.query);
|
||||||
|
|
||||||
|
let seed_result = match parsed_query {
|
||||||
|
TimelineQuery::EntityDirect { entity_type, iid } => {
|
||||||
|
// 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)
|
// Construct OllamaClient for hybrid search (same pattern as run_search)
|
||||||
let ollama_cfg = &config.embedding;
|
let ollama_cfg = &config.embedding;
|
||||||
let client = OllamaClient::new(OllamaConfig {
|
let client = OllamaClient::new(OllamaConfig {
|
||||||
@@ -63,10 +112,10 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
|||||||
|
|
||||||
// Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback)
|
// Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback)
|
||||||
let spinner = stage_spinner(1, 3, "Seeding timeline...", params.robot_mode);
|
let spinner = stage_spinner(1, 3, "Seeding timeline...", params.robot_mode);
|
||||||
let seed_result = seed_timeline(
|
let result = seed_timeline(
|
||||||
&conn,
|
&conn,
|
||||||
Some(&client),
|
Some(&client),
|
||||||
¶ms.query,
|
query,
|
||||||
project_id,
|
project_id,
|
||||||
since_ms,
|
since_ms,
|
||||||
params.max_seeds,
|
params.max_seeds,
|
||||||
@@ -74,6 +123,9 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
spinner.finish_and_clear();
|
spinner.finish_and_clear();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Stage 3: EXPAND
|
// Stage 3: EXPAND
|
||||||
let spinner = stage_spinner(2, 3, "Expanding cross-references...", params.robot_mode);
|
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 { .. }))
|
.filter(|e| matches!(e.event_type, TimelineEventType::DiscussionThread { .. }))
|
||||||
.count()
|
.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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -794,11 +794,14 @@ pub struct EmbedArgs {
|
|||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
#[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 'auth' --since 30d -p group/repo # Scoped to project and time
|
||||||
lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")]
|
lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")]
|
||||||
pub struct TimelineArgs {
|
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,
|
pub query: String,
|
||||||
|
|
||||||
/// Scope to a specific project (fuzzy match)
|
/// Scope to a specific project (fuzzy match)
|
||||||
|
|||||||
@@ -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<i64>,
|
||||||
|
) -> Result<EntityRef> {
|
||||||
|
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::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -409,4 +480,106 @@ mod tests {
|
|||||||
let long = "a".repeat(300);
|
let long = "a".repeat(300);
|
||||||
assert_eq!(truncate_to_chars(&long, 200).chars().count(), 200);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use tracing::debug;
|
|||||||
|
|
||||||
use crate::core::error::Result;
|
use crate::core::error::Result;
|
||||||
use crate::core::timeline::{
|
use crate::core::timeline::{
|
||||||
EntityRef, MatchedDiscussion, TimelineEvent, TimelineEventType, resolve_entity_ref,
|
EntityRef, MatchedDiscussion, TimelineEvent, TimelineEventType, resolve_entity_by_iid,
|
||||||
truncate_to_chars,
|
resolve_entity_ref, truncate_to_chars,
|
||||||
};
|
};
|
||||||
use crate::embedding::ollama::OllamaClient;
|
use crate::embedding::ollama::OllamaClient;
|
||||||
use crate::search::{FtsQueryMode, SearchFilters, SearchMode, search_hybrid, to_fts_query};
|
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<i64>,
|
||||||
|
) -> Result<SeedResult> {
|
||||||
|
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<MatchedDiscussion> = 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::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
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.
|
/// 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).
|
/// Discussion and note documents are resolved to their parent entity (issue or MR).
|
||||||
/// Returns (entities, matched_discussions).
|
/// Returns (entities, matched_discussions).
|
||||||
|
|||||||
@@ -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_type, "merge_request");
|
||||||
assert_eq!(result.matched_discussions[0].entity_id, mr_id);
|
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<i64> = 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);
|
||||||
|
}
|
||||||
|
|||||||
15
src/main.rs
15
src/main.rs
@@ -2345,13 +2345,22 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
|||||||
"example": "lore completions bash > ~/.local/share/bash-completion/completions/lore"
|
"example": "lore completions bash > ~/.local/share/bash-completion/completions/lore"
|
||||||
},
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"description": "Chronological timeline of events matching a keyword query",
|
"description": "Chronological timeline of events matching a keyword query or entity reference",
|
||||||
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--expand-mentions", "-n/--limit", "--fields <list>", "--max-seeds", "--max-entities", "--max-evidence"],
|
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--expand-mentions", "-n/--limit", "--fields <list>", "--max-seeds", "--max-entities", "--max-evidence"],
|
||||||
"example": "lore --robot timeline '<keyword>' --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": {
|
"response_schema": {
|
||||||
"ok": "bool",
|
"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"},
|
"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"]}
|
"fields_presets": {"minimal": ["timestamp", "type", "entity_iid", "detail"]}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user