Split the monolithic who.rs into a who/ directory module with 7 focused files. The 5 query modes (expert, workload, reviews, active, overlap) share no query-level code — only types and a few small helpers — making this a clean mechanical extraction. New structure: who/types.rs — all pub result structs/enums (~185 lines) who/mod.rs — dispatch, shared helpers, JSON envelope (~428 lines) who/expert.rs — query + render + json for expert mode (~839 lines) who/workload.rs — query + render + json for workload mode (~370 lines) who/reviews.rs — query + render + json for reviews mode (~214 lines) who/active.rs — query + render + json for active mode (~299 lines) who/overlap.rs — query + render + json for overlap mode (~323 lines) Token savings: an agent working on any single mode now loads ~400-960 lines instead of 2,598 (63-85% reduction). Public API unchanged — parent mod.rs re-exports are identical. Test re-exports use #[cfg(test)] use (not pub use) to avoid visibility conflicts with pub(super) items in submodules. All 79 who tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
840 lines
30 KiB
Rust
840 lines
30 KiB
Rust
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<i64>,
|
|
since_ms: i64,
|
|
as_of_ms: i64,
|
|
limit: usize,
|
|
scoring: &ScoringConfig,
|
|
detail: bool,
|
|
explain_score: bool,
|
|
include_bots: bool,
|
|
) -> Result<ExpertResult> {
|
|
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<String, UserAccum> = 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<String> = 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<ScoredUser> = 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<Expert> = 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<Contribution>,
|
|
last_seen_ms: i64,
|
|
mr_ids_author: HashSet<i64>,
|
|
mr_ids_reviewer: HashSet<i64>,
|
|
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<String> {
|
|
let all_mr_ids: HashSet<i64> = 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<String> = (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<i64> = 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<ExpertMrDetail>.
|
|
pub(super) fn query_expert_details(
|
|
conn: &Connection,
|
|
pq: &PathQuery,
|
|
experts: &[Expert],
|
|
since_ms: i64,
|
|
project_id: Option<i64>,
|
|
) -> Result<HashMap<String, Vec<ExpertMrDetail>>> {
|
|
let path_op = if pq.is_prefix {
|
|
"LIKE ?1 ESCAPE '\\'"
|
|
} else {
|
|
"= ?1"
|
|
};
|
|
|
|
// Build IN clause for usernames
|
|
let placeholders: Vec<String> = 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<Box<dyn rusqlite::types::ToSql>> = 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::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
let mut map: HashMap<String, Vec<ExpertMrDetail>> = 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::<Vec<_>>()
|
|
.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::<Vec<_>>());
|
|
}
|
|
obj
|
|
}).collect::<Vec<_>>(),
|
|
})
|
|
}
|