feat(me): implement lore me personal work dashboard command

Complete implementation of the lore me command with:
- Config: gitlab.username field for identity resolution
- CLI: MeArgs with --issues, --mrs, --activity, --since, --project, --all, --user, --fields
- Identity: username resolution with precedence (CLI > config > error)
- Scope: project scope resolution with fuzzy matching and mutual exclusivity
- Types: AttentionState enum (5 states with sort ordering), dashboard structs
- Queries: open issues, authored MRs, reviewing MRs (all with attention state CTEs)
- Activity: 5-source feed (notes, state/label/milestone events, assignment detection)
- Human renderer: summary header, attention legend, section cards, event badges
- Robot renderer: {ok,data,meta} envelope with --fields minimal preset
- Handler: full wiring with section filtering, error paths, exit codes
- Autocorrect: me command flags registered

21 beads (bd-qpk3 through bd-32aw) implemented by 3-agent swarm.
978 tests pass, clippy clean.
This commit is contained in:
teernisse
2026-02-18 21:52:10 -05:00
parent 8fdb366b6d
commit 34680f0087
21 changed files with 3338 additions and 149 deletions

View File

@@ -96,6 +96,7 @@ fn test_config(default_project: Option<&str>) -> Config {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: None,
},
projects: vec![ProjectConfig {
path: "group/project".to_string(),

View 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)");
}

421
src/cli/commands/me/mod.rs Normal file
View File

