Add structured tracing spans to trace and file-history pipelines so debug logging (-vv) shows path resolution counts, MR match counts, and discussion counts at each stage. This makes empty-result debugging straightforward. Add a hints field to TraceResult and FileHistoryResult that carries machine-readable diagnostic strings explaining *why* results may be empty (e.g., "Run 'lore sync' to fetch MR file changes"). The CLI renders these as info lines; robot mode includes them in JSON when non-empty. Also: fix filter_map(Result::ok) → collect::<Result> in trace.rs (same pattern fixed in prior commit for file_history/path_resolver), and switch conn.prepare → conn.prepare_cached for the MR query. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
414 lines
13 KiB
Rust
414 lines
13 KiB
Rust
use serde::Serialize;
|
|
|
|
use tracing::info;
|
|
|
|
use crate::Config;
|
|
use crate::cli::render::{self, Icons, Theme};
|
|
use crate::core::db::create_connection;
|
|
use crate::core::error::Result;
|
|
use crate::core::file_history::resolve_rename_chain;
|
|
use crate::core::paths::get_db_path;
|
|
use crate::core::project::resolve_project;
|
|
use crate::core::time::ms_to_iso;
|
|
|
|
/// Maximum rename chain BFS depth.
|
|
const MAX_RENAME_HOPS: usize = 10;
|
|
|
|
/// A single MR that touched the file.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FileHistoryMr {
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub state: String,
|
|
pub author_username: String,
|
|
pub change_type: String,
|
|
pub merged_at_iso: Option<String>,
|
|
pub updated_at_iso: String,
|
|
pub merge_commit_sha: Option<String>,
|
|
pub web_url: Option<String>,
|
|
}
|
|
|
|
/// A DiffNote discussion snippet on the file.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FileDiscussion {
|
|
pub discussion_id: String,
|
|
pub author_username: String,
|
|
pub body_snippet: String,
|
|
pub path: String,
|
|
pub created_at_iso: String,
|
|
}
|
|
|
|
/// Full result of a file-history query.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FileHistoryResult {
|
|
pub path: String,
|
|
pub rename_chain: Vec<String>,
|
|
pub renames_followed: bool,
|
|
pub merge_requests: Vec<FileHistoryMr>,
|
|
pub discussions: Vec<FileDiscussion>,
|
|
pub total_mrs: usize,
|
|
pub paths_searched: usize,
|
|
/// Diagnostic hints explaining why results may be empty.
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub hints: Vec<String>,
|
|
}
|
|
|
|
/// Run the file-history query.
|
|
pub fn run_file_history(
|
|
config: &Config,
|
|
path: &str,
|
|
project: Option<&str>,
|
|
no_follow_renames: bool,
|
|
merged_only: bool,
|
|
include_discussions: bool,
|
|
limit: usize,
|
|
) -> Result<FileHistoryResult> {
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
let conn = create_connection(&db_path)?;
|
|
|
|
let project_id = project.map(|p| resolve_project(&conn, p)).transpose()?;
|
|
|
|
// Resolve rename chain unless disabled
|
|
let (all_paths, renames_followed) = if no_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 a project scope, can't resolve renames (need project_id)
|
|
(vec![path.to_string()], false)
|
|
};
|
|
|
|
let paths_searched = all_paths.len();
|
|
|
|
info!(
|
|
paths = paths_searched,
|
|
renames_followed, "file-history: resolved {} path(s) for '{}'", paths_searched, path
|
|
);
|
|
|
|
// Build placeholders for IN clause
|
|
let placeholders: Vec<String> = (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 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, 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} {merged_filter} \
|
|
ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC \
|
|
LIMIT ?{}",
|
|
all_paths.len() + 2
|
|
);
|
|
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
|
|
// Bind parameters: ?1 = project_id (or 0 placeholder), ?2..?N+1 = paths, ?N+2 = limit
|
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = 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();
|
|
|
|
let merge_requests: Vec<FileHistoryMr> = stmt
|
|
.query_map(param_refs.as_slice(), |row| {
|
|
let merged_at: Option<i64> = row.get(5)?;
|
|
let updated_at: i64 = row.get(6)?;
|
|
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_iso: merged_at.map(ms_to_iso),
|
|
updated_at_iso: ms_to_iso(updated_at),
|
|
merge_commit_sha: row.get(7)?,
|
|
web_url: row.get(8)?,
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
let total_mrs = merge_requests.len();
|
|
|
|
info!(
|
|
mr_count = total_mrs,
|
|
"file-history: found {} MR(s) touching '{}'", total_mrs, path
|
|
);
|
|
|
|
// Optionally fetch DiffNote discussions on this file
|
|
let discussions = if include_discussions && !merge_requests.is_empty() {
|
|
let discs = fetch_file_discussions(&conn, &all_paths, project_id)?;
|
|
info!(
|
|
discussion_count = discs.len(),
|
|
"file-history: found {} discussion(s)",
|
|
discs.len()
|
|
);
|
|
discs
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Build diagnostic hints when no results found
|
|
let hints = if total_mrs == 0 {
|
|
build_file_history_hints(&conn, project_id, &all_paths)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(FileHistoryResult {
|
|
path: path.to_string(),
|
|
rename_chain: all_paths,
|
|
renames_followed,
|
|
merge_requests,
|
|
discussions,
|
|
total_mrs,
|
|
paths_searched,
|
|
hints,
|
|
})
|
|
}
|
|
|
|
/// Fetch DiffNote discussions that reference the given file paths.
|
|
fn fetch_file_discussions(
|
|
conn: &rusqlite::Connection,
|
|
paths: &[String],
|
|
project_id: Option<i64>,
|
|
) -> Result<Vec<FileDiscussion>> {
|
|
let placeholders: Vec<String> = (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"
|
|
);
|
|
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
|
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = 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<FileDiscussion> = 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
|
|
};
|
|
let created_at: i64 = row.get(4)?;
|
|
Ok(FileDiscussion {
|
|
discussion_id: row.get(0)?,
|
|
author_username: row.get(1)?,
|
|
body_snippet: snippet,
|
|
path: row.get(3)?,
|
|
created_at_iso: ms_to_iso(created_at),
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(discussions)
|
|
}
|
|
|
|
/// Build diagnostic hints explaining why a file-history query returned no results.
|
|
fn build_file_history_hints(
|
|
conn: &rusqlite::Connection,
|
|
project_id: Option<i64>,
|
|
paths: &[String],
|
|
) -> Result<Vec<String>> {
|
|
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::<Vec<_>>()
|
|
.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)
|
|
}
|
|
|
|
// ── Human output ────────────────────────────────────────────────────────────
|
|
|
|
pub fn print_file_history(result: &FileHistoryResult) {
|
|
// Header
|
|
let paths_info = if result.paths_searched > 1 {
|
|
format!(
|
|
" (via {} paths, {} MRs)",
|
|
result.paths_searched, result.total_mrs
|
|
)
|
|
} else {
|
|
format!(" ({} MRs)", result.total_mrs)
|
|
};
|
|
|
|
println!();
|
|
println!(
|
|
"{}",
|
|
Theme::bold().render(&format!("File History: {}{}", result.path, paths_info))
|
|
);
|
|
|
|
// Rename chain
|
|
if result.renames_followed && result.rename_chain.len() > 1 {
|
|
let chain_str: Vec<&str> = result.rename_chain.iter().map(String::as_str).collect();
|
|
println!(
|
|
" Rename chain: {}",
|
|
Theme::dim().render(&chain_str.join(" -> "))
|
|
);
|
|
}
|
|
|
|
if result.merge_requests.is_empty() {
|
|
println!(
|
|
"\n {} {}",
|
|
Icons::info(),
|
|
Theme::dim().render("No merge requests found touching this file.")
|
|
);
|
|
if !result.renames_followed && result.rename_chain.len() == 1 {
|
|
println!(
|
|
" {} Searched: {}",
|
|
Icons::info(),
|
|
Theme::dim().render(&result.rename_chain[0])
|
|
);
|
|
}
|
|
for hint in &result.hints {
|
|
println!(" {} {}", Icons::info(), Theme::dim().render(hint));
|
|
}
|
|
println!();
|
|
return;
|
|
}
|
|
|
|
println!();
|
|
|
|
for mr in &result.merge_requests {
|
|
let (icon, state_style) = match mr.state.as_str() {
|
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
|
"closed" => (Icons::mr_closed(), Theme::warning()),
|
|
_ => (Icons::mr_opened(), Theme::dim()),
|
|
};
|
|
|
|
let date = mr
|
|
.merged_at_iso
|
|
.as_deref()
|
|
.or(Some(mr.updated_at_iso.as_str()))
|
|
.unwrap_or("")
|
|
.split('T')
|
|
.next()
|
|
.unwrap_or("");
|
|
|
|
println!(
|
|
" {} {} {} {} @{} {} {}",
|
|
icon,
|
|
Theme::accent().render(&format!("!{}", mr.iid)),
|
|
render::truncate(&mr.title, 50),
|
|
state_style.render(&mr.state),
|
|
mr.author_username,
|
|
date,
|
|
Theme::dim().render(&mr.change_type),
|
|
);
|
|
}
|
|
|
|
// Discussions
|
|
if !result.discussions.is_empty() {
|
|
println!(
|
|
"\n {} File discussions ({}):",
|
|
Icons::note(),
|
|
result.discussions.len()
|
|
);
|
|
for d in &result.discussions {
|
|
let date = d.created_at_iso.split('T').next().unwrap_or("");
|
|
println!(
|
|
" @{} ({}) [{}]: {}",
|
|
d.author_username,
|
|
date,
|
|
Theme::dim().render(&d.path),
|
|
d.body_snippet
|
|
);
|
|
}
|
|
}
|
|
|
|
println!();
|
|
}
|
|
|
|
// ── Robot (JSON) output ─────────────────────────────────────────────────────
|
|
|
|
pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) {
|
|
let output = serde_json::json!({
|
|
"ok": true,
|
|
"data": {
|
|
"path": result.path,
|
|
"rename_chain": if result.renames_followed { Some(&result.rename_chain) } else { None },
|
|
"merge_requests": result.merge_requests,
|
|
"discussions": if result.discussions.is_empty() { None } else { Some(&result.discussions) },
|
|
},
|
|
"meta": {
|
|
"elapsed_ms": elapsed_ms,
|
|
"total_mrs": result.total_mrs,
|
|
"renames_followed": result.renames_followed,
|
|
"paths_searched": result.paths_searched,
|
|
"hints": if result.hints.is_empty() { None } else { Some(&result.hints) },
|
|
}
|
|
});
|
|
|
|
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
|
}
|