1 Commits

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

21 beads (bd-qpk3 through bd-32aw) implemented by 3-agent swarm.
978 tests pass, clippy clean.
2026-02-20 11:09:47 -05:00
5 changed files with 125 additions and 77 deletions

View File

@@ -97,14 +97,11 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
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,
};
let since_ms = args
.since
.as_deref()
.and_then(parse_since)
.unwrap_or_else(|| crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY);
// 6. Determine which sections to query
let show_all = args.show_all_sections();
@@ -187,7 +184,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
if robot_mode {
let fields = args.fields.as_deref();
render_robot::print_me_json(&dashboard, elapsed_ms, fields)?;
render_robot::print_me_json(&dashboard, elapsed_ms, fields);
} else if show_all {
render_human::print_me_dashboard(&dashboard, single_project);
} else {

View File

@@ -10,9 +10,6 @@ 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.
@@ -26,47 +23,62 @@ pub fn query_open_issues(
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
"WITH my_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.issue_id IS NOT NULL
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
),
others_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
),
any_latest AS (
SELECT d.issue_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.issue_id IS NOT NULL
GROUP BY d.issue_id
)
SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url,
CASE
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention'
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM issues i
JOIN issue_assignees ia ON ia.issue_id = i.id
JOIN projects p ON i.project_id = p.id
LEFT JOIN note_ts nt ON nt.issue_id = i.id
LEFT JOIN my_latest ml ON ml.issue_id = i.id
LEFT JOIN others_latest ol ON ol.issue_id = i.id
LEFT JOIN any_latest al ON al.issue_id = i.id
WHERE ia.username = ?1
AND i.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts)
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 0
WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL
WHEN al.ts IS NULL AND ml.ts IS NULL
THEN 1
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 3
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 2
ELSE 1
END,
i.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
i.updated_at DESC"
);
let params = build_params(username, project_ids);
@@ -103,14 +115,28 @@ pub fn query_authored_mrs(
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
"WITH my_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
others_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
any_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
)
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
@@ -119,31 +145,32 @@ pub fn query_authored_mrs(
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)
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention'
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM merge_requests m
JOIN projects p ON m.project_id = p.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
LEFT JOIN my_latest ml ON ml.merge_request_id = m.id
LEFT JOIN others_latest ol ON ol.merge_request_id = m.id
LEFT JOIN any_latest al ON al.merge_request_id = m.id
WHERE m.author_username = ?1
AND m.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
WHEN 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
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2
ELSE 1
END,
m.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
m.updated_at DESC"
);
let params = build_params(username, project_ids);
@@ -182,45 +209,63 @@ pub fn query_reviewing_mrs(
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
"WITH my_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL
WHERE n.author_username = ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
others_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.author_username != ?1 AND n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
),
any_latest AS (
SELECT d.merge_request_id, MAX(n.created_at) AS ts
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE n.is_system = 0
AND d.merge_request_id IS NOT NULL
GROUP BY d.merge_request_id
)
SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status,
m.author_username, m.updated_at, m.web_url,
CASE
-- 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)
WHEN m.draft = 1 AND NOT EXISTS (
SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id
) THEN 'not_ready'
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts)
THEN 'needs_attention'
WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms})
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000)
THEN 'stale'
WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0)
THEN 'awaiting_response'
ELSE 'not_started'
END AS attention_state
FROM merge_requests m
JOIN mr_reviewers r ON r.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.id
LEFT JOIN my_latest ml ON ml.merge_request_id = m.id
LEFT JOIN others_latest ol ON ol.merge_request_id = m.id
LEFT JOIN any_latest al ON al.merge_request_id = m.id
WHERE r.username = ?1
AND m.state = 'opened'
{project_clause}
ORDER BY
CASE
WHEN 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
WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4
WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 0
WHEN al.ts IS NULL AND ml.ts IS NULL THEN 1
WHEN al.ts IS NOT NULL AND al.ts < (strftime('%s', 'now') * 1000 - 30 * 24 * 3600 * 1000) THEN 3
WHEN ml.ts IS NOT NULL AND ml.ts >= COALESCE(ol.ts, 0) THEN 2
ELSE 1
END,
m.updated_at DESC",
stale_ms = STALE_THRESHOLD_MS,
m.updated_at DESC"
);
let params = build_params(username, project_ids);
@@ -507,7 +552,7 @@ fn build_params(username: &str, project_ids: &[i64]) -> Vec<Box<dyn rusqlite::ty
params
}
/// Populate labels for issues via cached per-item queries.
/// Populate labels for issues (avoids N+1 when there are few issues).
fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> {
if issues.is_empty() {
return Ok(());
@@ -531,7 +576,7 @@ fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()
Ok(())
}
/// Populate labels for MRs via cached per-item queries.
/// Populate labels for MRs (avoids N+1 when there are few MRs).
fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> {
if mrs.is_empty() {
return Ok(());

View File

@@ -138,7 +138,6 @@ fn print_attention_legend() {
(AttentionState::NotStarted, "not started"),
(AttentionState::AwaitingResponse, "awaiting response"),
(AttentionState::Stale, "stale (30d+)"),
(AttentionState::NotReady, "draft (not ready)"),
];
let legend: Vec<String> = states
@@ -370,7 +369,7 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
let s = format!("#{iid}");
Theme::issue_ref().render(&s)
}
"mr" => {
"merge_request" => {
let s = format!("!{iid}");
Theme::mr_ref().render(&s)
}
@@ -441,7 +440,7 @@ mod tests {
#[test]
fn format_entity_ref_mr() {
let result = format_entity_ref("mr", 99);
let result = format_entity_ref("merge_request", 99);
assert!(result.contains("99"), "got: {result}");
}

View File

@@ -10,19 +10,20 @@ use super::types::{
// ─── 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<()> {
pub fn print_me_json(dashboard: &MeDashboard, elapsed_ms: u64, fields: Option<&[String]>) {
let envelope = MeJsonEnvelope {
ok: true,
data: MeDataJson::from_dashboard(dashboard),
meta: RobotMeta { elapsed_ms },
};
let mut value = serde_json::to_value(&envelope)
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
let mut value = match serde_json::to_value(&envelope) {
Ok(v) => v,
Err(e) => {
eprintln!("Error serializing me JSON: {e}");
return;
}
};
// Apply --fields filtering (Task #19)
if let Some(f) = fields {
@@ -37,10 +38,10 @@ pub fn print_me_json(
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(())
match serde_json::to_string(&value) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"),
}
}
// ─── JSON Envelope ───────────────────────────────────────────────────────────

View File

@@ -168,6 +168,7 @@ 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,6 +200,7 @@ 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
@@ -222,6 +224,7 @@ 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]
@@ -269,6 +272,7 @@ 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());
@@ -295,6 +299,7 @@ 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());
}
@@ -312,6 +317,7 @@ 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]