use serde::Serialize; use tracing::info; use super::error::Result; use super::file_history::resolve_rename_chain; use super::time::ms_to_iso; /// Maximum rename chain BFS depth. const MAX_RENAME_HOPS: usize = 10; /// A linked issue found via entity_references on the MR. #[derive(Debug, Serialize)] pub struct TraceIssue { pub iid: i64, pub title: String, pub state: String, pub reference_type: String, pub web_url: Option, } /// A DiffNote discussion relevant to the traced file. #[derive(Debug, Serialize)] pub struct TraceDiscussion { pub discussion_id: String, pub mr_iid: i64, pub author_username: String, pub body: String, pub path: String, pub created_at_iso: String, } /// A single trace chain: an MR that touched the file, plus linked issues and discussions. #[derive(Debug, Serialize)] pub struct TraceChain { pub mr_iid: i64, pub mr_title: String, pub mr_state: String, pub mr_author: String, pub change_type: String, pub merged_at_iso: Option, pub updated_at_iso: String, pub web_url: Option, pub issues: Vec, pub discussions: Vec, } /// Result of a trace query. #[derive(Debug, Serialize)] pub struct TraceResult { pub path: String, pub resolved_paths: Vec, pub renames_followed: bool, pub trace_chains: Vec, pub total_chains: usize, /// Diagnostic hints explaining why results may be empty. #[serde(skip_serializing_if = "Vec::is_empty")] pub hints: Vec, } /// Run the trace query: file -> MR -> issue chain. pub fn run_trace( conn: &rusqlite::Connection, project_id: Option, path: &str, follow_renames: bool, include_discussions: bool, limit: usize, ) -> Result { // Resolve rename chain let (all_paths, renames_followed) = if follow_renames { if let Some(pid) = project_id { let chain = resolve_rename_chain(conn, pid, path, MAX_RENAME_HOPS)?; let followed = chain.len() > 1; (chain, followed) } else { (vec![path.to_string()], false) } } else { (vec![path.to_string()], false) }; info!( paths = all_paths.len(), renames_followed, "trace: resolved {} path(s) for '{}'", all_paths.len(), path ); // Build placeholders for IN clause let placeholders: Vec = (0..all_paths.len()) .map(|i| format!("?{}", i + 2)) .collect(); let in_clause = placeholders.join(", "); let project_filter = if project_id.is_some() { "AND mfc.project_id = ?1" } else { "" }; // Step 1: Find MRs that touched the file let mr_sql = format!( "SELECT DISTINCT \ mr.id, mr.iid, mr.title, mr.state, mr.author_username, \ mfc.change_type, mr.merged_at, mr.updated_at, mr.web_url \ FROM mr_file_changes mfc \ JOIN merge_requests mr ON mr.id = mfc.merge_request_id \ WHERE mfc.new_path IN ({in_clause}) {project_filter} \ ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC \ LIMIT ?{}", all_paths.len() + 2 ); let mut stmt = conn.prepare_cached(&mr_sql)?; let mut params: Vec> = Vec::new(); params.push(Box::new(project_id.unwrap_or(0))); for p in &all_paths { params.push(Box::new(p.clone())); } params.push(Box::new(limit as i64)); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); struct MrRow { id: i64, iid: i64, title: String, state: String, author: String, change_type: String, merged_at: Option, updated_at: i64, web_url: Option, } let mr_rows: Vec = stmt .query_map(param_refs.as_slice(), |row| { Ok(MrRow { id: row.get(0)?, iid: row.get(1)?, title: row.get(2)?, state: row.get(3)?, author: row.get(4)?, change_type: row.get(5)?, merged_at: row.get(6)?, updated_at: row.get(7)?, web_url: row.get(8)?, }) })? .collect::, _>>()?; info!( mr_count = mr_rows.len(), "trace: found {} MR(s) touching '{}'", mr_rows.len(), path ); // Step 2: For each MR, find linked issues + optional discussions let mut trace_chains = Vec::with_capacity(mr_rows.len()); for mr in &mr_rows { let issues = fetch_linked_issues(conn, mr.id)?; let discussions = if include_discussions { fetch_trace_discussions(conn, mr.id, mr.iid, &all_paths)? } else { Vec::new() }; info!( mr_iid = mr.iid, issues = issues.len(), discussions = discussions.len(), "trace: MR !{}: {} issue(s), {} discussion(s)", mr.iid, issues.len(), discussions.len() ); trace_chains.push(TraceChain { mr_iid: mr.iid, mr_title: mr.title.clone(), mr_state: mr.state.clone(), mr_author: mr.author.clone(), change_type: mr.change_type.clone(), merged_at_iso: mr.merged_at.map(ms_to_iso), updated_at_iso: ms_to_iso(mr.updated_at), web_url: mr.web_url.clone(), issues, discussions, }); } let total_chains = trace_chains.len(); // Build diagnostic hints when no results found let hints = if total_chains == 0 { build_trace_hints(conn, project_id, &all_paths)? } else { Vec::new() }; Ok(TraceResult { path: path.to_string(), resolved_paths: all_paths, renames_followed, trace_chains, total_chains, hints, }) } /// Fetch issues linked to an MR via entity_references. /// source = merge_request -> target = issue (closes/mentioned/related) fn fetch_linked_issues(conn: &rusqlite::Connection, mr_id: i64) -> Result> { let sql = "SELECT DISTINCT i.iid, i.title, i.state, er.reference_type, i.web_url \ FROM entity_references er \ JOIN issues i ON i.id = er.target_entity_id \ WHERE er.source_entity_type = 'merge_request' \ AND er.source_entity_id = ?1 \ AND er.target_entity_type = 'issue' \ AND er.target_entity_id IS NOT NULL \ ORDER BY \ CASE er.reference_type WHEN 'closes' THEN 0 WHEN 'related' THEN 1 ELSE 2 END, \ i.iid"; let mut stmt = conn.prepare_cached(sql)?; let issues: Vec = stmt .query_map(rusqlite::params![mr_id], |row| { Ok(TraceIssue { iid: row.get(0)?, title: row.get(1)?, state: row.get(2)?, reference_type: row.get(3)?, web_url: row.get(4)?, }) })? .collect::, _>>()?; Ok(issues) } /// Fetch DiffNote discussions on a specific MR that reference the traced paths. fn fetch_trace_discussions( conn: &rusqlite::Connection, mr_id: i64, mr_iid: i64, paths: &[String], ) -> Result> { let placeholders: Vec = (0..paths.len()).map(|i| format!("?{}", i + 2)).collect(); let in_clause = placeholders.join(", "); let sql = format!( "SELECT d.gitlab_discussion_id, n.author_username, n.body, n.position_new_path, n.created_at \ FROM notes n \ JOIN discussions d ON d.id = n.discussion_id \ WHERE d.merge_request_id = ?1 \ AND n.position_new_path IN ({in_clause}) \ AND n.is_system = 0 \ ORDER BY n.created_at DESC" ); let mut stmt = conn.prepare_cached(&sql)?; let mut params: Vec> = Vec::new(); params.push(Box::new(mr_id)); for p in paths { params.push(Box::new(p.clone())); } let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let discussions: Vec = stmt .query_map(param_refs.as_slice(), |row| { let created_at: i64 = row.get(4)?; Ok(TraceDiscussion { discussion_id: row.get(0)?, mr_iid, author_username: row.get(1)?, body: row.get(2)?, path: row.get(3)?, created_at_iso: ms_to_iso(created_at), }) })? .collect::, _>>()?; Ok(discussions) } /// Build diagnostic hints explaining why a trace query returned no results. fn build_trace_hints( conn: &rusqlite::Connection, project_id: Option, paths: &[String], ) -> Result> { let mut hints = Vec::new(); // Check if mr_file_changes has ANY rows for this project let has_file_changes: bool = if let Some(pid) = project_id { conn.query_row( "SELECT EXISTS(SELECT 1 FROM mr_file_changes WHERE project_id = ?1 LIMIT 1)", rusqlite::params![pid], |row| row.get(0), )? } else { conn.query_row( "SELECT EXISTS(SELECT 1 FROM mr_file_changes LIMIT 1)", [], |row| row.get(0), )? }; if !has_file_changes { hints.push( "No MR file changes have been synced yet. Run 'lore sync' to fetch file change data." .to_string(), ); return Ok(hints); } // File changes exist but none match these paths let path_list = paths .iter() .map(|p| format!("'{p}'")) .collect::>() .join(", "); hints.push(format!( "Searched paths [{}] were not found in MR file changes. \ The file may predate the sync window or use a different path.", path_list )); Ok(hints) } #[cfg(test)] #[path = "trace_tests.rs"] mod tests;