Adds 'references' entity type to the count command with breakdowns by reference_type (closes/mentioned/related), source_method (api/note_parse/description_parse), and unresolved count. Includes human and robot output formatters, 2 unit tests.
625 lines
20 KiB
Rust
625 lines
20 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use crate::cli::render::{self, Theme};
|
|
use rusqlite::Connection;
|
|
use serde::Serialize;
|
|
|
|
use crate::Config;
|
|
use crate::cli::robot::RobotMeta;
|
|
use crate::core::db::create_connection;
|
|
use crate::core::error::Result;
|
|
use crate::core::events_db::{self, EventCounts};
|
|
use crate::core::paths::get_db_path;
|
|
|
|
pub struct CountResult {
|
|
pub entity: String,
|
|
pub count: i64,
|
|
pub system_count: Option<i64>,
|
|
pub state_breakdown: Option<StateBreakdown>,
|
|
}
|
|
|
|
pub struct StateBreakdown {
|
|
pub opened: i64,
|
|
pub closed: i64,
|
|
pub merged: Option<i64>,
|
|
pub locked: Option<i64>,
|
|
}
|
|
|
|
pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Result<CountResult> {
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
let conn = create_connection(&db_path)?;
|
|
|
|
match entity {
|
|
"issues" => count_issues(&conn),
|
|
"discussions" => count_discussions(&conn, type_filter),
|
|
"notes" => count_notes(&conn, type_filter),
|
|
"mrs" => count_mrs(&conn),
|
|
_ => Ok(CountResult {
|
|
entity: entity.to_string(),
|
|
count: 0,
|
|
system_count: None,
|
|
state_breakdown: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn count_issues(conn: &Connection) -> Result<CountResult> {
|
|
// Single query with conditional aggregation instead of 3 separate queries
|
|
let (count, opened, closed): (i64, i64, i64) = conn.query_row(
|
|
"SELECT
|
|
COUNT(*),
|
|
COALESCE(SUM(CASE WHEN state = 'opened' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN state = 'closed' THEN 1 ELSE 0 END), 0)
|
|
FROM issues",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
)?;
|
|
|
|
Ok(CountResult {
|
|
entity: "Issues".to_string(),
|
|
count,
|
|
system_count: None,
|
|
state_breakdown: Some(StateBreakdown {
|
|
opened,
|
|
closed,
|
|
merged: None,
|
|
locked: None,
|
|
}),
|
|
})
|
|
}
|
|
|
|
fn count_mrs(conn: &Connection) -> Result<CountResult> {
|
|
// Single query with conditional aggregation instead of 5 separate queries
|
|
let (count, opened, merged, closed, locked): (i64, i64, i64, i64, i64) = conn.query_row(
|
|
"SELECT
|
|
COUNT(*),
|
|
COALESCE(SUM(CASE WHEN state = 'opened' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN state = 'merged' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN state = 'closed' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN state = 'locked' THEN 1 ELSE 0 END), 0)
|
|
FROM merge_requests",
|
|
[],
|
|
|row| {
|
|
Ok((
|
|
row.get(0)?,
|
|
row.get(1)?,
|
|
row.get(2)?,
|
|
row.get(3)?,
|
|
row.get(4)?,
|
|
))
|
|
},
|
|
)?;
|
|
|
|
Ok(CountResult {
|
|
entity: "Merge Requests".to_string(),
|
|
count,
|
|
system_count: None,
|
|
state_breakdown: Some(StateBreakdown {
|
|
opened,
|
|
closed,
|
|
merged: Some(merged),
|
|
locked: Some(locked),
|
|
}),
|
|
})
|
|
}
|
|
|
|
fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result<CountResult> {
|
|
let (count, entity_name) = match type_filter {
|
|
Some("issue") => {
|
|
let count: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
(count, "Issue Discussions")
|
|
}
|
|
Some("mr") => {
|
|
let count: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM discussions WHERE noteable_type = 'MergeRequest'",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
(count, "MR Discussions")
|
|
}
|
|
_ => {
|
|
let count: i64 =
|
|
conn.query_row("SELECT COUNT(*) FROM discussions", [], |row| row.get(0))?;
|
|
(count, "Discussions")
|
|
}
|
|
};
|
|
|
|
Ok(CountResult {
|
|
entity: entity_name.to_string(),
|
|
count,
|
|
system_count: None,
|
|
state_breakdown: None,
|
|
})
|
|
}
|
|
|
|
fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResult> {
|
|
let (total, system_count, entity_name) = match type_filter {
|
|
Some("issue") => {
|
|
let (total, system): (i64, i64) = conn.query_row(
|
|
"SELECT COUNT(*), COALESCE(SUM(n.is_system), 0)
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
WHERE d.noteable_type = 'Issue'",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
)?;
|
|
(total, system, "Issue Notes")
|
|
}
|
|
Some("mr") => {
|
|
let (total, system): (i64, i64) = conn.query_row(
|
|
"SELECT COUNT(*), COALESCE(SUM(n.is_system), 0)
|
|
FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
WHERE d.noteable_type = 'MergeRequest'",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
)?;
|
|
(total, system, "MR Notes")
|
|
}
|
|
_ => {
|
|
let (total, system): (i64, i64) = conn.query_row(
|
|
"SELECT COUNT(*), COALESCE(SUM(is_system), 0) FROM notes",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
)?;
|
|
(total, system, "Notes")
|
|
}
|
|
};
|
|
|
|
let non_system = total - system_count;
|
|
|
|
Ok(CountResult {
|
|
entity: entity_name.to_string(),
|
|
count: non_system,
|
|
system_count: Some(system_count),
|
|
state_breakdown: None,
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CountJsonOutput {
|
|
ok: bool,
|
|
data: CountJsonData,
|
|
meta: RobotMeta,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CountJsonData {
|
|
entity: String,
|
|
count: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
system_excluded: Option<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
breakdown: Option<CountJsonBreakdown>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CountJsonBreakdown {
|
|
opened: i64,
|
|
closed: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
merged: Option<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
locked: Option<i64>,
|
|
}
|
|
|
|
pub fn run_count_events(config: &Config) -> Result<EventCounts> {
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
let conn = create_connection(&db_path)?;
|
|
events_db::count_events(&conn)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// References count
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ReferenceCountResult {
|
|
pub total: i64,
|
|
pub by_type: HashMap<String, i64>,
|
|
pub by_method: HashMap<String, i64>,
|
|
pub unresolved: i64,
|
|
}
|
|
|
|
pub fn run_count_references(config: &Config) -> Result<ReferenceCountResult> {
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
let conn = create_connection(&db_path)?;
|
|
count_references(&conn)
|
|
}
|
|
|
|
fn count_references(conn: &Connection) -> Result<ReferenceCountResult> {
|
|
let (total, closes, mentioned, related, api, note_parse, desc_parse, unresolved): (
|
|
i64,
|
|
i64,
|
|
i64,
|
|
i64,
|
|
i64,
|
|
i64,
|
|
i64,
|
|
i64,
|
|
) = conn.query_row(
|
|
"SELECT
|
|
COUNT(*) AS total,
|
|
COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0)
|
|
FROM entity_references",
|
|
[],
|
|
|row| {
|
|
Ok((
|
|
row.get(0)?,
|
|
row.get(1)?,
|
|
row.get(2)?,
|
|
row.get(3)?,
|
|
row.get(4)?,
|
|
row.get(5)?,
|
|
row.get(6)?,
|
|
row.get(7)?,
|
|
))
|
|
},
|
|
)?;
|
|
|
|
let mut by_type = HashMap::new();
|
|
by_type.insert("closes".to_string(), closes);
|
|
by_type.insert("mentioned".to_string(), mentioned);
|
|
by_type.insert("related".to_string(), related);
|
|
|
|
let mut by_method = HashMap::new();
|
|
by_method.insert("api".to_string(), api);
|
|
by_method.insert("note_parse".to_string(), note_parse);
|
|
by_method.insert("description_parse".to_string(), desc_parse);
|
|
|
|
Ok(ReferenceCountResult {
|
|
total,
|
|
by_type,
|
|
by_method,
|
|
unresolved,
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct EventCountJsonOutput {
|
|
ok: bool,
|
|
data: EventCountJsonData,
|
|
meta: RobotMeta,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct EventCountJsonData {
|
|
state_events: EventTypeCounts,
|
|
label_events: EventTypeCounts,
|
|
milestone_events: EventTypeCounts,
|
|
total: usize,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct EventTypeCounts {
|
|
issue: usize,
|
|
merge_request: usize,
|
|
total: usize,
|
|
}
|
|
|
|
pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
|
|
let output = EventCountJsonOutput {
|
|
ok: true,
|
|
data: EventCountJsonData {
|
|
state_events: EventTypeCounts {
|
|
issue: counts.state_issue,
|
|
merge_request: counts.state_mr,
|
|
total: counts.state_issue + counts.state_mr,
|
|
},
|
|
label_events: EventTypeCounts {
|
|
issue: counts.label_issue,
|
|
merge_request: counts.label_mr,
|
|
total: counts.label_issue + counts.label_mr,
|
|
},
|
|
milestone_events: EventTypeCounts {
|
|
issue: counts.milestone_issue,
|
|
merge_request: counts.milestone_mr,
|
|
total: counts.milestone_issue + counts.milestone_mr,
|
|
},
|
|
total: counts.total(),
|
|
},
|
|
meta: RobotMeta { elapsed_ms },
|
|
};
|
|
|
|
println!("{}", serde_json::to_string(&output).unwrap());
|
|
}
|
|
|
|
pub fn print_event_count(counts: &EventCounts) {
|
|
println!(
|
|
"{:<20} {:>8} {:>8} {:>8}",
|
|
Theme::info().bold().render("Event Type"),
|
|
Theme::bold().render("Issues"),
|
|
Theme::bold().render("MRs"),
|
|
Theme::bold().render("Total")
|
|
);
|
|
|
|
let state_total = counts.state_issue + counts.state_mr;
|
|
let label_total = counts.label_issue + counts.label_mr;
|
|
let milestone_total = counts.milestone_issue + counts.milestone_mr;
|
|
|
|
println!(
|
|
"{:<20} {:>8} {:>8} {:>8}",
|
|
"State events",
|
|
render::format_number(counts.state_issue as i64),
|
|
render::format_number(counts.state_mr as i64),
|
|
render::format_number(state_total as i64)
|
|
);
|
|
println!(
|
|
"{:<20} {:>8} {:>8} {:>8}",
|
|
"Label events",
|
|
render::format_number(counts.label_issue as i64),
|
|
render::format_number(counts.label_mr as i64),
|
|
render::format_number(label_total as i64)
|
|
);
|
|
println!(
|
|
"{:<20} {:>8} {:>8} {:>8}",
|
|
"Milestone events",
|
|
render::format_number(counts.milestone_issue as i64),
|
|
render::format_number(counts.milestone_mr as i64),
|
|
render::format_number(milestone_total as i64)
|
|
);
|
|
|
|
let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue;
|
|
let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr;
|
|
println!(
|
|
"{:<20} {:>8} {:>8} {:>8}",
|
|
Theme::bold().render("Total"),
|
|
render::format_number(total_issues as i64),
|
|
render::format_number(total_mrs as i64),
|
|
Theme::bold().render(&render::format_number(counts.total() as i64))
|
|
);
|
|
}
|
|
|
|
pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
|
|
let breakdown = result.state_breakdown.as_ref().map(|b| CountJsonBreakdown {
|
|
opened: b.opened,
|
|
closed: b.closed,
|
|
merged: b.merged,
|
|
locked: b.locked.filter(|&l| l > 0),
|
|
});
|
|
|
|
let output = CountJsonOutput {
|
|
ok: true,
|
|
data: CountJsonData {
|
|
entity: result.entity.to_lowercase().replace(' ', "_"),
|
|
count: result.count,
|
|
system_excluded: result.system_count,
|
|
breakdown,
|
|
},
|
|
meta: RobotMeta { elapsed_ms },
|
|
};
|
|
|
|
println!("{}", serde_json::to_string(&output).unwrap());
|
|
}
|
|
|
|
pub fn print_count(result: &CountResult) {
|
|
let count_str = render::format_number(result.count);
|
|
|
|
if let Some(system_count) = result.system_count {
|
|
println!(
|
|
"{}: {:>10} {}",
|
|
Theme::info().render(&result.entity),
|
|
Theme::bold().render(&count_str),
|
|
Theme::dim().render(&format!(
|
|
"(excluding {} system)",
|
|
render::format_number(system_count)
|
|
))
|
|
);
|
|
} else {
|
|
println!(
|
|
"{}: {:>10}",
|
|
Theme::info().render(&result.entity),
|
|
Theme::bold().render(&count_str)
|
|
);
|
|
}
|
|
|
|
if let Some(breakdown) = &result.state_breakdown {
|
|
println!(" opened: {:>10}", render::format_number(breakdown.opened));
|
|
if let Some(merged) = breakdown.merged {
|
|
println!(" merged: {:>10}", render::format_number(merged));
|
|
}
|
|
println!(" closed: {:>10}", render::format_number(breakdown.closed));
|
|
if let Some(locked) = breakdown.locked
|
|
&& locked > 0
|
|
{
|
|
println!(" locked: {:>10}", render::format_number(locked));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// References output
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn print_reference_count(result: &ReferenceCountResult) {
|
|
println!(
|
|
"{}: {:>10}",
|
|
Theme::info().render("References"),
|
|
Theme::bold().render(&render::format_number(result.total))
|
|
);
|
|
|
|
println!(" By type:");
|
|
for key in &["closes", "mentioned", "related"] {
|
|
let val = result.by_type.get(*key).copied().unwrap_or(0);
|
|
println!(" {:<20} {:>10}", key, render::format_number(val));
|
|
}
|
|
|
|
println!(" By source:");
|
|
for key in &["api", "note_parse", "description_parse"] {
|
|
let val = result.by_method.get(*key).copied().unwrap_or(0);
|
|
println!(" {:<20} {:>10}", key, render::format_number(val));
|
|
}
|
|
|
|
let pct = if result.total > 0 {
|
|
format!(
|
|
" ({:.1}%)",
|
|
result.unresolved as f64 / result.total as f64 * 100.0
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
println!(
|
|
" Unresolved: {:>10}{}",
|
|
render::format_number(result.unresolved),
|
|
Theme::dim().render(&pct)
|
|
);
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RefCountJsonOutput {
|
|
ok: bool,
|
|
data: RefCountJsonData,
|
|
meta: RobotMeta,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RefCountJsonData {
|
|
entity: String,
|
|
total: i64,
|
|
by_type: HashMap<String, i64>,
|
|
by_method: HashMap<String, i64>,
|
|
unresolved: i64,
|
|
}
|
|
|
|
pub fn print_reference_count_json(result: &ReferenceCountResult, elapsed_ms: u64) {
|
|
let output = RefCountJsonOutput {
|
|
ok: true,
|
|
data: RefCountJsonData {
|
|
entity: "references".to_string(),
|
|
total: result.total,
|
|
by_type: result.by_type.clone(),
|
|
by_method: result.by_method.clone(),
|
|
unresolved: result.unresolved,
|
|
},
|
|
meta: RobotMeta { elapsed_ms },
|
|
};
|
|
|
|
println!("{}", serde_json::to_string(&output).unwrap_or_default());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::cli::render;
|
|
|
|
#[test]
|
|
fn format_number_handles_small_numbers() {
|
|
assert_eq!(render::format_number(0), "0");
|
|
assert_eq!(render::format_number(1), "1");
|
|
assert_eq!(render::format_number(100), "100");
|
|
assert_eq!(render::format_number(999), "999");
|
|
}
|
|
|
|
#[test]
|
|
fn format_number_adds_thousands_separators() {
|
|
assert_eq!(render::format_number(1000), "1,000");
|
|
assert_eq!(render::format_number(12345), "12,345");
|
|
assert_eq!(render::format_number(1234567), "1,234,567");
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_references_query() {
|
|
use std::path::Path;
|
|
|
|
use crate::core::db::{create_connection, run_migrations};
|
|
|
|
use super::count_references;
|
|
|
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
|
|
// Insert 3 entity_references rows with varied types/methods.
|
|
// First need a project row to satisfy FK.
|
|
conn.execute(
|
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
|
|
VALUES (1, 100, 'g/test', 'https://git.example.com/g/test')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Need source entities for the FK.
|
|
conn.execute(
|
|
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at)
|
|
VALUES (1, 200, 1, 1, 'Issue 1', 'opened', 0, 0, 0)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Row 1: closes / api / resolved (target_entity_id = 1)
|
|
conn.execute(
|
|
"INSERT INTO entity_references
|
|
(project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id,
|
|
reference_type, source_method, created_at)
|
|
VALUES (1, 'issue', 1, 'issue', 1, 'closes', 'api', 1000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Row 2: mentioned / note_parse / unresolved (target_entity_id = NULL)
|
|
conn.execute(
|
|
"INSERT INTO entity_references
|
|
(project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id,
|
|
target_project_path, target_entity_iid,
|
|
reference_type, source_method, created_at)
|
|
VALUES (1, 'issue', 1, 'merge_request', NULL, 'other/proj', 42, 'mentioned', 'note_parse', 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Row 3: related / api / unresolved (target_entity_id = NULL)
|
|
conn.execute(
|
|
"INSERT INTO entity_references
|
|
(project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id,
|
|
target_project_path, target_entity_iid,
|
|
reference_type, source_method, created_at)
|
|
VALUES (1, 'issue', 1, 'issue', NULL, 'other/proj2', 99, 'related', 'api', 3000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let result = count_references(&conn).unwrap();
|
|
|
|
assert_eq!(result.total, 3);
|
|
assert_eq!(*result.by_type.get("closes").unwrap(), 1);
|
|
assert_eq!(*result.by_type.get("mentioned").unwrap(), 1);
|
|
assert_eq!(*result.by_type.get("related").unwrap(), 1);
|
|
assert_eq!(*result.by_method.get("api").unwrap(), 2);
|
|
assert_eq!(*result.by_method.get("note_parse").unwrap(), 1);
|
|
assert_eq!(*result.by_method.get("description_parse").unwrap(), 0);
|
|
assert_eq!(result.unresolved, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_references_empty_table() {
|
|
use std::path::Path;
|
|
|
|
use crate::core::db::{create_connection, run_migrations};
|
|
|
|
use super::count_references;
|
|
|
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
|
|
let result = count_references(&conn).unwrap();
|
|
|
|
assert_eq!(result.total, 0);
|
|
assert_eq!(*result.by_type.get("closes").unwrap(), 0);
|
|
assert_eq!(*result.by_type.get("mentioned").unwrap(), 0);
|
|
assert_eq!(*result.by_type.get("related").unwrap(), 0);
|
|
assert_eq!(*result.by_method.get("api").unwrap(), 0);
|
|
assert_eq!(*result.by_method.get("note_parse").unwrap(), 0);
|
|
assert_eq!(*result.by_method.get("description_parse").unwrap(), 0);
|
|
assert_eq!(result.unresolved, 0);
|
|
}
|
|
}
|