refactor(who): split 2598-line who.rs into per-mode modules
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>
This commit is contained in:
299
src/cli/commands/who/active.rs
Normal file
299
src/cli/commands/who/active.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::render::{self, Theme};
|
||||
use crate::core::error::Result;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
pub(super) fn query_active(
|
||||
conn: &Connection,
|
||||
project_id: Option<i64>,
|
||||
since_ms: i64,
|
||||
limit: usize,
|
||||
include_closed: bool,
|
||||
) -> Result<ActiveResult> {
|
||||
let limit_plus_one = (limit + 1) as i64;
|
||||
|
||||
// State filter for open-entities-only (default behavior)
|
||||
let state_joins = if include_closed {
|
||||
""
|
||||
} else {
|
||||
" LEFT JOIN issues i ON d.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON d.merge_request_id = m.id"
|
||||
};
|
||||
let state_filter = if include_closed {
|
||||
""
|
||||
} else {
|
||||
" AND (i.id IS NULL OR i.state = 'opened')
|
||||
AND (m.id IS NULL OR m.state = 'opened')"
|
||||
};
|
||||
|
||||
// Total unresolved count -- conditionally built
|
||||
let total_sql_global = format!(
|
||||
"SELECT COUNT(*) FROM discussions d
|
||||
{state_joins}
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND d.last_note_at >= ?1
|
||||
{state_filter}"
|
||||
);
|
||||
let total_sql_scoped = format!(
|
||||
"SELECT COUNT(*) FROM discussions d
|
||||
{state_joins}
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND d.last_note_at >= ?1
|
||||
AND d.project_id = ?2
|
||||
{state_filter}"
|
||||
);
|
||||
|
||||
let total_unresolved_in_window: u32 = match project_id {
|
||||
None => conn.query_row(&total_sql_global, rusqlite::params![since_ms], |row| {
|
||||
row.get(0)
|
||||
})?,
|
||||
Some(pid) => {
|
||||
conn.query_row(&total_sql_scoped, rusqlite::params![since_ms, pid], |row| {
|
||||
row.get(0)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
// Active discussions with context -- conditionally built SQL
|
||||
let sql_global = format!(
|
||||
"
|
||||
WITH picked AS (
|
||||
SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
|
||||
d.project_id, d.last_note_at
|
||||
FROM discussions d
|
||||
{state_joins}
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND d.last_note_at >= ?1
|
||||
{state_filter}
|
||||
ORDER BY d.last_note_at DESC
|
||||
LIMIT ?2
|
||||
),
|
||||
note_counts AS (
|
||||
SELECT
|
||||
n.discussion_id,
|
||||
COUNT(*) AS note_count
|
||||
FROM notes n
|
||||
JOIN picked p ON p.id = n.discussion_id
|
||||
WHERE n.is_system = 0
|
||||
GROUP BY n.discussion_id
|
||||
),
|
||||
participants AS (
|
||||
SELECT
|
||||
x.discussion_id,
|
||||
GROUP_CONCAT(x.author_username, X'1F') AS participants
|
||||
FROM (
|
||||
SELECT DISTINCT n.discussion_id, n.author_username
|
||||
FROM notes n
|
||||
JOIN picked p ON p.id = n.discussion_id
|
||||
WHERE n.is_system = 0 AND n.author_username IS NOT NULL
|
||||
) x
|
||||
GROUP BY x.discussion_id
|
||||
)
|
||||
SELECT
|
||||
p.id AS discussion_id,
|
||||
p.noteable_type,
|
||||
COALESCE(i.iid, m.iid) AS entity_iid,
|
||||
COALESCE(i.title, m.title) AS entity_title,
|
||||
proj.path_with_namespace,
|
||||
p.last_note_at,
|
||||
COALESCE(nc.note_count, 0) AS note_count,
|
||||
COALESCE(pa.participants, '') AS participants
|
||||
FROM picked p
|
||||
JOIN projects proj ON p.project_id = proj.id
|
||||
LEFT JOIN issues i ON p.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON p.merge_request_id = m.id
|
||||
LEFT JOIN note_counts nc ON nc.discussion_id = p.id
|
||||
LEFT JOIN participants pa ON pa.discussion_id = p.id
|
||||
ORDER BY p.last_note_at DESC
|
||||
"
|
||||
);
|
||||
|
||||
let sql_scoped = format!(
|
||||
"
|
||||
WITH picked AS (
|
||||
SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
|
||||
d.project_id, d.last_note_at
|
||||
FROM discussions d
|
||||
{state_joins}
|
||||
WHERE d.resolvable = 1 AND d.resolved = 0
|
||||
AND d.last_note_at >= ?1
|
||||
AND d.project_id = ?2
|
||||
{state_filter}
|
||||
ORDER BY d.last_note_at DESC
|
||||
LIMIT ?3
|
||||
),
|
||||
note_counts AS (
|
||||
SELECT
|
||||
n.discussion_id,
|
||||
COUNT(*) AS note_count
|
||||
FROM notes n
|
||||
JOIN picked p ON p.id = n.discussion_id
|
||||
WHERE n.is_system = 0
|
||||
GROUP BY n.discussion_id
|
||||
),
|
||||
participants AS (
|
||||
SELECT
|
||||
x.discussion_id,
|
||||
GROUP_CONCAT(x.author_username, X'1F') AS participants
|
||||
FROM (
|
||||
SELECT DISTINCT n.discussion_id, n.author_username
|
||||
FROM notes n
|
||||
JOIN picked p ON p.id = n.discussion_id
|
||||
WHERE n.is_system = 0 AND n.author_username IS NOT NULL
|
||||
) x
|
||||
GROUP BY x.discussion_id
|
||||
)
|
||||
SELECT
|
||||
p.id AS discussion_id,
|
||||
p.noteable_type,
|
||||
COALESCE(i.iid, m.iid) AS entity_iid,
|
||||
COALESCE(i.title, m.title) AS entity_title,
|
||||
proj.path_with_namespace,
|
||||
p.last_note_at,
|
||||
COALESCE(nc.note_count, 0) AS note_count,
|
||||
COALESCE(pa.participants, '') AS participants
|
||||
FROM picked p
|
||||
JOIN projects proj ON p.project_id = proj.id
|
||||
LEFT JOIN issues i ON p.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON p.merge_request_id = m.id
|
||||
LEFT JOIN note_counts nc ON nc.discussion_id = p.id
|
||||
LEFT JOIN participants pa ON pa.discussion_id = p.id
|
||||
ORDER BY p.last_note_at DESC
|
||||
"
|
||||
);
|
||||
|
||||
// Row-mapping closure shared between both variants
|
||||
let map_row = |row: &rusqlite::Row| -> rusqlite::Result<ActiveDiscussion> {
|
||||
let noteable_type: String = row.get(1)?;
|
||||
let entity_type = if noteable_type == "MergeRequest" {
|
||||
"MR"
|
||||
} else {
|
||||
"Issue"
|
||||
};
|
||||
let participants_csv: Option<String> = row.get(7)?;
|
||||
// Sort participants for deterministic output -- GROUP_CONCAT order is undefined
|
||||
let mut participants: Vec<String> = participants_csv
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|csv| csv.split('\x1F').map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
participants.sort();
|
||||
|
||||
const MAX_PARTICIPANTS: usize = 50;
|
||||
let participants_total = participants.len() as u32;
|
||||
let participants_truncated = participants.len() > MAX_PARTICIPANTS;
|
||||
if participants_truncated {
|
||||
participants.truncate(MAX_PARTICIPANTS);
|
||||
}
|
||||
|
||||
Ok(ActiveDiscussion {
|
||||
discussion_id: row.get(0)?,
|
||||
entity_type: entity_type.to_string(),
|
||||
entity_iid: row.get(2)?,
|
||||
entity_title: row.get(3)?,
|
||||
project_path: row.get(4)?,
|
||||
last_note_at: row.get(5)?,
|
||||
note_count: row.get(6)?,
|
||||
participants,
|
||||
participants_total,
|
||||
participants_truncated,
|
||||
})
|
||||
};
|
||||
|
||||
// Select variant first, then prepare exactly one statement
|
||||
let discussions: Vec<ActiveDiscussion> = match project_id {
|
||||
None => {
|
||||
let mut stmt = conn.prepare_cached(&sql_global)?;
|
||||
stmt.query_map(rusqlite::params![since_ms, limit_plus_one], &map_row)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
}
|
||||
Some(pid) => {
|
||||
let mut stmt = conn.prepare_cached(&sql_scoped)?;
|
||||
stmt.query_map(rusqlite::params![since_ms, pid, limit_plus_one], &map_row)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
}
|
||||
};
|
||||
|
||||
let truncated = discussions.len() > limit;
|
||||
let discussions: Vec<ActiveDiscussion> = discussions.into_iter().take(limit).collect();
|
||||
|
||||
Ok(ActiveResult {
|
||||
discussions,
|
||||
total_unresolved_in_window,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!(
|
||||
"Active Discussions ({} unresolved in window)",
|
||||
r.total_unresolved_in_window
|
||||
))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
super::print_scope_hint(project_path);
|
||||
println!();
|
||||
|
||||
if r.discussions.is_empty() {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No active unresolved discussions in this time window.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
for disc in &r.discussions {
|
||||
let prefix = if disc.entity_type == "MR" { "!" } else { "#" };
|
||||
let participants_str = disc
|
||||
.participants
|
||||
.iter()
|
||||
.map(|p| format!("@{p}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
println!(
|
||||
" {} {} {} {} notes {}",
|
||||
Theme::info().render(&format!("{prefix}{}", disc.entity_iid)),
|
||||
render::truncate(&disc.entity_title, 40),
|
||||
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
|
||||
disc.note_count,
|
||||
Theme::dim().render(&disc.project_path),
|
||||
);
|
||||
if !participants_str.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&participants_str));
|
||||
}
|
||||
}
|
||||
if r.truncated {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
pub(super) fn active_to_json(r: &ActiveResult) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"total_unresolved_in_window": r.total_unresolved_in_window,
|
||||
"truncated": r.truncated,
|
||||
"discussions": r.discussions.iter().map(|d| serde_json::json!({
|
||||
"discussion_id": d.discussion_id,
|
||||
"entity_type": d.entity_type,
|
||||
"entity_iid": d.entity_iid,
|
||||
"entity_title": d.entity_title,
|
||||
"project_path": d.project_path,
|
||||
"last_note_at": ms_to_iso(d.last_note_at),
|
||||
"note_count": d.note_count,
|
||||
"participants": d.participants,
|
||||
"participants_total": d.participants_total,
|
||||
"participants_truncated": d.participants_truncated,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user