feat(who): expand expert + overlap queries with mr_file_changes and mr_reviewers
Chain: bd-jec (config flag) -> bd-2yo (fetch MR diffs) -> bd-3qn6 (rewrite who queries) - Add fetch_mr_file_changes config option and --no-file-changes CLI flag - Add GitLab MR diffs API fetch pipeline with watermark-based sync - Create migration 020 for diffs_synced_for_updated_at watermark column - Rewrite query_expert() and query_overlap() to use 4-signal UNION ALL: DiffNote reviewers, DiffNote MR authors, file-change authors, file-change reviewers - Deduplicate across signal types via COUNT(DISTINCT CASE WHEN ... THEN mr_id END) - Add insert_file_change test helper, 8 new who tests, all 397 tests pass - Also includes: list performance migration 019, autocorrect module, README updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -501,6 +501,20 @@ async fn run_ingest_inner(
|
||||
ProgressEvent::ClosesIssuesFetchComplete { .. } => {
|
||||
disc_bar_clone.finish_and_clear();
|
||||
}
|
||||
ProgressEvent::MrDiffsFetchStarted { total } => {
|
||||
disc_bar_clone.reset();
|
||||
disc_bar_clone.set_length(total as u64);
|
||||
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
stage_bar_clone.set_message(
|
||||
"Fetching MR file changes...".to_string()
|
||||
);
|
||||
}
|
||||
ProgressEvent::MrDiffFetched { current, total: _ } => {
|
||||
disc_bar_clone.set_position(current as u64);
|
||||
}
|
||||
ProgressEvent::MrDiffsFetchComplete { .. } => {
|
||||
disc_bar_clone.finish_and_clear();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
@@ -335,18 +335,12 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
|
||||
(SELECT GROUP_CONCAT(ia.username, X'1F')
|
||||
FROM issue_assignees ia
|
||||
WHERE ia.issue_id = i.id) AS assignees_csv,
|
||||
COALESCE(d.total, 0) AS discussion_count,
|
||||
COALESCE(d.unresolved, 0) AS unresolved_count
|
||||
(SELECT COUNT(*) FROM discussions d
|
||||
WHERE d.issue_id = i.id) AS discussion_count,
|
||||
(SELECT COUNT(*) FROM discussions d
|
||||
WHERE d.issue_id = i.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count
|
||||
FROM issues i
|
||||
JOIN projects p ON i.project_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT issue_id,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved
|
||||
FROM discussions
|
||||
WHERE issue_id IS NOT NULL
|
||||
GROUP BY issue_id
|
||||
) d ON d.issue_id = i.id
|
||||
{where_sql}
|
||||
ORDER BY {sort_column} {order}
|
||||
LIMIT ?"
|
||||
@@ -528,18 +522,12 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
|
||||
(SELECT GROUP_CONCAT(mr.username, X'1F')
|
||||
FROM mr_reviewers mr
|
||||
WHERE mr.merge_request_id = m.id) AS reviewers_csv,
|
||||
COALESCE(d.total, 0) AS discussion_count,
|
||||
COALESCE(d.unresolved, 0) AS unresolved_count
|
||||
(SELECT COUNT(*) FROM discussions d
|
||||
WHERE d.merge_request_id = m.id) AS discussion_count,
|
||||
(SELECT COUNT(*) FROM discussions d
|
||||
WHERE d.merge_request_id = m.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count
|
||||
FROM merge_requests m
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT merge_request_id,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved
|
||||
FROM discussions
|
||||
WHERE merge_request_id IS NOT NULL
|
||||
GROUP BY merge_request_id
|
||||
) d ON d.merge_request_id = m.id
|
||||
{where_sql}
|
||||
ORDER BY {sort_column} {order}
|
||||
LIMIT ?"
|
||||
|
||||
@@ -433,7 +433,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> R
|
||||
// Heuristic is now only a fallback; probes decide first when ambiguous.
|
||||
let looks_like_file = !forced_dir && (is_root || last_segment.contains('.'));
|
||||
|
||||
// Probe 1: exact file exists (project-scoped via nullable binding)
|
||||
// Probe 1: exact file exists in DiffNotes OR mr_file_changes (project-scoped)
|
||||
let exact_exists = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM notes
|
||||
@@ -445,9 +445,19 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> R
|
||||
rusqlite::params![trimmed, project_id],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.is_ok();
|
||||
.is_ok()
|
||||
|| conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM mr_file_changes
|
||||
WHERE new_path = ?1
|
||||
AND (?2 IS NULL OR project_id = ?2)
|
||||
LIMIT 1",
|
||||
rusqlite::params![trimmed, project_id],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.is_ok();
|
||||
|
||||
// Probe 2: directory prefix exists (project-scoped)
|
||||
// Probe 2: directory prefix exists in DiffNotes OR mr_file_changes (project-scoped)
|
||||
let prefix_exists = if !forced_dir && !exact_exists {
|
||||
let escaped = escape_like(trimmed);
|
||||
let pat = format!("{escaped}/%");
|
||||
@@ -462,6 +472,16 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option<i64>) -> R
|
||||
|_| Ok(()),
|
||||
)
|
||||
.is_ok()
|
||||
|| conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM mr_file_changes
|
||||
WHERE new_path LIKE ?1 ESCAPE '\\'
|
||||
AND (?2 IS NULL OR project_id = ?2)
|
||||
LIMIT 1",
|
||||
rusqlite::params![pat, project_id],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -513,125 +533,117 @@ fn query_expert(
|
||||
let pq = build_path_query(conn, path, project_id)?;
|
||||
let limit_plus_one = (limit + 1) as i64;
|
||||
|
||||
let sql_prefix = "
|
||||
WITH activity AS (
|
||||
SELECT
|
||||
n.author_username AS username,
|
||||
'reviewer' AS role,
|
||||
COUNT(DISTINCT m.id) AS mr_cnt,
|
||||
COUNT(*) AS note_cnt,
|
||||
MAX(n.created_at) AS last_seen_at
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged')
|
||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY n.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
COUNT(DISTINCT m.id) AS mr_cnt,
|
||||
0 AS note_cnt,
|
||||
MAX(n.created_at) AS last_seen_at
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND m.author_username IS NOT NULL
|
||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
)
|
||||
SELECT
|
||||
username,
|
||||
SUM(CASE WHEN role = 'reviewer' THEN mr_cnt ELSE 0 END) AS review_mr_count,
|
||||
SUM(CASE WHEN role = 'reviewer' THEN note_cnt ELSE 0 END) AS review_note_count,
|
||||
SUM(CASE WHEN role = 'author' THEN mr_cnt ELSE 0 END) AS author_mr_count,
|
||||
MAX(last_seen_at) AS last_seen_at,
|
||||
(
|
||||
(SUM(CASE WHEN role = 'reviewer' THEN mr_cnt ELSE 0 END) * 20) +
|
||||
(SUM(CASE WHEN role = 'author' THEN mr_cnt ELSE 0 END) * 12) +
|
||||
(SUM(CASE WHEN role = 'reviewer' THEN note_cnt ELSE 0 END) * 1)
|
||||
) AS score
|
||||
FROM activity
|
||||
GROUP BY username
|
||||
ORDER BY score DESC, last_seen_at DESC, username ASC
|
||||
LIMIT ?4
|
||||
";
|
||||
|
||||
let sql_exact = "
|
||||
WITH activity AS (
|
||||
SELECT
|
||||
n.author_username AS username,
|
||||
'reviewer' AS role,
|
||||
COUNT(DISTINCT m.id) AS mr_cnt,
|
||||
COUNT(*) AS note_cnt,
|
||||
MAX(n.created_at) AS last_seen_at
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged')
|
||||
AND n.position_new_path = ?1
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY n.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
COUNT(DISTINCT m.id) AS mr_cnt,
|
||||
0 AS note_cnt,
|
||||
MAX(n.created_at) AS last_seen_at
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND m.author_username IS NOT NULL
|
||||
AND n.position_new_path = ?1
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
)
|
||||
SELECT
|
||||
username,
|
||||
SUM(CASE WHEN role = 'reviewer' THEN mr_cnt ELSE 0 END) AS review_mr_count,
|
||||
SUM(CASE WHEN role = 'reviewer' THEN note_cnt ELSE 0 END) AS review_note_count,
|
||||
SUM(CASE WHEN role = 'author' THEN mr_cnt ELSE 0 END) AS author_mr_count,
|
||||
MAX(last_seen_at) AS last_seen_at,
|
||||
(
|
||||
(SUM(CASE WHEN role = 'reviewer' THEN mr_cnt ELSE 0 END) * 20) +
|
||||
(SUM(CASE WHEN role = 'author' THEN mr_cnt ELSE 0 END) * 12) +
|
||||
(SUM(CASE WHEN role = 'reviewer' THEN note_cnt ELSE 0 END) * 1)
|
||||
) AS score
|
||||
FROM activity
|
||||
GROUP BY username
|
||||
ORDER BY score DESC, last_seen_at DESC, username ASC
|
||||
LIMIT ?4
|
||||
";
|
||||
|
||||
let mut stmt = if pq.is_prefix {
|
||||
conn.prepare_cached(sql_prefix)?
|
||||
// Build SQL with 4 signal sources (UNION ALL), deduplicating via COUNT(DISTINCT mr_id):
|
||||
// 1. DiffNote reviewer — left inline review comments (not self-review)
|
||||
// 2. DiffNote MR author — authored MR that has DiffNotes on this path
|
||||
// 3. File-change author — authored MR that touched this path (mr_file_changes)
|
||||
// 4. File-change reviewer — assigned reviewer on MR that touched this path
|
||||
let path_op = if pq.is_prefix {
|
||||
"LIKE ?1 ESCAPE '\\'"
|
||||
} else {
|
||||
conn.prepare_cached(sql_exact)?
|
||||
"= ?1"
|
||||
};
|
||||
let sql = format!(
|
||||
"
|
||||
WITH signals AS (
|
||||
-- 1. DiffNote reviewer (individual notes for note_cnt)
|
||||
SELECT
|
||||
n.author_username AS username,
|
||||
'diffnote_reviewer' AS signal,
|
||||
m.id AS mr_id,
|
||||
n.id AS note_id,
|
||||
n.created_at AS seen_at
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged')
|
||||
AND n.position_new_path {path_op}
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 2. DiffNote MR author
|
||||
SELECT DISTINCT
|
||||
m.author_username AS username,
|
||||
'diffnote_author' AS signal,
|
||||
m.id AS mr_id,
|
||||
NULL AS note_id,
|
||||
MAX(n.created_at) AS seen_at
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND m.author_username IS NOT NULL
|
||||
AND m.state IN ('opened','merged')
|
||||
AND n.position_new_path {path_op}
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY m.author_username, m.id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 3. MR author via file changes
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'file_author' AS signal,
|
||||
m.id AS mr_id,
|
||||
NULL AS note_id,
|
||||
m.updated_at AS seen_at
|
||||
FROM mr_file_changes fc
|
||||
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||
WHERE m.author_username IS NOT NULL
|
||||
AND m.state IN ('opened','merged')
|
||||
AND fc.new_path {path_op}
|
||||
AND m.updated_at >= ?2
|
||||
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 4. MR reviewer via file changes + mr_reviewers
|
||||
SELECT
|
||||
r.username AS username,
|
||||
'file_reviewer' AS signal,
|
||||
m.id AS mr_id,
|
||||
NULL AS note_id,
|
||||
m.updated_at AS seen_at
|
||||
FROM mr_file_changes fc
|
||||
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||
WHERE r.username IS NOT NULL
|
||||
AND m.state IN ('opened','merged')
|
||||
AND fc.new_path {path_op}
|
||||
AND m.updated_at >= ?2
|
||||
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||
)
|
||||
SELECT
|
||||
username,
|
||||
COUNT(DISTINCT CASE WHEN signal IN ('diffnote_reviewer', 'file_reviewer')
|
||||
THEN mr_id END) AS review_mr_count,
|
||||
COUNT(CASE WHEN signal = 'diffnote_reviewer' THEN note_id END) AS review_note_count,
|
||||
COUNT(DISTINCT CASE WHEN signal IN ('diffnote_author', 'file_author')
|
||||
THEN mr_id END) AS author_mr_count,
|
||||
MAX(seen_at) AS last_seen_at,
|
||||
(
|
||||
(COUNT(DISTINCT CASE WHEN signal IN ('diffnote_reviewer', 'file_reviewer')
|
||||
THEN mr_id END) * 20) +
|
||||
(COUNT(DISTINCT CASE WHEN signal IN ('diffnote_author', 'file_author')
|
||||
THEN mr_id END) * 12) +
|
||||
(COUNT(CASE WHEN signal = 'diffnote_reviewer' THEN note_id END) * 1)
|
||||
) AS score
|
||||
FROM signals
|
||||
GROUP BY username
|
||||
ORDER BY score DESC, last_seen_at DESC, username ASC
|
||||
LIMIT ?4
|
||||
"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
|
||||
let experts: Vec<Expert> = stmt
|
||||
.query_map(
|
||||
@@ -1160,97 +1172,100 @@ fn query_overlap(
|
||||
) -> Result<OverlapResult> {
|
||||
let pq = build_path_query(conn, path, project_id)?;
|
||||
|
||||
let sql_prefix = "SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
||||
SELECT
|
||||
n.author_username AS username,
|
||||
'reviewer' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged')
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY n.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path LIKE ?1 ESCAPE '\\'
|
||||
AND n.is_system = 0
|
||||
AND m.state IN ('opened', 'merged')
|
||||
AND m.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
)";
|
||||
|
||||
let sql_exact = "SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
||||
SELECT
|
||||
n.author_username AS username,
|
||||
'reviewer' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path = ?1
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged')
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY n.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path = ?1
|
||||
AND n.is_system = 0
|
||||
AND m.state IN ('opened', 'merged')
|
||||
AND m.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
)";
|
||||
|
||||
let mut stmt = if pq.is_prefix {
|
||||
conn.prepare_cached(sql_prefix)?
|
||||
// Build SQL with 4 signal sources, matching the expert query expansion.
|
||||
// Each row produces (username, role, mr_id, mr_ref, seen_at) for Rust-side accumulation.
|
||||
let path_op = if pq.is_prefix {
|
||||
"LIKE ?1 ESCAPE '\\'"
|
||||
} else {
|
||||
conn.prepare_cached(sql_exact)?
|
||||
"= ?1"
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
|
||||
-- 1. DiffNote reviewer
|
||||
SELECT
|
||||
n.author_username AS username,
|
||||
'reviewer' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path {path_op}
|
||||
AND n.is_system = 0
|
||||
AND n.author_username IS NOT NULL
|
||||
AND (m.author_username IS NULL OR n.author_username != m.author_username)
|
||||
AND m.state IN ('opened','merged')
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY n.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 2. DiffNote MR author
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(n.created_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM merge_requests m
|
||||
JOIN discussions d ON d.merge_request_id = m.id
|
||||
JOIN notes n ON n.discussion_id = d.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE n.note_type = 'DiffNote'
|
||||
AND n.position_new_path {path_op}
|
||||
AND n.is_system = 0
|
||||
AND m.state IN ('opened', 'merged')
|
||||
AND m.author_username IS NOT NULL
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 3. MR author via file changes
|
||||
SELECT
|
||||
m.author_username AS username,
|
||||
'author' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(m.updated_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM mr_file_changes fc
|
||||
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
WHERE m.author_username IS NOT NULL
|
||||
AND m.state IN ('opened','merged')
|
||||
AND fc.new_path {path_op}
|
||||
AND m.updated_at >= ?2
|
||||
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||
GROUP BY m.author_username
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 4. MR reviewer via file changes + mr_reviewers
|
||||
SELECT
|
||||
r.username AS username,
|
||||
'reviewer' AS role,
|
||||
COUNT(DISTINCT m.id) AS touch_count,
|
||||
MAX(m.updated_at) AS last_seen_at,
|
||||
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
|
||||
FROM mr_file_changes fc
|
||||
JOIN merge_requests m ON fc.merge_request_id = m.id
|
||||
JOIN projects p ON m.project_id = p.id
|
||||
JOIN mr_reviewers r ON r.merge_request_id = m.id
|
||||
WHERE r.username IS NOT NULL
|
||||
AND m.state IN ('opened','merged')
|
||||
AND fc.new_path {path_op}
|
||||
AND m.updated_at >= ?2
|
||||
AND (?3 IS NULL OR fc.project_id = ?3)
|
||||
GROUP BY r.username
|
||||
)"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare_cached(&sql)?;
|
||||
let rows: Vec<(String, String, u32, i64, Option<String>)> = stmt
|
||||
.query_map(rusqlite::params![pq.value, since_ms, project_id], |row| {
|
||||
Ok((
|
||||
@@ -2117,7 +2132,6 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)",
|
||||
@@ -2126,6 +2140,21 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_file_change(
|
||||
conn: &Connection,
|
||||
mr_id: i64,
|
||||
project_id: i64,
|
||||
new_path: &str,
|
||||
change_type: &str,
|
||||
) {
|
||||
conn.execute(
|
||||
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![mr_id, project_id, new_path, change_type],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_path_discrimination() {
|
||||
// Contains '/' -> file path
|
||||
@@ -2678,4 +2707,142 @@ mod tests {
|
||||
let result = query_expert(&conn, "src/auth/", None, 0, 10).unwrap();
|
||||
assert!(!result.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expert_file_changes_only() {
|
||||
// MR author should appear even when there are zero DiffNotes
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "team/backend");
|
||||
insert_mr(&conn, 1, 1, 100, "file_author", "merged");
|
||||
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||
|
||||
let result = query_expert(&conn, "src/auth/login.rs", None, 0, 20).unwrap();
|
||||
assert_eq!(result.experts.len(), 1);
|
||||
assert_eq!(result.experts[0].username, "file_author");
|
||||
assert!(result.experts[0].author_mr_count > 0);
|
||||
assert_eq!(result.experts[0].review_mr_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expert_mr_reviewer_via_file_changes() {
|
||||
// A reviewer assigned via mr_reviewers should appear when that MR
|
||||
// touched the queried file (via mr_file_changes)
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "team/backend");
|
||||
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||
insert_reviewer(&conn, 1, "assigned_reviewer");
|
||||
|
||||
let result = query_expert(&conn, "src/auth/login.rs", None, 0, 20).unwrap();
|
||||
let reviewer = result
|
||||
.experts
|
||||
.iter()
|
||||
.find(|e| e.username == "assigned_reviewer");
|
||||
assert!(reviewer.is_some(), "assigned_reviewer should appear");
|
||||
assert!(reviewer.unwrap().review_mr_count > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expert_deduplicates_across_signals() {
|
||||
// User who is BOTH a DiffNote reviewer AND an mr_reviewers entry for
|
||||
// the same MR should be counted only once per MR
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "team/backend");
|
||||
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
||||
insert_diffnote(
|
||||
&conn,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
"reviewer_b",
|
||||
"src/auth/login.rs",
|
||||
"looks good",
|
||||
);
|
||||
// Same user also listed as assigned reviewer, with file change data
|
||||
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||
insert_reviewer(&conn, 1, "reviewer_b");
|
||||
|
||||
let result = query_expert(&conn, "src/auth/login.rs", None, 0, 20).unwrap();
|
||||
let reviewer = result
|
||||
.experts
|
||||
.iter()
|
||||
.find(|e| e.username == "reviewer_b")
|
||||
.unwrap();
|
||||
// Should be 1 MR, not 2 (dedup across DiffNote + mr_reviewers)
|
||||
assert_eq!(reviewer.review_mr_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expert_combined_diffnote_and_file_changes() {
|
||||
// Author with DiffNotes on path A and file_changes on path B should
|
||||
// get credit for both when queried with a directory prefix
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "team/backend");
|
||||
// MR 1: has DiffNotes on login.rs
|
||||
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
|
||||
insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note");
|
||||
// MR 2: has file_changes on session.rs (no DiffNotes)
|
||||
insert_mr(&conn, 2, 1, 200, "author_a", "merged");
|
||||
insert_file_change(&conn, 2, 1, "src/auth/session.rs", "added");
|
||||
|
||||
let result = query_expert(&conn, "src/auth/", None, 0, 20).unwrap();
|
||||
let author = result
|
||||
.experts
|
||||
.iter()
|
||||
.find(|e| e.username == "author_a")
|
||||
.unwrap();
|
||||
// Should count 2 authored MRs (one from DiffNote path, one from file changes)
|
||||
assert_eq!(author.author_mr_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expert_file_changes_prefix_match() {
|
||||
// Directory prefix queries should pick up mr_file_changes under the directory
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "team/backend");
|
||||
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||
insert_file_change(&conn, 1, 1, "src/auth/session.rs", "added");
|
||||
|
||||
let result = query_expert(&conn, "src/auth/", None, 0, 20).unwrap();
|
||||
assert_eq!(result.path_match, "prefix");
|
||||
assert_eq!(result.experts.len(), 1);
|
||||
assert_eq!(result.experts[0].username, "author_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overlap_file_changes_only() {
|
||||
// Overlap mode should also find users via mr_file_changes
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "team/backend");
|
||||
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
|
||||
insert_reviewer(&conn, 1, "reviewer_x");
|
||||
|
||||
let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
|
||||
assert!(
|
||||
result.users.iter().any(|u| u.username == "author_a"),
|
||||
"author_a should appear via file_changes"
|
||||
);
|
||||
assert!(
|
||||
result.users.iter().any(|u| u.username == "reviewer_x"),
|
||||
"reviewer_x should appear via mr_reviewers + file_changes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_path_query_resolves_via_file_changes() {
|
||||
// DB probe should detect exact file match from mr_file_changes even
|
||||
// when no DiffNotes exist for the path
|
||||
let conn = setup_test_db();
|
||||
insert_project(&conn, 1, "team/backend");
|
||||
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
|
||||
insert_file_change(&conn, 1, 1, "src/Dockerfile", "modified");
|
||||
|
||||
let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
|
||||
assert_eq!(pq.value, "src/Dockerfile");
|
||||
assert!(!pq.is_prefix);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user