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:
teernisse
2026-02-20 14:25:08 -05:00
parent a5c2589c7d
commit 9c1a9bfe5d
16 changed files with 3060 additions and 10 deletions

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