use std::collections::{HashMap, HashSet}; use rusqlite::Connection; use crate::cli::render::{self, Icons, Theme}; use crate::core::config::ScoringConfig; use crate::core::error::Result; use crate::core::path_resolver::{PathQuery, build_path_query}; use crate::core::time::ms_to_iso; use super::types::*; pub(super) fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 { let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0); let hl = f64::from(half_life_days); if hl <= 0.0 { return 0.0; } 2.0_f64.powf(-days / hl) } // ─── Query: Expert Mode ───────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] pub(super) fn query_expert( conn: &Connection, path: &str, project_id: Option, since_ms: i64, as_of_ms: i64, limit: usize, scoring: &ScoringConfig, detail: bool, explain_score: bool, include_bots: bool, ) -> Result { let pq = build_path_query(conn, path, project_id)?; let sql = build_expert_sql_v2(pq.is_prefix); let mut stmt = conn.prepare_cached(&sql)?; // Params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, // ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars let rows = stmt.query_map( rusqlite::params![ pq.value, since_ms, project_id, as_of_ms, scoring.closed_mr_multiplier, scoring.reviewer_min_note_chars, ], |row| { Ok(SignalRow { username: row.get(0)?, signal: row.get(1)?, mr_id: row.get(2)?, qty: row.get(3)?, ts: row.get(4)?, state_mult: row.get(5)?, }) }, )?; // Per-user accumulator keyed by username. let mut accum: HashMap = HashMap::new(); for row_result in rows { let r = row_result?; let entry = accum .entry(r.username.clone()) .or_insert_with(|| UserAccum { contributions: Vec::new(), last_seen_ms: 0, mr_ids_author: HashSet::new(), mr_ids_reviewer: HashSet::new(), note_count: 0, }); if r.ts > entry.last_seen_ms { entry.last_seen_ms = r.ts; } match r.signal.as_str() { "diffnote_author" | "file_author" => { entry.mr_ids_author.insert(r.mr_id); } "file_reviewer_participated" | "file_reviewer_assigned" => { entry.mr_ids_reviewer.insert(r.mr_id); } "note_group" => { entry.note_count += r.qty as u32; // DiffNote reviewers are also reviewer activity. entry.mr_ids_reviewer.insert(r.mr_id); } _ => {} } entry.contributions.push(Contribution { signal: r.signal, mr_id: r.mr_id, qty: r.qty, ts: r.ts, state_mult: r.state_mult, }); } // Bot filtering: exclude configured bot usernames (case-insensitive). if !include_bots && !scoring.excluded_usernames.is_empty() { let excluded: HashSet = scoring .excluded_usernames .iter() .map(|u| u.to_lowercase()) .collect(); accum.retain(|username, _| !excluded.contains(&username.to_lowercase())); } // Compute decayed scores with deterministic ordering. let mut scored: Vec = accum .into_iter() .map(|(username, mut ua)| { // Sort contributions by mr_id ASC for deterministic f64 summation. ua.contributions.sort_by_key(|c| c.mr_id); let mut comp_author = 0.0_f64; let mut comp_reviewer_participated = 0.0_f64; let mut comp_reviewer_assigned = 0.0_f64; let mut comp_notes = 0.0_f64; for c in &ua.contributions { let elapsed = as_of_ms - c.ts; match c.signal.as_str() { "diffnote_author" | "file_author" => { let decay = half_life_decay(elapsed, scoring.author_half_life_days); comp_author += scoring.author_weight as f64 * decay * c.state_mult; } "file_reviewer_participated" => { let decay = half_life_decay(elapsed, scoring.reviewer_half_life_days); comp_reviewer_participated += scoring.reviewer_weight as f64 * decay * c.state_mult; } "file_reviewer_assigned" => { let decay = half_life_decay(elapsed, scoring.reviewer_assignment_half_life_days); comp_reviewer_assigned += scoring.reviewer_assignment_weight as f64 * decay * c.state_mult; } "note_group" => { let decay = half_life_decay(elapsed, scoring.note_half_life_days); // Diminishing returns: log2(1 + count) per MR. let note_value = (1.0 + c.qty as f64).log2(); comp_notes += scoring.note_bonus as f64 * note_value * decay * c.state_mult; } _ => {} } } let raw_score = comp_author + comp_reviewer_participated + comp_reviewer_assigned + comp_notes; ScoredUser { username, raw_score, components: ScoreComponents { author: comp_author, reviewer_participated: comp_reviewer_participated, reviewer_assigned: comp_reviewer_assigned, notes: comp_notes, }, accum: ua, } }) .collect(); // Sort: raw_score DESC, last_seen DESC, username ASC (deterministic tiebreaker). scored.sort_by(|a, b| { b.raw_score .partial_cmp(&a.raw_score) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| b.accum.last_seen_ms.cmp(&a.accum.last_seen_ms)) .then_with(|| a.username.cmp(&b.username)) }); let truncated = scored.len() > limit; scored.truncate(limit); // Build Expert structs with MR refs. let mut experts: Vec = scored .into_iter() .map(|su| { let mut mr_refs = build_mr_refs_for_user(conn, &su.accum); mr_refs.sort(); let mr_refs_total = mr_refs.len() as u32; let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER; if mr_refs_truncated { mr_refs.truncate(MAX_MR_REFS_PER_USER); } Expert { username: su.username, score: su.raw_score.round() as i64, score_raw: if explain_score { Some(su.raw_score) } else { None }, components: if explain_score { Some(su.components) } else { None }, review_mr_count: su.accum.mr_ids_reviewer.len() as u32, review_note_count: su.accum.note_count, author_mr_count: su.accum.mr_ids_author.len() as u32, last_seen_ms: su.accum.last_seen_ms, mr_refs, mr_refs_total, mr_refs_truncated, details: None, } }) .collect(); // Populate per-MR detail when --detail is requested if detail && !experts.is_empty() { let details_map = query_expert_details(conn, &pq, &experts, since_ms, project_id)?; for expert in &mut experts { expert.details = details_map.get(&expert.username).cloned(); } } Ok(ExpertResult { path_query: if pq.is_prefix { // Use raw input (unescaped) for display — pq.value has LIKE escaping. path.trim_end_matches('/').to_string() } else { // For exact matches (including suffix-resolved), show the resolved path. pq.value.clone() }, path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(), experts, truncated, }) } struct SignalRow { username: String, signal: String, mr_id: i64, qty: i64, ts: i64, state_mult: f64, } /// Per-user signal accumulator used during Rust-side scoring. struct UserAccum { contributions: Vec, last_seen_ms: i64, mr_ids_author: HashSet, mr_ids_reviewer: HashSet, note_count: u32, } /// A single contribution to a user's score (one signal row). struct Contribution { signal: String, mr_id: i64, qty: i64, ts: i64, state_mult: f64, } /// Intermediate scored user before building Expert structs. struct ScoredUser { username: String, raw_score: f64, components: ScoreComponents, accum: UserAccum, } /// Build MR refs (e.g. "group/project!123") for a user from their accumulated MR IDs. fn build_mr_refs_for_user(conn: &Connection, ua: &UserAccum) -> Vec { let all_mr_ids: HashSet = ua .mr_ids_author .iter() .chain(ua.mr_ids_reviewer.iter()) .copied() .chain(ua.contributions.iter().map(|c| c.mr_id)) .collect(); if all_mr_ids.is_empty() { return Vec::new(); } let placeholders: Vec = (1..=all_mr_ids.len()).map(|i| format!("?{i}")).collect(); let sql = format!( "SELECT p.path_with_namespace || '!' || CAST(m.iid AS TEXT) FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE m.id IN ({})", placeholders.join(",") ); let mut stmt = match conn.prepare(&sql) { Ok(s) => s, Err(_) => return Vec::new(), }; let mut mr_ids_vec: Vec = all_mr_ids.into_iter().collect(); mr_ids_vec.sort_unstable(); let params: Vec<&dyn rusqlite::types::ToSql> = mr_ids_vec .iter() .map(|id| id as &dyn rusqlite::types::ToSql) .collect(); stmt.query_map(&*params, |row| row.get::<_, String>(0)) .map(|rows| rows.filter_map(|r| r.ok()).collect()) .unwrap_or_default() } /// Build the CTE-based expert SQL for time-decay scoring (v2). /// /// Returns raw signal rows `(username, signal, mr_id, qty, ts, state_mult)` that /// Rust aggregates with per-signal decay and `log2(1+count)` for note groups. /// /// Parameters: `?1` = path, `?2` = since_ms, `?3` = project_id (nullable), /// `?4` = as_of_ms, `?5` = closed_mr_multiplier, `?6` = reviewer_min_note_chars pub(super) fn build_expert_sql_v2(is_prefix: bool) -> String { let path_op = if is_prefix { "LIKE ?1 ESCAPE '\\'" } else { "= ?1" }; // INDEXED BY hints for each branch: // - new_path branch: idx_notes_diffnote_path_created (existing) // - old_path branch: idx_notes_old_path_author (migration 026) format!( " WITH matched_notes_raw AS ( -- Branch 1: match on position_new_path SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id FROM notes n INDEXED BY idx_notes_diffnote_path_created WHERE n.note_type = 'DiffNote' AND n.is_system = 0 AND n.author_username IS NOT NULL AND n.created_at >= ?2 AND n.created_at < ?4 AND (?3 IS NULL OR n.project_id = ?3) AND n.position_new_path {path_op} UNION ALL -- Branch 2: match on position_old_path SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id FROM notes n INDEXED BY idx_notes_old_path_author WHERE n.note_type = 'DiffNote' AND n.is_system = 0 AND n.author_username IS NOT NULL AND n.created_at >= ?2 AND n.created_at < ?4 AND (?3 IS NULL OR n.project_id = ?3) AND n.position_old_path IS NOT NULL AND n.position_old_path {path_op} ), matched_notes AS ( -- Dedup: prevent double-counting when old_path = new_path (no rename) SELECT DISTINCT id, discussion_id, author_username, created_at, project_id FROM matched_notes_raw ), matched_file_changes_raw AS ( -- Branch 1: match on new_path SELECT fc.merge_request_id, fc.project_id FROM mr_file_changes fc INDEXED BY idx_mfc_new_path_project_mr WHERE (?3 IS NULL OR fc.project_id = ?3) AND fc.new_path {path_op} UNION ALL -- Branch 2: match on old_path SELECT fc.merge_request_id, fc.project_id FROM mr_file_changes fc INDEXED BY idx_mfc_old_path_project_mr WHERE (?3 IS NULL OR fc.project_id = ?3) AND fc.old_path IS NOT NULL AND fc.old_path {path_op} ), matched_file_changes AS ( -- Dedup: prevent double-counting when old_path = new_path (no rename) SELECT DISTINCT merge_request_id, project_id FROM matched_file_changes_raw ), mr_activity AS ( -- Centralized state-aware timestamps and state multiplier. -- Scoped to MRs matched by file changes to avoid materializing the full MR table. SELECT DISTINCT m.id AS mr_id, m.author_username, m.state, CASE WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at) WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at) ELSE COALESCE(m.updated_at, m.created_at) END AS activity_ts, CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult FROM merge_requests m JOIN matched_file_changes mfc ON mfc.merge_request_id = m.id WHERE m.state IN ('opened','merged','closed') ), reviewer_participation AS ( -- Precompute which (mr_id, username) pairs have substantive DiffNote participation. SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username FROM matched_notes mn JOIN discussions d ON mn.discussion_id = d.id JOIN notes n_body ON mn.id = n_body.id WHERE d.merge_request_id IS NOT NULL AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6 ), raw AS ( -- Signal 1: DiffNote reviewer (individual notes for note_cnt) SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal, m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at, CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult FROM matched_notes mn JOIN discussions d ON mn.discussion_id = d.id JOIN merge_requests m ON d.merge_request_id = m.id WHERE (m.author_username IS NULL OR mn.author_username != m.author_username) AND m.state IN ('opened','merged','closed') UNION ALL -- Signal 2: DiffNote MR author SELECT m.author_username AS username, 'diffnote_author' AS signal, m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at, CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult FROM merge_requests m JOIN discussions d ON d.merge_request_id = m.id JOIN matched_notes mn ON mn.discussion_id = d.id WHERE m.author_username IS NOT NULL AND m.state IN ('opened','merged','closed') GROUP BY m.author_username, m.id UNION ALL -- Signal 3: MR author via file changes (uses mr_activity CTE) SELECT a.author_username AS username, 'file_author' AS signal, a.mr_id, NULL AS note_id, a.activity_ts AS seen_at, a.state_mult FROM mr_activity a WHERE a.author_username IS NOT NULL AND a.activity_ts >= ?2 AND a.activity_ts < ?4 UNION ALL -- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path) SELECT r.username AS username, 'file_reviewer_participated' AS signal, a.mr_id, NULL AS note_id, a.activity_ts AS seen_at, a.state_mult FROM mr_activity a JOIN mr_reviewers r ON r.merge_request_id = a.mr_id JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username WHERE r.username IS NOT NULL AND (a.author_username IS NULL OR r.username != a.author_username) AND a.activity_ts >= ?2 AND a.activity_ts < ?4 UNION ALL -- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path) SELECT r.username AS username, 'file_reviewer_assigned' AS signal, a.mr_id, NULL AS note_id, a.activity_ts AS seen_at, a.state_mult FROM mr_activity a JOIN mr_reviewers r ON r.merge_request_id = a.mr_id LEFT JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username WHERE rp.username IS NULL AND r.username IS NOT NULL AND (a.author_username IS NULL OR r.username != a.author_username) AND a.activity_ts >= ?2 AND a.activity_ts < ?4 ), aggregated AS ( -- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts) SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult FROM raw WHERE signal != 'diffnote_reviewer' GROUP BY username, signal, mr_id UNION ALL -- Note signals: 1 row per (username, mr_id) with note_count and max_ts SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL GROUP BY username, mr_id ) SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE username IS NOT NULL " ) } /// Query per-MR detail for a set of experts. Returns a map of username -> Vec. pub(super) fn query_expert_details( conn: &Connection, pq: &PathQuery, experts: &[Expert], since_ms: i64, project_id: Option, ) -> Result>> { let path_op = if pq.is_prefix { "LIKE ?1 ESCAPE '\\'" } else { "= ?1" }; // Build IN clause for usernames let placeholders: Vec = experts .iter() .enumerate() .map(|(i, _)| format!("?{}", i + 4)) .collect(); let in_clause = placeholders.join(","); let sql = format!( " WITH signals AS ( -- 1. DiffNote reviewer (matches both new_path and old_path for renamed files) SELECT n.author_username AS username, 'reviewer' AS role, m.id AS mr_id, (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref, m.title AS title, COUNT(*) AS note_count, MAX(n.created_at) AS last_activity FROM notes n JOIN discussions d ON n.discussion_id = d.id JOIN merge_requests m ON d.merge_request_id = m.id JOIN projects p ON m.project_id = p.id WHERE n.note_type = 'DiffNote' AND n.is_system = 0 AND n.author_username IS NOT NULL AND (m.author_username IS NULL OR n.author_username != m.author_username) AND m.state IN ('opened','merged','closed') AND (n.position_new_path {path_op} OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op})) AND n.created_at >= ?2 AND (?3 IS NULL OR n.project_id = ?3) AND n.author_username IN ({in_clause}) GROUP BY n.author_username, m.id UNION ALL -- 2. DiffNote MR author (matches both new_path and old_path for renamed files) SELECT m.author_username AS username, 'author' AS role, m.id AS mr_id, (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref, m.title AS title, 0 AS note_count, MAX(n.created_at) AS last_activity FROM merge_requests m JOIN discussions d ON d.merge_request_id = m.id JOIN notes n ON n.discussion_id = d.id JOIN projects p ON m.project_id = p.id WHERE n.note_type = 'DiffNote' AND n.is_system = 0 AND m.author_username IS NOT NULL AND m.state IN ('opened','merged','closed') AND (n.position_new_path {path_op} OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op})) AND n.created_at >= ?2 AND (?3 IS NULL OR n.project_id = ?3) AND m.author_username IN ({in_clause}) GROUP BY m.author_username, m.id UNION ALL -- 3. MR author via file changes (matches both new_path and old_path) SELECT m.author_username AS username, 'author' AS role, m.id AS mr_id, (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref, m.title AS title, 0 AS note_count, m.updated_at AS last_activity FROM mr_file_changes fc JOIN merge_requests m ON fc.merge_request_id = m.id JOIN projects p ON m.project_id = p.id WHERE m.author_username IS NOT NULL AND m.state IN ('opened','merged','closed') AND (fc.new_path {path_op} OR (fc.old_path IS NOT NULL AND fc.old_path {path_op})) AND m.updated_at >= ?2 AND (?3 IS NULL OR fc.project_id = ?3) AND m.author_username IN ({in_clause}) UNION ALL -- 4. MR reviewer via file changes + mr_reviewers (matches both new_path and old_path) SELECT r.username AS username, 'reviewer' AS role, m.id AS mr_id, (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref, m.title AS title, 0 AS note_count, m.updated_at AS last_activity FROM mr_file_changes fc JOIN merge_requests m ON fc.merge_request_id = m.id JOIN projects p ON m.project_id = p.id JOIN mr_reviewers r ON r.merge_request_id = m.id WHERE r.username IS NOT NULL AND (m.author_username IS NULL OR r.username != m.author_username) AND m.state IN ('opened','merged','closed') AND (fc.new_path {path_op} OR (fc.old_path IS NOT NULL AND fc.old_path {path_op})) AND m.updated_at >= ?2 AND (?3 IS NULL OR fc.project_id = ?3) AND r.username IN ({in_clause}) ) SELECT username, mr_ref, title, GROUP_CONCAT(DISTINCT role) AS roles, SUM(note_count) AS total_notes, MAX(last_activity) AS last_activity FROM signals GROUP BY username, mr_ref ORDER BY username ASC, last_activity DESC " ); // prepare() not prepare_cached(): the IN clause varies by expert count, // so the SQL shape changes per invocation and caching wastes memory. let mut stmt = conn.prepare(&sql)?; // Build params: ?1=path, ?2=since_ms, ?3=project_id, ?4..=usernames let mut params: Vec> = Vec::new(); params.push(Box::new(pq.value.clone())); params.push(Box::new(since_ms)); params.push(Box::new(project_id)); for expert in experts { params.push(Box::new(expert.username.clone())); } let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let rows: Vec<(String, String, String, String, u32, i64)> = stmt .query_map(param_refs.as_slice(), |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get::<_, String>(3)?, row.get(4)?, row.get(5)?, )) })? .collect::, _>>()?; let mut map: HashMap> = HashMap::new(); for (username, mr_ref, title, roles_csv, note_count, last_activity) in rows { let has_author = roles_csv.contains("author"); let has_reviewer = roles_csv.contains("reviewer"); let role = match (has_author, has_reviewer) { (true, true) => "A+R", (true, false) => "A", (false, true) => "R", _ => "?", } .to_string(); map.entry(username).or_default().push(ExpertMrDetail { mr_ref, title, role, note_count, last_activity_ms: last_activity, }); } Ok(map) } pub(super) fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) { println!(); println!( "{}", Theme::bold().render(&format!("Experts for {}", r.path_query)) ); println!("{}", "\u{2500}".repeat(60)); println!( " {}", Theme::dim().render(&format!( "(matching {} {})", r.path_match, if r.path_match == "exact" { "file" } else { "directory prefix" } )) ); super::print_scope_hint(project_path); println!(); if r.experts.is_empty() { println!( " {}", Theme::dim().render("No experts found for this path.") ); println!(); return; } println!( " {:<16} {:>6} {:>12} {:>6} {:>12} {} {}", Theme::bold().render("Username"), Theme::bold().render("Score"), Theme::bold().render("Reviewed(MRs)"), Theme::bold().render("Notes"), Theme::bold().render("Authored(MRs)"), Theme::bold().render("Last Seen"), Theme::bold().render("MR Refs"), ); for expert in &r.experts { let reviews = if expert.review_mr_count > 0 { expert.review_mr_count.to_string() } else { "-".to_string() }; let notes = if expert.review_note_count > 0 { expert.review_note_count.to_string() } else { "-".to_string() }; let authored = if expert.author_mr_count > 0 { expert.author_mr_count.to_string() } else { "-".to_string() }; let mr_str = expert .mr_refs .iter() .take(5) .cloned() .collect::>() .join(", "); let overflow = if expert.mr_refs_total > 5 { format!(" +{}", expert.mr_refs_total - 5) } else { String::new() }; println!( " {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}", Theme::info().render(&format!("{} {}", Icons::user(), expert.username)), expert.score, reviews, notes, authored, render::format_relative_time(expert.last_seen_ms), if mr_str.is_empty() { String::new() } else { format!(" {mr_str}") }, overflow, ); // Print detail sub-rows when populated if let Some(details) = &expert.details { const MAX_DETAIL_DISPLAY: usize = 10; for d in details.iter().take(MAX_DETAIL_DISPLAY) { let notes_str = if d.note_count > 0 { format!("{} notes", d.note_count) } else { String::new() }; println!( " {:<3} {:<30} {:>30} {:>10} {}", Theme::dim().render(&d.role), d.mr_ref, render::truncate(&format!("\"{}\"", d.title), 30), notes_str, Theme::dim().render(&render::format_relative_time(d.last_activity_ms)), ); } if details.len() > MAX_DETAIL_DISPLAY { println!( " {}", Theme::dim().render(&format!("+{} more", details.len() - MAX_DETAIL_DISPLAY)) ); } } } if r.truncated { println!( " {}", Theme::dim().render("(showing first -n; rerun with a higher --limit)") ); } println!(); } pub(super) fn expert_to_json(r: &ExpertResult) -> serde_json::Value { serde_json::json!({ "path_query": r.path_query, "path_match": r.path_match, "scoring_model_version": 2, "truncated": r.truncated, "experts": r.experts.iter().map(|e| { let mut obj = serde_json::json!({ "username": e.username, "score": e.score, "review_mr_count": e.review_mr_count, "review_note_count": e.review_note_count, "author_mr_count": e.author_mr_count, "last_seen_at": ms_to_iso(e.last_seen_ms), "mr_refs": e.mr_refs, "mr_refs_total": e.mr_refs_total, "mr_refs_truncated": e.mr_refs_truncated, }); if let Some(raw) = e.score_raw { obj["score_raw"] = serde_json::json!(raw); } if let Some(comp) = &e.components { obj["components"] = serde_json::json!({ "author": comp.author, "reviewer_participated": comp.reviewer_participated, "reviewer_assigned": comp.reviewer_assigned, "notes": comp.notes, }); } if let Some(details) = &e.details { obj["details"] = serde_json::json!(details.iter().map(|d| serde_json::json!({ "mr_ref": d.mr_ref, "title": d.title, "role": d.role, "note_count": d.note_count, "last_activity_at": ms_to_iso(d.last_activity_ms), })).collect::>()); } obj }).collect::>(), }) }