Files
gitlore/src/cli/robot.rs
teernisse 9c1a9bfe5d 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
2026-02-20 14:31:57 -05:00

119 lines
3.9 KiB
Rust

use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct RobotMeta {
pub elapsed_ms: u64,
}
/// Filter JSON object fields in-place for `--fields` support.
/// Retains only the specified field names on each item in the list array.
pub fn filter_fields(value: &mut serde_json::Value, list_key: &str, fields: &[String]) {
if fields.is_empty() {
return;
}
if let Some(items) = value
.get_mut("data")
.and_then(|d| d.get_mut(list_key))
.and_then(|v| v.as_array_mut())
{
for item in items {
if let Some(obj) = item.as_object_mut() {
obj.retain(|k, _| fields.iter().any(|f| f == k));
}
}
}
}
/// Expand the `minimal` preset into concrete field names.
pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec<String> {
if fields.len() == 1 && fields[0] == "minimal" {
match entity {
"issues" => ["iid", "title", "state", "updated_at_iso"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"mrs" => ["iid", "title", "state", "updated_at_iso"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"search" => ["document_id", "title", "source_type", "score"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"timeline" => ["timestamp", "type", "entity_iid", "detail"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"who_expert" => ["username", "score"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"who_workload" => ["iid", "title", "state"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"who_active" => ["entity_type", "iid", "title", "participants"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"who_overlap" => ["username", "touch_count"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"who_reviews" => ["name", "count", "percentage"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"notes" => ["id", "author_username", "body", "created_at_iso"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"me_items" => ["iid", "title", "attention_state", "updated_at_iso"]
.iter()
.map(|s| (*s).to_string())
.collect(),
"me_activity" => ["timestamp_iso", "event_type", "entity_iid", "actor"]
.iter()
.map(|s| (*s).to_string())
.collect(),
_ => fields.to_vec(),
}
} else {
fields.to_vec()
}
}
/// Strip `response_schema` from every command entry for `--brief` mode.
pub fn strip_schemas(commands: &mut serde_json::Value) {
if let Some(map) = commands.as_object_mut() {
for (_cmd_name, cmd) in map.iter_mut() {
if let Some(obj) = cmd.as_object_mut() {
obj.remove("response_schema");
obj.remove("example_output");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_fields_preset_notes() {
let fields = vec!["minimal".to_string()];
let expanded = expand_fields_preset(&fields, "notes");
assert_eq!(
expanded,
["id", "author_username", "body", "created_at_iso"]
);
}
#[test]
fn test_expand_fields_preset_passthrough() {
let fields = vec!["id".to_string(), "body".to_string()];
let expanded = expand_fields_preset(&fields, "notes");
assert_eq!(expanded, ["id", "body"]);
}
}