feat(who): expand expert + overlap queries with mr_file_changes and mr_reviewers

Chain: bd-jec (config flag) -> bd-2yo (fetch MR diffs) -> bd-3qn6 (rewrite who queries)

- Add fetch_mr_file_changes config option and --no-file-changes CLI flag
- Add GitLab MR diffs API fetch pipeline with watermark-based sync
- Create migration 020 for diffs_synced_for_updated_at watermark column
- Rewrite query_expert() and query_overlap() to use 4-signal UNION ALL:
  DiffNote reviewers, DiffNote MR authors, file-change authors, file-change reviewers
- Deduplicate across signal types via COUNT(DISTINCT CASE WHEN ... THEN mr_id END)
- Add insert_file_change test helper, 8 new who tests, all 397 tests pass
- Also includes: list performance migration 019, autocorrect module, README updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-08 13:35:14 -05:00
parent 435a208c93
commit 95b7183add
19 changed files with 2139 additions and 291 deletions

View File

@@ -8,6 +8,7 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use lore::Config;
use lore::cli::autocorrect::{self, CorrectionResult};
use lore::cli::commands::{
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser, open_mr_in_browser,
@@ -49,10 +50,20 @@ async fn main() {
// Phase 1: Early robot mode detection for structured clap errors
let robot_mode_early = Cli::detect_robot_mode_from_env();
let cli = match Cli::try_parse() {
// Phase 1.5: Pre-clap arg correction for agent typo tolerance
let raw_args: Vec<String> = std::env::args().collect();
let correction_result = autocorrect::correct_args(raw_args);
// Emit correction warnings to stderr (before clap parsing, so they appear
// even if clap still fails on something else)
if !correction_result.corrections.is_empty() {
emit_correction_warnings(&correction_result, robot_mode_early);
}
let cli = match Cli::try_parse_from(&correction_result.args) {
Ok(cli) => cli,
Err(e) => {
handle_clap_error(e, robot_mode_early);
handle_clap_error(e, robot_mode_early, &correction_result);
}
};
let robot_mode = cli.is_robot_mode();
@@ -386,9 +397,50 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
std::process::exit(1);
}
/// Emit stderr warnings for any corrections applied during Phase 1.5.
fn emit_correction_warnings(result: &CorrectionResult, robot_mode: bool) {
if robot_mode {
#[derive(Serialize)]
struct CorrectionWarning<'a> {
warning: CorrectionWarningInner<'a>,
}
#[derive(Serialize)]
struct CorrectionWarningInner<'a> {
r#type: &'static str,
corrections: &'a [autocorrect::Correction],
teaching: Vec<String>,
}
let teaching: Vec<String> = result
.corrections
.iter()
.map(autocorrect::format_teaching_note)
.collect();
let warning = CorrectionWarning {
warning: CorrectionWarningInner {
r#type: "ARG_CORRECTED",
corrections: &result.corrections,
teaching,
},
};
if let Ok(json) = serde_json::to_string(&warning) {
eprintln!("{json}");
}
} else {
for c in &result.corrections {
eprintln!(
"{} {}",
style("Auto-corrected:").yellow(),
autocorrect::format_teaching_note(c)
);
}
}
}
/// Phase 1 & 4: Handle clap parsing errors with structured JSON output in robot mode.
/// Also includes fuzzy command matching to suggest similar commands.
fn handle_clap_error(e: clap::Error, robot_mode: bool) -> ! {
/// Also includes fuzzy command matching and flag-level suggestions.
fn handle_clap_error(e: clap::Error, robot_mode: bool, corrections: &CorrectionResult) -> ! {
use clap::error::ErrorKind;
// Always let clap handle --help and --version normally (print and exit 0).
@@ -406,15 +458,58 @@ fn handle_clap_error(e: clap::Error, robot_mode: bool) -> ! {
.unwrap_or("Parse error")
.to_string();
// Phase 4: Try to suggest similar command for unknown commands
let suggestion = if e.kind() == ErrorKind::InvalidSubcommand {
if let Some(invalid_cmd) = extract_invalid_subcommand(&e) {
suggest_similar_command(&invalid_cmd)
} else {
"Run 'lore robot-docs' for valid commands".to_string()
let (suggestion, correction, valid_values) = match e.kind() {
// Phase 4: Suggest similar command for unknown subcommands
ErrorKind::InvalidSubcommand => {
let suggestion = if let Some(invalid_cmd) = extract_invalid_subcommand(&e) {
suggest_similar_command(&invalid_cmd)
} else {
"Run 'lore robot-docs' for valid commands".to_string()
};
(suggestion, None, None)
}
} else {
"Run 'lore robot-docs' for valid commands".to_string()
// Flag-level fuzzy matching for unknown flags
ErrorKind::UnknownArgument => {
let invalid_flag = extract_invalid_flag(&e);
let similar = invalid_flag
.as_deref()
.and_then(|flag| autocorrect::suggest_similar_flag(flag, &corrections.args));
let suggestion = if let Some(ref s) = similar {
format!("Did you mean '{s}'? Run 'lore robot-docs' for all flags")
} else {
"Run 'lore robot-docs' for valid flags".to_string()
};
(suggestion, similar, None)
}
// Value-level suggestions for invalid enum values
ErrorKind::InvalidValue => {
let (flag, valid_vals) = extract_invalid_value_context(&e);
let suggestion = if let Some(vals) = &valid_vals {
format!(
"Valid values: {}. Run 'lore robot-docs' for details",
vals.join(", ")
)
} else if let Some(ref f) = flag {
if let Some(vals) = autocorrect::valid_values_for_flag(f) {
format!("Valid values for {f}: {}", vals.join(", "))
} else {
"Run 'lore robot-docs' for valid values".to_string()
}
} else {
"Run 'lore robot-docs' for valid values".to_string()
};
let vals_vec = valid_vals.or_else(|| {
flag.as_deref()
.and_then(autocorrect::valid_values_for_flag)
.map(|v| v.iter().map(|s| (*s).to_string()).collect())
});
(suggestion, None, vals_vec)
}
_ => (
"Run 'lore robot-docs' for valid commands".to_string(),
None,
None,
),
};
let output = RobotErrorWithSuggestion {
@@ -422,6 +517,8 @@ fn handle_clap_error(e: clap::Error, robot_mode: bool) -> ! {
code: error_code.to_string(),
message,
suggestion,
correction,
valid_values,
},
};
eprintln!(
@@ -467,6 +564,61 @@ fn extract_invalid_subcommand(e: &clap::Error) -> Option<String> {
None
}
/// Extract the invalid flag from a clap UnknownArgument error.
/// Format is typically: "error: unexpected argument '--xyzzy' found"
fn extract_invalid_flag(e: &clap::Error) -> Option<String> {
let msg = e.to_string();
if let Some(start) = msg.find('\'')
&& let Some(end) = msg[start + 1..].find('\'')
{
let value = &msg[start + 1..start + 1 + end];
if value.starts_with('-') {
return Some(value.to_string());
}
}
None
}
/// Extract flag name and valid values from a clap InvalidValue error.
/// Returns (flag_name, valid_values_if_listed_in_error).
fn extract_invalid_value_context(e: &clap::Error) -> (Option<String>, Option<Vec<String>>) {
let msg = e.to_string();
// Try to find the flag name from "[possible values: ...]" pattern or from the arg info
// Clap format: "error: invalid value 'opend' for '--state <STATE>'"
let flag = if let Some(for_pos) = msg.find("for '") {
let after_for = &msg[for_pos + 5..];
if let Some(end) = after_for.find('\'') {
let raw = &after_for[..end];
// Strip angle-bracket value placeholder: "--state <STATE>" -> "--state"
Some(raw.split_whitespace().next().unwrap_or(raw).to_string())
} else {
None
}
} else {
None
};
// Try to extract possible values from the error message
// Clap format: "[possible values: opened, closed, merged, locked, all]"
let valid_values = if let Some(pv_pos) = msg.find("[possible values: ") {
let after_pv = &msg[pv_pos + 18..];
after_pv.find(']').map(|end| {
after_pv[..end]
.split(", ")
.map(|s| s.trim().to_string())
.collect()
})
} else {
// Fall back to our static registry
flag.as_deref()
.and_then(autocorrect::valid_values_for_flag)
.map(|v| v.iter().map(|s| (*s).to_string()).collect())
};
(flag, valid_values)
}
/// Phase 4: Suggest similar command using fuzzy matching
fn suggest_similar_command(invalid: &str) -> String {
const VALID_COMMANDS: &[&str] = &[
@@ -1009,6 +1161,8 @@ async fn handle_init(
code: "MISSING_FLAGS".to_string(),
message: format!("Robot mode requires flags: {}", missing.join(", ")),
suggestion: "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project".to_string(),
correction: None,
valid_values: None,
},
};
eprintln!("{}", serde_json::to_string(&output)?);
@@ -1347,6 +1501,8 @@ fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
code: "NOT_IMPLEMENTED".to_string(),
message: "The 'backup' command is not yet implemented.".to_string(),
suggestion: "Use manual database backup: cp ~/.local/share/lore/lore.db ~/.local/share/lore/lore.db.bak".to_string(),
correction: None,
valid_values: None,
},
};
eprintln!("{}", serde_json::to_string(&output)?);
@@ -1367,6 +1523,8 @@ fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
message: "The 'reset' command is not yet implemented.".to_string(),
suggestion: "Manually delete the database: rm ~/.local/share/lore/lore.db"
.to_string(),
correction: None,
valid_values: None,
},
};
eprintln!("{}", serde_json::to_string(&output)?);
@@ -1403,6 +1561,10 @@ struct RobotErrorSuggestionData {
code: String,
message: String,
suggestion: String,
#[serde(skip_serializing_if = "Option::is_none")]
correction: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
valid_values: Option<Vec<String>>,
}
async fn handle_migrate(
@@ -1420,6 +1582,8 @@ async fn handle_migrate(
code: "DB_ERROR".to_string(),
message: format!("Database not found at {}", db_path.display()),
suggestion: "Run 'lore init' first".to_string(),
correction: None,
valid_values: None,
},
};
eprintln!("{}", serde_json::to_string(&output)?);
@@ -1625,6 +1789,9 @@ async fn handle_sync_cmd(
if args.no_events {
config.sync.fetch_resource_events = false;
}
if args.no_file_changes {
config.sync.fetch_mr_file_changes = false;
}
let options = SyncOptions {
full: args.full && !args.no_full,
force: args.force && !args.no_force,