Files
gitlore/src/cli/commands/me/me_tests.rs
teernisse 9c909df6b2 feat(me): add 30-day mention age cutoff to filter stale @-mentions
Previously, query_mentioned_in returned mentions from any time in the
entity's history as long as the entity was still open (or recently closed).
This caused noise: a mention from 6 months ago on a still-open issue would
appear in the dashboard indefinitely.

Now the SQL filters notes by created_at > mention_cutoff_ms, defaulting to
30 days. The recency_cutoff (7 days) still governs closed/merged entity
visibility — this new cutoff governs mention note age on open entities.

Signature change: query_mentioned_in gains a mention_cutoff_ms parameter.
All existing test call sites updated. Two new tests verify the boundary:
- mentioned_in_excludes_old_mention_on_open_issue (45-day mention filtered)
- mentioned_in_includes_recent_mention_on_open_issue (5-day mention kept)
2026-03-12 10:08:22 -04:00

1384 lines
44 KiB
Rust

use super::*;
use crate::cli::commands::me::types::{ActivityEventType, AttentionState};
use crate::core::time::now_ms;
use crate::test_support::{insert_project, setup_test_db};
use rusqlite::Connection;
// ─── Helpers ────────────────────────────────────────────────────────────────
fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) {
insert_issue_with_status(
conn,
id,
project_id,
iid,
author,
"opened",
Some("In Progress"),
);
}
fn insert_issue_with_state(
conn: &Connection,
id: i64,
project_id: i64,
iid: i64,
author: &str,
state: &str,
) {
// For closed issues, don't set status_name (they won't appear in dashboard anyway)
let status_name = if state == "opened" {
Some("In Progress")
} else {
None
};
insert_issue_with_status(conn, id, project_id, iid, author, state, status_name);
}
#[allow(clippy::too_many_arguments)]
fn insert_issue_with_status(
conn: &Connection,
id: i64,
project_id: i64,
iid: i64,
author: &str,
state: &str,
status_name: Option<&str>,
) {
let ts = now_ms();
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, status_name, author_username, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
id,
id * 10,
project_id,
iid,
format!("Issue {iid}"),
state,
status_name,
author,
ts,
ts,
ts
],
)
.unwrap();
}
fn insert_assignee(conn: &Connection, issue_id: i64, username: &str) {
conn.execute(
"INSERT INTO issue_assignees (issue_id, username) VALUES (?1, ?2)",
rusqlite::params![issue_id, username],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_mr(
conn: &Connection,
id: i64,
project_id: i64,
iid: i64,
author: &str,
state: &str,
draft: bool,
) {
let ts = now_ms();
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, draft, last_seen_at, updated_at, created_at, merged_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![
id,
id * 10,
project_id,
iid,
format!("MR {iid}"),
author,
state,
i32::from(draft),
ts,
ts,
ts,
if state == "merged" { Some(ts) } else { None::<i64> }
],
)
.unwrap();
}
fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) {
conn.execute(
"INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)",
rusqlite::params![mr_id, username],
)
.unwrap();
}
fn insert_discussion(
conn: &Connection,
id: i64,
project_id: i64,
mr_id: Option<i64>,
issue_id: Option<i64>,
) {
let noteable_type = if mr_id.is_some() {
"MergeRequest"
} else {
"Issue"
};
let ts = now_ms();
conn.execute(
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, issue_id, noteable_type, resolvable, resolved, last_seen_at, last_note_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 0, ?7, ?8)",
rusqlite::params![
id,
format!("disc-{id}"),
project_id,
mr_id,
issue_id,
noteable_type,
ts,
ts
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_note_at(
conn: &Connection,
id: i64,
discussion_id: i64,
project_id: i64,
author: &str,
is_system: bool,
body: &str,
created_at: i64,
) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, 'DiscussionNote', ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
id * 10,
discussion_id,
project_id,
i32::from(is_system),
author,
body,
created_at,
created_at,
now_ms()
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_state_event(
conn: &Connection,
id: i64,
project_id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
state: &str,
actor: &str,
created_at: i64,
) {
conn.execute(
"INSERT INTO resource_state_events (id, gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![id, id * 10, project_id, issue_id, mr_id, state, actor, created_at],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_label_event(
conn: &Connection,
id: i64,
project_id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
action: &str,
label_name: &str,
actor: &str,
created_at: i64,
) {
conn.execute(
"INSERT INTO resource_label_events (id, gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
id,
id * 10,
project_id,
issue_id,
mr_id,
action,
label_name,
actor,
created_at
],
)
.unwrap();
}
// ─── Open Issues Tests (Task #7) ───────────────────────────────────────────
#[test]
fn open_issues_returns_assigned_only() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue(&conn, 11, 1, 43, "someone");
// Only assign issue 42 to alice
insert_assignee(&conn, 10, "alice");
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 42);
}
#[test]
fn open_issues_excludes_closed() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue_with_state(&conn, 11, 1, 43, "someone", "closed");
insert_assignee(&conn, 10, "alice");
insert_assignee(&conn, 11, "alice");
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 42);
}
#[test]
fn open_issues_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue(&conn, 11, 2, 43, "someone");
insert_assignee(&conn, 10, "alice");
insert_assignee(&conn, 11, "alice");
// Filter to project 1 only
let results = query_open_issues(&conn, "alice", &[1]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a");
}
#[test]
fn open_issues_empty_when_unassigned() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "alice");
// alice authored but is NOT assigned
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert!(results.is_empty());
}
// ─── Attention State Tests (Task #10) ──────────────────────────────────────
#[test]
fn attention_state_not_started_no_notes() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::NotStarted);
}
#[test]
fn attention_state_needs_attention_others_replied() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
// alice comments first, then bob replies after
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t1);
insert_note_at(&conn, 201, disc_id, 1, "bob", false, "reply", t2);
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::NeedsAttention);
}
#[test]
fn attention_state_awaiting_response() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
// bob first, then alice replies (alice's latest >= others' latest)
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "question", t1);
insert_note_at(&conn, 201, disc_id, 1, "alice", false, "my reply", t2);
let results = query_open_issues(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse);
}
// ─── Authored MRs Tests (Task #8) ─────────────────────────────────────────
#[test]
fn authored_mrs_returns_own_only() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
insert_mr(&conn, 11, 1, 100, "bob", "opened", false);
let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 99);
}
#[test]
fn authored_mrs_excludes_merged() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
insert_mr(&conn, 11, 1, 100, "alice", "merged", false);
let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 99);
}
#[test]
fn authored_mrs_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
insert_mr(&conn, 11, 2, 100, "alice", "opened", false);
let results = query_authored_mrs(&conn, "alice", &[2]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-b");
}
#[test]
fn authored_mr_not_ready_when_draft_no_reviewers() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", true);
// No reviewers added
let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].draft);
assert_eq!(results[0].attention_state, AttentionState::NotReady);
}
#[test]
fn authored_mr_not_ready_overridden_when_has_reviewers() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", true);
insert_reviewer(&conn, 10, "bob");
let results = query_authored_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
// Draft with reviewers -> not_started (not not_ready), since no one has commented
assert_eq!(results[0].attention_state, AttentionState::NotStarted);
}
// ─── Reviewing MRs Tests (Task #9) ────────────────────────────────────────
#[test]
fn reviewing_mrs_returns_reviewer_items() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_mr(&conn, 11, 1, 100, "charlie", "opened", false);
insert_reviewer(&conn, 10, "alice");
// alice is NOT a reviewer of MR 100
let results = query_reviewing_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].iid, 99);
}
#[test]
fn reviewing_mrs_includes_author_username() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_reviewer(&conn, 10, "alice");
let results = query_reviewing_mrs(&conn, "alice", &[]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].author_username, Some("bob".to_string()));
}
#[test]
fn reviewing_mrs_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_mr(&conn, 11, 2, 100, "bob", "opened", false);
insert_reviewer(&conn, 10, "alice");
insert_reviewer(&conn, 11, "alice");
let results = query_reviewing_mrs(&conn, "alice", &[1]).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a");
}
// ─── Activity Feed Tests (Tasks #11-13) ────────────────────────────────────
#[test]
fn activity_note_on_assigned_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Note);
assert_eq!(results[0].entity_iid, 42);
assert_eq!(results[0].entity_type, "issue");
assert!(!results[0].is_own);
}
#[test]
fn activity_note_on_authored_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false);
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "nice work", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Note);
assert_eq!(results[0].entity_type, "mr");
assert_eq!(results[0].entity_iid, 99);
}
#[test]
fn activity_state_event_on_my_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let t = now_ms() - 1000;
insert_state_event(&conn, 300, 1, Some(10), None, "closed", "bob", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::StatusChange);
assert_eq!(results[0].summary, "closed");
}
#[test]
fn activity_label_event_on_my_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let t = now_ms() - 1000;
insert_label_event(&conn, 400, 1, Some(10), None, "add", "bug", "bob", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::LabelChange);
assert!(results[0].summary.contains("bug"));
}
#[test]
fn activity_excludes_unassociated_items() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
// Issue NOT assigned to alice
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert!(
results.is_empty(),
"should not see activity on unassigned issues"
);
}
#[test]
fn activity_since_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let old_t = now_ms() - 100_000_000; // ~1 day ago
let recent_t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "old comment", old_t);
insert_note_at(
&conn,
201,
disc_id,
1,
"bob",
false,
"new comment",
recent_t,
);
// since = 50 seconds ago, should only get the recent note
let since = now_ms() - 50_000;
let results = query_activity(&conn, "alice", &[], since).unwrap();
assert_eq!(results.len(), 1);
// Notes no longer duplicate body into body_preview (summary carries the content)
assert_eq!(results[0].body_preview, None);
assert_eq!(results[0].summary, "new comment");
}
#[test]
fn activity_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue(&conn, 11, 2, 43, "someone");
insert_assignee(&conn, 10, "alice");
insert_assignee(&conn, 11, "alice");
let disc_a = 100;
let disc_b = 101;
insert_discussion(&conn, disc_a, 1, None, Some(10));
insert_discussion(&conn, disc_b, 2, None, Some(11));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_a, 1, "bob", false, "comment a", t);
insert_note_at(&conn, 201, disc_b, 2, "bob", false, "comment b", t);
// Filter to project 1 only
let results = query_activity(&conn, "alice", &[1], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a");
}
#[test]
fn activity_sorted_newest_first() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "first", t1);
insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "second", t2);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 2);
assert!(
results[0].timestamp >= results[1].timestamp,
"should be sorted newest first"
);
}
#[test]
fn activity_is_own_flag() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].is_own);
}
// ─── Activity on Closed/Merged Items ─────────────────────────────────────────
#[test]
fn activity_note_on_merged_authored_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "merged", false);
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"follow-up question",
t,
);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(
results.len(),
1,
"should see activity on merged MR authored by user"
);
assert_eq!(results[0].entity_iid, 99);
assert_eq!(results[0].entity_type, "mr");
}
#[test]
fn activity_note_on_closed_mr_as_reviewer() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "closed", false);
insert_reviewer(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "can you re-check?", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(
results.len(),
1,
"should see activity on closed MR where user is reviewer"
);
}
#[test]
fn activity_note_on_closed_assigned_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"reopening discussion",
t,
);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(
results.len(),
1,
"should see activity on closed issue assigned to user"
);
}
#[test]
fn since_last_check_includes_comment_on_merged_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "merged", false);
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"post-merge question",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(
total_events, 1,
"should see others' comments on merged MR in inbox"
);
}
// ─── Assignment Detection Tests (Task #12) ─────────────────────────────────
#[test]
fn activity_assignment_system_note() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", true, "assigned to @alice", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Assign);
}
#[test]
fn activity_unassignment_system_note() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", true, "unassigned @alice", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Unassign);
}
#[test]
fn activity_review_request_system_note() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_reviewer(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
true,
"requested review from @alice",
t,
);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest);
}
// ─── Since-Last-Check Mention Tests ─────────────────────────────────────────
#[test]
fn since_last_check_detects_mention_with_trailing_comma() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"please review this @alice, thanks",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected mention with comma to match");
}
#[test]
fn since_last_check_ignores_email_like_text() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"contact alice at foo@alice.com",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 0, "email text should not count as mention");
}
#[test]
fn since_last_check_detects_mention_with_trailing_period() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"please review this @alice.",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected mention with period to match");
}
#[test]
fn since_last_check_detects_mention_inside_parentheses() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"thanks (@alice) for the update",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected parenthesized mention to match");
}
#[test]
fn since_last_check_ignores_domain_like_text() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"@alice.com is the old hostname",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(
total_events, 0,
"domain-like text should not count as mention"
);
}
// ─── Mentioned In Tests ─────────────────────────────────────────────────────
#[test]
fn mentioned_in_finds_mention_on_unassigned_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
// alice is NOT assigned to issue 42
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"hey @alice can you look?",
t,
);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_type, "issue");
assert_eq!(results[0].iid, 42);
}
#[test]
fn mentioned_in_excludes_assigned_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice"); // alice IS assigned
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert!(results.is_empty(), "should exclude assigned issues");
}
#[test]
fn mentioned_in_excludes_authored_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "alice"); // alice IS author
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert!(results.is_empty(), "should exclude authored issues");
}
#[test]
fn mentioned_in_finds_mention_on_non_authored_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
// alice is NOT author or reviewer
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "cc @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_type, "mr");
assert_eq!(results[0].iid, 99);
}
#[test]
fn mentioned_in_excludes_authored_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false); // alice IS author
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert!(results.is_empty(), "should exclude authored MRs");
}
#[test]
fn mentioned_in_excludes_reviewer_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_reviewer(&conn, 10, "alice"); // alice IS reviewer
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "charlie", false, "@alice fyi", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert!(
results.is_empty(),
"should exclude MRs where user is reviewer"
);
}
#[test]
fn mentioned_in_includes_recently_closed_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
// Update updated_at to recent (within 7-day window)
conn.execute(
"UPDATE issues SET updated_at = ?1 WHERE id = 10",
rusqlite::params![now_ms() - 2 * 24 * 3600 * 1000], // 2 days ago
)
.unwrap();
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert_eq!(results.len(), 1, "recently closed issue should be included");
assert_eq!(results[0].state, "closed");
}
#[test]
fn mentioned_in_excludes_old_closed_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
// Update updated_at to old (outside 7-day window)
conn.execute(
"UPDATE issues SET updated_at = ?1 WHERE id = 10",
rusqlite::params![now_ms() - 30 * 24 * 3600 * 1000], // 30 days ago
)
.unwrap();
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert!(results.is_empty(), "old closed issue should be excluded");
}
#[test]
fn mentioned_in_attention_needs_attention_when_unreplied() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"@alice please review",
t,
);
// alice has NOT replied
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::NeedsAttention);
}
#[test]
fn mentioned_in_attention_awaiting_when_replied() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"@alice please review",
t1,
);
insert_note_at(&conn, 201, disc_id, 1, "alice", false, "looks good", t2);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse);
}
#[test]
fn mentioned_in_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue(&conn, 11, 2, 43, "someone");
let disc_a = 100;
let disc_b = 101;
insert_discussion(&conn, disc_a, 1, None, Some(10));
insert_discussion(&conn, disc_b, 2, None, Some(11));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_a, 1, "bob", false, "@alice", t);
insert_note_at(&conn, 201, disc_b, 2, "bob", false, "@alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[1], recency_cutoff, 0).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a");
}
#[test]
fn mentioned_in_deduplicates_multiple_mentions_same_entity() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
// Two different people mention alice on the same issue
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t1);
insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "@alice +1", t2);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert_eq!(results.len(), 1, "should deduplicate to one entity");
}
#[test]
fn mentioned_in_rejects_false_positive_email() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"email foo@alice.com",
t,
);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap();
assert!(results.is_empty(), "email-like text should not match");
}
#[test]
fn mentioned_in_excludes_old_mention_on_open_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
// Mention from 45 days ago — outside 30-day mention window
let t = now_ms() - 45 * 24 * 3600 * 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let mention_cutoff = now_ms() - 30 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, mention_cutoff).unwrap();
assert!(
results.is_empty(),
"mentions older than 30 days should be excluded"
);
}
#[test]
fn mentioned_in_includes_recent_mention_on_open_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
// Mention from 5 days ago — within 30-day window
let t = now_ms() - 5 * 24 * 3600 * 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let mention_cutoff = now_ms() - 30 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, mention_cutoff).unwrap();
assert_eq!(results.len(), 1, "recent mentions should be included");
}
// ─── Helper Tests ──────────────────────────────────────────────────────────
#[test]
fn mentioned_in_sql_materializes_core_ctes() {
let sql = build_mentioned_in_sql("");
assert!(
sql.contains("candidate_issues AS MATERIALIZED"),
"candidate_issues should be materialized"
);
assert!(
sql.contains("candidate_mrs AS MATERIALIZED"),
"candidate_mrs should be materialized"
);
assert!(
sql.contains("note_ts_issue AS MATERIALIZED"),
"note_ts_issue should be materialized"
);
assert!(
sql.contains("note_ts_mr AS MATERIALIZED"),
"note_ts_mr should be materialized"
);
}
#[test]
fn parse_attention_state_all_variants() {
assert_eq!(
parse_attention_state("needs_attention"),
AttentionState::NeedsAttention
);
assert_eq!(
parse_attention_state("not_started"),
AttentionState::NotStarted
);
assert_eq!(
parse_attention_state("awaiting_response"),
AttentionState::AwaitingResponse
);
assert_eq!(parse_attention_state("stale"), AttentionState::Stale);
assert_eq!(parse_attention_state("not_ready"), AttentionState::NotReady);
assert_eq!(parse_attention_state("unknown"), AttentionState::NotStarted);
}
#[test]
fn format_attention_reason_not_started() {
let reason = format_attention_reason(&AttentionState::NotStarted, None, None, None);
assert_eq!(reason, "No discussion yet");
}
#[test]
fn format_attention_reason_not_ready() {
let reason = format_attention_reason(&AttentionState::NotReady, None, None, None);
assert_eq!(reason, "Draft with no reviewers assigned");
}
#[test]
fn format_attention_reason_stale_with_timestamp() {
let stale_ts = now_ms() - 35 * 24 * 3600 * 1000; // 35 days ago
let reason = format_attention_reason(&AttentionState::Stale, None, None, Some(stale_ts));
assert!(reason.starts_with("No activity for"), "got: {reason}");
// 35 days = 1 month in our duration bucketing
assert!(reason.contains("1 month"), "got: {reason}");
}
#[test]
fn format_attention_reason_needs_attention_both_timestamps() {
let my_ts = now_ms() - 2 * 86_400_000; // 2 days ago
let others_ts = now_ms() - 3_600_000; // 1 hour ago
let reason = format_attention_reason(
&AttentionState::NeedsAttention,
Some(my_ts),
Some(others_ts),
Some(others_ts),
);
assert!(reason.contains("Others replied"), "got: {reason}");
assert!(reason.contains("you last commented"), "got: {reason}");
}
#[test]
fn format_attention_reason_needs_attention_no_self_comment() {
let others_ts = now_ms() - 3_600_000; // 1 hour ago
let reason = format_attention_reason(
&AttentionState::NeedsAttention,
None,
Some(others_ts),
Some(others_ts),
);
assert!(reason.contains("Others commented"), "got: {reason}");
assert!(reason.contains("you haven't replied"), "got: {reason}");
}
#[test]
fn format_attention_reason_awaiting_response() {
let my_ts = now_ms() - 7_200_000; // 2 hours ago
let reason = format_attention_reason(
&AttentionState::AwaitingResponse,
Some(my_ts),
None,
Some(my_ts),
);
assert!(reason.contains("You replied"), "got: {reason}");
assert!(reason.contains("awaiting others"), "got: {reason}");
}
#[test]
fn parse_event_type_all_variants() {
assert_eq!(parse_event_type("note"), ActivityEventType::Note);
assert_eq!(parse_event_type("mention_note"), ActivityEventType::Note);
assert_eq!(
parse_event_type("status_change"),
ActivityEventType::StatusChange
);
assert_eq!(
parse_event_type("label_change"),
ActivityEventType::LabelChange
);
assert_eq!(parse_event_type("assign"), ActivityEventType::Assign);
assert_eq!(parse_event_type("unassign"), ActivityEventType::Unassign);
assert_eq!(
parse_event_type("review_request"),
ActivityEventType::ReviewRequest
);
assert_eq!(
parse_event_type("milestone_change"),
ActivityEventType::MilestoneChange
);
assert_eq!(parse_event_type("unknown"), ActivityEventType::Note);
}
#[test]
fn build_project_clause_empty() {
assert_eq!(build_project_clause("i.project_id", &[]), "");
}
#[test]
fn build_project_clause_single() {
let clause = build_project_clause("i.project_id", &[1]);
assert_eq!(clause, "AND i.project_id = ?2");
}
#[test]
fn build_project_clause_multiple() {
let clause = build_project_clause("i.project_id", &[1, 2, 3]);
assert_eq!(clause, "AND i.project_id IN (?2,?3,?4)");
}
#[test]
fn build_project_clause_at_custom_start() {
let clause = build_project_clause_at("p.id", &[1, 2], 3);
assert_eq!(clause, "AND p.id IN (?3,?4)");
}