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
119 lines
3.9 KiB
Rust
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"]);
|
|
}
|
|
}
|