Files
gitlore/src/cli/commands/me/render_robot.rs
teernisse 4ab04a0a1c test(me): add integration tests for gitlab_base_url in robot JSON envelope
Guards against regression in the wiring chain run_me -> print_me_json ->
MeJsonEnvelope where the gitlab_base_url meta field could silently
disappear.

- me_envelope_includes_gitlab_base_url_in_meta: verifies full envelope
  serialization preserves the base URL in meta
- activity_event_carries_url_construction_fields: verifies activity events
  contain entity_type + entity_iid + project fields, then demonstrates
  URL construction by combining with meta.gitlab_base_url
2026-03-12 10:08:22 -04:00

586 lines
20 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));
}
/// Integration test: full envelope serialization includes gitlab_base_url in meta.
/// Guards against drift where the wiring from run_me -> print_me_json -> JSON
/// could silently lose the base URL field.
#[test]
fn me_envelope_includes_gitlab_base_url_in_meta() {
let dashboard = MeDashboard {
username: "testuser".to_string(),
since_ms: Some(1_700_000_000_000),
summary: MeSummary {
project_count: 1,
open_issue_count: 0,
authored_mr_count: 0,
reviewing_mr_count: 0,
mentioned_in_count: 0,
needs_attention_count: 0,
},
open_issues: vec![],
open_mrs_authored: vec![],
reviewing_mrs: vec![],
mentioned_in: vec![],
activity: vec![],
since_last_check: None,
};
let envelope = MeJsonEnvelope {
ok: true,
data: MeDataJson::from_dashboard(&dashboard),
meta: RobotMeta::with_base_url(42, "https://gitlab.example.com"),
};
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], serde_json::json!(true));
assert_eq!(value["meta"]["elapsed_ms"], serde_json::json!(42));
assert_eq!(
value["meta"]["gitlab_base_url"],
serde_json::json!("https://gitlab.example.com")
);
}
/// Verify activity events carry the fields needed for URL construction
/// (entity_type, entity_iid, project) so consumers can combine with
/// meta.gitlab_base_url to build links.
#[test]
fn activity_event_carries_url_construction_fields() {
let dashboard = MeDashboard {
username: "testuser".to_string(),
since_ms: Some(1_700_000_000_000),
summary: MeSummary {
project_count: 1,
open_issue_count: 0,
authored_mr_count: 0,
reviewing_mr_count: 0,
mentioned_in_count: 0,
needs_attention_count: 0,
},
open_issues: vec![],
open_mrs_authored: vec![],
reviewing_mrs: vec![],
mentioned_in: vec![],
activity: vec![MeActivityEvent {
timestamp: 1_700_000_000_000,
event_type: ActivityEventType::Note,
entity_type: "mr".to_string(),
entity_iid: 99,
project_path: "group/repo".to_string(),
actor: Some("alice".to_string()),
is_own: false,
summary: "Commented on MR".to_string(),
body_preview: None,
}],
since_last_check: None,
};
let envelope = MeJsonEnvelope {
ok: true,
data: MeDataJson::from_dashboard(&dashboard),
meta: RobotMeta::with_base_url(0, "https://gitlab.example.com"),
};
let value = serde_json::to_value(&envelope).unwrap();
let event = &value["data"]["activity"][0];
// These three fields + meta.gitlab_base_url = complete URL
assert_eq!(event["entity_type"], "mr");
assert_eq!(event["entity_iid"], 99);
assert_eq!(event["project"], "group/repo");
// Consumer constructs: https://gitlab.example.com/group/repo/-/merge_requests/99
let base = value["meta"]["gitlab_base_url"].as_str().unwrap();
let project = event["project"].as_str().unwrap();
let entity_path = match event["entity_type"].as_str().unwrap() {
"issue" => "issues",
"mr" => "merge_requests",
other => panic!("unexpected entity_type: {other}"),
};
let iid = event["entity_iid"].as_i64().unwrap();
let url = format!("{base}/{project}/-/{entity_path}/{iid}");
assert_eq!(
url,
"https://gitlab.example.com/group/repo/-/merge_requests/99"
);
}
}