Previously, query_mentioned_in returned mentions from any time in the entity's history as long as the entity was still open (or recently closed). This caused noise: a mention from 6 months ago on a still-open issue would appear in the dashboard indefinitely. Now the SQL filters notes by created_at > mention_cutoff_ms, defaulting to 30 days. The recency_cutoff (7 days) still governs closed/merged entity visibility — this new cutoff governs mention note age on open entities. Signature change: query_mentioned_in gains a mention_cutoff_ms parameter. All existing test call sites updated. Two new tests verify the boundary: - mentioned_in_excludes_old_mention_on_open_issue (45-day mention filtered) - mentioned_in_includes_recent_mention_on_open_issue (5-day mention kept)
531 lines
18 KiB
Rust
531 lines
18 KiB
Rust
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::cursor;
|
|
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_mentioned_in, query_open_issues, query_reviewing_mrs,
|
|
query_since_last_check,
|
|
};
|
|
use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck};
|
|
|
|
/// Default activity lookback: 1 day in milliseconds.
|
|
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
|
|
const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000;
|
|
/// Recency window for closed/merged items in the "Mentioned In" section: 7 days.
|
|
const RECENCY_WINDOW_MS: i64 = 7 * MS_PER_DAY;
|
|
/// Only show mentions from notes created within this window (30 days).
|
|
const MENTION_WINDOW_MS: i64 = 30 * MS_PER_DAY;
|
|
|
|
/// 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();
|
|
let username = resolve_username(args, config)?;
|
|
|
|
// 0. Handle --reset-cursor early return
|
|
if args.reset_cursor {
|
|
cursor::reset_cursor(username)
|
|
.map_err(|e| LoreError::Other(format!("reset cursor: {e}")))?;
|
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
|
if robot_mode {
|
|
render_robot::print_cursor_reset_json(elapsed_ms)?;
|
|
} else {
|
|
println!("Cursor reset for @{username}. Next `lore me` will establish a new baseline.");
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// 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 project scope
|
|
let project_ids = resolve_project_scope(&conn, args, config)?;
|
|
let single_project = project_ids.len() == 1;
|
|
|
|
// 4. Parse --since (default 1d for activity feed)
|
|
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,
|
|
};
|
|
|
|
// 5. 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;
|
|
let want_mentions = show_all || args.mentions;
|
|
|
|
// 6. 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 mentioned_in = if want_mentions {
|
|
let recency_cutoff = crate::core::time::now_ms() - RECENCY_WINDOW_MS;
|
|
let mention_cutoff = crate::core::time::now_ms() - MENTION_WINDOW_MS;
|
|
query_mentioned_in(
|
|
&conn,
|
|
username,
|
|
&project_ids,
|
|
recency_cutoff,
|
|
mention_cutoff,
|
|
)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let activity = if want_activity {
|
|
query_activity(&conn, username, &project_ids, since_ms)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// 6b. Since-last-check (cursor-based inbox)
|
|
let cursor_ms = cursor::read_cursor(username);
|
|
// Capture global watermark BEFORE project filtering so --project doesn't
|
|
// permanently skip events from other projects.
|
|
let mut global_watermark: Option<i64> = None;
|
|
let since_last_check = if let Some(prev_cursor) = cursor_ms {
|
|
let groups = query_since_last_check(&conn, username, prev_cursor)?;
|
|
// Watermark from ALL groups (unfiltered) — this is the true high-water mark
|
|
global_watermark = groups.iter().map(|g| g.latest_timestamp).max();
|
|
// If --project was passed, filter groups by project for display only
|
|
let groups = if !project_ids.is_empty() {
|
|
filter_groups_by_project_ids(&conn, &groups, &project_ids)
|
|
} else {
|
|
groups
|
|
};
|
|
let total = groups.iter().map(|g| g.events.len()).sum();
|
|
Some(SinceLastCheck {
|
|
cursor_ms: prev_cursor,
|
|
groups,
|
|
total_event_count: total,
|
|
})
|
|
} else {
|
|
None // First run — no section shown
|
|
};
|
|
|
|
// 7. 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()
|
|
+ mentioned_in
|
|
.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);
|
|
}
|
|
for m in &mentioned_in {
|
|
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(),
|
|
mentioned_in_count: mentioned_in.len(),
|
|
needs_attention_count,
|
|
};
|
|
|
|
// 8. Assemble dashboard
|
|
let dashboard = MeDashboard {
|
|
username: username.to_string(),
|
|
since_ms: Some(since_ms),
|
|
summary,
|
|
open_issues,
|
|
open_mrs_authored,
|
|
reviewing_mrs,
|
|
mentioned_in,
|
|
activity,
|
|
since_last_check,
|
|
};
|
|
|
|
// 9. 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, &config.gitlab.base_url)?;
|
|
} 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_mentions,
|
|
want_activity,
|
|
);
|
|
}
|
|
|
|
// 10. Advance cursor AFTER successful render (watermark pattern)
|
|
// Uses max event timestamp from UNFILTERED results so --project filtering
|
|
// doesn't permanently skip events from other projects.
|
|
let watermark = global_watermark.unwrap_or_else(crate::core::time::now_ms);
|
|
cursor::write_cursor(username, watermark)
|
|
.map_err(|e| LoreError::Other(format!("write cursor: {e}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Filter since-last-check groups to only those matching the given project IDs.
|
|
/// Used when --project narrows the display scope (cursor is still global).
|
|
fn filter_groups_by_project_ids(
|
|
conn: &Connection,
|
|
groups: &[types::SinceCheckGroup],
|
|
project_ids: &[i64],
|
|
) -> Vec<types::SinceCheckGroup> {
|
|
// Resolve project IDs to paths for matching
|
|
let paths: HashSet<String> = project_ids
|
|
.iter()
|
|
.filter_map(|pid| {
|
|
conn.query_row(
|
|
"SELECT path_with_namespace FROM projects WHERE id = ?1",
|
|
rusqlite::params![pid],
|
|
|row| row.get::<_, String>(0),
|
|
)
|
|
.ok()
|
|
})
|
|
.collect();
|
|
|
|
groups
|
|
.iter()
|
|
.filter(|g| paths.contains(&g.project_path))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
#[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,
|
|
mentions: false,
|
|
since: None,
|
|
project: None,
|
|
all: false,
|
|
user: user.map(String::from),
|
|
fields: None,
|
|
reset_cursor: false,
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|