RobotMeta previously required direct struct literal construction with only elapsed_ms. This made it impossible to add optional fields without updating every call site to include them. Introduce two constructors: - RobotMeta::new(elapsed_ms) — standard meta with timing only - RobotMeta::with_base_url(elapsed_ms, base_url) — meta enriched with the GitLab instance URL, enabling consumers to construct entity links without needing config access The gitlab_base_url field uses #[serde(skip_serializing_if = "Option::is_none")] so existing JSON envelopes are byte-identical — no breaking change for any robot mode consumer. All 22 call sites across handlers, count, cron, drift, embed, generate_docs, ingest, list (mrs/notes), related, show, stats, sync_status, and who are updated from struct literals to RobotMeta::new(). Three tests verify the new constructors and trailing-slash normalization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
430 lines
15 KiB
Rust
430 lines
15 KiB
Rust
mod active;
|
|
mod expert;
|
|
mod overlap;
|
|
mod reviews;
|
|
pub mod types;
|
|
mod workload;
|
|
|
|
pub use types::*;
|
|
|
|
// Re-export submodule functions for tests (tests use `use super::*`).
|
|
#[cfg(test)]
|
|
use active::query_active;
|
|
#[cfg(test)]
|
|
use expert::{build_expert_sql_v2, half_life_decay, query_expert};
|
|
#[cfg(test)]
|
|
use overlap::{format_overlap_role, query_overlap};
|
|
#[cfg(test)]
|
|
use reviews::{normalize_review_prefix, query_reviews};
|
|
#[cfg(test)]
|
|
use workload::query_workload;
|
|
|
|
use rusqlite::Connection;
|
|
use serde::Serialize;
|
|
|
|
use crate::Config;
|
|
use crate::cli::WhoArgs;
|
|
use crate::cli::render::Theme;
|
|
use crate::cli::robot::RobotMeta;
|
|
use crate::core::db::create_connection;
|
|
use crate::core::error::{LoreError, Result};
|
|
use crate::core::path_resolver::normalize_repo_path;
|
|
use crate::core::paths::get_db_path;
|
|
use crate::core::project::resolve_project;
|
|
use crate::core::time::{ms_to_iso, now_ms, parse_since, parse_since_from};
|
|
|
|
#[cfg(test)]
|
|
use crate::core::config::ScoringConfig;
|
|
#[cfg(test)]
|
|
use crate::core::path_resolver::{SuffixResult, build_path_query, escape_like, suffix_probe};
|
|
|
|
// ─── Mode Discrimination ────────────────────────────────────────────────────
|
|
|
|
/// Determines which query mode to run based on args.
|
|
/// Path variants own their strings because path normalization produces new `String`s.
|
|
/// Username variants borrow from args since no normalization is needed.
|
|
enum WhoMode<'a> {
|
|
/// lore who <file-path> OR lore who --path <path>
|
|
Expert { path: String },
|
|
/// lore who <username>
|
|
Workload { username: &'a str },
|
|
/// lore who <username> --reviews
|
|
Reviews { username: &'a str },
|
|
/// lore who --active
|
|
Active,
|
|
/// lore who --overlap <path>
|
|
Overlap { path: String },
|
|
}
|
|
|
|
fn resolve_mode<'a>(args: &'a WhoArgs) -> Result<WhoMode<'a>> {
|
|
// Explicit --path flag always wins (handles root files like README.md,
|
|
// LICENSE, Makefile -- anything without a / that can't be auto-detected)
|
|
if let Some(p) = &args.path {
|
|
return Ok(WhoMode::Expert {
|
|
path: normalize_repo_path(p),
|
|
});
|
|
}
|
|
if args.active {
|
|
return Ok(WhoMode::Active);
|
|
}
|
|
if let Some(path) = &args.overlap {
|
|
return Ok(WhoMode::Overlap {
|
|
path: normalize_repo_path(path),
|
|
});
|
|
}
|
|
if let Some(target) = &args.target {
|
|
let clean = target.strip_prefix('@').unwrap_or(target);
|
|
if args.reviews {
|
|
return Ok(WhoMode::Reviews { username: clean });
|
|
}
|
|
// Disambiguation: if target contains '/', it's a file path.
|
|
// GitLab usernames never contain '/'.
|
|
// Root files (no '/') require --path.
|
|
if clean.contains('/') {
|
|
return Ok(WhoMode::Expert {
|
|
path: normalize_repo_path(clean),
|
|
});
|
|
}
|
|
return Ok(WhoMode::Workload { username: clean });
|
|
}
|
|
Err(LoreError::Other(
|
|
"Provide a username, file path, --active, or --overlap <path>.\n\n\
|
|
Examples:\n \
|
|
lore who src/features/auth/\n \
|
|
lore who @username\n \
|
|
lore who --active\n \
|
|
lore who --overlap src/features/\n \
|
|
lore who --path README.md\n \
|
|
lore who --path Makefile"
|
|
.to_string(),
|
|
))
|
|
}
|
|
|
|
fn validate_mode_flags(mode: &WhoMode<'_>, args: &WhoArgs) -> Result<()> {
|
|
if args.detail && !matches!(mode, WhoMode::Expert { .. }) {
|
|
return Err(LoreError::Other(
|
|
"--detail is only supported in expert mode (`lore who --path <path>` or `lore who <path/with/slash>`).".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ─── Entry Point ─────────────────────────────────────────────────────────────
|
|
|
|
/// Main entry point. Resolves mode + resolved inputs once, then dispatches.
|
|
pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun> {
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
let conn = create_connection(&db_path)?;
|
|
|
|
let project_id = args
|
|
.project
|
|
.as_deref()
|
|
.map(|p| resolve_project(&conn, p))
|
|
.transpose()?;
|
|
|
|
let project_path = project_id
|
|
.map(|id| lookup_project_path(&conn, id))
|
|
.transpose()?;
|
|
|
|
let mode = resolve_mode(args)?;
|
|
validate_mode_flags(&mode, args)?;
|
|
|
|
// since_mode semantics:
|
|
// - expert/reviews/active/overlap: default window applies if args.since is None -> "default"
|
|
// - workload: no default window; args.since None => "none"
|
|
let since_mode_for_defaulted = if args.since.is_some() {
|
|
"explicit"
|
|
} else {
|
|
"default"
|
|
};
|
|
let since_mode_for_workload = if args.since.is_some() {
|
|
"explicit"
|
|
} else {
|
|
"none"
|
|
};
|
|
|
|
let limit = args.limit.map_or(usize::MAX, usize::from);
|
|
|
|
match mode {
|
|
WhoMode::Expert { path } => {
|
|
// Compute as_of first so --since durations are relative to it.
|
|
let as_of_ms = match &args.as_of {
|
|
Some(v) => parse_since(v).ok_or_else(|| {
|
|
LoreError::Other(format!(
|
|
"Invalid --as-of value: '{v}'. Use a duration (30d, 6m) or date (2024-01-15)"
|
|
))
|
|
})?,
|
|
None => now_ms(),
|
|
};
|
|
let since_ms = if args.all_history {
|
|
0
|
|
} else {
|
|
resolve_since_from(args.since.as_deref(), "24m", as_of_ms)?
|
|
};
|
|
let result = expert::query_expert(
|
|
&conn,
|
|
&path,
|
|
project_id,
|
|
since_ms,
|
|
as_of_ms,
|
|
limit,
|
|
&config.scoring,
|
|
args.detail,
|
|
args.explain_score,
|
|
args.include_bots,
|
|
)?;
|
|
Ok(WhoRun {
|
|
resolved_input: WhoResolvedInput {
|
|
mode: "expert".to_string(),
|
|
project_id,
|
|
project_path,
|
|
since_ms: Some(since_ms),
|
|
since_iso: Some(ms_to_iso(since_ms)),
|
|
since_mode: since_mode_for_defaulted.to_string(),
|
|
limit: args.limit,
|
|
},
|
|
result: WhoResult::Expert(result),
|
|
})
|
|
}
|
|
WhoMode::Workload { username } => {
|
|
let since_ms = args
|
|
.since
|
|
.as_deref()
|
|
.map(resolve_since_required)
|
|
.transpose()?;
|
|
|
|
let result = workload::query_workload(
|
|
&conn,
|
|
username,
|
|
project_id,
|
|
since_ms,
|
|
limit,
|
|
args.include_closed,
|
|
)?;
|
|
Ok(WhoRun {
|
|
resolved_input: WhoResolvedInput {
|
|
mode: "workload".to_string(),
|
|
project_id,
|
|
project_path,
|
|
since_ms,
|
|
since_iso: since_ms.map(ms_to_iso),
|
|
since_mode: since_mode_for_workload.to_string(),
|
|
limit: args.limit,
|
|
},
|
|
result: WhoResult::Workload(result),
|
|
})
|
|
}
|
|
WhoMode::Reviews { username } => {
|
|
let since_ms = resolve_since(args.since.as_deref(), "6m")?;
|
|
let result = reviews::query_reviews(&conn, username, project_id, since_ms)?;
|
|
Ok(WhoRun {
|
|
resolved_input: WhoResolvedInput {
|
|
mode: "reviews".to_string(),
|
|
project_id,
|
|
project_path,
|
|
since_ms: Some(since_ms),
|
|
since_iso: Some(ms_to_iso(since_ms)),
|
|
since_mode: since_mode_for_defaulted.to_string(),
|
|
limit: args.limit,
|
|
},
|
|
result: WhoResult::Reviews(result),
|
|
})
|
|
}
|
|
WhoMode::Active => {
|
|
let since_ms = resolve_since(args.since.as_deref(), "7d")?;
|
|
|
|
let result =
|
|
active::query_active(&conn, project_id, since_ms, limit, args.include_closed)?;
|
|
Ok(WhoRun {
|
|
resolved_input: WhoResolvedInput {
|
|
mode: "active".to_string(),
|
|
project_id,
|
|
project_path,
|
|
since_ms: Some(since_ms),
|
|
since_iso: Some(ms_to_iso(since_ms)),
|
|
since_mode: since_mode_for_defaulted.to_string(),
|
|
limit: args.limit,
|
|
},
|
|
result: WhoResult::Active(result),
|
|
})
|
|
}
|
|
WhoMode::Overlap { path } => {
|
|
let since_ms = resolve_since(args.since.as_deref(), "30d")?;
|
|
|
|
let result = overlap::query_overlap(&conn, &path, project_id, since_ms, limit)?;
|
|
Ok(WhoRun {
|
|
resolved_input: WhoResolvedInput {
|
|
mode: "overlap".to_string(),
|
|
project_id,
|
|
project_path,
|
|
since_ms: Some(since_ms),
|
|
since_iso: Some(ms_to_iso(since_ms)),
|
|
since_mode: since_mode_for_defaulted.to_string(),
|
|
limit: args.limit,
|
|
},
|
|
result: WhoResult::Overlap(result),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/// Look up the project path for a resolved project ID.
|
|
fn lookup_project_path(conn: &Connection, project_id: i64) -> Result<String> {
|
|
conn.query_row(
|
|
"SELECT path_with_namespace FROM projects WHERE id = ?1",
|
|
rusqlite::params![project_id],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|e| LoreError::Other(format!("Failed to look up project path: {e}")))
|
|
}
|
|
|
|
/// Parse --since with a default fallback.
|
|
fn resolve_since(input: Option<&str>, default: &str) -> Result<i64> {
|
|
let s = input.unwrap_or(default);
|
|
parse_since(s).ok_or_else(|| {
|
|
LoreError::Other(format!(
|
|
"Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
|
|
))
|
|
})
|
|
}
|
|
|
|
/// Parse --since with a default fallback, relative to a reference timestamp.
|
|
/// Durations (7d, 2w, 6m) are computed from `reference_ms` instead of now.
|
|
fn resolve_since_from(input: Option<&str>, default: &str, reference_ms: i64) -> Result<i64> {
|
|
let s = input.unwrap_or(default);
|
|
parse_since_from(s, reference_ms).ok_or_else(|| {
|
|
LoreError::Other(format!(
|
|
"Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
|
|
))
|
|
})
|
|
}
|
|
|
|
/// Parse --since without a default (returns error if invalid).
|
|
fn resolve_since_required(input: &str) -> Result<i64> {
|
|
parse_since(input).ok_or_else(|| {
|
|
LoreError::Other(format!(
|
|
"Invalid --since value: '{input}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)"
|
|
))
|
|
})
|
|
}
|
|
|
|
// ─── Human Output ────────────────────────────────────────────────────────────
|
|
|
|
pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {
|
|
match result {
|
|
WhoResult::Expert(r) => expert::print_expert_human(r, project_path),
|
|
WhoResult::Workload(r) => workload::print_workload_human(r),
|
|
WhoResult::Reviews(r) => reviews::print_reviews_human(r),
|
|
WhoResult::Active(r) => active::print_active_human(r, project_path),
|
|
WhoResult::Overlap(r) => overlap::print_overlap_human(r, project_path),
|
|
}
|
|
}
|
|
|
|
/// Print a dim hint when results aggregate across all projects.
|
|
pub(super) fn print_scope_hint(project_path: Option<&str>) {
|
|
if project_path.is_none() {
|
|
println!(
|
|
" {}",
|
|
Theme::dim().render("(aggregated across all projects; use -p to scope)")
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Robot JSON Output ───────────────────────────────────────────────────────
|
|
|
|
pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) {
|
|
let (mode, data) = match &run.result {
|
|
WhoResult::Expert(r) => ("expert", expert::expert_to_json(r)),
|
|
WhoResult::Workload(r) => ("workload", workload::workload_to_json(r)),
|
|
WhoResult::Reviews(r) => ("reviews", reviews::reviews_to_json(r)),
|
|
WhoResult::Active(r) => ("active", active::active_to_json(r)),
|
|
WhoResult::Overlap(r) => ("overlap", overlap::overlap_to_json(r)),
|
|
};
|
|
|
|
// Raw CLI args -- what the user typed
|
|
let input = serde_json::json!({
|
|
"target": args.target,
|
|
"path": args.path,
|
|
"project": args.project,
|
|
"since": args.since,
|
|
"limit": args.limit,
|
|
"detail": args.detail,
|
|
"as_of": args.as_of,
|
|
"explain_score": args.explain_score,
|
|
"include_bots": args.include_bots,
|
|
"all_history": args.all_history,
|
|
});
|
|
|
|
// Resolved/computed values -- what actually ran
|
|
let resolved_input = serde_json::json!({
|
|
"mode": run.resolved_input.mode,
|
|
"project_id": run.resolved_input.project_id,
|
|
"project_path": run.resolved_input.project_path,
|
|
"since_ms": run.resolved_input.since_ms,
|
|
"since_iso": run.resolved_input.since_iso,
|
|
"since_mode": run.resolved_input.since_mode,
|
|
"limit": run.resolved_input.limit,
|
|
});
|
|
|
|
let output = WhoJsonEnvelope {
|
|
ok: true,
|
|
data: WhoJsonData {
|
|
mode: mode.to_string(),
|
|
input,
|
|
resolved_input,
|
|
result: data,
|
|
},
|
|
meta: RobotMeta::new(elapsed_ms),
|
|
};
|
|
|
|
let mut value = serde_json::to_value(&output).unwrap_or_else(|e| {
|
|
serde_json::json!({"ok":false,"error":{"code":"INTERNAL_ERROR","message":format!("JSON serialization failed: {e}")}})
|
|
});
|
|
|
|
if let Some(f) = &args.fields {
|
|
let preset_key = format!("who_{mode}");
|
|
let expanded = crate::cli::robot::expand_fields_preset(f, &preset_key);
|
|
// Each who mode uses a different array key; try all possible keys
|
|
for key in &[
|
|
"experts",
|
|
"assigned_issues",
|
|
"authored_mrs",
|
|
"review_mrs",
|
|
"categories",
|
|
"discussions",
|
|
"users",
|
|
] {
|
|
crate::cli::robot::filter_fields(&mut value, key, &expanded);
|
|
}
|
|
}
|
|
|
|
match serde_json::to_string(&value) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct WhoJsonEnvelope {
|
|
ok: bool,
|
|
data: WhoJsonData,
|
|
meta: RobotMeta,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct WhoJsonData {
|
|
mode: String,
|
|
input: serde_json::Value,
|
|
resolved_input: serde_json::Value,
|
|
#[serde(flatten)]
|
|
result: serde_json::Value,
|
|
}
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
#[path = "../who_tests.rs"]
|
|
mod tests;
|