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:
334
src/cli/commands/me/render_robot.rs
Normal file
334
src/cli/commands/me/render_robot.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user