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:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-8con
|
bd-2ez
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
|||||||
),
|
),
|
||||||
("show", &["--project"]),
|
("show", &["--project"]),
|
||||||
("reset", &["--yes"]),
|
("reset", &["--yes"]),
|
||||||
|
("related", &["--limit", "--project"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Valid values for enum-like flags, used for post-clap error enhancement.
|
/// Valid values for enum-like flags, used for post-clap error enhancement.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::cli::render::{self, Theme};
|
use crate::cli::render::{self, Theme};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -211,6 +213,78 @@ pub fn run_count_events(config: &Config) -> Result<EventCounts> {
|
|||||||
events_db::count_events(&conn)
|
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)]
|
#[derive(Serialize)]
|
||||||
struct EventCountJsonOutput {
|
struct EventCountJsonOutput {
|
||||||
ok: bool,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::cli::render;
|
use crate::cli::render;
|
||||||
@@ -381,4 +526,99 @@ mod tests {
|
|||||||
assert_eq!(render::format_number(12345), "12,345");
|
assert_eq!(render::format_number(12345), "12,345");
|
||||||
assert_eq!(render::format_number(1234567), "1,234,567");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ pub mod who;
|
|||||||
|
|
||||||
pub use auth_test::run_auth_test;
|
pub use auth_test::run_auth_test;
|
||||||
pub use count::{
|
pub use count::{
|
||||||
print_count, print_count_json, print_event_count, print_event_count_json, run_count,
|
print_count, print_count_json, print_event_count, print_event_count_json,
|
||||||
run_count_events,
|
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 doctor::{DoctorChecks, print_doctor_results, run_doctor};
|
||||||
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
|
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
|
||||||
|
|||||||
@@ -1102,8 +1102,8 @@ pub struct RelatedArgs {
|
|||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
pub struct CountArgs {
|
pub struct CountArgs {
|
||||||
/// Entity type to count (issues, mrs, discussions, notes, events)
|
/// Entity type to count (issues, mrs, discussions, notes, events, references)
|
||||||
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
|
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events", "references"])]
|
||||||
pub entity: String,
|
pub entity: String,
|
||||||
|
|
||||||
/// Parent type filter: issue or mr (for discussions/notes)
|
/// Parent type filter: issue or mr (for discussions/notes)
|
||||||
|
|||||||
23
src/main.rs
23
src/main.rs
@@ -17,12 +17,13 @@ use lore::cli::commands::{
|
|||||||
print_event_count_json, print_file_history, print_file_history_json, print_generate_docs,
|
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_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_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_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_reference_count,
|
||||||
print_related_json, print_search_results, print_search_results_json, print_show_issue,
|
print_reference_count_json, print_related, print_related_json, print_search_results,
|
||||||
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
|
print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr,
|
||||||
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
|
print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
|
||||||
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
|
print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta,
|
||||||
query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed,
|
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_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_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,
|
run_sync_status, run_timeline, run_tui, run_who,
|
||||||
@@ -1224,6 +1225,16 @@ async fn handle_count(
|
|||||||
return Ok(());
|
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())?;
|
let result = run_count(&config, &args.entity, args.for_entity.as_deref())?;
|
||||||
if robot_mode {
|
if robot_mode {
|
||||||
print_count_json(&result, start.elapsed().as_millis() as u64);
|
print_count_json(&result, start.elapsed().as_millis() as u64);
|
||||||
|
|||||||
Reference in New Issue
Block a user