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,334 @@
use serde::Serialize;
use crate::cli::robot::RobotMeta;
use crate::core::time::ms_to_iso;
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
};
// ─── 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<()> {
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}")))?;
// Apply --fields filtering (Task #19)
if let Some(f) = fields {
let expanded = crate::cli::robot::expand_fields_preset(f, "me_items");
// Filter all item arrays
for key in &["open_issues", "open_mrs_authored", "reviewing_mrs"] {
crate::cli::robot::filter_fields(&mut value, key, &expanded);
}
// Activity gets its own minimal preset
let activity_expanded = crate::cli::robot::expand_fields_preset(f, "me_activity");
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(())
}
// ─── JSON Envelope ───────────────────────────────────────────────────────────
#[derive(Serialize)]
struct MeJsonEnvelope {
ok: bool,
data: MeDataJson,
meta: RobotMeta,
}
#[derive(Serialize)]
struct MeDataJson {
username: String,
since_iso: Option<String>,
summary: SummaryJson,
open_issues: Vec<IssueJson>,
open_mrs_authored: Vec<MrJson>,
reviewing_mrs: Vec<MrJson>,
activity: Vec<ActivityJson>,
}
impl MeDataJson {
fn from_dashboard(d: &MeDashboard) -> Self {
Self {
username: d.username.clone(),
since_iso: d.since_ms.map(ms_to_iso),
summary: SummaryJson::from(&d.summary),
open_issues: d.open_issues.iter().map(IssueJson::from).collect(),
open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(),
reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(),
activity: d.activity.iter().map(ActivityJson::from).collect(),
}
}
}
// ─── Summary ─────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct SummaryJson {
project_count: usize,
open_issue_count: usize,
authored_mr_count: usize,
reviewing_mr_count: usize,
needs_attention_count: usize,
}
impl From<&MeSummary> for SummaryJson {
fn from(s: &MeSummary) -> Self {
Self {
project_count: s.project_count,
open_issue_count: s.open_issue_count,
authored_mr_count: s.authored_mr_count,
reviewing_mr_count: s.reviewing_mr_count,
needs_attention_count: s.needs_attention_count,
}
}
}
// ─── Issue ───────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct IssueJson {
project: String,
iid: i64,
title: String,
state: String,
attention_state: String,
status_name: Option<String>,
labels: Vec<String>,
updated_at_iso: String,
web_url: Option<String>,
}
impl From<&MeIssue> for IssueJson {
fn from(i: &MeIssue) -> Self {
Self {
project: i.project_path.clone(),
iid: i.iid,
title: i.title.clone(),
state: "opened".to_string(),
attention_state: attention_state_str(&i.attention_state),
status_name: i.status_name.clone(),
labels: i.labels.clone(),
updated_at_iso: ms_to_iso(i.updated_at),
web_url: i.web_url.clone(),
}
}
}
// ─── MR ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct MrJson {
project: String,
iid: i64,
title: String,
state: String,
attention_state: String,
draft: bool,
detailed_merge_status: Option<String>,
author_username: Option<String>,
labels: Vec<String>,
updated_at_iso: String,
web_url: Option<String>,
}
impl From<&MeMr> for MrJson {
fn from(m: &MeMr) -> Self {
Self {
project: m.project_path.clone(),
iid: m.iid,
title: m.title.clone(),
state: "opened".to_string(),
attention_state: attention_state_str(&m.attention_state),
draft: m.draft,
detailed_merge_status: m.detailed_merge_status.clone(),
author_username: m.author_username.clone(),
labels: m.labels.clone(),
updated_at_iso: ms_to_iso(m.updated_at),
web_url: m.web_url.clone(),
}
}
}
// ─── Activity ────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct ActivityJson {
timestamp_iso: String,
event_type: String,
entity_type: String,
entity_iid: i64,
project: String,
actor: Option<String>,
is_own: bool,
summary: String,
body_preview: Option<String>,
}
impl From<&MeActivityEvent> for ActivityJson {
fn from(e: &MeActivityEvent) -> Self {
Self {
timestamp_iso: ms_to_iso(e.timestamp),
event_type: event_type_str(&e.event_type),
entity_type: e.entity_type.clone(),
entity_iid: e.entity_iid,
project: e.project_path.clone(),
actor: e.actor.clone(),
is_own: e.is_own,
summary: e.summary.clone(),
body_preview: e.body_preview.clone(),
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/// Convert `AttentionState` to its programmatic string representation.
fn attention_state_str(state: &AttentionState) -> String {
match state {
AttentionState::NeedsAttention => "needs_attention",
AttentionState::NotStarted => "not_started",
AttentionState::AwaitingResponse => "awaiting_response",
AttentionState::Stale => "stale",
AttentionState::NotReady => "not_ready",
}
.to_string()
}
/// Convert `ActivityEventType` to its programmatic string representation.
fn event_type_str(event_type: &ActivityEventType) -> String {
match event_type {
ActivityEventType::Note => "note",
ActivityEventType::StatusChange => "status_change",
ActivityEventType::LabelChange => "label_change",
ActivityEventType::Assign => "assign",
ActivityEventType::Unassign => "unassign",
ActivityEventType::ReviewRequest => "review_request",
ActivityEventType::MilestoneChange => "milestone_change",
}
.to_string()
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attention_state_str_all_variants() {
assert_eq!(
attention_state_str(&AttentionState::NeedsAttention),
"needs_attention"
);
assert_eq!(
attention_state_str(&AttentionState::NotStarted),
"not_started"
);
assert_eq!(
attention_state_str(&AttentionState::AwaitingResponse),
"awaiting_response"
);
assert_eq!(attention_state_str(&AttentionState::Stale), "stale");
assert_eq!(attention_state_str(&AttentionState::NotReady), "not_ready");
}
#[test]
fn event_type_str_all_variants() {
assert_eq!(event_type_str(&ActivityEventType::Note), "note");
assert_eq!(
event_type_str(&ActivityEventType::StatusChange),
"status_change"
);
assert_eq!(
event_type_str(&ActivityEventType::LabelChange),
"label_change"
);
assert_eq!(event_type_str(&ActivityEventType::Assign), "assign");
assert_eq!(event_type_str(&ActivityEventType::Unassign), "unassign");
assert_eq!(
event_type_str(&ActivityEventType::ReviewRequest),
"review_request"
);
assert_eq!(
event_type_str(&ActivityEventType::MilestoneChange),
"milestone_change"
);
}
#[test]
fn issue_json_from_me_issue() {
let issue = MeIssue {
iid: 42,
title: "Fix auth bug".to_string(),
project_path: "group/repo".to_string(),
attention_state: AttentionState::NeedsAttention,
status_name: Some("In progress".to_string()),
labels: vec!["bug".to_string()],
updated_at: 1_700_000_000_000,
web_url: Some("https://gitlab.com/group/repo/-/issues/42".to_string()),
};
let json = IssueJson::from(&issue);
assert_eq!(json.iid, 42);
assert_eq!(json.attention_state, "needs_attention");
assert_eq!(json.state, "opened");
assert_eq!(json.status_name, Some("In progress".to_string()));
}
#[test]
fn mr_json_from_me_mr() {
let mr = MeMr {
iid: 99,
title: "Add feature".to_string(),
project_path: "group/repo".to_string(),
attention_state: AttentionState::AwaitingResponse,
draft: true,
detailed_merge_status: Some("mergeable".to_string()),
author_username: Some("alice".to_string()),
labels: vec![],
updated_at: 1_700_000_000_000,
web_url: None,
};
let json = MrJson::from(&mr);
assert_eq!(json.iid, 99);
assert_eq!(json.attention_state, "awaiting_response");
assert!(json.draft);
assert_eq!(json.author_username, Some("alice".to_string()));
}
#[test]
fn activity_json_from_event() {
let event = MeActivityEvent {
timestamp: 1_700_000_000_000,
event_type: ActivityEventType::Note,
entity_type: "issue".to_string(),
entity_iid: 42,
project_path: "group/repo".to_string(),
actor: Some("bob".to_string()),
is_own: false,
summary: "Added a comment".to_string(),
body_preview: Some("This looks good".to_string()),
};
let json = ActivityJson::from(&event);
assert_eq!(json.event_type, "note");
assert_eq!(json.entity_iid, 42);
assert!(!json.is_own);
assert_eq!(json.body_preview, Some("This looks good".to_string()));
}
}