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:
@@ -124,6 +124,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--no-docs",
|
||||
"--no-events",
|
||||
"--no-file-changes",
|
||||
"--no-status",
|
||||
"--dry-run",
|
||||
"--no-dry-run",
|
||||
"--timings",
|
||||
@@ -232,6 +233,16 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--default-project",
|
||||
],
|
||||
),
|
||||
(
|
||||
"file-history",
|
||||
&[
|
||||
"--project",
|
||||
"--discussions",
|
||||
"--no-follow-renames",
|
||||
"--merged",
|
||||
"--limit",
|
||||
],
|
||||
),
|
||||
("generate-docs", &["--full", "--project"]),
|
||||
("completions", &[]),
|
||||
("robot-docs", &["--brief"]),
|
||||
@@ -296,6 +307,8 @@ const SUBCOMMAND_ALIASES: &[(&str, &str)] = &[
|
||||
("syncstatus", "status"),
|
||||
("auth_test", "auth"),
|
||||
("authtest", "auth"),
|
||||
("file_history", "file-history"),
|
||||
("filehistory", "file-history"),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
|
||||
@@ -234,6 +234,10 @@ pub enum Commands {
|
||||
/// People intelligence: experts, workload, active discussions, overlap
|
||||
Who(WhoArgs),
|
||||
|
||||
/// Show MRs that touched a file, with linked discussions
|
||||
#[command(name = "file-history")]
|
||||
FileHistory(FileHistoryArgs),
|
||||
|
||||
/// Detect discussion divergence from original intent
|
||||
Drift {
|
||||
/// Entity type (currently only "issues" supported)
|
||||
@@ -966,6 +970,42 @@ pub struct WhoArgs {
|
||||
pub all_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore file-history src/main.rs # MRs that touched this file
|
||||
lore file-history src/auth/ -p group/repo # Scoped to project
|
||||
lore file-history src/foo.rs --discussions # Include DiffNote snippets
|
||||
lore file-history src/bar.rs --no-follow-renames # Skip rename chain")]
|
||||
pub struct FileHistoryArgs {
|
||||
/// File path to trace history for
|
||||
pub path: String,
|
||||
|
||||
/// Scope to a specific project (fuzzy match)
|
||||
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||
pub project: Option<String>,
|
||||
|
||||
/// Include discussion snippets from DiffNotes on this file
|
||||
#[arg(long, help_heading = "Output")]
|
||||
pub discussions: bool,
|
||||
|
||||
/// Disable rename chain resolution
|
||||
#[arg(long = "no-follow-renames", help_heading = "Filters")]
|
||||
pub no_follow_renames: bool,
|
||||
|
||||
/// Only show merged MRs
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub merged: bool,
|
||||
|
||||
/// Maximum results
|
||||
#[arg(
|
||||
short = 'n',
|
||||
long = "limit",
|
||||
default_value = "50",
|
||||
help_heading = "Output"
|
||||
)]
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct CountArgs {
|
||||
/// Entity type to count (issues, mrs, discussions, notes, events)
|
||||
|
||||
72
src/main.rs
72
src/main.rs
@@ -13,23 +13,24 @@ use lore::cli::commands::{
|
||||
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser,
|
||||
open_mr_in_browser, print_count, print_count_json, print_doctor_results, print_drift_human,
|
||||
print_drift_json, print_dry_run_preview, print_dry_run_preview_json, print_embed,
|
||||
print_embed_json, print_event_count, print_event_count_json, print_generate_docs,
|
||||
print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues,
|
||||
print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes,
|
||||
print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_search_results,
|
||||
print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr,
|
||||
print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
|
||||
print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta,
|
||||
print_who_human, print_who_json, query_notes, run_auth_test, run_count, run_count_events,
|
||||
run_doctor, run_drift, run_embed, run_generate_docs, run_ingest, run_ingest_dry_run, run_init,
|
||||
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
|
||||
run_sync_status, run_timeline, run_who,
|
||||
print_embed_json, print_event_count, print_event_count_json, print_file_history,
|
||||
print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
|
||||
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
|
||||
print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json,
|
||||
print_list_notes_jsonl, print_search_results, print_search_results_json, print_show_issue,
|
||||
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
|
||||
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
|
||||
print_timeline_json_with_meta, print_who_human, print_who_json, query_notes, run_auth_test,
|
||||
run_count, run_count_events, run_doctor, run_drift, run_embed, run_file_history,
|
||||
run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs,
|
||||
run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline,
|
||||
run_who,
|
||||
};
|
||||
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
|
||||
use lore::cli::robot::{RobotMeta, strip_schemas};
|
||||
use lore::cli::{
|
||||
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
|
||||
NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs, WhoArgs,
|
||||
Cli, Commands, CountArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs, IngestArgs, IssuesArgs,
|
||||
MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs, WhoArgs,
|
||||
};
|
||||
use lore::core::db::{
|
||||
LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations,
|
||||
@@ -195,6 +196,9 @@ async fn main() {
|
||||
handle_timeline(cli.config.as_deref(), args, robot_mode).await
|
||||
}
|
||||
Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),
|
||||
Some(Commands::FileHistory(args)) => {
|
||||
handle_file_history(cli.config.as_deref(), args, robot_mode)
|
||||
}
|
||||
Some(Commands::Drift {
|
||||
entity_type,
|
||||
iid,
|
||||
@@ -720,6 +724,7 @@ fn suggest_similar_command(invalid: &str) -> String {
|
||||
("notes", "notes"),
|
||||
("note", "notes"),
|
||||
("drift", "drift"),
|
||||
("file-history", "file-history"),
|
||||
];
|
||||
|
||||
let invalid_lower = invalid.to_lowercase();
|
||||
@@ -1852,6 +1857,37 @@ async fn handle_stats(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_file_history(
|
||||
config_override: Option<&str>,
|
||||
args: FileHistoryArgs,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
|
||||
let project = config
|
||||
.effective_project(args.project.as_deref())
|
||||
.map(String::from);
|
||||
|
||||
let result = run_file_history(
|
||||
&config,
|
||||
&args.path,
|
||||
project.as_deref(),
|
||||
args.no_follow_renames,
|
||||
args.merged,
|
||||
args.discussions,
|
||||
args.limit,
|
||||
)?;
|
||||
|
||||
if robot_mode {
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
print_file_history_json(&result, elapsed_ms);
|
||||
} else {
|
||||
print_file_history(&result);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_timeline(
|
||||
config_override: Option<&str>,
|
||||
args: TimelineArgs,
|
||||
@@ -2520,6 +2556,16 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"active_minimal": ["entity_type", "iid", "title", "participants"]
|
||||
}
|
||||
},
|
||||
"file-history": {
|
||||
"description": "Show MRs that touched a file, with rename chain resolution and optional DiffNote discussions",
|
||||
"flags": ["<path>", "-p/--project <path>", "--discussions", "--no-follow-renames", "--merged", "-n/--limit <N>"],
|
||||
"example": "lore --robot file-history src/main.rs -p group/repo",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"path": "string", "rename_chain": "[string]?", "merge_requests": "[{iid:int, title:string, state:string, author_username:string, change_type:string, merged_at_iso:string?, updated_at_iso:string, merge_commit_sha:string?, web_url:string?}]", "discussions": "[{discussion_id:string, author_username:string, body_snippet:string, path:string, created_at_iso:string}]?"},
|
||||
"meta": {"elapsed_ms": "int", "total_mrs": "int", "renames_followed": "bool", "paths_searched": "int"}
|
||||
}
|
||||
},
|
||||
"drift": {
|
||||
"description": "Detect discussion divergence from original issue intent",
|
||||
"flags": ["<entity_type: issues>", "<IID>", "--threshold <0.0-1.0>", "-p/--project <path>"],
|
||||
|
||||
Reference in New Issue
Block a user