feat(cli): implement 'lore file-history' command (bd-z94)
Adds file-history command showing which MRs touched a file, with:
- Rename chain resolution via BFS (resolve_rename_chain from bd-1yx)
- DiffNote discussion snippets with --discussions flag
- --merged filter, --no-follow-renames, -n limit
- Human output with styled MR list and rename chain display
- Robot JSON output with {ok, data, meta} envelope
- Autocorrect registry and robot-docs manifest entry
- Fixes pre-existing --no-status missing from sync autocorrect registry
This commit is contained in:
334
src/cli/commands/file_history.rs
Normal file
334
src/cli/commands/file_history.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
use serde::Serialize;
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
// 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)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
|
||||
let total_mrs = merge_requests.len();
|
||||
|
||||
// Optionally fetch DiffNote discussions on this file
|
||||
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 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 \
|
||||
LIMIT 50"
|
||||
);
|
||||
|
||||
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),
|
||||
})
|
||||
})?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.collect();
|
||||
|
||||
Ok(discussions)
|
||||
}
|
||||
|
||||
// ── 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.")
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("Hint: Run 'lore sync' to fetch MR file changes.")
|
||||
);
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod count;
|
||||
pub mod doctor;
|
||||
pub mod drift;
|
||||
pub mod embed;
|
||||
pub mod file_history;
|
||||
pub mod generate_docs;
|
||||
pub mod ingest;
|
||||
pub mod init;
|
||||
@@ -23,6 +24,7 @@ pub use count::{
|
||||
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
|
||||
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
|
||||
pub use embed::{print_embed, print_embed_json, run_embed};
|
||||
pub use file_history::{print_file_history, print_file_history_json, run_file_history};
|
||||
pub use generate_docs::{print_generate_docs, print_generate_docs_json, run_generate_docs};
|
||||
pub use ingest::{
|
||||
DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json,
|
||||
|
||||
Reference in New Issue
Block a user