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_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::<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.
|
||||
pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<TimelineResult> {
|
||||
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<Ti
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// 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()
|
||||
});
|
||||
// Parse query for entity-direct syntax (issue:N, mr:N, i:N, m:N)
|
||||
let parsed_query = parse_timeline_query(¶ms.query);
|
||||
|
||||
// Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback)
|
||||
let spinner = stage_spinner(1, 3, "Seeding timeline...", params.robot_mode);
|
||||
let seed_result = seed_timeline(
|
||||
&conn,
|
||||
Some(&client),
|
||||
¶ms.query,
|
||||
project_id,
|
||||
since_ms,
|
||||
params.max_seeds,
|
||||
params.max_evidence,
|
||||
)
|
||||
.await?;
|
||||
spinner.finish_and_clear();
|
||||
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)
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user