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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user