#![allow(dead_code)] //! File History screen actions — query MRs that touched a file path. //! //! Wraps the SQL queries from `lore::cli::commands::file_history` but uses //! an injected `Connection` (TUI manages its own DB connection). use anyhow::Result; use rusqlite::Connection; use lore::core::file_history::resolve_rename_chain; use crate::state::file_history::{FileDiscussion, FileHistoryMr, FileHistoryResult}; /// Maximum rename chain BFS depth. const MAX_RENAME_HOPS: usize = 10; /// Default result limit. const DEFAULT_LIMIT: usize = 50; /// Fetch file history: MRs that touched a file path, with optional rename resolution. pub fn fetch_file_history( conn: &Connection, project_id: Option, path: &str, follow_renames: bool, merged_only: bool, include_discussions: bool, ) -> Result { // Resolve rename chain unless disabled. let (all_paths, renames_followed) = if !follow_renames { (vec![path.to_string()], false) } else 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 { // Without project scope, can't resolve renames. (vec![path.to_string()], false) }; let paths_searched = all_paths.len(); // Build IN clause placeholders. let placeholders: Vec = (0..all_paths.len()) .map(|i| format!("?{}", i + 2)) .collect(); let in_clause = placeholders.join(", "); let merged_filter = if merged_only { " AND mr.state = 'merged'" } else { "" }; let project_filter = if project_id.is_some() { "AND mfc.project_id = ?1" } else { "" }; let limit_param = all_paths.len() + 2; let sql = format!( "SELECT DISTINCT \ mr.iid, mr.title, mr.state, mr.author_username, \ mfc.change_type, mr.merged_at, mr.updated_at, mr.merge_commit_sha \ 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} {merged_filter} \ ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC \ LIMIT ?{limit_param}" ); let mut stmt = conn.prepare(&sql)?; // Bind: ?1=project_id, ?2..?N+1=paths, ?N+2=limit. 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(DEFAULT_LIMIT as i64)); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let merge_requests: Vec = stmt .query_map(param_refs.as_slice(), |row| { Ok(FileHistoryMr { iid: row.get(0)?, title: row.get(1)?, state: row.get(2)?, author_username: row.get(3)?, change_type: row.get(4)?, merged_at_ms: row.get(5)?, updated_at_ms: row.get::<_, i64>(6)?, merge_commit_sha: row.get(7)?, }) })? .collect::, _>>()?; let total_mrs = merge_requests.len(); // Optionally fetch DiffNote discussions. let discussions = if include_discussions && !merge_requests.is_empty() { fetch_file_discussions(conn, &all_paths, project_id)? } else { Vec::new() }; Ok(FileHistoryResult { path: path.to_string(), rename_chain: all_paths, renames_followed, merge_requests, discussions, total_mrs, paths_searched, }) } /// Fetch DiffNote discussions referencing the given file paths. fn fetch_file_discussions( conn: &Connection, paths: &[String], project_id: Option, ) -> Result> { let placeholders: Vec = (0..paths.len()).map(|i| format!("?{}", i + 2)).collect(); let in_clause = placeholders.join(", "); let project_filter = if project_id.is_some() { "AND d.project_id = ?1" } else { "" }; 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 n.position_new_path IN ({in_clause}) {project_filter} \ AND n.is_system = 0 \ ORDER BY n.created_at DESC \ LIMIT 50" ); let mut stmt = conn.prepare(&sql)?; let mut params: Vec> = Vec::new(); params.push(Box::new(project_id.unwrap_or(0))); 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 body: String = row.get(2)?; let snippet = if body.len() > 200 { format!("{}...", &body[..body.floor_char_boundary(200)]) } else { body }; Ok(FileDiscussion { discussion_id: row.get(0)?, author_username: row.get(1)?, body_snippet: snippet, path: row.get(3)?, created_at_ms: row.get(4)?, }) })? .collect::, _>>()?; Ok(discussions) } /// Fetch distinct file paths from mr_file_changes for autocomplete. pub fn fetch_file_history_paths(conn: &Connection, project_id: Option) -> Result> { let sql = if project_id.is_some() { "SELECT DISTINCT new_path FROM mr_file_changes WHERE project_id = ?1 ORDER BY new_path LIMIT 5000" } else { "SELECT DISTINCT new_path FROM mr_file_changes ORDER BY new_path LIMIT 5000" }; let mut stmt = conn.prepare(sql)?; let paths: Vec = if let Some(pid) = project_id { stmt.query_map([pid], |row| row.get(0))? .collect::, _>>()? } else { stmt.query_map([], |row| row.get(0))? .collect::, _>>()? }; Ok(paths) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// Minimal schema for file history queries. fn create_file_history_schema(conn: &Connection) { conn.execute_batch( " CREATE TABLE projects ( id INTEGER PRIMARY KEY, gitlab_project_id INTEGER UNIQUE NOT NULL, path_with_namespace TEXT NOT NULL ); CREATE TABLE merge_requests ( id INTEGER PRIMARY KEY, gitlab_id INTEGER UNIQUE NOT NULL, project_id INTEGER NOT NULL, iid INTEGER NOT NULL, title TEXT, state TEXT, author_id INTEGER, author_username TEXT, draft INTEGER NOT NULL DEFAULT 0, created_at INTEGER, updated_at INTEGER, merged_at INTEGER, merge_commit_sha TEXT, web_url TEXT, last_seen_at INTEGER NOT NULL ); CREATE TABLE mr_file_changes ( id INTEGER PRIMARY KEY, merge_request_id INTEGER NOT NULL, project_id INTEGER NOT NULL, new_path TEXT NOT NULL, old_path TEXT, change_type TEXT NOT NULL ); CREATE TABLE discussions ( id INTEGER PRIMARY KEY, gitlab_discussion_id TEXT NOT NULL, project_id INTEGER NOT NULL, noteable_type TEXT NOT NULL, issue_id INTEGER, merge_request_id INTEGER, last_seen_at INTEGER NOT NULL ); CREATE TABLE notes ( id INTEGER PRIMARY KEY, gitlab_id INTEGER UNIQUE NOT NULL, discussion_id INTEGER NOT NULL, project_id INTEGER NOT NULL, is_system INTEGER NOT NULL DEFAULT 0, author_username TEXT, body TEXT, note_type TEXT, position_new_path TEXT, position_old_path TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL ); ", ) .expect("create file history schema"); } #[test] fn test_fetch_file_history_empty_db() { let conn = Connection::open_in_memory().unwrap(); create_file_history_schema(&conn); let result = fetch_file_history(&conn, None, "src/lib.rs", false, false, false).unwrap(); assert!(result.merge_requests.is_empty()); assert_eq!(result.total_mrs, 0); assert_eq!(result.path, "src/lib.rs"); } #[test] fn test_fetch_file_history_returns_mrs() { let conn = Connection::open_in_memory().unwrap(); create_file_history_schema(&conn); // Insert project, MR, and file change. conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'grp/repo')", [], ).unwrap(); conn.execute( "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, author_username, updated_at, last_seen_at) \ VALUES (1, 1000, 1, 42, 'Fix auth', 'merged', 'alice', 1700000000000, 1700000000000)", [], ).unwrap(); conn.execute( "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) \ VALUES (1, 1, 'src/auth.rs', 'modified')", [], ) .unwrap(); let result = fetch_file_history(&conn, Some(1), "src/auth.rs", false, false, false).unwrap(); assert_eq!(result.merge_requests.len(), 1); assert_eq!(result.merge_requests[0].iid, 42); assert_eq!(result.merge_requests[0].title, "Fix auth"); assert_eq!(result.merge_requests[0].change_type, "modified"); } #[test] fn test_fetch_file_history_merged_only() { let conn = Connection::open_in_memory().unwrap(); create_file_history_schema(&conn); conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'grp/repo')", [], ).unwrap(); // Merged MR. conn.execute( "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, author_username, updated_at, last_seen_at) \ VALUES (1, 1000, 1, 42, 'Merged MR', 'merged', 'alice', 1700000000000, 1700000000000)", [], ).unwrap(); // Open MR. conn.execute( "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, author_username, updated_at, last_seen_at) \ VALUES (2, 1001, 1, 43, 'Open MR', 'opened', 'bob', 1700000000000, 1700000000000)", [], ).unwrap(); conn.execute( "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (1, 1, 'src/lib.rs', 'modified')", [], ).unwrap(); conn.execute( "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (2, 1, 'src/lib.rs', 'modified')", [], ).unwrap(); // Without merged_only: both MRs. let all = fetch_file_history(&conn, Some(1), "src/lib.rs", false, false, false).unwrap(); assert_eq!(all.merge_requests.len(), 2); // With merged_only: only the merged one. let merged = fetch_file_history(&conn, Some(1), "src/lib.rs", false, true, false).unwrap(); assert_eq!(merged.merge_requests.len(), 1); assert_eq!(merged.merge_requests[0].state, "merged"); } #[test] fn test_fetch_file_history_paths_empty() { let conn = Connection::open_in_memory().unwrap(); create_file_history_schema(&conn); let paths = fetch_file_history_paths(&conn, None).unwrap(); assert!(paths.is_empty()); } #[test] fn test_fetch_file_history_paths_returns_distinct() { let conn = Connection::open_in_memory().unwrap(); create_file_history_schema(&conn); conn.execute( "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (1, 1, 'src/a.rs', 'modified')", [], ).unwrap(); conn.execute( "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (2, 1, 'src/a.rs', 'modified')", [], ).unwrap(); conn.execute( "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (3, 1, 'src/b.rs', 'added')", [], ).unwrap(); let paths = fetch_file_history_paths(&conn, None).unwrap(); assert_eq!(paths, vec!["src/a.rs", "src/b.rs"]); } }