feat(me): add lore me personal work dashboard command
Implement a personal work dashboard that shows everything relevant to the
configured GitLab user: open issues assigned to them, MRs they authored,
MRs they are reviewing, and a chronological activity feed.
Design decisions:
- Attention state computed from GitLab interaction data (comments, reviews)
with no local state tracking -- purely derived from existing synced data
- Username resolution: --user flag > config.gitlab.username > actionable error
- Project scoping: --project (fuzzy) | --all | default_project | all
- Section filtering: --issues, --mrs, --activity (combinable, default = all)
- Activity feed controlled by --since (default 30d); work item sections
always show all open items regardless of --since
Architecture (src/cli/commands/me/):
- types.rs: MeDashboard, MeSummary, AttentionState data types
- queries.rs: 4 SQL queries (open_issues, authored_mrs, reviewing_mrs,
activity) using existing issue_assignees, mr_reviewers, notes tables
- render_human.rs: colored terminal output with attention state indicators
- render_robot.rs: {ok, data, meta} JSON envelope with field selection
- mod.rs: orchestration (resolve_username, resolve_project_scope, run_me)
- me_tests.rs: comprehensive unit tests covering all query paths
Config additions:
- New optional gitlab.username field in config.json
- Tests for config with/without username
- Existing test configs updated with username: None
CLI wiring:
- MeArgs struct with section filter, since, project, all, user, fields flags
- Autocorrect support for me command flags
- LoreRenderer::try_get() for safe renderer access in me module
- Robot mode field selection presets (me_items, me_activity)
- handle_me() in main.rs command dispatch
Also fixes duplicate assertions in surgical sync tests (removed 6
duplicate assert! lines that were copy-paste artifacts).
Spec: docs/lore-me-spec.md
This commit is contained in:
749
src/cli/commands/me/me_tests.rs
Normal file
749
src/cli/commands/me/me_tests.rs
Normal file
@@ -0,0 +1,749 @@
|
||||
use super::*;
|
||||
use crate::cli::commands::me::types::{ActivityEventType, AttentionState};
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use crate::core::time::now_ms;
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_project(conn: &Connection, id: i64, path: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![
|
||||
id,
|
||||
id * 100,
|
||||
path,
|
||||
format!("https://git.example.com/{path}")
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) {
|
||||
insert_issue_with_state(conn, id, project_id, iid, author, "opened");
|
||||
}
|
||||
|
||||
fn insert_issue_with_state(
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
project_id: i64,
|
||||
iid: i64,
|
||||
author: &str,
|
||||
state: &str,
|
||||
) {
|
||||
let ts = now_ms();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
rusqlite::params![
|
||||
id,
|
||||
id * 10,
|
||||
project_id,
|
||||
iid,
|
||||
format!("Issue {iid}"),
|
||||
state,
|
||||
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);
|
||||
assert_eq!(results[0].body_preview, Some("new comment".to_string()));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
|
||||
// ─── Helper Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[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 parse_event_type_all_variants() {
|
||||
assert_eq!(parse_event_type("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)");
|
||||
}
|
||||
Reference in New Issue
Block a user