@@ -0,0 +1,421 @@
pub mod queries;
pub mod render_human;
pub mod render_robot;
pub mod types;
use std::collections::HashSet;
use rusqlite::Connection;
use crate::Config;
use crate::cli::MeArgs;
use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::parse_since;
use self::queries::{query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs};
use self::types::{AttentionState, MeDashboard, MeSummary};
/// Default activity lookback: 30 days in milliseconds (AC-2.3).
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 30;
const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000;
/// Resolve the effective username from CLI flag or config.
///
/// Precedence: `--user` flag > `config.gitlab.username` > error (AC-1.2).
pub fn resolve_username<'a>(args: &'a MeArgs, config: &'a Config) -> Result<&'a str> {
if let Some(ref user) = args.user {
return Ok(user.as_str());
}
if let Some(ref username) = config.gitlab.username {
return Ok(username.as_str());
}
Err(LoreError::ConfigInvalid {
details: "No GitLab username configured. Set gitlab.username in config.json or pass --user <username>.".to_string(),
})
}
/// Resolve the project scope for the dashboard.
///
/// Returns a list of project IDs to filter by. An empty vec means "all projects".
///
/// Precedence (AC-8):
/// - `--project` and `--all` both set → error (AC-8.4, clap also enforces this)
/// - `--all` → empty vec (all projects)
/// - `--project` → resolve to single project ID via fuzzy match
/// - config.default_project → resolve that
/// - no default → empty vec (all projects)
pub fn resolve_project_scope(
conn: &Connection,
args: &MeArgs,
config: &Config,
) -> Result<Vec<i64>> {
if args.all {
return Ok(Vec::new());
}
if let Some(ref project) = args.project {
let id = resolve_project(conn, project)?;
return Ok(vec![id]);
}
if let Some(ref dp) = config.default_project {
let id = resolve_project(conn, dp)?;
return Ok(vec![id]);
}
Ok(Vec::new())
}
/// Run the `lore me` personal dashboard command.
///
/// Orchestrates: username resolution → project scope → query execution →
/// summary computation → dashboard assembly → rendering.
pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
let start = std::time::Instant::now();
// 1. Open DB
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
// 2. Check for synced data (AC-10.2)
let has_data: bool = conn
.query_row("SELECT EXISTS(SELECT 1 FROM projects LIMIT 1)", [], |row| {
row.get(0)
})
.unwrap_or(false);
if !has_data {
return Err(LoreError::NotFound(
"No synced data found. Run `lore sync` first to fetch your GitLab data.".to_string(),
));
}
// 3. Resolve username
let username = resolve_username(args, config)?;
// 4. Resolve project scope
let project_ids = resolve_project_scope(&conn, args, config)?;
let single_project = project_ids.len() == 1;
// 5. Parse --since (default 30d for activity feed, AC-2.3)
let since_ms = args
.since
.as_deref()
.and_then(parse_since)
.unwrap_or_else(|| crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY);
// 6. Determine which sections to query
let show_all = args.show_all_sections();
let want_issues = show_all || args.issues;
let want_mrs = show_all || args.mrs;
let want_activity = show_all || args.activity;
// 7. Run queries for requested sections
let open_issues = if want_issues {
query_open_issues(&conn, username, &project_ids)?
} else {
Vec::new()
};
let open_mrs_authored = if want_mrs {
query_authored_mrs(&conn, username, &project_ids)?
} else {
Vec::new()
};
let reviewing_mrs = if want_mrs {
query_reviewing_mrs(&conn, username, &project_ids)?
} else {
Vec::new()
};
let activity = if want_activity {
query_activity(&conn, username, &project_ids, since_ms)?
} else {
Vec::new()
};
// 8. Compute summary
let needs_attention_count = open_issues
.iter()
.filter(|i| i.attention_state == AttentionState::NeedsAttention)
.count()
+ open_mrs_authored
.iter()
.filter(|m| m.attention_state == AttentionState::NeedsAttention)
.count()
+ reviewing_mrs
.iter()
.filter(|m| m.attention_state == AttentionState::NeedsAttention)
.count();
// Count distinct projects across all items
let mut project_paths: HashSet<&str> = HashSet::new();
for i in &open_issues {
project_paths.insert(&i.project_path);
}
for m in &open_mrs_authored {
project_paths.insert(&m.project_path);
}
for m in &reviewing_mrs {
project_paths.insert(&m.project_path);
}
let summary = MeSummary {
project_count: project_paths.len(),
open_issue_count: open_issues.len(),
authored_mr_count: open_mrs_authored.len(),
reviewing_mr_count: reviewing_mrs.len(),
needs_attention_count,
};
// 9. Assemble dashboard
let dashboard = MeDashboard {
username: username.to_string(),
since_ms: Some(since_ms),
summary,
open_issues,
open_mrs_authored,
reviewing_mrs,
activity,
};
// 10. Render
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
let fields = args.fields.as_deref();
render_robot::print_me_json(&dashboard, elapsed_ms, fields);
} else if show_all {
render_human::print_me_dashboard(&dashboard, single_project);
} else {
render_human::print_me_dashboard_filtered(
&dashboard,
single_project,
want_issues,
want_mrs,
want_activity,
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::{
EmbeddingConfig, GitLabConfig, LoggingConfig, ProjectConfig, ScoringConfig, StorageConfig,
SyncConfig,
};
use crate::core::db::{create_connection, run_migrations};
use std::path::Path;
fn test_config(username: Option<&str>) -> Config {
Config {
gitlab: GitLabConfig {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: username.map(String::from),
},
projects: vec![ProjectConfig {
path: "group/project".to_string(),
}],
default_project: None,
sync: SyncConfig::default(),
storage: StorageConfig::default(),
embedding: EmbeddingConfig::default(),
logging: LoggingConfig::default(),
scoring: ScoringConfig::default(),
}
}
fn test_args(user: Option<&str>) -> MeArgs {
MeArgs {
issues: false,
mrs: false,
activity: false,
since: None,
project: None,
all: false,
user: user.map(String::from),
fields: None,
}
}
#[test]
fn resolve_username_cli_flag_wins() {
let config = test_config(Some("config-user"));
let args = test_args(Some("cli-user"));
let result = resolve_username(&args, &config).unwrap();
assert_eq!(result, "cli-user");
}
#[test]
fn resolve_username_falls_back_to_config() {
let config = test_config(Some("config-user"));
let args = test_args(None);
let result = resolve_username(&args, &config).unwrap();
assert_eq!(result, "config-user");
}
#[test]
fn resolve_username_errors_when_both_absent() {
let config = test_config(None);
let args = test_args(None);
let err = resolve_username(&args, &config).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("username"), "unexpected error: {msg}");
assert!(msg.contains("--user"), "should suggest --user flag: {msg}");
}
fn test_config_with_default_project(
username: Option<&str>,
default_project: Option<&str>,
) -> Config {
Config {
gitlab: GitLabConfig {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
token: None,
username: username.map(String::from),
},
projects: vec![
ProjectConfig {
path: "group/project".to_string(),
},
ProjectConfig {
path: "other/repo".to_string(),
},
],
default_project: default_project.map(String::from),
sync: SyncConfig::default(),
storage: StorageConfig::default(),
embedding: EmbeddingConfig::default(),
logging: LoggingConfig::default(),
scoring: ScoringConfig::default(),
}
}
fn setup_test_db() -> Connection {
let conn = create_connection(Path::new(":memory:")).unwrap();
run_migrations(&conn).unwrap();
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)
VALUES (1, 'group/project', 'https://gitlab.example.com/group/project')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)
VALUES (2, 'other/repo', 'https://gitlab.example.com/other/repo')",
[],
)
.unwrap();
conn
}
#[test]
fn resolve_project_scope_all_flag_returns_empty() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let mut args = test_args(None);
args.all = true;
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert!(ids.is_empty(), "expected empty for --all, got {ids:?}");
}
#[test]
fn resolve_project_scope_project_flag_resolves() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let mut args = test_args(None);
args.project = Some("group/project".to_string());
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert_eq!(ids.len(), 1);
}
#[test]
fn resolve_project_scope_default_project() {
let conn = setup_test_db();
let config = test_config_with_default_project(Some("jdoe"), Some("other/repo"));
let args = test_args(None);
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert_eq!(ids.len(), 1);
}
#[test]
fn resolve_project_scope_no_default_returns_empty() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let args = test_args(None);
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert!(ids.is_empty(), "expected empty, got {ids:?}");
}
#[test]
fn resolve_project_scope_project_flag_fuzzy_match() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let mut args = test_args(None);
args.project = Some("project".to_string());
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert_eq!(ids.len(), 1);
}
#[test]
fn resolve_project_scope_all_overrides_default_project() {
let conn = setup_test_db();
let config = test_config_with_default_project(Some("jdoe"), Some("group/project"));
let mut args = test_args(None);
args.all = true;
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert!(
ids.is_empty(),
"expected --all to override default_project, got {ids:?}"
);
}
#[test]
fn resolve_project_scope_project_flag_overrides_default() {
let conn = setup_test_db();
let config = test_config_with_default_project(Some("jdoe"), Some("group/project"));
let mut args = test_args(None);
args.project = Some("other/repo".to_string());
let ids = resolve_project_scope(&conn, &args, &config).unwrap();
assert_eq!(ids.len(), 1, "expected --project to override default");
// Verify it resolved the explicit project, not the default
let resolved_path: String = conn
.query_row(
"SELECT path_with_namespace FROM projects WHERE id = ?1",
rusqlite::params![ids[0]],
|row| row.get(0),
)
.unwrap();
assert_eq!(resolved_path, "other/repo");
}
#[test]
fn resolve_project_scope_unknown_project_errors() {
let conn = setup_test_db();
let config = test_config(Some("jdoe"));
let mut args = test_args(None);
args.project = Some("nonexistent/project".to_string());
let err = resolve_project_scope(&conn, &args, &config).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found"), "expected not found error: {msg}");
}
#[test]
fn show_all_sections_true_when_no_flags() {
let args = test_args(None);
assert!(args.show_all_sections());
}
#[test]
fn show_all_sections_false_with_issues_flag() {
let mut args = test_args(None);
args.issues = true;
assert!(!args.show_all_sections());
}
}

View File

