fix: safe uploads, AI history continuity, deep search, sudo credentials #55
@ -204,15 +204,19 @@ pub async fn chat_message(
|
||||
}
|
||||
};
|
||||
|
||||
// Load conversation history (use and_then to keep stmt lifetime within closure)
|
||||
// Load conversation history across ALL conversations for this issue
|
||||
let history: Vec<Message> = {
|
||||
let db = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let raw: Vec<(String, String)> = db
|
||||
.prepare(
|
||||
"SELECT role, content FROM ai_messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
|
||||
"SELECT am.role, am.content \
|
||||
FROM ai_messages am \
|
||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
||||
WHERE ac.issue_id = ?1 \
|
||||
ORDER BY am.created_at ASC",
|
||||
)
|
||||
.and_then(|mut stmt| {
|
||||
stmt.query_map([&conversation_id], |row| {
|
||||
stmt.query_map([&issue_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
@ -1025,4 +1029,194 @@ mod tests {
|
||||
let list = extract_list(text, "KEY_FINDINGS:");
|
||||
assert_eq!(list, vec!["Actual item"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_query_same_conversation() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["issue-1", "Test", &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["msg-1", "conv-1", "user", "Hello", 5, "2025-01-01 10:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["msg-2", "conv-1", "assistant", "Hi there", 8, "2025-01-01 10:00:01"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let issue_id = "issue-1";
|
||||
let raw: Vec<(String, String)> = conn
|
||||
.prepare(
|
||||
"SELECT am.role, am.content \
|
||||
FROM ai_messages am \
|
||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
||||
WHERE ac.issue_id = ?1 \
|
||||
ORDER BY am.created_at ASC",
|
||||
)
|
||||
.and_then(|mut stmt| {
|
||||
stmt.query_map([&issue_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(raw.len(), 2);
|
||||
assert_eq!(raw[0], ("user".to_string(), "Hello".to_string()));
|
||||
assert_eq!(raw[1], ("assistant".to_string(), "Hi there".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_query_across_conversations() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["issue-1", "Test", &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["msg-1", "conv-1", "user", "From conv 1", 5, "2025-01-01 10:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["msg-2", "conv-2", "user", "From conv 2", 5, "2025-01-01 11:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let issue_id = "issue-1";
|
||||
let raw: Vec<(String, String)> = conn
|
||||
.prepare(
|
||||
"SELECT am.role, am.content \
|
||||
FROM ai_messages am \
|
||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
||||
WHERE ac.issue_id = ?1 \
|
||||
ORDER BY am.created_at ASC",
|
||||
)
|
||||
.and_then(|mut stmt| {
|
||||
stmt.query_map([&issue_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(raw.len(), 2);
|
||||
assert_eq!(raw[0].1, "From conv 1");
|
||||
assert_eq!(raw[1].1, "From conv 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_query_empty_for_new_issue() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["issue-new", "Empty", &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let issue_id = "issue-new";
|
||||
let raw: Vec<(String, String)> = conn
|
||||
.prepare(
|
||||
"SELECT am.role, am.content \
|
||||
FROM ai_messages am \
|
||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
||||
WHERE ac.issue_id = ?1 \
|
||||
ORDER BY am.created_at ASC",
|
||||
)
|
||||
.and_then(|mut stmt| {
|
||||
stmt.query_map([&issue_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(raw.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_query_ordered_by_created_at() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["issue-1", "Test", &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
|
||||
)
|
||||
.unwrap();
|
||||
// Insert messages out of order: conv-2 message is earlier than conv-1 message
|
||||
conn.execute(
|
||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["msg-1", "conv-1", "user", "Second", 5, "2025-01-01 12:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["msg-2", "conv-2", "user", "First", 5, "2025-01-01 09:00:00"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let issue_id = "issue-1";
|
||||
let raw: Vec<(String, String)> = conn
|
||||
.prepare(
|
||||
"SELECT am.role, am.content \
|
||||
FROM ai_messages am \
|
||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
||||
WHERE ac.issue_id = ?1 \
|
||||
ORDER BY am.created_at ASC",
|
||||
)
|
||||
.and_then(|mut stmt| {
|
||||
stmt.query_map([&issue_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(raw.len(), 2);
|
||||
assert_eq!(raw[0].1, "First");
|
||||
assert_eq!(raw[1].1, "Second");
|
||||
}
|
||||
}
|
||||
|
||||
@ -347,7 +347,7 @@ pub async fn list_issues(
|
||||
let offset = filter.offset.unwrap_or(0);
|
||||
|
||||
let mut sql = String::from(
|
||||
"SELECT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
|
||||
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
|
||||
(SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \
|
||||
(SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \
|
||||
FROM issues i WHERE 1=1",
|
||||
@ -384,9 +384,19 @@ pub async fn list_issues(
|
||||
}
|
||||
if let Some(ref search) = filter.search {
|
||||
let pattern = format!("%{search}%");
|
||||
let idx = params.len() + 1;
|
||||
sql.push_str(&format!(
|
||||
" AND (i.title LIKE ?{0} OR i.description LIKE ?{0} OR i.category LIKE ?{0})",
|
||||
params.len() + 1
|
||||
" AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
|
||||
OR EXISTS (SELECT 1 FROM ai_messages am \
|
||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
||||
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
|
||||
OR EXISTS (SELECT 1 FROM resolution_steps rs \
|
||||
WHERE rs.issue_id = i.id \
|
||||
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
|
||||
OR EXISTS (SELECT 1 FROM log_files lf \
|
||||
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
|
||||
OR EXISTS (SELECT 1 FROM timeline_events te \
|
||||
WHERE te.issue_id = i.id AND te.description LIKE ?{idx}))",
|
||||
));
|
||||
params.push(Box::new(pattern));
|
||||
}
|
||||
@ -635,3 +645,163 @@ pub async fn get_timeline_events(
|
||||
.collect();
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
crate::db::migrations::run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_issue(conn: &Connection, id: &str, title: &str, description: &str) {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, assigned_to, tags) \
|
||||
VALUES (?1, ?2, ?3, 'medium', 'open', 'general', 'manual', ?4, ?5, '', '[]')",
|
||||
rusqlite::params![id, title, description, &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn run_search_query(conn: &Connection, search: &str) -> Vec<String> {
|
||||
let pattern = format!("%{search}%");
|
||||
let idx = 1;
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
|
||||
(SELECT COUNT(*) FROM log_files lf2 WHERE lf2.issue_id = i.id) as log_count, \
|
||||
(SELECT COUNT(*) FROM resolution_steps rs2 WHERE rs2.issue_id = i.id) as step_count \
|
||||
FROM issues i WHERE 1=1 \
|
||||
AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
|
||||
OR EXISTS (SELECT 1 FROM ai_messages am \
|
||||
JOIN ai_conversations ac ON ac.id = am.conversation_id \
|
||||
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
|
||||
OR EXISTS (SELECT 1 FROM resolution_steps rs \
|
||||
WHERE rs.issue_id = i.id \
|
||||
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
|
||||
OR EXISTS (SELECT 1 FROM log_files lf \
|
||||
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
|
||||
OR EXISTS (SELECT 1 FROM timeline_events te \
|
||||
WHERE te.issue_id = i.id AND te.description LIKE ?{idx})) \
|
||||
ORDER BY i.updated_at DESC"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).unwrap();
|
||||
stmt.query_map([&pattern], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_finds_by_title() {
|
||||
let conn = setup_test_db();
|
||||
insert_issue(&conn, "issue-1", "Kubernetes OOM crash", "No details");
|
||||
insert_issue(&conn, "issue-2", "Network timeout", "No details");
|
||||
|
||||
let results = run_search_query(&conn, "Kubernetes");
|
||||
assert_eq!(results, vec!["issue-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_finds_by_description() {
|
||||
let conn = setup_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
"issue-1",
|
||||
"Generic title",
|
||||
"The pod was killed due to memory pressure",
|
||||
);
|
||||
insert_issue(&conn, "issue-2", "Other", "All fine");
|
||||
|
||||
let results = run_search_query(&conn, "memory pressure");
|
||||
assert_eq!(results, vec!["issue-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_finds_by_ai_message_content() {
|
||||
let conn = setup_test_db();
|
||||
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["msg-1", "conv-1", "assistant", "The root cause is a deadlock in PostgreSQL", 10, &now],
|
||||
).unwrap();
|
||||
|
||||
let results = run_search_query(&conn, "deadlock in PostgreSQL");
|
||||
assert_eq!(results, vec!["issue-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_finds_by_resolution_step_answer() {
|
||||
let conn = setup_test_db();
|
||||
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO resolution_steps (id, issue_id, step_order, why_question, answer, evidence, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params!["step-1", "issue-1", 1, "Why did it fail?", "Connection pool exhausted", "metrics dashboard", &now],
|
||||
).unwrap();
|
||||
|
||||
let results = run_search_query(&conn, "pool exhausted");
|
||||
assert_eq!(results, vec!["issue-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_finds_by_log_file_name() {
|
||||
let conn = setup_test_db();
|
||||
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
rusqlite::params!["lf-1", "issue-1", "nginx-error-2025.log", "/tmp/nginx-error-2025.log", 1024, "text/plain", "abc", &now, 0],
|
||||
).unwrap();
|
||||
|
||||
let results = run_search_query(&conn, "nginx-error");
|
||||
assert_eq!(results, vec!["issue-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_finds_by_timeline_description() {
|
||||
let conn = setup_test_db();
|
||||
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
|
||||
conn.execute(
|
||||
"INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["te-1", "issue-1", "triage_started", "Engineer started investigating DNS resolution failure", "{}", "2025-01-15 10:00:00"],
|
||||
).unwrap();
|
||||
|
||||
let results = run_search_query(&conn, "DNS resolution");
|
||||
assert_eq!(results, vec!["issue-1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_no_duplicates_for_multiple_matches() {
|
||||
let conn = setup_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
"issue-1",
|
||||
"Kubernetes crash",
|
||||
"Kubernetes pod killed",
|
||||
);
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params!["msg-1", "conv-1", "assistant", "Kubernetes OOM detected", 5, &now],
|
||||
).unwrap();
|
||||
|
||||
let results = run_search_query(&conn, "Kubernetes");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], "issue-1");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user