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

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

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

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());
}
}