The `me` dashboard robot output now includes `meta.gitlab_base_url` so
consuming agents can construct clickable issue/MR links without needing
access to the lore config file. The pattern is:
{gitlab_base_url}/{project}/-/issues/{iid}
{gitlab_base_url}/{project}/-/merge_requests/{iid}
This uses the new RobotMeta::with_base_url() constructor. The base URL
is sourced from config.gitlab.base_url (already available in the me
command's execution context) and normalized to strip trailing slashes.
robot-docs updated to document the new meta field and URL construction
pattern for the me command's response schema.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
483 lines
16 KiB
Rust
483 lines
16 KiB
Rust
use serde::Serialize;
|
|
|
|
use crate::cli::robot::RobotMeta;
|
|
use crate::core::time::ms_to_iso;
|
|
|
|
use super::types::{
|
|
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMention, MeMr,
|
|
MeSummary, SinceCheckEvent, SinceCheckGroup, SinceLastCheck,
|
|
};
|
|
|
|
// ─── 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]>,
|
|
gitlab_base_url: &str,
|
|
) -> crate::core::error::Result<()> {
|
|
let envelope = MeJsonEnvelope {
|
|
ok: true,
|
|
data: MeDataJson::from_dashboard(dashboard),
|
|
meta: RobotMeta::with_base_url(elapsed_ms, gitlab_base_url),
|
|
};
|
|
|
|
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 issue/MR arrays with the items preset
|
|
for key in &["open_issues", "open_mrs_authored", "reviewing_mrs"] {
|
|
crate::cli::robot::filter_fields(&mut value, key, &expanded);
|
|
}
|
|
|
|
// Mentioned-in gets its own preset (needs entity_type + state to disambiguate)
|
|
let mentions_expanded = crate::cli::robot::expand_fields_preset(f, "me_mentions");
|
|
crate::cli::robot::filter_fields(&mut value, "mentioned_in", &mentions_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(())
|
|
}
|
|
|
|
/// Print `--reset-cursor` response using standard robot envelope.
|
|
pub fn print_cursor_reset_json(elapsed_ms: u64) -> crate::core::error::Result<()> {
|
|
let value = cursor_reset_envelope_json(elapsed_ms);
|
|
let json = serde_json::to_string(&value)
|
|
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
|
|
println!("{json}");
|
|
Ok(())
|
|
}
|
|
|
|
fn cursor_reset_envelope_json(elapsed_ms: u64) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"ok": true,
|
|
"data": {
|
|
"cursor_reset": true
|
|
},
|
|
"meta": {
|
|
"elapsed_ms": elapsed_ms
|
|
}
|
|
})
|
|
}
|
|
|
|
// ─── JSON Envelope ───────────────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct MeJsonEnvelope {
|
|
ok: bool,
|
|
data: MeDataJson,
|
|
meta: RobotMeta,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct MeDataJson {
|
|
username: String,
|
|
since_iso: Option<String>,
|
|
summary: SummaryJson,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
since_last_check: Option<SinceLastCheckJson>,
|
|
open_issues: Vec<IssueJson>,
|
|
open_mrs_authored: Vec<MrJson>,
|
|
reviewing_mrs: Vec<MrJson>,
|
|
mentioned_in: Vec<MentionJson>,
|
|
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),
|
|
since_last_check: d.since_last_check.as_ref().map(SinceLastCheckJson::from),
|
|
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(),
|
|
mentioned_in: d.mentioned_in.iter().map(MentionJson::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,
|
|
mentioned_in_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,
|
|
mentioned_in_count: s.mentioned_in_count,
|
|
needs_attention_count: s.needs_attention_count,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Issue ───────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct IssueJson {
|
|
project: String,
|
|
iid: i64,
|
|
title: String,
|
|
state: String,
|
|
attention_state: String,
|
|
attention_reason: 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),
|
|
attention_reason: i.attention_reason.clone(),
|
|
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,
|
|
attention_reason: 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),
|
|
attention_reason: m.attention_reason.clone(),
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Mention ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct MentionJson {
|
|
entity_type: String,
|
|
project: String,
|
|
iid: i64,
|
|
title: String,
|
|
state: String,
|
|
attention_state: String,
|
|
attention_reason: String,
|
|
updated_at_iso: String,
|
|
web_url: Option<String>,
|
|
}
|
|
|
|
impl From<&MeMention> for MentionJson {
|
|
fn from(m: &MeMention) -> Self {
|
|
Self {
|
|
entity_type: m.entity_type.clone(),
|
|
project: m.project_path.clone(),
|
|
iid: m.iid,
|
|
title: m.title.clone(),
|
|
state: m.state.clone(),
|
|
attention_state: attention_state_str(&m.attention_state),
|
|
attention_reason: m.attention_reason.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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Since Last Check ────────────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct SinceLastCheckJson {
|
|
cursor_iso: String,
|
|
total_event_count: usize,
|
|
groups: Vec<SinceCheckGroupJson>,
|
|
}
|
|
|
|
impl From<&SinceLastCheck> for SinceLastCheckJson {
|
|
fn from(s: &SinceLastCheck) -> Self {
|
|
Self {
|
|
cursor_iso: ms_to_iso(s.cursor_ms),
|
|
total_event_count: s.total_event_count,
|
|
groups: s.groups.iter().map(SinceCheckGroupJson::from).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SinceCheckGroupJson {
|
|
entity_type: String,
|
|
entity_iid: i64,
|
|
entity_title: String,
|
|
project: String,
|
|
events: Vec<SinceCheckEventJson>,
|
|
}
|
|
|
|
impl From<&SinceCheckGroup> for SinceCheckGroupJson {
|
|
fn from(g: &SinceCheckGroup) -> Self {
|
|
Self {
|
|
entity_type: g.entity_type.clone(),
|
|
entity_iid: g.entity_iid,
|
|
entity_title: g.entity_title.clone(),
|
|
project: g.project_path.clone(),
|
|
events: g.events.iter().map(SinceCheckEventJson::from).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SinceCheckEventJson {
|
|
timestamp_iso: String,
|
|
event_type: String,
|
|
actor: Option<String>,
|
|
summary: String,
|
|
body_preview: Option<String>,
|
|
}
|
|
|
|
impl From<&SinceCheckEvent> for SinceCheckEventJson {
|
|
fn from(e: &SinceCheckEvent) -> Self {
|
|
Self {
|
|
timestamp_iso: ms_to_iso(e.timestamp),
|
|
event_type: event_type_str(&e.event_type),
|
|
actor: e.actor.clone(),
|
|
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,
|
|
attention_reason: "Others commented recently; you haven't replied".to_string(),
|
|
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.attention_reason,
|
|
"Others commented recently; you haven't replied"
|
|
);
|
|
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,
|
|
attention_reason: "You replied moments ago; awaiting others".to_string(),
|
|
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_eq!(
|
|
json.attention_reason,
|
|
"You replied moments ago; awaiting others"
|
|
);
|
|
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()));
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_reset_envelope_includes_meta_elapsed_ms() {
|
|
let value = cursor_reset_envelope_json(17);
|
|
assert_eq!(value["ok"], serde_json::json!(true));
|
|
assert_eq!(value["data"]["cursor_reset"], serde_json::json!(true));
|
|
assert_eq!(value["meta"]["elapsed_ms"], serde_json::json!(17));
|
|
}
|
|
}
|