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:
191
src/main.rs
191
src/main.rs
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user