feat(cli): add 'lore count references' command (bd-2ez)

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.
This commit is contained in:
teernisse
2026-02-19 09:01:05 -05:00
parent c8dece8c60
commit 574cd55eff
7 changed files with 265 additions and 12 deletions

View File

@@ -287,6 +287,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
),
("show", &["--project"]),
("reset", &["--yes"]),
("related", &["--limit", "--project"]),
];
/// Valid values for enum-like flags, used for post-clap error enhancement.

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -211,6 +213,78 @@ pub fn run_count_events(config: &Config) -> Result<EventCounts> {
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,
@@ -363,6 +437,77 @@ pub fn print_count(result: &CountResult) {
}
}
// ---------------------------------------------------------------------------
// 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;
@@ -381,4 +526,99 @@ mod tests {
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);
}
}

View File

@@ -22,8 +22,9 @@ pub mod who;
pub use auth_test::run_auth_test;
pub use count::{
print_count, print_count_json, print_event_count, print_event_count_json, run_count,
run_count_events,
print_count, print_count_json, print_event_count, print_event_count_json,
print_reference_count, print_reference_count_json, run_count, run_count_events,
run_count_references,
};
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};

View File

@@ -1102,8 +1102,8 @@ pub struct RelatedArgs {
#[derive(Parser)]
pub struct CountArgs {
/// Entity type to count (issues, mrs, discussions, notes, events)
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
/// Entity type to count (issues, mrs, discussions, notes, events, references)
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events", "references"])]
pub entity: String,
/// Parent type filter: issue or mr (for discussions/notes)

View File

@@ -17,12 +17,13 @@ use lore::cli::commands::{
print_event_count_json, print_file_history, print_file_history_json, print_generate_docs,
print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues,
print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes,
print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_related,
print_related_json, print_search_results, print_search_results_json, print_show_issue,
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed,
print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_reference_count,
print_reference_count_json, print_related, print_related_json, print_search_results,
print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr,
print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta,
print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test,
run_count, run_count_events, run_count_references, run_doctor, run_drift, run_embed,
run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues,
run_list_mrs, run_related, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
run_sync_status, run_timeline, run_tui, run_who,
@@ -1224,6 +1225,16 @@ async fn handle_count(
return Ok(());
}
if args.entity == "references" {
let result = run_count_references(&config)?;
if robot_mode {
print_reference_count_json(&result, start.elapsed().as_millis() as u64);
} else {
print_reference_count(&result);
}
return Ok(());
}
let result = run_count(&config, &args.entity, args.for_entity.as_deref())?;
if robot_mode {
print_count_json(&result, start.elapsed().as_millis() as u64);