feat(cli): implement 'lore trace' command (bd-2n4, bd-9dd)
Gate 5 Code Trace - Tier 1 (API-only, no git blame). Answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains. New files: - src/core/trace.rs: run_trace() query logic with rename-aware path resolution, entity_reference-based issue linking, and DiffNote discussion extraction - src/core/trace_tests.rs: 7 unit tests for query logic - src/cli/commands/trace.rs: CLI command with human output, robot JSON output, and :line suffix parsing (5 tests) Human output shows full content (no truncation). Robot JSON truncates discussion bodies to 500 chars for token efficiency. Wiring: - TraceArgs + Commands::Trace in cli/mod.rs - handle_trace in main.rs - VALID_COMMANDS + robot-docs manifest entry - COMMAND_FLAGS autocorrect registry entry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
260
src/core/trace_tests.rs
Normal file
260
src/core/trace_tests.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use std::path::Path;
|
||||
|
||||
fn setup_test_db() -> rusqlite::Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed_project(conn: &rusqlite::Connection) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||
VALUES (1, 100, 'group/repo', 'https://gitlab.example.com/group/repo', 1000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
1
|
||||
}
|
||||
|
||||
fn insert_mr(
|
||||
conn: &rusqlite::Connection,
|
||||
id: i64,
|
||||
iid: i64,
|
||||
title: &str,
|
||||
state: &str,
|
||||
merged_at: Option<i64>,
|
||||
) {
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, \
|
||||
created_at, updated_at, last_seen_at, source_branch, target_branch, merged_at, web_url)
|
||||
VALUES (?1, ?2, ?3, 1, ?4, ?5, 'dev', 1000, 2000, 2000, 'feature', 'main', ?6, \
|
||||
'https://gitlab.example.com/group/repo/-/merge_requests/' || ?3)",
|
||||
rusqlite::params![id, 300 + id, iid, title, state, merged_at],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_file_change(
|
||||
conn: &rusqlite::Connection,
|
||||
mr_id: i64,
|
||||
old_path: Option<&str>,
|
||||
new_path: &str,
|
||||
change_type: &str,
|
||||
) {
|
||||
conn.execute(
|
||||
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
|
||||
VALUES (?1, 1, ?2, ?3, ?4)",
|
||||
rusqlite::params![mr_id, old_path, new_path, change_type],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_entity_ref(
|
||||
conn: &rusqlite::Connection,
|
||||
source_type: &str,
|
||||
source_id: i64,
|
||||
target_type: &str,
|
||||
target_id: i64,
|
||||
ref_type: &str,
|
||||
) {
|
||||
conn.execute(
|
||||
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, \
|
||||
target_entity_type, target_entity_id, reference_type, source_method, created_at)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, 'api', 1000)",
|
||||
rusqlite::params![source_type, source_id, target_type, target_id, ref_type],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_issue(conn: &rusqlite::Connection, id: i64, iid: i64, title: &str, state: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, \
|
||||
last_seen_at, web_url)
|
||||
VALUES (?1, ?2, 1, ?3, ?4, ?5, 1000, 2000, 2000, \
|
||||
'https://gitlab.example.com/group/repo/-/issues/' || ?3)",
|
||||
rusqlite::params![id, 400 + id, iid, title, state],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_discussion_and_note(
|
||||
conn: &rusqlite::Connection,
|
||||
discussion_id: i64,
|
||||
mr_id: i64,
|
||||
note_id: i64,
|
||||
author: &str,
|
||||
body: &str,
|
||||
position_new_path: Option<&str>,
|
||||
) {
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, \
|
||||
noteable_type, last_seen_at)
|
||||
VALUES (?1, 'disc-' || ?1, 1, ?2, 'MergeRequest', 2000)",
|
||||
rusqlite::params![discussion_id, mr_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, \
|
||||
is_system, created_at, updated_at, last_seen_at, position_new_path)
|
||||
VALUES (?1, ?2, ?3, 1, ?4, ?5, 0, 1500, 1500, 2000, ?6)",
|
||||
rusqlite::params![
|
||||
note_id,
|
||||
500 + note_id,
|
||||
discussion_id,
|
||||
author,
|
||||
body,
|
||||
position_new_path
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trace_empty_file() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn);
|
||||
|
||||
let result = run_trace(&conn, Some(1), "src/nonexistent.rs", false, false, 10).unwrap();
|
||||
assert!(result.trace_chains.is_empty());
|
||||
assert_eq!(result.resolved_paths, ["src/nonexistent.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trace_finds_mr() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn);
|
||||
insert_mr(&conn, 1, 10, "Add auth module", "merged", Some(3000));
|
||||
insert_file_change(&conn, 1, None, "src/auth.rs", "added");
|
||||
|
||||
let result = run_trace(&conn, Some(1), "src/auth.rs", false, false, 10).unwrap();
|
||||
assert_eq!(result.trace_chains.len(), 1);
|
||||
|
||||
let chain = &result.trace_chains[0];
|
||||
assert_eq!(chain.mr_iid, 10);
|
||||
assert_eq!(chain.mr_title, "Add auth module");
|
||||
assert_eq!(chain.mr_state, "merged");
|
||||
assert_eq!(chain.change_type, "added");
|
||||
assert!(chain.merged_at_iso.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trace_follows_renames() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn);
|
||||
|
||||
// MR 1: added old_auth.rs
|
||||
insert_mr(&conn, 1, 10, "Add old auth", "merged", Some(1000));
|
||||
insert_file_change(&conn, 1, None, "src/old_auth.rs", "added");
|
||||
|
||||
// MR 2: renamed old_auth.rs -> auth.rs
|
||||
insert_mr(&conn, 2, 11, "Rename auth", "merged", Some(2000));
|
||||
insert_file_change(&conn, 2, Some("src/old_auth.rs"), "src/auth.rs", "renamed");
|
||||
|
||||
// Query auth.rs with follow_renames -- should find both MRs
|
||||
let result = run_trace(&conn, Some(1), "src/auth.rs", true, false, 10).unwrap();
|
||||
|
||||
assert!(result.renames_followed);
|
||||
assert!(
|
||||
result
|
||||
.resolved_paths
|
||||
.contains(&"src/old_auth.rs".to_string())
|
||||
);
|
||||
assert!(result.resolved_paths.contains(&"src/auth.rs".to_string()));
|
||||
// MR 2 touches auth.rs (new_path), MR 1 touches old_auth.rs (new_path in its row)
|
||||
assert_eq!(result.trace_chains.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trace_links_issues() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn);
|
||||
insert_mr(&conn, 1, 10, "Fix login bug", "merged", Some(3000));
|
||||
insert_file_change(&conn, 1, None, "src/login.rs", "modified");
|
||||
insert_issue(&conn, 1, 42, "Login broken on mobile", "closed");
|
||||
insert_entity_ref(&conn, "merge_request", 1, "issue", 1, "closes");
|
||||
|
||||
let result = run_trace(&conn, Some(1), "src/login.rs", false, false, 10).unwrap();
|
||||
assert_eq!(result.trace_chains.len(), 1);
|
||||
assert_eq!(result.trace_chains[0].issues.len(), 1);
|
||||
|
||||
let issue = &result.trace_chains[0].issues[0];
|
||||
assert_eq!(issue.iid, 42);
|
||||
assert_eq!(issue.title, "Login broken on mobile");
|
||||
assert_eq!(issue.reference_type, "closes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trace_limits_chains() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn);
|
||||
|
||||
for i in 1..=3 {
|
||||
insert_mr(
|
||||
&conn,
|
||||
i,
|
||||
10 + i,
|
||||
&format!("MR {i}"),
|
||||
"merged",
|
||||
Some(1000 * i),
|
||||
);
|
||||
insert_file_change(&conn, i, None, "src/shared.rs", "modified");
|
||||
}
|
||||
|
||||
let result = run_trace(&conn, Some(1), "src/shared.rs", false, false, 1).unwrap();
|
||||
assert_eq!(result.trace_chains.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trace_no_follow_renames() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn);
|
||||
|
||||
// MR 1: added old_name.rs
|
||||
insert_mr(&conn, 1, 10, "Add old file", "merged", Some(1000));
|
||||
insert_file_change(&conn, 1, None, "src/old_name.rs", "added");
|
||||
|
||||
// MR 2: renamed old_name.rs -> new_name.rs
|
||||
insert_mr(&conn, 2, 11, "Rename file", "merged", Some(2000));
|
||||
insert_file_change(
|
||||
&conn,
|
||||
2,
|
||||
Some("src/old_name.rs"),
|
||||
"src/new_name.rs",
|
||||
"renamed",
|
||||
);
|
||||
|
||||
// Without follow_renames -- should only find MR 2 (new_path = new_name.rs)
|
||||
let result = run_trace(&conn, Some(1), "src/new_name.rs", false, false, 10).unwrap();
|
||||
assert_eq!(result.resolved_paths, ["src/new_name.rs"]);
|
||||
assert!(!result.renames_followed);
|
||||
assert_eq!(result.trace_chains.len(), 1);
|
||||
assert_eq!(result.trace_chains[0].mr_iid, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trace_includes_discussions() {
|
||||
let conn = setup_test_db();
|
||||
seed_project(&conn);
|
||||
insert_mr(&conn, 1, 10, "Refactor auth", "merged", Some(3000));
|
||||
insert_file_change(&conn, 1, None, "src/auth.rs", "modified");
|
||||
insert_discussion_and_note(
|
||||
&conn,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
"reviewer",
|
||||
"This function should handle the error case.",
|
||||
Some("src/auth.rs"),
|
||||
);
|
||||
|
||||
let result = run_trace(&conn, Some(1), "src/auth.rs", false, true, 10).unwrap();
|
||||
assert_eq!(result.trace_chains.len(), 1);
|
||||
assert_eq!(result.trace_chains[0].discussions.len(), 1);
|
||||
|
||||
let disc = &result.trace_chains[0].discussions[0];
|
||||
assert_eq!(disc.author_username, "reviewer");
|
||||
assert!(disc.body.contains("error case"));
|
||||
assert_eq!(disc.mr_iid, 10);
|
||||
}
|
||||
Reference in New Issue
Block a user