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:
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user