feat(who): configurable scoring weights, MR refs, detail mode, and suffix path resolution

Expert mode now surfaces the specific MR references (project/path!iid) that
contributed to each expert's score, capped at 50 per user. A new --detail flag
adds per-MR breakdowns showing role (Author/Reviewer/both), note count, and
last activity timestamp.

Scoring weights (author_weight, reviewer_weight, note_bonus) are now
configurable via the config file's `scoring` section with validation that
rejects negative values. Defaults shift to author_weight=25, reviewer_weight=10,
note_bonus=1 — better reflecting that code authorship is a stronger expertise
signal than review assignment alone.

Path resolution gains suffix matching: typing "login.rs" auto-resolves to
"src/auth/login.rs" when unambiguous, with clear disambiguation errors when
multiple paths match. Project-scoping (-p) narrows the candidate set.

The MAX_MR_REFS_PER_USER constant is promoted to module scope for reuse
across expert and overlap modes. Human output shows MR refs inline and detail
sub-rows when requested. Robot JSON includes mr_refs, mr_refs_total,
mr_refs_truncated, and optional details array.

Includes comprehensive tests for suffix resolution, scoring weight
configurability, MR ref aggregation across projects, and detail mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-09 10:15:15 -05:00
parent d36850f181
commit 41504b4941
5 changed files with 994 additions and 56 deletions

View File

@@ -177,6 +177,8 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
"--since",
"--project",
"--limit",
"--detail",
"--no-detail",
],
),
(

File diff suppressed because it is too large Load Diff

View File

@@ -751,6 +751,13 @@ pub struct WhoArgs {
help_heading = "Output"
)]
pub limit: u16,
/// Show per-MR detail breakdown (expert mode only)
#[arg(long, help_heading = "Output", overrides_with = "no_detail")]
pub detail: bool,
#[arg(long = "no-detail", hide = true, overrides_with = "detail")]
pub no_detail: bool,
}
#[derive(Parser)]

View File

@@ -146,6 +146,32 @@ impl Default for LoggingConfig {
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ScoringConfig {
/// Points per MR where the user authored code touching the path.
#[serde(rename = "authorWeight")]
pub author_weight: i64,
/// Points per MR where the user reviewed code touching the path.
#[serde(rename = "reviewerWeight")]
pub reviewer_weight: i64,
/// Bonus points per individual inline review comment (DiffNote).
#[serde(rename = "noteBonus")]
pub note_bonus: i64,
}
impl Default for ScoringConfig {
fn default() -> Self {
Self {
author_weight: 25,
reviewer_weight: 10,
note_bonus: 1,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub gitlab: GitLabConfig,
@@ -162,6 +188,9 @@ pub struct Config {
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub scoring: ScoringConfig,
}
impl Config {
@@ -207,10 +236,31 @@ impl Config {
});
}
validate_scoring(&config.scoring)?;
Ok(config)
}
}
fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
if scoring.author_weight < 0 {
return Err(LoreError::ConfigInvalid {
details: "scoring.authorWeight must be >= 0".to_string(),
});
}
if scoring.reviewer_weight < 0 {
return Err(LoreError::ConfigInvalid {
details: "scoring.reviewerWeight must be >= 0".to_string(),
});
}
if scoring.note_bonus < 0 {
return Err(LoreError::ConfigInvalid {
details: "scoring.noteBonus must be >= 0".to_string(),
});
}
Ok(())
}
#[derive(Debug, serde::Serialize)]
pub struct MinimalConfig {
pub gitlab: MinimalGitLabConfig,
@@ -236,3 +286,81 @@ impl serde::Serialize for ProjectConfig {
state.end()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_config(dir: &TempDir, scoring_json: &str) -> std::path::PathBuf {
let path = dir.path().join("config.json");
let config = format!(
r#"{{
"gitlab": {{
"baseUrl": "https://gitlab.example.com",
"tokenEnvVar": "GITLAB_TOKEN"
}},
"projects": [
{{ "path": "group/project" }}
],
"scoring": {scoring_json}
}}"#
);
fs::write(&path, config).unwrap();
path
}
#[test]
fn test_load_rejects_negative_author_weight() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"{
"authorWeight": -1,
"reviewerWeight": 10,
"noteBonus": 1
}"#,
);
let err = Config::load_from_path(&path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("scoring.authorWeight"),
"unexpected error: {msg}"
);
}
#[test]
fn test_load_rejects_negative_reviewer_weight() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"{
"authorWeight": 25,
"reviewerWeight": -1,
"noteBonus": 1
}"#,
);
let err = Config::load_from_path(&path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("scoring.reviewerWeight"),
"unexpected error: {msg}"
);
}
#[test]
fn test_load_rejects_negative_note_bonus() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"{
"authorWeight": 25,
"reviewerWeight": 10,
"noteBonus": -1
}"#,
);
let err = Config::load_from_path(&path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("scoring.noteBonus"), "unexpected error: {msg}");
}
}