feat: implement lore explain command (bd-9lbr)

Auto-generates structured narratives for issues and MRs from local DB:
- EntitySummary with title, state, author, labels, status
- Key decisions heuristic (correlates state/label changes with nearby notes)
- Activity summary with event counts and time span
- Open threads detection (unresolved discussions)
- Related entities (closing MRs, related issues)
- Timeline of all events in chronological order

7 unit tests, robot-docs entry, autocorrect registry, CLI dispatch wired.
This commit is contained in:
teernisse
2026-02-19 09:26:54 -05:00
parent 1e679a6d72
commit e8ecb561cf
10 changed files with 2352 additions and 79 deletions

File diff suppressed because one or more lines are too long

View File

@@ -28,8 +28,8 @@ pub fn check_schema_version(conn: &Connection, minimum: i32) -> SchemaCheck {
return SchemaCheck::NoDB; return SchemaCheck::NoDB;
} }
// Read the current version. // Read the highest version (one row per migration).
match conn.query_row("SELECT version FROM schema_version LIMIT 1", [], |r| { match conn.query_row("SELECT MAX(version) FROM schema_version", [], |r| {
r.get::<_, i32>(0) r.get::<_, i32>(0)
}) { }) {
Ok(version) if version >= minimum => SchemaCheck::Compatible { version }, Ok(version) if version >= minimum => SchemaCheck::Compatible { version },
@@ -65,7 +65,7 @@ pub fn check_data_readiness(conn: &Connection) -> Result<DataReadiness> {
.unwrap_or(false); .unwrap_or(false);
let schema_version = conn let schema_version = conn
.query_row("SELECT version FROM schema_version LIMIT 1", [], |r| { .query_row("SELECT MAX(version) FROM schema_version", [], |r| {
r.get::<_, i32>(0) r.get::<_, i32>(0)
}) })
.unwrap_or(0); .unwrap_or(0);
@@ -247,6 +247,24 @@ mod tests {
assert!(matches!(result, SchemaCheck::NoDB)); assert!(matches!(result, SchemaCheck::NoDB));
} }
#[test]
fn test_schema_preflight_multiple_migration_rows() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER, applied_at INTEGER, description TEXT);
INSERT INTO schema_version VALUES (1, 0, 'Initial');
INSERT INTO schema_version VALUES (2, 0, 'Second');
INSERT INTO schema_version VALUES (27, 0, 'Latest');",
)
.unwrap();
let result = check_schema_version(&conn, 20);
assert!(
matches!(result, SchemaCheck::Compatible { version: 27 }),
"should use MAX(version), not first row: {result:?}"
);
}
#[test] #[test]
fn test_check_data_readiness_empty() { fn test_check_data_readiness_empty() {
let conn = Connection::open_in_memory().unwrap(); let conn = Connection::open_in_memory().unwrap();

View File

@@ -71,6 +71,8 @@ pub struct LaunchOptions {
/// 2. **Data readiness** — check whether the database has any entity data. /// 2. **Data readiness** — check whether the database has any entity data.
/// If empty, start on the Bootstrap screen; otherwise start on Dashboard. /// If empty, start on the Bootstrap screen; otherwise start on Dashboard.
pub fn launch_tui(options: LaunchOptions) -> Result<()> { pub fn launch_tui(options: LaunchOptions) -> Result<()> {
let _options = options; // remaining fields (fresh, ascii, etc.) consumed in later phases
// 1. Resolve database path. // 1. Resolve database path.
let db_path = lore::core::paths::get_db_path(None); let db_path = lore::core::paths::get_db_path(None);
if !db_path.exists() { if !db_path.exists() {
@@ -84,7 +86,7 @@ pub fn launch_tui(options: LaunchOptions) -> Result<()> {
// 2. Open DB and run schema preflight. // 2. Open DB and run schema preflight.
let db = db::DbManager::open(&db_path) let db = db::DbManager::open(&db_path)
.with_context(|| format!("opening database at {}", db_path.display()))?; .with_context(|| format!("opening database at {}", db_path.display()))?;
db.with_reader(|conn| schema_preflight(conn))?; db.with_reader(schema_preflight)?;
// 3. Check data readiness — bootstrap screen if empty. // 3. Check data readiness — bootstrap screen if empty.
let start_on_bootstrap = db.with_reader(|conn| { let start_on_bootstrap = db.with_reader(|conn| {

View File

@@ -289,6 +289,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
("show", &["--project"]), ("show", &["--project"]),
("reset", &["--yes"]), ("reset", &["--yes"]),
("related", &["--limit", "--project"]), ("related", &["--limit", "--project"]),
("explain", &["--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.

View File

@@ -8,8 +8,9 @@ use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use crate::core::config::Config; use crate::core::config::Config;
use crate::core::db::{create_connection, get_db_path}; use crate::core::db::create_connection;
use crate::core::error::Result; use crate::core::error::Result;
use crate::core::paths::get_db_path;
use super::show::{ClosingMrRef, RelatedIssueRef}; use super::show::{ClosingMrRef, RelatedIssueRef};
@@ -384,20 +385,15 @@ struct IssueRow {
status_name: Option<String>, status_name: Option<String>,
} }
fn find_issue_row( fn find_issue_row(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> {
conn: &Connection,
iid: i64,
project_filter: Option<&str>,
) -> Result<IssueRow> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter { let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
Some(project) => { Some(project) => {
let project_id = resolve_project(conn, project)?; let project_id = resolve_project(conn, project)?;
( (
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
i.created_at, i.updated_at, i.web_url, i.created_at, i.updated_at, i.web_url,
ws.status_name i.status_name
FROM issues i FROM issues i
LEFT JOIN work_item_status ws ON ws.issue_id = i.id
WHERE i.iid = ?1 AND i.project_id = ?2", WHERE i.iid = ?1 AND i.project_id = ?2",
vec![ vec![
Box::new(iid) as Box<dyn rusqlite::ToSql>, Box::new(iid) as Box<dyn rusqlite::ToSql>,
@@ -408,9 +404,8 @@ fn find_issue_row(
None => ( None => (
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
i.created_at, i.updated_at, i.web_url, i.created_at, i.updated_at, i.web_url,
ws.status_name i.status_name
FROM issues i FROM issues i
LEFT JOIN work_item_status ws ON ws.issue_id = i.id
WHERE i.iid = ?1", WHERE i.iid = ?1",
vec![Box::new(iid) as Box<dyn rusqlite::ToSql>], vec![Box::new(iid) as Box<dyn rusqlite::ToSql>],
), ),
@@ -433,9 +428,9 @@ fn find_issue_row(
}) })
}) })
.map_err(|e| match e { .map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => { rusqlite::Error::QueryReturnedNoRows => crate::core::error::LoreError::NotFound(format!(
crate::core::error::LoreError::NotFound(format!("Issue #{iid} not found in local database. Run 'lore sync' first.")) "Issue #{iid} not found in local database. Run 'lore sync' first."
} )),
other => crate::core::error::LoreError::Database(other), other => crate::core::error::LoreError::Database(other),
}) })
} }
@@ -453,11 +448,7 @@ struct MrRow {
web_url: Option<String>, web_url: Option<String>,
} }
fn find_mr_row( fn find_mr_row(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<MrRow> {
conn: &Connection,
iid: i64,
project_filter: Option<&str>,
) -> Result<MrRow> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter { let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
Some(project) => { Some(project) => {
let project_id = resolve_project(conn, project)?; let project_id = resolve_project(conn, project)?;
@@ -497,9 +488,9 @@ fn find_mr_row(
}) })
}) })
.map_err(|e| match e { .map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => { rusqlite::Error::QueryReturnedNoRows => crate::core::error::LoreError::NotFound(format!(
crate::core::error::LoreError::NotFound(format!("MR !{iid} not found in local database. Run 'lore sync' first.")) "MR !{iid} not found in local database. Run 'lore sync' first."
} )),
other => crate::core::error::LoreError::Database(other), other => crate::core::error::LoreError::Database(other),
}) })
} }
@@ -518,15 +509,16 @@ fn resolve_project(conn: &Connection, project: &str) -> Result<i64> {
); );
id.map_err(|_| { id.map_err(|_| {
crate::core::error::LoreError::NotFound(format!( crate::core::error::LoreError::NotFound(format!("Project matching '{project}' not found."))
"Project matching '{project}' not found."
))
}) })
} }
fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> { fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare_cached( let mut stmt = conn.prepare_cached(
"SELECT label FROM issue_labels WHERE issue_id = ? ORDER BY label", "SELECT l.name FROM issue_labels il
JOIN labels l ON l.id = il.label_id
WHERE il.issue_id = ?
ORDER BY l.name",
)?; )?;
let labels: Vec<String> = stmt let labels: Vec<String> = stmt
.query_map([issue_id], |row| row.get(0))? .query_map([issue_id], |row| row.get(0))?
@@ -546,7 +538,10 @@ fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result<Vec<String>>
fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result<Vec<String>> { fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare_cached( let mut stmt = conn.prepare_cached(
"SELECT label FROM mr_labels WHERE merge_request_id = ? ORDER BY label", "SELECT l.name FROM mr_labels ml
JOIN labels l ON l.id = ml.label_id
WHERE ml.merge_request_id = ?
ORDER BY l.name",
)?; )?;
let labels: Vec<String> = stmt let labels: Vec<String> = stmt
.query_map([mr_id], |row| row.get(0))? .query_map([mr_id], |row| row.get(0))?
@@ -671,7 +666,7 @@ fn query_open_threads(
}; };
let sql = format!( let sql = format!(
"SELECT d.gitlab_id, "SELECT d.gitlab_discussion_id,
MIN(n.author_username) AS started_by, MIN(n.author_username) AS started_by,
MIN(n.created_at) AS started_at, MIN(n.created_at) AS started_at,
COUNT(n.id) AS note_count, COUNT(n.id) AS note_count,
@@ -806,9 +801,12 @@ fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len { if s.len() <= max_len {
s.to_string() s.to_string()
} else { } else {
// Find a char boundary near max_len // Find a char boundary at or before max_len
let truncated = &s[..s.floor_char_boundary(max_len)]; let mut end = max_len;
format!("{truncated}...") while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
} }
} }
@@ -826,7 +824,7 @@ pub fn print_explain_json(response: &ExplainResponse, elapsed_ms: u64) {
} }
pub fn print_explain_human(response: &ExplainResponse) { pub fn print_explain_human(response: &ExplainResponse) {
use crate::core::time::format_ms_relative; use crate::core::time::ms_to_iso;
// Header // Header
let entity = &response.entity; let entity = &response.entity;
@@ -841,7 +839,7 @@ pub fn print_explain_human(response: &ExplainResponse) {
"State: {} | Author: {} | Created: {}", "State: {} | Author: {} | Created: {}",
entity.state, entity.state,
entity.author, entity.author,
format_ms_relative(entity.created_at), ms_to_iso(entity.created_at),
); );
if !entity.assignees.is_empty() { if !entity.assignees.is_empty() {
@@ -864,12 +862,7 @@ pub fn print_explain_human(response: &ExplainResponse) {
if !response.key_decisions.is_empty() { if !response.key_decisions.is_empty() {
println!("--- Key Decisions ({}) ---", response.key_decisions.len()); println!("--- Key Decisions ({}) ---", response.key_decisions.len());
for d in &response.key_decisions { for d in &response.key_decisions {
println!( println!(" {} | {} | {}", ms_to_iso(d.timestamp), d.actor, d.action,);
" {} | {} | {}",
format_ms_relative(d.timestamp),
d.actor,
d.action,
);
// Show first line of the context note // Show first line of the context note
let first_line = d.context_note.lines().next().unwrap_or(""); let first_line = d.context_note.lines().next().unwrap_or("");
if !first_line.is_empty() { if !first_line.is_empty() {
@@ -886,11 +879,7 @@ pub fn print_explain_human(response: &ExplainResponse) {
a.state_changes, a.label_changes, a.notes, a.state_changes, a.label_changes, a.notes,
); );
if let (Some(first), Some(last)) = (a.first_event, a.last_event) { if let (Some(first), Some(last)) = (a.first_event, a.last_event) {
println!( println!(" Span: {} to {}", ms_to_iso(first), ms_to_iso(last),);
" Span: {} to {}",
format_ms_relative(first),
format_ms_relative(last),
);
} }
println!(); println!();
@@ -903,7 +892,7 @@ pub fn print_explain_human(response: &ExplainResponse) {
t.discussion_id, t.discussion_id,
t.started_by, t.started_by,
t.note_count, t.note_count,
format_ms_relative(t.last_note_at), ms_to_iso(t.last_note_at),
); );
} }
println!(); println!();
@@ -935,7 +924,7 @@ pub fn print_explain_human(response: &ExplainResponse) {
for e in &response.timeline_excerpt { for e in &response.timeline_excerpt {
println!( println!(
" {} | {} | {} | {}", " {} | {} | {} | {}",
format_ms_relative(e.timestamp), ms_to_iso(e.timestamp),
e.event_type, e.event_type,
e.actor, e.actor,
e.summary.lines().next().unwrap_or(""), e.summary.lines().next().unwrap_or(""),
@@ -971,8 +960,8 @@ mod tests {
fn insert_issue(conn: &Connection, id: i64, iid: i64, title: &str) { fn insert_issue(conn: &Connection, id: i64, iid: i64, title: &str) {
conn.execute( conn.execute(
"INSERT INTO issues (id, project_id, gitlab_id, iid, title, state, author_username, created_at, updated_at) "INSERT INTO issues (id, project_id, gitlab_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?1, ?2, ?3, 'opened', 'alice', 1000000, 2000000)", VALUES (?1, 1, ?1, ?2, ?3, 'opened', 'alice', 1000000, 2000000, 9999999)",
rusqlite::params![id, iid, title], rusqlite::params![id, iid, title],
) )
.unwrap(); .unwrap();
@@ -980,37 +969,60 @@ mod tests {
fn insert_mr(conn: &Connection, id: i64, iid: i64, title: &str) { fn insert_mr(conn: &Connection, id: i64, iid: i64, title: &str) {
conn.execute( conn.execute(
"INSERT INTO merge_requests (id, project_id, gitlab_id, iid, title, state, author_username, source_branch, target_branch, created_at, updated_at) "INSERT INTO merge_requests (id, project_id, gitlab_id, iid, title, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?1, ?2, ?3, 'merged', 'bob', 'feat', 'main', 1000000, 2000000)", VALUES (?1, 1, ?1, ?2, ?3, 'merged', 'bob', 'feat', 'main', 1000000, 2000000, 9999999)",
rusqlite::params![id, iid, title], rusqlite::params![id, iid, title],
) )
.unwrap(); .unwrap();
} }
fn insert_discussion(conn: &Connection, id: i64, gitlab_id: &str, noteable_type: &str, entity_id: i64, resolved: bool) { fn insert_discussion(
conn: &Connection,
id: i64,
gitlab_id: &str,
noteable_type: &str,
entity_id: i64,
resolved: bool,
) {
let (issue_id, mr_id) = match noteable_type { let (issue_id, mr_id) = match noteable_type {
"Issue" => (Some(entity_id), None), "Issue" => (Some(entity_id), None),
"MergeRequest" => (None, Some(entity_id)), "MergeRequest" => (None, Some(entity_id)),
_ => panic!("bad noteable_type"), _ => panic!("bad noteable_type"),
}; };
conn.execute( conn.execute(
"INSERT INTO discussions (id, gitlab_id, project_id, noteable_type, issue_id, merge_request_id, resolved, individual_note) "INSERT INTO discussions (id, gitlab_discussion_id, project_id, noteable_type, issue_id, merge_request_id, resolved, individual_note, last_seen_at)
VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, 0)", VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, 0, 9999999)",
rusqlite::params![id, gitlab_id, noteable_type, issue_id, mr_id, resolved], rusqlite::params![id, gitlab_id, noteable_type, issue_id, mr_id, resolved],
) )
.unwrap(); .unwrap();
} }
fn insert_note(conn: &Connection, id: i64, discussion_id: i64, author: &str, body: &str, created_at: i64, is_system: bool) { fn insert_note(
conn: &Connection,
id: i64,
discussion_id: i64,
author: &str,
body: &str,
created_at: i64,
is_system: bool,
) {
conn.execute( conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, author_username, body, created_at, updated_at, is_system, noteable_type) "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, is_system, last_seen_at)
VALUES (?1, ?1, ?2, ?3, ?4, ?5, ?5, ?6, 'Issue')", VALUES (?1, ?1, ?2, 1, ?3, ?4, ?5, ?5, ?6, 9999999)",
rusqlite::params![id, discussion_id, author, body, created_at, is_system], rusqlite::params![id, discussion_id, author, body, created_at, is_system],
) )
.unwrap(); .unwrap();
} }
fn insert_state_event(conn: &Connection, id: i64, issue_id: Option<i64>, mr_id: Option<i64>, state: &str, actor: &str, created_at: i64) { fn insert_state_event(
conn: &Connection,
id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
state: &str,
actor: &str,
created_at: i64,
) {
conn.execute( conn.execute(
"INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) "INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6)", VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6)",
@@ -1019,7 +1031,17 @@ mod tests {
.unwrap(); .unwrap();
} }
fn insert_label_event(conn: &Connection, id: i64, issue_id: Option<i64>, mr_id: Option<i64>, action: &str, label: &str, actor: &str, created_at: i64) { #[allow(clippy::too_many_arguments)]
fn insert_label_event(
conn: &Connection,
id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
action: &str,
label: &str,
actor: &str,
created_at: i64,
) {
conn.execute( conn.execute(
"INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) "INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7)", VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7)",
@@ -1064,7 +1086,15 @@ mod tests {
// Note by alice at t=1000000 + 30min (within 60min window) // Note by alice at t=1000000 + 30min (within 60min window)
insert_discussion(&conn, 1, "disc-1", "Issue", 1, true); insert_discussion(&conn, 1, "disc-1", "Issue", 1, true);
insert_note(&conn, 1, 1, "alice", "Fixed by reverting the config change", 1_000_000 + 30 * 60 * 1000, false); insert_note(
&conn,
1,
1,
"alice",
"Fixed by reverting the config change",
1_000_000 + 30 * 60 * 1000,
false,
);
let response = explain_issue(&conn, 42, Some("group/repo")).unwrap(); let response = explain_issue(&conn, 42, Some("group/repo")).unwrap();
@@ -1085,7 +1115,15 @@ mod tests {
// Note by BOB (different actor) within window // Note by BOB (different actor) within window
insert_discussion(&conn, 1, "disc-1", "Issue", 1, true); insert_discussion(&conn, 1, "disc-1", "Issue", 1, true);
insert_note(&conn, 1, 1, "bob", "Why was this closed?", 1_000_000 + 10 * 60 * 1000, false); insert_note(
&conn,
1,
1,
"bob",
"Why was this closed?",
1_000_000 + 10 * 60 * 1000,
false,
);
let response = explain_issue(&conn, 42, Some("group/repo")).unwrap(); let response = explain_issue(&conn, 42, Some("group/repo")).unwrap();
@@ -1105,7 +1143,15 @@ mod tests {
// Unresolved discussion // Unresolved discussion
insert_discussion(&conn, 2, "disc-open", "Issue", 1, false); insert_discussion(&conn, 2, "disc-open", "Issue", 1, false);
insert_note(&conn, 2, 2, "bob", "What about edge cases?", 1600000, false); insert_note(&conn, 2, 2, "bob", "What about edge cases?", 1600000, false);
insert_note(&conn, 3, 2, "alice", "Good point, investigating", 1700000, false); insert_note(
&conn,
3,
2,
"alice",
"Good point, investigating",
1700000,
false,
);
let response = explain_issue(&conn, 42, Some("group/repo")).unwrap(); let response = explain_issue(&conn, 42, Some("group/repo")).unwrap();

View File

@@ -0,0 +1,696 @@
use serde::Serialize;
use crate::cli::WhoArgs;
use crate::cli::render::{self, Icons, Theme};
use crate::cli::robot::RobotMeta;
use crate::core::time::ms_to_iso;
use crate::core::who_types::{
ActiveResult, ExpertResult, OverlapResult, ReviewsResult, WhoResult, WorkloadResult,
};
use super::queries::format_overlap_role;
use super::{WhoRun, WhoResolvedInput};
// ─── Human Output ────────────────────────────────────────────────────────────
pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {
match result {
WhoResult::Expert(r) => print_expert_human(r, project_path),
WhoResult::Workload(r) => print_workload_human(r),
WhoResult::Reviews(r) => print_reviews_human(r),
WhoResult::Active(r) => print_active_human(r, project_path),
WhoResult::Overlap(r) => print_overlap_human(r, project_path),
}
}
/// Print a dim hint when results aggregate across all projects.
fn print_scope_hint(project_path: Option<&str>) {
if project_path.is_none() {
println!(
" {}",
Theme::dim().render("(aggregated across all projects; use -p to scope)")
);
}
}
fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
println!();
println!(
"{}",
Theme::bold().render(&format!("Experts for {}", r.path_query))
);
println!("{}", "\u{2500}".repeat(60));
println!(
" {}",
Theme::dim().render(&format!(
"(matching {} {})",
r.path_match,
if r.path_match == "exact" {
"file"
} else {
"directory prefix"
}
))
);
print_scope_hint(project_path);
println!();
if r.experts.is_empty() {
println!(
" {}",
Theme::dim().render("No experts found for this path.")
);
println!();
return;
}
println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {} {}",
Theme::bold().render("Username"),
Theme::bold().render("Score"),
Theme::bold().render("Reviewed(MRs)"),
Theme::bold().render("Notes"),
Theme::bold().render("Authored(MRs)"),
Theme::bold().render("Last Seen"),
Theme::bold().render("MR Refs"),
);
for expert in &r.experts {
let reviews = if expert.review_mr_count > 0 {
expert.review_mr_count.to_string()
} else {
"-".to_string()
};
let notes = if expert.review_note_count > 0 {
expert.review_note_count.to_string()
} else {
"-".to_string()
};
let authored = if expert.author_mr_count > 0 {
expert.author_mr_count.to_string()
} else {
"-".to_string()
};
let mr_str = expert
.mr_refs
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let overflow = if expert.mr_refs_total > 5 {
format!(" +{}", expert.mr_refs_total - 5)
} else {
String::new()
};
println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
Theme::info().render(&format!("{} {}", Icons::user(), expert.username)),
expert.score,
reviews,
notes,
authored,
render::format_relative_time(expert.last_seen_ms),
if mr_str.is_empty() {
String::new()
} else {
format!(" {mr_str}")
},
overflow,
);
// Print detail sub-rows when populated
if let Some(details) = &expert.details {
const MAX_DETAIL_DISPLAY: usize = 10;
for d in details.iter().take(MAX_DETAIL_DISPLAY) {
let notes_str = if d.note_count > 0 {
format!("{} notes", d.note_count)
} else {
String::new()
};
println!(
" {:<3} {:<30} {:>30} {:>10} {}",
Theme::dim().render(&d.role),
d.mr_ref,
render::truncate(&format!("\"{}\"", d.title), 30),
notes_str,
Theme::dim().render(&render::format_relative_time(d.last_activity_ms)),
);
}
if details.len() > MAX_DETAIL_DISPLAY {
println!(
" {}",
Theme::dim().render(&format!("+{} more", details.len() - MAX_DETAIL_DISPLAY))
);
}
}
}
if r.truncated {
println!(
" {}",
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
}
fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
"{}",
Theme::bold().render(&format!(
"{} {} -- Workload Summary",
Icons::user(),
r.username
))
);
println!("{}", "\u{2500}".repeat(60));
if !r.assigned_issues.is_empty() {
println!(
"{}",
render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len()))
);
for item in &r.assigned_issues {
println!(
" {} {} {}",
Theme::info().render(&item.ref_),
render::truncate(&item.title, 40),
Theme::dim().render(&render::format_relative_time(item.updated_at)),
);
}
if r.assigned_issues_truncated {
println!(
" {}",
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
if !r.authored_mrs.is_empty() {
println!(
"{}",
render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len()))
);
for mr in &r.authored_mrs {
let draft = if mr.draft { " [draft]" } else { "" };
println!(
" {} {}{} {}",
Theme::info().render(&mr.ref_),
render::truncate(&mr.title, 35),
Theme::dim().render(draft),
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
);
}
if r.authored_mrs_truncated {
println!(
" {}",
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
if !r.reviewing_mrs.is_empty() {
println!(
"{}",
render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len()))
);
for mr in &r.reviewing_mrs {
let author = mr
.author_username
.as_deref()
.map(|a| format!(" by @{a}"))
.unwrap_or_default();
println!(
" {} {}{} {}",
Theme::info().render(&mr.ref_),
render::truncate(&mr.title, 30),
Theme::dim().render(&author),
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
);
}
if r.reviewing_mrs_truncated {
println!(
" {}",
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
if !r.unresolved_discussions.is_empty() {
println!(
"{}",
render::section_divider(&format!(
"Unresolved Discussions ({})",
r.unresolved_discussions.len()
))
);
for disc in &r.unresolved_discussions {
println!(
" {} {} {} {}",
Theme::dim().render(&disc.entity_type),
Theme::info().render(&disc.ref_),
render::truncate(&disc.entity_title, 35),
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
);
}
if r.unresolved_discussions_truncated {
println!(
" {}",
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
if r.assigned_issues.is_empty()
&& r.authored_mrs.is_empty()
&& r.reviewing_mrs.is_empty()
&& r.unresolved_discussions.is_empty()
{
println!();
println!(
" {}",
Theme::dim().render("No open work items found for this user.")
);
}
println!();
}
fn print_reviews_human(r: &ReviewsResult) {
println!();
println!(
"{}",
Theme::bold().render(&format!(
"{} {} -- Review Patterns",
Icons::user(),
r.username
))
);
println!("{}", "\u{2500}".repeat(60));
println!();
if r.total_diffnotes == 0 {
println!(
" {}",
Theme::dim().render("No review comments found for this user.")
);
println!();
return;
}
println!(
" {} DiffNotes across {} MRs ({} categorized)",
Theme::bold().render(&r.total_diffnotes.to_string()),
Theme::bold().render(&r.mrs_reviewed.to_string()),
Theme::bold().render(&r.categorized_count.to_string()),
);
println!();
if !r.categories.is_empty() {
println!(
" {:<16} {:>6} {:>6}",
Theme::bold().render("Category"),
Theme::bold().render("Count"),
Theme::bold().render("%"),
);
for cat in &r.categories {
println!(
" {:<16} {:>6} {:>5.1}%",
Theme::info().render(&cat.name),
cat.count,
cat.percentage,
);
}
}
let uncategorized = r.total_diffnotes - r.categorized_count;
if uncategorized > 0 {
println!();
println!(
" {} {} uncategorized (no **prefix** convention)",
Theme::dim().render("Note:"),
uncategorized,
);
}
println!();
}
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));
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!();
}
fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!();
println!(
"{}",
Theme::bold().render(&format!("Overlap for {}", r.path_query))
);
println!("{}", "\u{2500}".repeat(60));
println!(
" {}",
Theme::dim().render(&format!(
"(matching {} {})",
r.path_match,
if r.path_match == "exact" {
"file"
} else {
"directory prefix"
}
))
);
print_scope_hint(project_path);
println!();
if r.users.is_empty() {
println!(
" {}",
Theme::dim().render("No overlapping users found for this path.")
);
println!();
return;
}
println!(
" {:<16} {:<6} {:>7} {:<12} {}",
Theme::bold().render("Username"),
Theme::bold().render("Role"),
Theme::bold().render("MRs"),
Theme::bold().render("Last Seen"),
Theme::bold().render("MR Refs"),
);
for user in &r.users {
let mr_str = user
.mr_refs
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let overflow = if user.mr_refs.len() > 5 {
format!(" +{}", user.mr_refs.len() - 5)
} else {
String::new()
};
println!(
" {:<16} {:<6} {:>7} {:<12} {}{}",
Theme::info().render(&format!("{} {}", Icons::user(), user.username)),
format_overlap_role(user),
user.touch_count,
render::format_relative_time(user.last_seen_at),
mr_str,
overflow,
);
}
if r.truncated {
println!(
" {}",
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
}
// ─── Robot JSON Output ───────────────────────────────────────────────────────
pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) {
let (mode, data) = match &run.result {
WhoResult::Expert(r) => ("expert", expert_to_json(r)),
WhoResult::Workload(r) => ("workload", workload_to_json(r)),
WhoResult::Reviews(r) => ("reviews", reviews_to_json(r)),
WhoResult::Active(r) => ("active", active_to_json(r)),
WhoResult::Overlap(r) => ("overlap", overlap_to_json(r)),
};
// Raw CLI args -- what the user typed
let input = serde_json::json!({
"target": args.target,
"path": args.path,
"project": args.project,
"since": args.since,
"limit": args.limit,
"detail": args.detail,
"as_of": args.as_of,
"explain_score": args.explain_score,
"include_bots": args.include_bots,
"all_history": args.all_history,
});
// Resolved/computed values -- what actually ran
let resolved_input = serde_json::json!({
"mode": run.resolved_input.mode,
"project_id": run.resolved_input.project_id,
"project_path": run.resolved_input.project_path,
"since_ms": run.resolved_input.since_ms,
"since_iso": run.resolved_input.since_iso,
"since_mode": run.resolved_input.since_mode,
"limit": run.resolved_input.limit,
});
let output = WhoJsonEnvelope {
ok: true,
data: WhoJsonData {
mode: mode.to_string(),
input,
resolved_input,
result: data,
},
meta: RobotMeta { elapsed_ms },
};
let mut value = serde_json::to_value(&output).unwrap_or_else(|e| {
serde_json::json!({"ok":false,"error":{"code":"INTERNAL_ERROR","message":format!("JSON serialization failed: {e}")}})
});
if let Some(f) = &args.fields {
let preset_key = format!("who_{mode}");
let expanded = crate::cli::robot::expand_fields_preset(f, &preset_key);
// Each who mode uses a different array key; try all possible keys
for key in &[
"experts",
"assigned_issues",
"authored_mrs",
"review_mrs",
"categories",
"discussions",
"users",
] {
crate::cli::robot::filter_fields(&mut value, key, &expanded);
}
}
println!("{}", serde_json::to_string(&value).unwrap());
}
#[derive(Serialize)]
struct WhoJsonEnvelope {
ok: bool,
data: WhoJsonData,
meta: RobotMeta,
}
#[derive(Serialize)]
struct WhoJsonData {
mode: String,
input: serde_json::Value,
resolved_input: serde_json::Value,
#[serde(flatten)]
result: serde_json::Value,
}
fn expert_to_json(r: &ExpertResult) -> serde_json::Value {
serde_json::json!({
"path_query": r.path_query,
"path_match": r.path_match,
"scoring_model_version": 2,
"truncated": r.truncated,
"experts": r.experts.iter().map(|e| {
let mut obj = serde_json::json!({
"username": e.username,
"score": e.score,
"review_mr_count": e.review_mr_count,
"review_note_count": e.review_note_count,
"author_mr_count": e.author_mr_count,
"last_seen_at": ms_to_iso(e.last_seen_ms),
"mr_refs": e.mr_refs,
"mr_refs_total": e.mr_refs_total,
"mr_refs_truncated": e.mr_refs_truncated,
});
if let Some(raw) = e.score_raw {
obj["score_raw"] = serde_json::json!(raw);
}
if let Some(comp) = &e.components {
obj["components"] = serde_json::json!({
"author": comp.author,
"reviewer_participated": comp.reviewer_participated,
"reviewer_assigned": comp.reviewer_assigned,
"notes": comp.notes,
});
}
if let Some(details) = &e.details {
obj["details"] = serde_json::json!(details.iter().map(|d| serde_json::json!({
"mr_ref": d.mr_ref,
"title": d.title,
"role": d.role,
"note_count": d.note_count,
"last_activity_at": ms_to_iso(d.last_activity_ms),
})).collect::<Vec<_>>());
}
obj
}).collect::<Vec<_>>(),
})
}
fn workload_to_json(r: &WorkloadResult) -> serde_json::Value {
serde_json::json!({
"username": r.username,
"assigned_issues": r.assigned_issues.iter().map(|i| serde_json::json!({
"iid": i.iid,
"ref": i.ref_,
"title": i.title,
"project_path": i.project_path,
"updated_at": ms_to_iso(i.updated_at),
})).collect::<Vec<_>>(),
"authored_mrs": r.authored_mrs.iter().map(|m| serde_json::json!({
"iid": m.iid,
"ref": m.ref_,
"title": m.title,
"draft": m.draft,
"project_path": m.project_path,
"updated_at": ms_to_iso(m.updated_at),
})).collect::<Vec<_>>(),
"reviewing_mrs": r.reviewing_mrs.iter().map(|m| serde_json::json!({
"iid": m.iid,
"ref": m.ref_,
"title": m.title,
"draft": m.draft,
"project_path": m.project_path,
"author_username": m.author_username,
"updated_at": ms_to_iso(m.updated_at),
})).collect::<Vec<_>>(),
"unresolved_discussions": r.unresolved_discussions.iter().map(|d| serde_json::json!({
"entity_type": d.entity_type,
"entity_iid": d.entity_iid,
"ref": d.ref_,
"entity_title": d.entity_title,
"project_path": d.project_path,
"last_note_at": ms_to_iso(d.last_note_at),
})).collect::<Vec<_>>(),
"summary": {
"assigned_issue_count": r.assigned_issues.len(),
"authored_mr_count": r.authored_mrs.len(),
"reviewing_mr_count": r.reviewing_mrs.len(),
"unresolved_discussion_count": r.unresolved_discussions.len(),
},
"truncation": {
"assigned_issues_truncated": r.assigned_issues_truncated,
"authored_mrs_truncated": r.authored_mrs_truncated,
"reviewing_mrs_truncated": r.reviewing_mrs_truncated,
"unresolved_discussions_truncated": r.unresolved_discussions_truncated,
}
})
}
fn reviews_to_json(r: &ReviewsResult) -> serde_json::Value {
serde_json::json!({
"username": r.username,
"total_diffnotes": r.total_diffnotes,
"categorized_count": r.categorized_count,
"mrs_reviewed": r.mrs_reviewed,
"categories": r.categories.iter().map(|c| serde_json::json!({
"name": c.name,
"count": c.count,
"percentage": (c.percentage * 10.0).round() / 10.0,
})).collect::<Vec<_>>(),
})
}
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<_>>(),
})
}
fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
serde_json::json!({
"path_query": r.path_query,
"path_match": r.path_match,
"truncated": r.truncated,
"users": r.users.iter().map(|u| serde_json::json!({
"username": u.username,
"role": format_overlap_role(u),
"author_touch_count": u.author_touch_count,
"review_touch_count": u.review_touch_count,
"touch_count": u.touch_count,
"last_seen_at": ms_to_iso(u.last_seen_at),
"mr_refs": u.mr_refs,
"mr_refs_total": u.mr_refs_total,
"mr_refs_truncated": u.mr_refs_truncated,
})).collect::<Vec<_>>(),
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
// ─── Scoring Helpers ─────────────────────────────────────────────────────────
/// Exponential half-life decay: `2^(-days / half_life)`.
///
/// Returns a value in `[0.0, 1.0]` representing how much of an original signal
/// is retained after `elapsed_ms` milliseconds, given a `half_life_days` period.
/// At `elapsed=0` the signal is fully retained (1.0); at `elapsed=half_life`
/// exactly half remains (0.5); the signal halves again for each additional
/// half-life period.
///
/// Returns `0.0` when `half_life_days` is zero (prevents division by zero).
/// Negative elapsed values are clamped to zero (future events retain full weight).
pub fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
let hl = f64::from(half_life_days);
if hl <= 0.0 {
return 0.0;
}
2.0_f64.powf(-days / hl)
}

View File

@@ -250,6 +250,20 @@ pub enum Commands {
#[command(visible_alias = "similar")] #[command(visible_alias = "similar")]
Related(RelatedArgs), Related(RelatedArgs),
/// Auto-generate a structured narrative for an issue or MR
Explain {
/// Entity type: "issues" or "mrs"
#[arg(value_parser = ["issues", "issue", "mrs", "mr"])]
entity_type: String,
/// Entity IID
iid: i64,
/// Scope to project (fuzzy match)
#[arg(short, long)]
project: Option<String>,
},
/// Detect discussion divergence from original intent /// Detect discussion divergence from original intent
Drift { Drift {
/// Entity type (currently only "issues" supported) /// Entity type (currently only "issues" supported)

View File

@@ -14,19 +14,20 @@ use lore::cli::commands::{
open_issue_in_browser, open_mr_in_browser, parse_trace_path, print_count, print_count_json, open_issue_in_browser, open_mr_in_browser, parse_trace_path, print_count, print_count_json,
print_doctor_results, print_drift_human, print_drift_json, print_dry_run_preview, print_doctor_results, print_drift_human, print_drift_json, print_dry_run_preview,
print_dry_run_preview_json, print_embed, print_embed_json, print_event_count, print_dry_run_preview_json, print_embed, print_embed_json, print_event_count,
print_event_count_json, print_file_history, print_file_history_json, print_generate_docs, print_event_count_json, print_explain_human, print_explain_json, print_file_history,
print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues, print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes, print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_reference_count, print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json,
print_reference_count_json, print_related, print_related_json, print_search_results, print_list_notes_jsonl, print_reference_count, print_reference_count_json, print_related,
print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr, print_related_json, print_search_results, print_search_results_json, print_show_issue,
print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json, print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta, print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test, print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
run_count, run_count_events, run_count_references, run_doctor, run_drift, run_embed, query_notes, run_auth_test, run_count, run_count_events, run_count_references, run_doctor,
run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_drift, run_embed, run_explain, run_file_history, run_generate_docs, run_ingest,
run_list_mrs, run_related, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs, run_related, run_search,
run_sync_status, run_timeline, run_tui, run_who, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, run_tui,
run_who,
}; };
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme}; use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas}; use lore::cli::robot::{RobotMeta, strip_schemas};
@@ -210,6 +211,17 @@ async fn main() {
handle_related(cli.config.as_deref(), args, robot_mode).await handle_related(cli.config.as_deref(), args, robot_mode).await
} }
Some(Commands::Tui(args)) => run_tui(&args, robot_mode), Some(Commands::Tui(args)) => run_tui(&args, robot_mode),
Some(Commands::Explain {
entity_type,
iid,
project,
}) => handle_explain(
cli.config.as_deref(),
&entity_type,
iid,
project.as_deref(),
robot_mode,
),
Some(Commands::Drift { Some(Commands::Drift {
entity_type, entity_type,
iid, iid,
@@ -734,6 +746,7 @@ fn suggest_similar_command(invalid: &str) -> String {
("who", "who"), ("who", "who"),
("notes", "notes"), ("notes", "notes"),
("note", "notes"), ("note", "notes"),
("explain", "explain"),
("drift", "drift"), ("drift", "drift"),
("file-history", "file-history"), ("file-history", "file-history"),
("trace", "trace"), ("trace", "trace"),
@@ -2814,6 +2827,17 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"meta": {"elapsed_ms": "int", "total_mrs": "int", "renames_followed": "bool", "paths_searched": "int"} "meta": {"elapsed_ms": "int", "total_mrs": "int", "renames_followed": "bool", "paths_searched": "int"}
} }
}, },
"explain": {
"description": "Auto-generate a structured narrative for an issue or MR",
"flags": ["<entity_type: issues|mrs>", "<IID>", "-p/--project <path>"],
"example": "lore --robot explain issues 42",
"notes": "Template-based (no LLM), deterministic. Sections: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt.",
"response_schema": {
"ok": "bool",
"data": "ExplainResponse with entity{type,iid,title,state,author,assignees,labels,created_at,updated_at,url?,status_name?}, description_excerpt, key_decisions[{timestamp,actor,action,context_note}], activity{state_changes,label_changes,notes,first_event?,last_event?}, open_threads[{discussion_id,started_by,started_at,note_count,last_note_at}], related{closing_mrs[],related_issues[]}, timeline_excerpt[{timestamp,event_type,actor,summary}]",
"meta": {"elapsed_ms": "int"}
}
},
"drift": { "drift": {
"description": "Detect discussion divergence from original issue intent", "description": "Detect discussion divergence from original issue intent",
"flags": ["<entity_type: issues>", "<IID>", "--threshold <0.0-1.0>", "-p/--project <path>"], "flags": ["<entity_type: issues>", "<IID>", "--threshold <0.0-1.0>", "-p/--project <path>"],
@@ -2873,6 +2897,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"file-history: MRs that touched a file with rename chain resolution", "file-history: MRs that touched a file with rename chain resolution",
"trace: File -> MR -> issue -> discussion decision chain", "trace: File -> MR -> issue -> discussion decision chain",
"related: Semantic similarity discovery via vector embeddings", "related: Semantic similarity discovery via vector embeddings",
"explain: Auto-generated narrative for any issue or MR (template-based, no LLM)",
"drift: Discussion divergence detection from original intent", "drift: Discussion divergence detection from original intent",
"notes: Rich note listing with author, type, resolution, path, and discussion filters", "notes: Rich note listing with author, type, resolution, path, and discussion filters",
"stats: Database statistics with document/note/discussion counts", "stats: Database statistics with document/note/discussion counts",
@@ -3073,6 +3098,26 @@ fn handle_who(
Ok(()) Ok(())
} }
fn handle_explain(
config_override: Option<&str>,
entity_type: &str,
iid: i64,
project: Option<&str>,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let response = run_explain(&config, entity_type, iid, project)?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
print_explain_json(&response, elapsed_ms);
} else {
print_explain_human(&response);
}
Ok(())
}
async fn handle_drift( async fn handle_drift(
config_override: Option<&str>, config_override: Option<&str>,
entity_type: &str, entity_type: &str,