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) 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>
261 lines
8.2 KiB
Rust
261 lines
8.2 KiB
Rust
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_snippet.contains("error case"));
|
|
assert_eq!(disc.mr_iid, 10);
|
|
}
|