Implements a cursor-based notification inbox that surfaces actionable events from others since the user's last `lore me` invocation. This addresses the core UX need: "what happened while I was away?" Event Sources (three-way UNION query): 1. Others' comments on user's open issues/MRs 2. @mentions on ANY item (not restricted to owned items) 3. Assignment/review-request system notes mentioning user Mention Detection: - SQL LIKE pre-filter for performance, then regex validation - Word-boundary-aware: rejects "alice" in "@alice-bot" or "alice@corp.com" - Domain rejection: "@alice.com" not matched (prevents email false positives) - Punctuation tolerance: "@alice," "@alice." "(@ alice)" all match Cursor Watermark Pattern: - Global watermark computed from ALL projects before --project filtering - Ensures --project display filter doesn't permanently skip events - Cursor advances only after successful render (no data loss on errors) - First run establishes baseline (no inbox shown), subsequent runs show delta Output: - Human: color-coded event badges, grouped by entity, actor + timestamp - Robot: standard envelope with since_last_check object containing cursor_iso, total_event_count, and groups array with nested events CLI additions: - --reset-cursor flag: clears cursor (next run shows no new events) - Autocorrect: --reset-cursor added to known me command flags Tests cover: - Mention with trailing comma/period/parentheses (should match) - Email-like text "@alice.com" (should NOT match) - Domain-like text "@alice.example" (should NOT match) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
668 lines
23 KiB
Rust
668 lines
23 KiB
Rust
use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme};
|
|
|
|
use super::types::{
|
|
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
|
SinceLastCheck,
|
|
};
|
|
|
|
// ─── Layout Helpers ─────────────────────────────────────────────────────────
|
|
|
|
/// Compute the title/summary column width for a section given its fixed overhead.
|
|
/// Returns a width clamped to [20, 80].
|
|
fn title_width(overhead: usize) -> usize {
|
|
render::terminal_width()
|
|
.saturating_sub(overhead)
|
|
.clamp(20, 80)
|
|
}
|
|
|
|
// ─── 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)
|
|
}
|
|
|
|
// ─── Merge Status Labels ────────────────────────────────────────────────────
|
|
|
|
/// Convert GitLab's `detailed_merge_status` API values to human-friendly labels.
|
|
fn humanize_merge_status(status: &str) -> &str {
|
|
match status {
|
|
"not_approved" => "needs approval",
|
|
"requested_changes" => "changes requested",
|
|
"mergeable" => "ready to merge",
|
|
"not_open" => "not open",
|
|
"checking" => "checking",
|
|
"ci_must_pass" => "CI pending",
|
|
"ci_still_running" => "CI running",
|
|
"discussions_not_resolved" => "unresolved threads",
|
|
"draft_status" => "draft",
|
|
"need_rebase" => "needs rebase",
|
|
"conflict" | "has_conflicts" => "has conflicts",
|
|
"blocked_status" => "blocked",
|
|
"approvals_syncing" => "syncing approvals",
|
|
"jira_association_missing" => "missing Jira link",
|
|
"unchecked" => "unchecked",
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
// ─── Event Badges ────────────────────────────────────────────────────────────
|
|
|
|
/// Return the badge label text for an activity event type.
|
|
fn activity_badge_label(event_type: &ActivityEventType) -> String {
|
|
match event_type {
|
|
ActivityEventType::Note => "note",
|
|
ActivityEventType::StatusChange => "status",
|
|
ActivityEventType::LabelChange => "label",
|
|
ActivityEventType::Assign | ActivityEventType::Unassign => "assign",
|
|
ActivityEventType::ReviewRequest => "review",
|
|
ActivityEventType::MilestoneChange => "milestone",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
/// Return the style for an activity event badge.
|
|
fn activity_badge_style(event_type: &ActivityEventType) -> lipgloss::Style {
|
|
match event_type {
|
|
ActivityEventType::Note => Theme::info(),
|
|
ActivityEventType::StatusChange => Theme::warning(),
|
|
ActivityEventType::LabelChange => Theme::accent(),
|
|
ActivityEventType::Assign
|
|
| ActivityEventType::Unassign
|
|
| ActivityEventType::ReviewRequest => Theme::success(),
|
|
ActivityEventType::MilestoneChange => accent_magenta(),
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
/// Very dark gray for system events (label, assign, status, milestone, review).
|
|
fn system_event_style() -> lipgloss::Style {
|
|
if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) {
|
|
lipgloss::Style::new().foreground("#555555")
|
|
} else {
|
|
lipgloss::Style::new().faint()
|
|
}
|
|
}
|
|
|
|
// ─── 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(render::terminal_width()));
|
|
|
|
// 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, title_width(43)),
|
|
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!(" ({})", humanize_merge_status(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, title_width(48)),
|
|
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, title_width(50)),
|
|
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()))
|
|
);
|
|
|
|
// Columns: badge | ref | summary | actor | time
|
|
// Table handles alignment, padding, and truncation automatically.
|
|
let summary_max = title_width(46);
|
|
let mut table = Table::new()
|
|
.columns(5)
|
|
.indent(4)
|
|
.align(1, Align::Right)
|
|
.align(4, Align::Right)
|
|
.max_width(2, summary_max);
|
|
|
|
for event in events {
|
|
let badge_label = activity_badge_label(&event.event_type);
|
|
let badge_style = activity_badge_style(&event.event_type);
|
|
|
|
let ref_text = match event.entity_type.as_str() {
|
|
"issue" => format!("#{}", event.entity_iid),
|
|
"mr" => format!("!{}", event.entity_iid),
|
|
_ => format!("{}:{}", event.entity_type, event.entity_iid),
|
|
};
|
|
let is_system = !matches!(event.event_type, ActivityEventType::Note);
|
|
// System events → very dark gray; own notes → standard dim; else → full color.
|
|
let subdued = is_system || event.is_own;
|
|
let subdued_style = || {
|
|
if is_system {
|
|
system_event_style()
|
|
} else {
|
|
Theme::dim()
|
|
}
|
|
};
|
|
|
|
let badge_style_final = if subdued {
|
|
subdued_style()
|
|
} else {
|
|
badge_style
|
|
};
|
|
|
|
let ref_style = if subdued {
|
|
Some(subdued_style())
|
|
} else {
|
|
match event.entity_type.as_str() {
|
|
"issue" => Some(Theme::issue_ref()),
|
|
"mr" => Some(Theme::mr_ref()),
|
|
_ => None,
|
|
}
|
|
};
|
|
|
|
let clean_summary = event.summary.replace('\n', " ");
|
|
let summary_style: Option<lipgloss::Style> =
|
|
if subdued { Some(subdued_style()) } else { None };
|
|
|
|
let actor_text = if event.is_own {
|
|
event
|
|
.actor
|
|
.as_deref()
|
|
.map_or("(you)".to_string(), |a| format!("@{a} (you)"))
|
|
} else {
|
|
event
|
|
.actor
|
|
.as_deref()
|
|
.map_or(String::new(), |a| format!("@{a}"))
|
|
};
|
|
let actor_style = if subdued {
|
|
subdued_style()
|
|
} else {
|
|
Theme::username()
|
|
};
|
|
|
|
let time = render::format_relative_time_compact(event.timestamp);
|
|
|
|
table.add_row(vec![
|
|
StyledCell::styled(badge_label, badge_style_final),
|
|
match ref_style {
|
|
Some(s) => StyledCell::styled(ref_text, s),
|
|
None => StyledCell::plain(ref_text),
|
|
},
|
|
match summary_style {
|
|
Some(s) => StyledCell::styled(clean_summary, s),
|
|
None => StyledCell::plain(clean_summary),
|
|
},
|
|
StyledCell::styled(actor_text, actor_style),
|
|
StyledCell::styled(time, Theme::dim()),
|
|
]);
|
|
}
|
|
|
|
// Render table rows and interleave per-event detail lines
|
|
let rendered = table.render();
|
|
for (line, event) in rendered.lines().zip(events.iter()) {
|
|
println!("{line}");
|
|
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), right-aligned to 6 chars.
|
|
#[cfg(test)]
|
|
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
|
match entity_type {
|
|
"issue" => {
|
|
let s = format!("{:>6}", format!("#{iid}"));
|
|
Theme::issue_ref().render(&s)
|
|
}
|
|
"mr" => {
|
|
let s = format!("{:>6}", format!("!{iid}"));
|
|
Theme::mr_ref().render(&s)
|
|
}
|
|
_ => format!("{:>6}", format!("{entity_type}:{iid}")),
|
|
}
|
|
}
|
|
|
|
// ─── Since Last Check ────────────────────────────────────────────────────────
|
|
|
|
/// Print the "since last check" section at the top of the dashboard.
|
|
pub fn print_since_last_check_section(since: &SinceLastCheck, single_project: bool) {
|
|
let relative = render::format_relative_time(since.cursor_ms);
|
|
|
|
if since.groups.is_empty() {
|
|
println!(
|
|
"\n {}",
|
|
Theme::dim().render(&format!(
|
|
"No new events since {} ({relative})",
|
|
render::format_datetime(since.cursor_ms),
|
|
))
|
|
);
|
|
return;
|
|
}
|
|
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!("Since Last Check ({relative})"))
|
|
);
|
|
|
|
for group in &since.groups {
|
|
// Entity header: !247 Fix race condition...
|
|
let ref_str = match group.entity_type.as_str() {
|
|
"issue" => format!("#{}", group.entity_iid),
|
|
"mr" => format!("!{}", group.entity_iid),
|
|
_ => format!("{}:{}", group.entity_type, group.entity_iid),
|
|
};
|
|
let ref_style = match group.entity_type.as_str() {
|
|
"issue" => Theme::issue_ref(),
|
|
"mr" => Theme::mr_ref(),
|
|
_ => Theme::bold(),
|
|
};
|
|
|
|
println!();
|
|
println!(
|
|
" {} {}",
|
|
ref_style.render(&ref_str),
|
|
Theme::bold().render(&render::truncate(&group.entity_title, title_width(20))),
|
|
);
|
|
if !single_project {
|
|
println!(" {}", Theme::dim().render(&group.project_path));
|
|
}
|
|
|
|
// Sub-events as indented rows
|
|
let summary_max = title_width(42);
|
|
let mut table = Table::new()
|
|
.columns(3)
|
|
.indent(6)
|
|
.align(2, Align::Right)
|
|
.max_width(1, summary_max);
|
|
|
|
for event in &group.events {
|
|
let badge = activity_badge_label(&event.event_type);
|
|
let badge_style = activity_badge_style(&event.event_type);
|
|
|
|
let actor_prefix = event
|
|
.actor
|
|
.as_deref()
|
|
.map(|a| format!("@{a} "))
|
|
.unwrap_or_default();
|
|
let clean_summary = event.summary.replace('\n', " ");
|
|
let summary_text = format!("{actor_prefix}{clean_summary}");
|
|
|
|
let time = render::format_relative_time_compact(event.timestamp);
|
|
|
|
table.add_row(vec![
|
|
StyledCell::styled(badge, badge_style),
|
|
StyledCell::plain(summary_text),
|
|
StyledCell::styled(time, Theme::dim()),
|
|
]);
|
|
}
|
|
|
|
let rendered = table.render();
|
|
for (line, event) in rendered.lines().zip(group.events.iter()) {
|
|
println!("{line}");
|
|
if let Some(preview) = &event.body_preview
|
|
&& !preview.is_empty()
|
|
{
|
|
let truncated = render::truncate(preview, 60);
|
|
println!(
|
|
" {}",
|
|
Theme::dim().render(&format!("\"{truncated}\""))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Footer
|
|
println!(
|
|
"\n {}",
|
|
Theme::dim().render(&format!(
|
|
"{} events across {} items",
|
|
since.total_event_count,
|
|
since.groups.len()
|
|
))
|
|
);
|
|
}
|
|
|
|
// ─── Full Dashboard ──────────────────────────────────────────────────────────
|
|
|
|
/// Render the complete human-mode dashboard.
|
|
pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) {
|
|
if let Some(ref since) = dashboard.since_last_check {
|
|
print_since_last_check_section(since, single_project);
|
|
}
|
|
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,
|
|
) {
|
|
if let Some(ref since) = dashboard.since_last_check {
|
|
print_since_last_check_section(since, single_project);
|
|
}
|
|
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 activity_badge_label_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!(!activity_badge_label(t).is_empty(), "empty for {t:?}");
|
|
}
|
|
}
|
|
}
|