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>
300 lines
10 KiB
Rust
300 lines
10 KiB
Rust
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<_>>(),
|
|
})
|
|
}
|