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>
342 lines
10 KiB
Rust
342 lines
10 KiB
Rust
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<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
pub updated_at_iso: String,
|
|
pub web_url: Option<String>,
|
|
pub issues: Vec<TraceIssue>,
|
|
pub discussions: Vec<TraceDiscussion>,
|
|
}
|
|
|
|
/// Result of a trace query.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct TraceResult {
|
|
pub path: String,
|
|
pub resolved_paths: Vec<String>,
|
|
pub renames_followed: bool,
|
|
pub trace_chains: Vec<TraceChain>,
|
|
pub total_chains: usize,
|
|
/// Diagnostic hints explaining why results may be empty.
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub hints: Vec<String>,
|
|
}
|
|
|
|
/// Run the trace query: file -> MR -> issue chain.
|
|
pub fn run_trace(
|
|
conn: &rusqlite::Connection,
|
|
project_id: Option<i64>,
|
|
path: &str,
|
|
follow_renames: bool,
|
|
include_discussions: bool,
|
|
limit: usize,
|
|
) -> Result<TraceResult> {
|
|
// 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<String> = (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<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();
|
|
|
|
struct MrRow {
|
|
id: i64,
|
|
iid: i64,
|
|
title: String,
|
|
state: String,
|
|
author: String,
|
|
change_type: String,
|
|
merged_at: Option<i64>,
|
|
updated_at: i64,
|
|
web_url: Option<String>,
|
|
}
|
|
|
|
let mr_rows: Vec<MrRow> = 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::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
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<Vec<TraceIssue>> {
|
|
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<TraceIssue> = 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::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
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<Vec<TraceDiscussion>> {
|
|
let placeholders: Vec<String> = (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<Box<dyn rusqlite::types::ToSql>> = 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<TraceDiscussion> = 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::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(discussions)
|
|
}
|
|
|
|
/// Build diagnostic hints explaining why a trace query returned no results.
|
|
fn build_trace_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)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "trace_tests.rs"]
|
|
mod tests;
|