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:
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
696
src/cli/commands/who/format.rs
Normal file
696
src/cli/commands/who/format.rs
Normal 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<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
1431
src/cli/commands/who/queries.rs
Normal file
1431
src/cli/commands/who/queries.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
src/cli/commands/who/scoring.rs
Normal file
20
src/cli/commands/who/scoring.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
71
src/main.rs
71
src/main.rs
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user