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:
@@ -286,6 +286,19 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
),
|
||||
("show", &["--project"]),
|
||||
("reset", &["--yes"]),
|
||||
(
|
||||
"me",
|
||||
&[
|
||||
"--issues",
|
||||
"--mrs",
|
||||
"--activity",
|
||||
"--since",
|
||||
"--project",
|
||||
"--all",
|
||||
"--user",
|
||||
"--fields",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Valid values for enum-like flags, used for post-clap error enhancement.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
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)");
|
||||
}
|
||||
424
src/cli/commands/me/mod.rs
Normal file
424
src/cli/commands/me/mod.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
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 = match args.since.as_deref() {
|
||||
Some(raw) => parse_since(raw).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --since value '{raw}'. Expected: 7d, 2w, 3m, YYYY-MM-DD, or Unix-ms timestamp."
|
||||
))
|
||||
})?,
|
||||
None => 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());
|
||||
}
|
||||
}
|
||||
560
src/cli/commands/me/queries.rs
Normal file
560
src/cli/commands/me/queries.rs
Normal file
@@ -0,0 +1,560 @@
|
||||
// ─── 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};
|
||||
|
||||
/// Stale threshold: items with no activity for 30 days are marked "stale".
|
||||
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
|
||||
|
||||
// ─── 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 note_ts AS (
|
||||
SELECT d.issue_id,
|
||||
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
|
||||
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
|
||||
MAX(n.created_at) AS any_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 nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||
THEN 'needs_attention'
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||
THEN 'stale'
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_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 note_ts nt ON nt.issue_id = i.id
|
||||
WHERE ia.username = ?1
|
||||
AND i.state = 'opened'
|
||||
{project_clause}
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||
THEN 0
|
||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
|
||||
THEN 1
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||
THEN 3
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
|
||||
THEN 2
|
||||
ELSE 1
|
||||
END,
|
||||
i.updated_at DESC",
|
||||
stale_ms = STALE_THRESHOLD_MS,
|
||||
);
|
||||
|
||||
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 note_ts AS (
|
||||
SELECT d.merge_request_id,
|
||||
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
|
||||
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
|
||||
MAX(n.created_at) AS any_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 nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||
THEN 'needs_attention'
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||
THEN 'stale'
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_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 note_ts nt ON nt.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 nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
|
||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
|
||||
ELSE 1
|
||||
END,
|
||||
m.updated_at DESC",
|
||||
stale_ms = STALE_THRESHOLD_MS,
|
||||
);
|
||||
|
||||
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 note_ts AS (
|
||||
SELECT d.merge_request_id,
|
||||
MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts,
|
||||
MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts,
|
||||
MAX(n.created_at) AS any_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
|
||||
-- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists
|
||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
|
||||
THEN 'needs_attention'
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
|
||||
THEN 'stale'
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_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 note_ts nt ON nt.merge_request_id = m.id
|
||||
WHERE r.username = ?1
|
||||
AND m.state = 'opened'
|
||||
{project_clause}
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 0
|
||||
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1
|
||||
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3
|
||||
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2
|
||||
ELSE 1
|
||||
END,
|
||||
m.updated_at DESC",
|
||||
stale_ms = STALE_THRESHOLD_MS,
|
||||
);
|
||||
|
||||
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 via cached per-item queries.
|
||||
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 via cached per-item queries.
|
||||
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;
|
||||
463
src/cli/commands/me/render_human.rs
Normal file
463
src/cli/commands/me/render_human.rs
Normal file
@@ -0,0 +1,463 @@
|
||||
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+)"),
|
||||
(AttentionState::NotReady, "draft (not ready)"),
|
||||
];
|
||||
|
||||
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)
|
||||
}
|
||||
"mr" => {
|
||||
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("mr", 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
334
src/cli/commands/me/render_robot.rs
Normal file
334
src/cli/commands/me/render_robot.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
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]>,
|
||||
) -> crate::core::error::Result<()> {
|
||||
let envelope = MeJsonEnvelope {
|
||||
ok: true,
|
||||
data: MeDataJson::from_dashboard(dashboard),
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
|
||||
let mut value = serde_json::to_value(&envelope)
|
||||
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&value)
|
||||
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── 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()));
|
||||
}
|
||||
}
|
||||
98
src/cli/commands/me/types.rs
Normal file
98
src/cli/commands/me/types.rs
Normal 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>,
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -261,6 +261,9 @@ pub enum Commands {
|
||||
/// People intelligence: experts, workload, active discussions, overlap
|
||||
Who(WhoArgs),
|
||||
|
||||
/// Personal work dashboard: open issues, authored/reviewing MRs, activity
|
||||
Me(MeArgs),
|
||||
|
||||
/// Show MRs that touched a file, with linked discussions
|
||||
#[command(name = "file-history")]
|
||||
FileHistory(FileHistoryArgs),
|
||||
@@ -1050,6 +1053,57 @@ pub struct WhoArgs {
|
||||
pub all_history: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore me # Full dashboard (default project or all)
|
||||
lore me --issues # Issues section only
|
||||
lore me --mrs # MRs section only
|
||||
lore me --activity # Activity feed only
|
||||
lore me --all # All synced projects
|
||||
lore me --since 2d # Activity window (default: 30d)
|
||||
lore me --project group/repo # Scope to one project
|
||||
lore me --user jdoe # Override configured username")]
|
||||
pub struct MeArgs {
|
||||
/// Show open issues section
|
||||
#[arg(long, help_heading = "Sections")]
|
||||
pub issues: bool,
|
||||
|
||||
/// Show authored + reviewing MRs section
|
||||
#[arg(long, help_heading = "Sections")]
|
||||
pub mrs: bool,
|
||||
|
||||
/// Show activity feed section
|
||||
#[arg(long, help_heading = "Sections")]
|
||||
pub activity: bool,
|
||||
|
||||
/// Activity window (e.g. 7d, 2w, 30d). Default: 30d. Only affects activity section.
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub since: Option<String>,
|
||||
|
||||
/// Scope to a project (supports fuzzy matching)
|
||||
#[arg(short = 'p', long, help_heading = "Filters", conflicts_with = "all")]
|
||||
pub project: Option<String>,
|
||||
|
||||
/// Show all synced projects (overrides default_project)
|
||||
#[arg(long, help_heading = "Filters", conflicts_with = "project")]
|
||||
pub all: bool,
|
||||
|
||||
/// Override configured username
|
||||
#[arg(long = "user", help_heading = "Filters")]
|
||||
pub user: Option<String>,
|
||||
|
||||
/// Select output fields (comma-separated, or 'minimal' preset)
|
||||
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
||||
pub fields: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MeArgs {
|
||||
/// Returns true if no section flags were passed (show all sections).
|
||||
pub fn show_all_sections(&self) -> bool {
|
||||
!self.issues && !self.mrs && !self.activity
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||
lore file-history src/main.rs # MRs that touched this file
|
||||
|
||||
@@ -263,6 +263,11 @@ impl LoreRenderer {
|
||||
.expect("LoreRenderer::init must be called before get")
|
||||
}
|
||||
|
||||
/// Try to get the global renderer. Returns `None` if `init` hasn't been called.
|
||||
pub fn try_get() -> Option<&'static LoreRenderer> {
|
||||
RENDERER.get()
|
||||
}
|
||||
|
||||
/// Whether color output is enabled.
|
||||
pub fn colors_enabled(&self) -> bool {
|
||||
self.colors
|
||||
|
||||
@@ -68,6 +68,14 @@ pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec<String> {
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect(),
|
||||
"me_items" => ["iid", "title", "attention_state", "updated_at_iso"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect(),
|
||||
"me_activity" => ["timestamp_iso", "event_type", "entity_iid", "actor"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect(),
|
||||
_ => fields.to_vec(),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -16,6 +16,10 @@ pub struct GitLabConfig {
|
||||
/// Optional stored token (env var takes priority when both are set).
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
|
||||
/// Optional GitLab username for `lore me` personal dashboard.
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
impl GitLabConfig {
|
||||
@@ -570,6 +574,7 @@ mod tests {
|
||||
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(),
|
||||
@@ -594,6 +599,7 @@ mod tests {
|
||||
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(),
|
||||
@@ -615,6 +621,7 @@ mod tests {
|
||||
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(),
|
||||
@@ -837,6 +844,7 @@ mod tests {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: env_var.to_string(),
|
||||
token: token.map(ToString::to_string),
|
||||
username: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -907,4 +915,39 @@ mod tests {
|
||||
let cfg = gitlab_cfg_with_env(VAR, None);
|
||||
assert!(cfg.resolve_token().is_err());
|
||||
}
|
||||
|
||||
// ── gitlab.username ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_config_loads_with_username() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("config.json");
|
||||
let config = r#"{
|
||||
"gitlab": {
|
||||
"baseUrl": "https://gitlab.example.com",
|
||||
"tokenEnvVar": "GITLAB_TOKEN",
|
||||
"username": "jdoe"
|
||||
},
|
||||
"projects": [{ "path": "group/project" }]
|
||||
}"#;
|
||||
fs::write(&path, config).unwrap();
|
||||
let cfg = Config::load_from_path(&path).unwrap();
|
||||
assert_eq!(cfg.gitlab.username.as_deref(), Some("jdoe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_loads_without_username() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("config.json");
|
||||
let config = r#"{
|
||||
"gitlab": {
|
||||
"baseUrl": "https://gitlab.example.com",
|
||||
"tokenEnvVar": "GITLAB_TOKEN"
|
||||
},
|
||||
"projects": [{ "path": "group/project" }]
|
||||
}"#;
|
||||
fs::write(&path, config).unwrap();
|
||||
let cfg = Config::load_from_path(&path).unwrap();
|
||||
assert_eq!(cfg.gitlab.username, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ fn test_config() -> 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/repo".to_string(),
|
||||
@@ -167,7 +168,6 @@ fn test_ingest_issue_by_iid_upserts_and_marks_dirty() {
|
||||
|
||||
let result = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap();
|
||||
|
||||
assert!(!result.skipped_stale);
|
||||
assert!(!result.skipped_stale);
|
||||
assert!(!result.dirty_source_keys.is_empty());
|
||||
|
||||
@@ -199,7 +199,6 @@ fn test_toctou_skips_stale_issue() {
|
||||
// Second ingest with same timestamp should be skipped
|
||||
let r2 = ingest_issue_by_iid(&conn, &config, 1, &issue).unwrap();
|
||||
assert!(r2.skipped_stale);
|
||||
assert!(r2.skipped_stale);
|
||||
assert!(r2.dirty_source_keys.is_empty());
|
||||
|
||||
// No new dirty mark
|
||||
@@ -223,7 +222,6 @@ fn test_toctou_allows_newer_issue() {
|
||||
let result = ingest_issue_by_iid(&conn, &config, 1, &issue_t2).unwrap();
|
||||
|
||||
assert!(!result.skipped_stale);
|
||||
assert!(!result.skipped_stale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -271,7 +269,6 @@ fn test_ingest_mr_by_iid_upserts_and_marks_dirty() {
|
||||
|
||||
let result = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap();
|
||||
|
||||
assert!(!result.skipped_stale);
|
||||
assert!(!result.skipped_stale);
|
||||
assert!(!result.dirty_source_keys.is_empty());
|
||||
|
||||
@@ -298,7 +295,6 @@ fn test_toctou_skips_stale_mr() {
|
||||
|
||||
let r2 = ingest_mr_by_iid(&conn, &config, 1, &mr).unwrap();
|
||||
assert!(r2.skipped_stale);
|
||||
assert!(r2.skipped_stale);
|
||||
assert!(r2.dirty_source_keys.is_empty());
|
||||
}
|
||||
|
||||
@@ -316,7 +312,6 @@ fn test_toctou_allows_newer_mr() {
|
||||
let result = ingest_mr_by_iid(&conn, &config, 1, &mr_t2).unwrap();
|
||||
|
||||
assert!(!result.skipped_stale);
|
||||
assert!(!result.skipped_stale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
19
src/main.rs
19
src/main.rs
@@ -25,15 +25,15 @@ use lore::cli::commands::{
|
||||
print_who_json, query_notes, run_auth_test, run_count, run_count_events, run_cron_install,
|
||||
run_cron_status, run_cron_uninstall, run_doctor, run_drift, run_embed, run_file_history,
|
||||
run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs,
|
||||
run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline,
|
||||
run_token_set, run_token_show, run_who,
|
||||
run_me, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status,
|
||||
run_timeline, run_token_set, run_token_show, run_who,
|
||||
};
|
||||
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
|
||||
use lore::cli::robot::{RobotMeta, strip_schemas};
|
||||
use lore::cli::{
|
||||
Cli, Commands, CountArgs, CronAction, CronArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs,
|
||||
IngestArgs, IssuesArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs,
|
||||
TokenAction, TokenArgs, TraceArgs, WhoArgs,
|
||||
IngestArgs, IssuesArgs, MeArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs,
|
||||
TimelineArgs, TokenAction, TokenArgs, TraceArgs, WhoArgs,
|
||||
};
|
||||
use lore::core::db::{
|
||||
LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations,
|
||||
@@ -202,6 +202,7 @@ async fn main() {
|
||||
handle_timeline(cli.config.as_deref(), args, robot_mode).await
|
||||
}
|
||||
Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),
|
||||
Some(Commands::Me(args)) => handle_me(cli.config.as_deref(), args, robot_mode),
|
||||
Some(Commands::FileHistory(args)) => {
|
||||
handle_file_history(cli.config.as_deref(), args, robot_mode)
|
||||
}
|
||||
@@ -3182,6 +3183,16 @@ fn handle_who(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_me(
|
||||
config_override: Option<&str>,
|
||||
args: MeArgs,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = Config::load(config_override)?;
|
||||
run_me(&config, &args, robot_mode)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_drift(
|
||||
config_override: Option<&str>,
|
||||
entity_type: &str,
|
||||
|
||||
Reference in New Issue
Block a user