@@ -0,0 +1,605 @@
// ─── Query Functions ────────────────────────────────────────────────────────
//
// SQL queries powering the `lore me` dashboard.
// Each function takes &Connection, username, optional project scope,
// and returns Result<Vec<StructType>>.
use rusqlite::Connection;
use crate::core::error::Result;
use super::types::{ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr};
// ─── Open Issues (AC-5.1, Task #7) ─────────────────────────────────────────
/// Query open issues assigned to the user via issue_assignees.
/// Returns issues sorted by attention state priority, then by most recently updated.
/// Attention state is computed inline using CTE-based note timestamp comparison.
pub fn query_open_issues(
conn: &Connection,
username: &str,
project_ids: &[i64],
) -> Result<Vec<MeIssue>> {
let project_clause = build_project_clause("i.project_id", project_ids);
let sql = format!(
"WITH my_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
),
others_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
),
any_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
)
SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
CASE
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention'
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM issues i
JOIN issue_assignees ia ON ia.issue_id = i.id
JOIN projects p ON i.project_id = p.id
LEFT JOIN my_latest ml ON ml.issue_id = i.id
LEFT JOIN others_latest ol ON ol.issue_id = i.id
LEFT JOIN any_latest al ON al.issue_id = i.id
WHERE ia.username = ?1
AND i.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL
THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 2
ELSE 1
END,
i.updated_at DESC"
);
let params = build_params(username, project_ids);
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(6)?;
Ok(MeIssue {
iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
project_path: row.get(2)?,
status_name: row.get(3)?,
updated_at: row.get(4)?,
web_url: row.get(5)?,
attention_state: parse_attention_state(&attention_str),
labels: Vec::new(),
})
})?;
let mut issues: Vec<MeIssue> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
populate_issue_labels(conn, &mut issues)?;
Ok(issues)
}
// ─── Authored MRs (AC-5.2, Task #8) ────────────────────────────────────────
/// Query open MRs authored by the user.
pub fn query_authored_mrs(
conn: &Connection,
username: &str,
project_ids: &[i64],
) -> Result<Vec<MeMr>> {
let project_clause = build_project_clause("m.project_id", project_ids);
let sql = format!(
"WITH my_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
others_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
any_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
)
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
m.updated_at, m.web_url,
CASE
WHEN m.draft = 1 AND NOT EXISTS (
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
) THEN 'not_ready'
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention'
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM merge_requests m
JOIN projects p ON m.project_id = p.id
LEFT JOIN my_latest ml ON ml.merge_request_id = m.id
LEFT JOIN others_latest ol ON ol.merge_request_id = m.id
LEFT JOIN any_latest al ON al.merge_request_id = m.id
WHERE m.author_username = ?1
AND m.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2
ELSE 1
END,
m.updated_at DESC"
);
let params = build_params(username, project_ids);
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(7)?;
Ok(MeMr {
iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
project_path: row.get(2)?,
draft: row.get::<_, i32>(3)? != 0,
detailed_merge_status: row.get(4)?,
updated_at: row.get(5)?,
web_url: row.get(6)?,
attention_state: parse_attention_state(&attention_str),
author_username: None,
labels: Vec::new(),
})
})?;
let mut mrs: Vec<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
populate_mr_labels(conn, &mut mrs)?;
Ok(mrs)
}
// ─── Reviewing MRs (AC-5.3, Task #9) ───────────────────────────────────────
/// Query open MRs where user is a reviewer.
pub fn query_reviewing_mrs(
conn: &Connection,
username: &str,
project_ids: &[i64],
) -> Result<Vec<MeMr>> {
let project_clause = build_project_clause("m.project_id", project_ids);
let sql = format!(
"WITH my_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
others_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
any_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
)
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
m.author_username, m.updated_at, m.web_url,
CASE
WHEN m.draft = 1 AND NOT EXISTS (
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
) THEN 'not_ready'
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention'
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale'
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM merge_requests m
JOIN mr_reviewers r ON r.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
LEFT JOIN my_latest ml ON ml.merge_request_id = m.id
LEFT JOIN others_latest ol ON ol.merge_request_id = m.id
LEFT JOIN any_latest al ON al.merge_request_id = m.id
WHERE r.username = ?1
AND m.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2
ELSE 1
END,
m.updated_at DESC"
);
let params = build_params(username, project_ids);
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(8)?;
Ok(MeMr {
iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
project_path: row.get(2)?,
draft: row.get::<_, i32>(3)? != 0,
detailed_merge_status: row.get(4)?,
author_username: row.get(5)?,
updated_at: row.get(6)?,
web_url: row.get(7)?,
attention_state: parse_attention_state(&attention_str),
labels: Vec::new(),
})
})?;
let mut mrs: Vec<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
populate_mr_labels(conn, &mut mrs)?;
Ok(mrs)
}
// ─── Activity Feed (AC-5.4, Tasks #11-13) ──────────────────────────────────
/// Query activity events on items currently associated with the user.
/// Combines notes, state events, label events, milestone events, and
/// assignment/reviewer system notes into a unified feed sorted newest-first.
pub fn query_activity(
conn: &Connection,
username: &str,
project_ids: &[i64],
since_ms: i64,
) -> Result<Vec<MeActivityEvent>> {
// Build project filter for activity sources.
// Activity params: ?1=username, ?2=since_ms, ?3+=project_ids
let project_clause = build_project_clause_at("p.id", project_ids, 3);
// Build the "my items" subquery fragments for issue/MR association checks.
// These ensure we only see activity on items CURRENTLY associated with the user (AC-3.6).
let my_issue_check = "EXISTS (
SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
)";
let my_mr_check = "(
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
OR EXISTS (SELECT 1 FROM mr_reviewers rv WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
)";
// Source 1: Human comments on my items
let notes_sql = format!(
"SELECT n.created_at, 'note',
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
n.author_username,
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
SUBSTR(n.body, 1, 200),
SUBSTR(n.body, 1, 200)
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN projects p ON d.project_id = p.id
LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
WHERE n.is_system = 0
AND n.created_at >= ?2
{project_clause}
AND (
(d.issue_id IS NOT NULL AND {issue_check})
OR (d.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
);
// Source 2: State events
let state_sql = format!(
"SELECT e.created_at, 'status_change',
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
e.actor_username,
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
e.state,
NULL
FROM resource_state_events e
JOIN projects p ON e.project_id = p.id
LEFT JOIN issues i ON e.issue_id = i.id
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
WHERE e.created_at >= ?2
{project_clause}
AND (
(e.issue_id IS NOT NULL AND {issue_check})
OR (e.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
);
// Source 3: Label events
let label_sql = format!(
"SELECT e.created_at, 'label_change',
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
e.actor_username,
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
(e.action || ' ' || COALESCE(e.label_name, '(deleted)')),
NULL
FROM resource_label_events e
JOIN projects p ON e.project_id = p.id
LEFT JOIN issues i ON e.issue_id = i.id
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
WHERE e.created_at >= ?2
{project_clause}
AND (
(e.issue_id IS NOT NULL AND {issue_check})
OR (e.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
);
// Source 4: Milestone events
let milestone_sql = format!(
"SELECT e.created_at, 'milestone_change',
CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
e.actor_username,
CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END,
(e.action || ' ' || COALESCE(e.milestone_title, '(deleted)')),
NULL
FROM resource_milestone_events e
JOIN projects p ON e.project_id = p.id
LEFT JOIN issues i ON e.issue_id = i.id
LEFT JOIN merge_requests m ON e.merge_request_id = m.id
WHERE e.created_at >= ?2
{project_clause}
AND (
(e.issue_id IS NOT NULL AND {issue_check})
OR (e.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"),
);
// Source 5: Assignment/reviewer system notes (AC-12)
let assign_sql = format!(
"SELECT n.created_at,
CASE
WHEN LOWER(n.body) LIKE '%assigned to @%' THEN 'assign'
WHEN LOWER(n.body) LIKE '%unassigned @%' THEN 'unassign'
WHEN LOWER(n.body) LIKE '%requested review from @%' THEN 'review_request'
ELSE 'assign'
END,
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
p.path_with_namespace,
n.author_username,
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
n.body,
NULL
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN projects p ON d.project_id = p.id
LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
WHERE n.is_system = 1
AND n.created_at >= ?2
{project_clause}
AND (
LOWER(n.body) LIKE '%assigned to @' || LOWER(?1) || '%'
OR LOWER(n.body) LIKE '%unassigned @' || LOWER(?1) || '%'
OR LOWER(n.body) LIKE '%requested review from @' || LOWER(?1) || '%'
)
AND (
(d.issue_id IS NOT NULL AND {issue_check})
OR (d.merge_request_id IS NOT NULL AND {mr_check})
)",
project_clause = project_clause,
issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
);
let full_sql = format!(
"{notes_sql}
UNION ALL {state_sql}
UNION ALL {label_sql}
UNION ALL {milestone_sql}
UNION ALL {assign_sql}
ORDER BY 1 DESC"
);
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(username.to_string()));
params.push(Box::new(since_ms));
for &pid in project_ids {
params.push(Box::new(pid));
}
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&full_sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let event_type_str: String = row.get(1)?;
Ok(MeActivityEvent {
timestamp: row.get(0)?,
event_type: parse_event_type(&event_type_str),
entity_type: row.get(2)?,
entity_iid: row.get(3)?,
project_path: row.get(4)?,
actor: row.get(5)?,
is_own: row.get::<_, i32>(6)? != 0,
summary: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
body_preview: row.get(8)?,
})
})?;
let events: Vec<MeActivityEvent> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(events)
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/// Parse attention state string from SQL CASE result.
fn parse_attention_state(s: &str) -> AttentionState {
match s {
"needs_attention" => AttentionState::NeedsAttention,
"not_started" => AttentionState::NotStarted,
"awaiting_response" => AttentionState::AwaitingResponse,
"stale" => AttentionState::Stale,
"not_ready" => AttentionState::NotReady,
_ => AttentionState::NotStarted,
}
}
/// Parse activity event type string from SQL.
fn parse_event_type(s: &str) -> ActivityEventType {
match s {
"note" => ActivityEventType::Note,
"status_change" => ActivityEventType::StatusChange,
"label_change" => ActivityEventType::LabelChange,
"assign" => ActivityEventType::Assign,
"unassign" => ActivityEventType::Unassign,
"review_request" => ActivityEventType::ReviewRequest,
"milestone_change" => ActivityEventType::MilestoneChange,
_ => ActivityEventType::Note,
}
}
/// Build a SQL clause for project ID filtering.
/// `start_idx` is the 1-based parameter index for the first project ID.
/// Returns empty string when no filter is needed (all projects).
fn build_project_clause_at(column: &str, project_ids: &[i64], start_idx: usize) -> String {
match project_ids.len() {
0 => String::new(),
1 => format!("AND {column} = ?{start_idx}"),
n => {
let placeholders: Vec<String> = (0..n).map(|i| format!("?{}", start_idx + i)).collect();
format!("AND {column} IN ({})", placeholders.join(","))
}
}
}
/// Convenience: project clause starting at param index 2 (after username at ?1).
fn build_project_clause(column: &str, project_ids: &[i64]) -> String {
build_project_clause_at(column, project_ids, 2)
}
/// Build the parameter vector: username first, then project IDs.
fn build_params(username: &str, project_ids: &[i64]) -> Vec<Box<dyn rusqlite::types::ToSql>> {
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(username.to_string()));
for &pid in project_ids {
params.push(Box::new(pid));
}
params
}
/// Populate labels for issues (avoids N+1 when there are few issues).
fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> {
if issues.is_empty() {
return Ok(());
}
for issue in issues.iter_mut() {
let mut stmt = conn.prepare_cached(
"SELECT l.name FROM labels l
JOIN issue_labels il ON l.id = il.label_id
JOIN issues i ON il.issue_id = i.id
JOIN projects p ON i.project_id = p.id
WHERE i.iid = ?1 AND p.path_with_namespace = ?2
ORDER BY l.name",
)?;
let labels: Vec<String> = stmt
.query_map(rusqlite::params![issue.iid, issue.project_path], |row| {
row.get(0)
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
issue.labels = labels;
}
Ok(())
}
/// Populate labels for MRs (avoids N+1 when there are few MRs).
fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> {
if mrs.is_empty() {
return Ok(());
}
for mr in mrs.iter_mut() {
let mut stmt = conn.prepare_cached(
"SELECT l.name FROM labels l
JOIN mr_labels ml ON l.id = ml.label_id
JOIN merge_requests m ON ml.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
WHERE m.iid = ?1 AND p.path_with_namespace = ?2
ORDER BY l.name",
)?;
let labels: Vec<String> = stmt
.query_map(rusqlite::params![mr.iid, mr.project_path], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
mr.labels = labels;
}
Ok(())
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
#[path = "me_tests.rs"]
mod tests;

View File

@@ -0,0 +1,462 @@
use crate::cli::render::{self, GlyphMode, Icons, LoreRenderer, Theme};
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
};
// ─── Glyph Mode Helper ──────────────────────────────────────────────────────
/// Get the current glyph mode, defaulting to Unicode if renderer not initialized.
fn glyph_mode() -> GlyphMode {
LoreRenderer::try_get().map_or(GlyphMode::Unicode, LoreRenderer::glyph_mode)
}
// ─── Attention Icons ─────────────────────────────────────────────────────────
/// Return the attention icon for the current glyph mode.
fn attention_icon(state: &AttentionState) -> &'static str {
let mode = glyph_mode();
match state {
AttentionState::NeedsAttention => match mode {
GlyphMode::Nerd => "\u{f0f3}", // bell
GlyphMode::Unicode => "\u{25c6}", // diamond
GlyphMode::Ascii => "[!]",
},
AttentionState::NotStarted => match mode {
GlyphMode::Nerd => "\u{f005}", // star
GlyphMode::Unicode => "\u{2605}", // black star
GlyphMode::Ascii => "[*]",
},
AttentionState::AwaitingResponse => match mode {
GlyphMode::Nerd => "\u{f017}", // clock
GlyphMode::Unicode => "\u{25f7}", // white circle with upper right quadrant
GlyphMode::Ascii => "[~]",
},
AttentionState::Stale => match mode {
GlyphMode::Nerd => "\u{f54c}", // skull
GlyphMode::Unicode => "\u{2620}", // skull and crossbones
GlyphMode::Ascii => "[x]",
},
AttentionState::NotReady => match mode {
GlyphMode::Nerd => "\u{f040}", // pencil
GlyphMode::Unicode => "\u{270e}", // lower right pencil
GlyphMode::Ascii => "[D]",
},
}
}
/// Style for an attention state.
fn attention_style(state: &AttentionState) -> lipgloss::Style {
match state {
AttentionState::NeedsAttention => Theme::warning(),
AttentionState::NotStarted => Theme::info(),
AttentionState::AwaitingResponse | AttentionState::Stale => Theme::dim(),
AttentionState::NotReady => Theme::state_draft(),
}
}
/// Render the styled attention icon for an item.
fn styled_attention(state: &AttentionState) -> String {
let icon = attention_icon(state);
attention_style(state).render(icon)
}
// ─── Event Badges ────────────────────────────────────────────────────────────
/// Render an activity event badge (colored).
fn event_badge(event_type: &ActivityEventType) -> String {
let mode = glyph_mode();
let (label, style) = match event_type {
ActivityEventType::Note => ("note", Theme::info()),
ActivityEventType::StatusChange => ("status", Theme::warning()),
ActivityEventType::LabelChange => ("label", Theme::accent()),
ActivityEventType::Assign | ActivityEventType::Unassign => ("assign", Theme::success()),
ActivityEventType::ReviewRequest => ("assign", Theme::success()),
ActivityEventType::MilestoneChange => ("milestone", accent_magenta()),
};
match mode {
GlyphMode::Ascii => style.render(&format!("[{label}]")),
_ => {
// For nerd/unicode, use colored bg with dark text where possible.
// lipgloss background support is limited, so we use colored text as a
// practical fallback that still provides the visual distinction.
style.render(&format!(" {label} "))
}
}
}
/// Magenta accent for milestone badges.
fn accent_magenta() -> lipgloss::Style {
if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) {
lipgloss::Style::new().foreground("#d946ef")
} else {
lipgloss::Style::new()
}
}
// ─── Summary Header ─────────────────────────────────────────────────────────
/// Print the summary header with counts and attention legend (Task #14).
pub fn print_summary_header(summary: &MeSummary, username: &str) {
println!();
println!(
"{}",
Theme::bold().render(&format!(
"{} {} -- Personal Dashboard",
Icons::user(),
username,
))
);
println!("{}", "\u{2500}".repeat(60));
// Counts line
let needs = if summary.needs_attention_count > 0 {
Theme::warning().render(&format!("{} need attention", summary.needs_attention_count))
} else {
Theme::dim().render("0 need attention")
};
println!(
" {} projects {} issues {} authored MRs {} reviewing MRs {}",
summary.project_count,
summary.open_issue_count,
summary.authored_mr_count,
summary.reviewing_mr_count,
needs,
);
// Attention legend
print_attention_legend();
}
/// Print the attention icon legend.
fn print_attention_legend() {
println!();
let states = [
(AttentionState::NeedsAttention, "needs attention"),
(AttentionState::NotStarted, "not started"),
(AttentionState::AwaitingResponse, "awaiting response"),
(AttentionState::Stale, "stale (30d+)"),
];
let legend: Vec<String> = states
.iter()
.map(|(state, label)| format!("{} {}", styled_attention(state), Theme::dim().render(label)))
.collect();
println!(" {}", legend.join(" "));
}
// ─── Open Issues Section ─────────────────────────────────────────────────────
/// Print the open issues section (Task #15).
pub fn print_issues_section(issues: &[MeIssue], single_project: bool) {
if issues.is_empty() {
println!("{}", render::section_divider("Open Issues (0)"));
println!(
" {}",
Theme::dim().render("No open issues assigned to you.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Open Issues ({})", issues.len()))
);
for issue in issues {
let attn = styled_attention(&issue.attention_state);
let ref_str = format!("#{}", issue.iid);
let status = issue
.status_name
.as_deref()
.map(|s| format!(" [{s}]"))
.unwrap_or_default();
let time = render::format_relative_time(issue.updated_at);
// Line 1: attention icon, issue ref, title, status, relative time
println!(
" {} {} {}{} {}",
attn,
Theme::issue_ref().render(&ref_str),
render::truncate(&issue.title, 40),
Theme::dim().render(&status),
Theme::dim().render(&time),
);
// Line 2: project path (suppressed in single-project mode)
if !single_project {
println!(" {}", Theme::dim().render(&issue.project_path),);
}
}
}
// ─── MR Sections ─────────────────────────────────────────────────────────────
/// Print the authored MRs section (Task #16).
pub fn print_authored_mrs_section(mrs: &[MeMr], single_project: bool) {
if mrs.is_empty() {
println!("{}", render::section_divider("Authored MRs (0)"));
println!(
" {}",
Theme::dim().render("No open MRs authored by you.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Authored MRs ({})", mrs.len()))
);
for mr in mrs {
let attn = styled_attention(&mr.attention_state);
let ref_str = format!("!{}", mr.iid);
let draft = if mr.draft {
Theme::state_draft().render(" [draft]")
} else {
String::new()
};
let merge_status = mr
.detailed_merge_status
.as_deref()
.filter(|s| !s.is_empty() && *s != "not_open")
.map(|s| format!(" ({s})"))
.unwrap_or_default();
let time = render::format_relative_time(mr.updated_at);
// Line 1: attention, MR ref, title, draft, merge status, time
println!(
" {} {} {}{}{} {}",
attn,
Theme::mr_ref().render(&ref_str),
render::truncate(&mr.title, 35),
draft,
Theme::dim().render(&merge_status),
Theme::dim().render(&time),
);
// Line 2: project path
if !single_project {
println!(" {}", Theme::dim().render(&mr.project_path),);
}
}
}
/// Print the reviewing MRs section (Task #16).
pub fn print_reviewing_mrs_section(mrs: &[MeMr], single_project: bool) {
if mrs.is_empty() {
println!("{}", render::section_divider("Reviewing MRs (0)"));
println!(
" {}",
Theme::dim().render("No open MRs awaiting your review.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Reviewing MRs ({})", mrs.len()))
);
for mr in mrs {
let attn = styled_attention(&mr.attention_state);
let ref_str = format!("!{}", mr.iid);
let author = mr
.author_username
.as_deref()
.map(|a| format!(" by {}", Theme::username().render(&format!("@{a}"))))
.unwrap_or_default();
let draft = if mr.draft {
Theme::state_draft().render(" [draft]")
} else {
String::new()
};
let time = render::format_relative_time(mr.updated_at);
// Line 1: attention, MR ref, title, author, draft, time
println!(
" {} {} {}{}{} {}",
attn,
Theme::mr_ref().render(&ref_str),
render::truncate(&mr.title, 30),
author,
draft,
Theme::dim().render(&time),
);
// Line 2: project path
if !single_project {
println!(" {}", Theme::dim().render(&mr.project_path),);
}
}
}
// ─── Activity Feed ───────────────────────────────────────────────────────────
/// Print the activity feed section (Task #17).
pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool) {
if events.is_empty() {
println!("{}", render::section_divider("Activity (0)"));
println!(
" {}",
Theme::dim().render("No recent activity on your items.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Activity ({})", events.len()))
);
for event in events {
let badge = event_badge(&event.event_type);
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
let time = render::format_relative_time_compact(event.timestamp);
let actor_str = if event.is_own {
Theme::dim().render(&format!(
"{}(you)",
event
.actor
.as_deref()
.map(|a| format!("@{a} "))
.unwrap_or_default()
))
} else {
event
.actor
.as_deref()
.map(|a| Theme::username().render(&format!("@{a}")))
.unwrap_or_default()
};
let summary = render::truncate(&event.summary, 40);
// Dim own actions
let summary_styled = if event.is_own {
Theme::dim().render(&summary)
} else {
summary
};
// Line 1: badge, entity ref, summary, actor, time
println!(
" {badge} {entity_ref:7} {summary_styled} {actor_str} {}",
Theme::dim().render(&time),
);
// Line 2: project path (if multi-project) + body preview for notes
if !single_project {
println!(" {}", Theme::dim().render(&event.project_path),);
}
if let Some(preview) = &event.body_preview
&& !preview.is_empty()
{
let truncated = render::truncate(preview, 60);
println!(" {}", Theme::dim().render(&format!("\"{truncated}\"")),);
}
}
}
/// Format an entity reference (#N for issues, !N for MRs).
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
match entity_type {
"issue" => {
let s = format!("#{iid}");
Theme::issue_ref().render(&s)
}
"merge_request" => {
let s = format!("!{iid}");
Theme::mr_ref().render(&s)
}
_ => format!("{entity_type}:{iid}"),
}
}
// ─── Full Dashboard ──────────────────────────────────────────────────────────
/// Render the complete human-mode dashboard.
pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) {
print_summary_header(&dashboard.summary, &dashboard.username);
print_issues_section(&dashboard.open_issues, single_project);
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
print_activity_section(&dashboard.activity, single_project);
println!();
}
/// Render a filtered dashboard (only requested sections).
pub fn print_me_dashboard_filtered(
dashboard: &MeDashboard,
single_project: bool,
show_issues: bool,
show_mrs: bool,
show_activity: bool,
) {
print_summary_header(&dashboard.summary, &dashboard.username);
if show_issues {
print_issues_section(&dashboard.open_issues, single_project);
}
if show_mrs {
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
}
if show_activity {
print_activity_section(&dashboard.activity, single_project);
}
println!();
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attention_icon_returns_nonempty_for_all_states() {
let states = [
AttentionState::NeedsAttention,
AttentionState::NotStarted,
AttentionState::AwaitingResponse,
AttentionState::Stale,
AttentionState::NotReady,
];
for state in &states {
assert!(!attention_icon(state).is_empty(), "empty for {state:?}");
}
}
#[test]
fn format_entity_ref_issue() {
let result = format_entity_ref("issue", 42);
assert!(result.contains("42"), "got: {result}");
}
#[test]
fn format_entity_ref_mr() {
let result = format_entity_ref("merge_request", 99);
assert!(result.contains("99"), "got: {result}");
}
#[test]
fn event_badge_returns_nonempty_for_all_types() {
let types = [
ActivityEventType::Note,
ActivityEventType::StatusChange,
ActivityEventType::LabelChange,
ActivityEventType::Assign,
ActivityEventType::Unassign,
ActivityEventType::ReviewRequest,
ActivityEventType::MilestoneChange,
];
for t in &types {
assert!(!event_badge(t).is_empty(), "empty for {t:?}");
}
}
}

View File

@@ -0,0 +1,335 @@
use serde::Serialize;
use crate::cli::robot::RobotMeta;
use crate::core::time::ms_to_iso;
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
};
// ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
/// Print the full me dashboard as robot-mode JSON.
pub fn print_me_json(dashboard: &MeDashboard, elapsed_ms: u64, fields: Option<&[String]>) {
let envelope = MeJsonEnvelope {
ok: true,
data: MeDataJson::from_dashboard(dashboard),
meta: RobotMeta { elapsed_ms },
};
let mut value = match serde_json::to_value(&envelope) {
Ok(v) => v,
Err(e) => {
eprintln!("Error serializing me JSON: {e}");
return;
}
};
// Apply --fields filtering (Task #19)
if let Some(f) = fields {
let expanded = crate::cli::robot::expand_fields_preset(f, "me_items");
// Filter all item arrays
for key in &["open_issues", "open_mrs_authored", "reviewing_mrs"] {
crate::cli::robot::filter_fields(&mut value, key, &expanded);
}
// Activity gets its own minimal preset
let activity_expanded = crate::cli::robot::expand_fields_preset(f, "me_activity");
crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded);
}
match serde_json::to_string(&value) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"),
}
}
// ─── JSON Envelope ───────────────────────────────────────────────────────────
#[derive(Serialize)]
struct MeJsonEnvelope {
ok: bool,
data: MeDataJson,
meta: RobotMeta,
}
#[derive(Serialize)]
struct MeDataJson {
username: String,
since_iso: Option<String>,
summary: SummaryJson,
open_issues: Vec<IssueJson>,
open_mrs_authored: Vec<MrJson>,
reviewing_mrs: Vec<MrJson>,
activity: Vec<ActivityJson>,
}
impl MeDataJson {
fn from_dashboard(d: &MeDashboard) -> Self {
Self {
username: d.username.clone(),
since_iso: d.since_ms.map(ms_to_iso),
summary: SummaryJson::from(&d.summary),
open_issues: d.open_issues.iter().map(IssueJson::from).collect(),
open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(),
reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(),
activity: d.activity.iter().map(ActivityJson::from).collect(),
}
}
}
// ─── Summary ─────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct SummaryJson {
project_count: usize,
open_issue_count: usize,
authored_mr_count: usize,
reviewing_mr_count: usize,
needs_attention_count: usize,
}
impl From<&MeSummary> for SummaryJson {
fn from(s: &MeSummary) -> Self {
Self {
project_count: s.project_count,
open_issue_count: s.open_issue_count,
authored_mr_count: s.authored_mr_count,
reviewing_mr_count: s.reviewing_mr_count,
needs_attention_count: s.needs_attention_count,
}
}
}
// ─── Issue ───────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct IssueJson {
project: String,
iid: i64,
title: String,
state: String,
attention_state: String,
status_name: Option<String>,
labels: Vec<String>,
updated_at_iso: String,
web_url: Option<String>,
}
impl From<&MeIssue> for IssueJson {
fn from(i: &MeIssue) -> Self {
Self {
project: i.project_path.clone(),
iid: i.iid,
title: i.title.clone(),
state: "opened".to_string(),
attention_state: attention_state_str(&i.attention_state),
status_name: i.status_name.clone(),
labels: i.labels.clone(),
updated_at_iso: ms_to_iso(i.updated_at),
web_url: i.web_url.clone(),
}
}
}
// ─── MR ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct MrJson {
project: String,
iid: i64,
title: String,
state: String,
attention_state: String,
draft: bool,
detailed_merge_status: Option<String>,
author_username: Option<String>,
labels: Vec<String>,
updated_at_iso: String,
web_url: Option<String>,
}
impl From<&MeMr> for MrJson {
fn from(m: &MeMr) -> Self {
Self {
project: m.project_path.clone(),
iid: m.iid,
title: m.title.clone(),
state: "opened".to_string(),
attention_state: attention_state_str(&m.attention_state),
draft: m.draft,
detailed_merge_status: m.detailed_merge_status.clone(),
author_username: m.author_username.clone(),
labels: m.labels.clone(),
updated_at_iso: ms_to_iso(m.updated_at),
web_url: m.web_url.clone(),
}
}
}
// ─── Activity ────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct ActivityJson {
timestamp_iso: String,
event_type: String,
entity_type: String,
entity_iid: i64,
project: String,
actor: Option<String>,
is_own: bool,
summary: String,
body_preview: Option<String>,
}
impl From<&MeActivityEvent> for ActivityJson {
fn from(e: &MeActivityEvent) -> Self {
Self {
timestamp_iso: ms_to_iso(e.timestamp),
event_type: event_type_str(&e.event_type),
entity_type: e.entity_type.clone(),
entity_iid: e.entity_iid,
project: e.project_path.clone(),
actor: e.actor.clone(),
is_own: e.is_own,
summary: e.summary.clone(),
body_preview: e.body_preview.clone(),
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/// Convert `AttentionState` to its programmatic string representation.
fn attention_state_str(state: &AttentionState) -> String {
match state {
AttentionState::NeedsAttention => "needs_attention",
AttentionState::NotStarted => "not_started",
AttentionState::AwaitingResponse => "awaiting_response",
AttentionState::Stale => "stale",
AttentionState::NotReady => "not_ready",
}
.to_string()
}
/// Convert `ActivityEventType` to its programmatic string representation.
fn event_type_str(event_type: &ActivityEventType) -> String {
match event_type {
ActivityEventType::Note => "note",
ActivityEventType::StatusChange => "status_change",
ActivityEventType::LabelChange => "label_change",
ActivityEventType::Assign => "assign",
ActivityEventType::Unassign => "unassign",
ActivityEventType::ReviewRequest => "review_request",
ActivityEventType::MilestoneChange => "milestone_change",
}
.to_string()
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attention_state_str_all_variants() {
assert_eq!(
attention_state_str(&AttentionState::NeedsAttention),
"needs_attention"
);
assert_eq!(
attention_state_str(&AttentionState::NotStarted),
"not_started"
);
assert_eq!(
attention_state_str(&AttentionState::AwaitingResponse),
"awaiting_response"
);
assert_eq!(attention_state_str(&AttentionState::Stale), "stale");
assert_eq!(attention_state_str(&AttentionState::NotReady), "not_ready");
}
#[test]
fn event_type_str_all_variants() {
assert_eq!(event_type_str(&ActivityEventType::Note), "note");
assert_eq!(
event_type_str(&ActivityEventType::StatusChange),
"status_change"
);
assert_eq!(
event_type_str(&ActivityEventType::LabelChange),
"label_change"
);
assert_eq!(event_type_str(&ActivityEventType::Assign), "assign");
assert_eq!(event_type_str(&ActivityEventType::Unassign), "unassign");
assert_eq!(
event_type_str(&ActivityEventType::ReviewRequest),
"review_request"
);
assert_eq!(
event_type_str(&ActivityEventType::MilestoneChange),
"milestone_change"
);
}
#[test]
fn issue_json_from_me_issue() {
let issue = MeIssue {
iid: 42,
title: "Fix auth bug".to_string(),
project_path: "group/repo".to_string(),
attention_state: AttentionState::NeedsAttention,
status_name: Some("In progress".to_string()),
labels: vec!["bug".to_string()],
updated_at: 1_700_000_000_000,
web_url: Some("https://gitlab.com/group/repo/-/issues/42".to_string()),
};
let json = IssueJson::from(&issue);
assert_eq!(json.iid, 42);
assert_eq!(json.attention_state, "needs_attention");
assert_eq!(json.state, "opened");
assert_eq!(json.status_name, Some("In progress".to_string()));
}
#[test]
fn mr_json_from_me_mr() {
let mr = MeMr {
iid: 99,
title: "Add feature".to_string(),
project_path: "group/repo".to_string(),
attention_state: AttentionState::AwaitingResponse,
draft: true,
detailed_merge_status: Some("mergeable".to_string()),
author_username: Some("alice".to_string()),
labels: vec![],
updated_at: 1_700_000_000_000,
web_url: None,
};
let json = MrJson::from(&mr);
assert_eq!(json.iid, 99);
assert_eq!(json.attention_state, "awaiting_response");
assert!(json.draft);
assert_eq!(json.author_username, Some("alice".to_string()));
}
#[test]
fn activity_json_from_event() {
let event = MeActivityEvent {
timestamp: 1_700_000_000_000,
event_type: ActivityEventType::Note,
entity_type: "issue".to_string(),
entity_iid: 42,
project_path: "group/repo".to_string(),
actor: Some("bob".to_string()),
is_own: false,
summary: "Added a comment".to_string(),
body_preview: Some("This looks good".to_string()),
};
let json = ActivityJson::from(&event);
assert_eq!(json.event_type, "note");
assert_eq!(json.entity_iid, 42);
assert!(!json.is_own);
assert_eq!(json.body_preview, Some("This looks good".to_string()));
}
}

View File

@@ -0,0 +1,98 @@
// ─── Dashboard Types ─────────────────────────────────────────────────────────
//
// Data structs for the `lore me` personal dashboard.
// These are populated by query functions and consumed by renderers.
/// Attention state for a work item (AC-4.4).
/// Ordered by display priority (first = most urgent).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AttentionState {
/// Others commented after me (or I never engaged but others have)
NeedsAttention = 0,
/// Zero non-system notes from anyone
NotStarted = 1,
/// My latest note >= all others' latest notes
AwaitingResponse = 2,
/// Latest note from anyone is older than 30 days
Stale = 3,
/// MR-only: draft with no reviewers
NotReady = 4,
}
/// Activity event type for the feed (AC-5.4, AC-6.4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActivityEventType {
/// Human comment (non-system note)
Note,
/// State change (opened/closed/reopened/merged)
StatusChange,
/// Label added or removed
LabelChange,
/// Assignment event
Assign,
/// Unassignment event
Unassign,
/// Review request
ReviewRequest,
/// Milestone change
MilestoneChange,
}
/// Summary counts for the dashboard header (AC-5.5).
pub struct MeSummary {
pub project_count: usize,
pub open_issue_count: usize,
pub authored_mr_count: usize,
pub reviewing_mr_count: usize,
pub needs_attention_count: usize,
}
/// An open issue assigned to the user (AC-5.1).
pub struct MeIssue {
pub iid: i64,
pub title: String,
pub project_path: String,
pub attention_state: AttentionState,
pub status_name: Option<String>,
pub labels: Vec<String>,
pub updated_at: i64,
pub web_url: Option<String>,
}
/// An open MR authored by or reviewing for the user (AC-5.2, AC-5.3).
pub struct MeMr {
pub iid: i64,
pub title: String,
pub project_path: String,
pub attention_state: AttentionState,
pub draft: bool,
pub detailed_merge_status: Option<String>,
pub author_username: Option<String>,
pub labels: Vec<String>,
pub updated_at: i64,
pub web_url: Option<String>,
}
/// An activity event in the feed (AC-5.4).
pub struct MeActivityEvent {
pub timestamp: i64,
pub event_type: ActivityEventType,
pub entity_type: String,
pub entity_iid: i64,
pub project_path: String,
pub actor: Option<String>,
pub is_own: bool,
pub summary: String,
pub body_preview: Option<String>,
}
/// The complete dashboard result.
pub struct MeDashboard {
pub username: String,
pub since_ms: Option<i64>,
pub summary: MeSummary,
pub open_issues: Vec<MeIssue>,
pub open_mrs_authored: Vec<MeMr>,
pub reviewing_mrs: Vec<MeMr>,
pub activity: Vec<MeActivityEvent>,
}

View File

@@ -10,6 +10,7 @@ pub mod generate_docs;
pub mod ingest;
pub mod init;
pub mod list;
pub mod me;
pub mod search;
pub mod show;
pub mod stats;
@@ -46,6 +47,7 @@ pub use list::{
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
print_list_notes, print_list_notes_json, query_notes, run_list_issues, run_list_mrs,
};
pub use me::run_me;
pub use search::{
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,
};