3 Commits

Author SHA1 Message Date
teernisse
2410f91843 feat(explain): enrich output with state, direction, excerpts, and truncation metadata
Several improvements to the explain command's data richness and correctness:

Entity summary:
- Add project_path field so consumers know which project the entity belongs to
  without needing to cross-reference

Related entities:
- Add state field (open/closed/merged) to RelatedIssue so consumers can
  distinguish live references from resolved ones
- Add direction field (incoming/outgoing) so consumers know the reference
  direction without inferring from position in the array
- Filter self-references: entities no longer appear as related to themselves,
  which previously produced confusing circular references in the output

Open threads:
- Add first_note_excerpt (first 200 chars of the first non-system note body)
  so consumers get a preview of what each thread is about without needing
  to fetch the full discussion
- Human renderer shows first line of excerpt in muted style

Activity summary:
- Floor first_event timestamp at entity created_at — resource label events
  from bulk operations or API imports can predate entity creation, producing
  nonsensical activity spans. The floor ensures the activity window starts
  no earlier than the entity itself
- Updated build_activity_summary signature to accept created_at_ms parameter
- All call sites (including tests) updated to pass the new parameter

Timeline excerpt:
- Replace bare Vec<TimelineEventSummary> with TimelineExcerpt struct containing
  events, total_events count, and truncated boolean
- Request a generous pipeline limit (500) from collect_events, then take the
  most recent MAX_TIMELINE_EVENTS from the tail — previously truncated from
  the front, losing the most recent (and most relevant) events
- Human renderer shows truncation note when applicable: "(showing N of M)"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:30:16 -04:00
teernisse
ddab186315 feat(me): include GitLab base URL in robot meta for URL construction
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>
2026-03-11 10:30:03 -04:00
teernisse
d6d1686f8e refactor(robot): add constructors to RobotMeta, support optional gitlab_base_url
RobotMeta previously required direct struct literal construction with only
elapsed_ms. This made it impossible to add optional fields without updating
every call site to include them.

Introduce two constructors:
- RobotMeta::new(elapsed_ms) — standard meta with timing only
- RobotMeta::with_base_url(elapsed_ms, base_url) — meta enriched with the
  GitLab instance URL, enabling consumers to construct entity links without
  needing config access

The gitlab_base_url field uses #[serde(skip_serializing_if = "Option::is_none")]
so existing JSON envelopes are byte-identical — no breaking change for any
robot mode consumer.

All 22 call sites across handlers, count, cron, drift, embed, generate_docs,
ingest, list (mrs/notes), related, show, stats, sync_status, and who are
updated from struct literals to RobotMeta::new(). Three tests verify the
new constructors and trailing-slash normalization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:29:56 -04:00
19 changed files with 220 additions and 65 deletions

View File

@@ -361,7 +361,7 @@ fn print_combined_ingest_json(
notes_upserted: mrs.notes_upserted,
},
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
println!(
@@ -975,9 +975,7 @@ async fn handle_auth_test(
name: result.name.clone(),
gitlab_url: result.base_url.clone(),
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
};
println!("{}", serde_json::to_string(&output)?);
} else {
@@ -1038,9 +1036,7 @@ async fn handle_doctor(
success: result.success,
checks: result.checks,
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
};
println!("{}", serde_json::to_string(&output)?);
} else {
@@ -1085,9 +1081,7 @@ fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
Some(git_hash)
},
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
};
println!("{}", serde_json::to_string(&output)?);
} else if git_hash.is_empty() {
@@ -1245,9 +1239,7 @@ async fn handle_migrate(
after_version,
migrated: after_version > before_version,
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
};
println!("{}", serde_json::to_string(&output)?);
} else if after_version > before_version {
@@ -1962,9 +1954,7 @@ async fn handle_health(
schema_version,
actions,
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
meta: RobotMeta::new(start.elapsed().as_millis() as u64),
};
println!("{}", serde_json::to_string(&output)?);
} else {

View File

@@ -382,7 +382,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"mentioned_in": "[{entity_type:string, project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, updated_at_iso:string, web_url:string?}]",
"activity": "[{timestamp_iso:string, event_type:string, entity_type:string, entity_iid:int, project:string, actor:string?, is_own:bool, summary:string, body_preview:string?}]"
},
"meta": {"elapsed_ms": "int"}
"meta": {"elapsed_ms": "int", "gitlab_base_url": "string (GitLab instance URL for constructing entity links: {base_url}/{project}/-/issues/{iid})"}
},
"fields_presets": {
"me_items_minimal": ["iid", "title", "attention_state", "attention_reason", "updated_at_iso"],
@@ -396,7 +396,8 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"since_default": "1d for activity feed",
"issue_filter": "Only In Progress / In Review status issues shown",
"since_last_check": "Cursor-based inbox showing events since last run. Null on first run (no cursor yet). Groups events by entity (issue/MR). Sources: others' comments on your items, @mentions, assignment/review-request notes. Cursor auto-advances after each run. Use --reset-cursor to clear.",
"cursor_persistence": "Stored per user in ~/.local/share/lore/me_cursor_<username>.json. --project filters display only for since-last-check; cursor still advances for all projects for that user."
"cursor_persistence": "Stored per user in ~/.local/share/lore/me_cursor_<username>.json. --project filters display only for since-last-check; cursor still advances for all projects for that user.",
"url_construction": "Use meta.gitlab_base_url + project + entity_type + iid to build links: {gitlab_base_url}/{project}/-/{issues|merge_requests}/{iid}"
}
},
"robot-docs": {

View File

@@ -254,7 +254,7 @@ pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
},
total: counts.total(),
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
match serde_json::to_string(&output) {
@@ -325,7 +325,7 @@ pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
system_excluded: result.system_count,
breakdown,
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
match serde_json::to_string(&output) {

View File

@@ -80,7 +80,7 @@ pub fn print_cron_install_json(result: &CronInstallResult, elapsed_ms: u64) {
log_path: result.log_path.display().to_string(),
replaced: result.replaced,
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
if let Ok(json) = serde_json::to_string(&output) {
println!("{json}");
@@ -128,7 +128,7 @@ pub fn print_cron_uninstall_json(result: &CronUninstallResult, elapsed_ms: u64)
action: "uninstall",
was_installed: result.was_installed,
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
if let Ok(json) = serde_json::to_string(&output) {
println!("{json}");
@@ -284,7 +284,7 @@ pub fn print_cron_status_json(info: &CronStatusInfo, elapsed_ms: u64) {
last_sync_at: info.last_sync.as_ref().map(|s| s.started_at_iso.clone()),
last_sync_status: info.last_sync.as_ref().map(|s| s.status.clone()),
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
if let Ok(json) = serde_json::to_string(&output) {
println!("{json}");

View File

@@ -468,7 +468,7 @@ pub fn print_drift_human(response: &DriftResponse) {
}
pub fn print_drift_json(response: &DriftResponse, elapsed_ms: u64) {
let meta = RobotMeta { elapsed_ms };
let meta = RobotMeta::new(elapsed_ms);
let output = serde_json::json!({
"ok": true,
"data": response,

View File

@@ -135,7 +135,7 @@ pub fn print_embed_json(result: &EmbedCommandResult, elapsed_ms: u64) {
let output = EmbedJsonOutput {
ok: true,
data: result,
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
match serde_json::to_string(&output) {
Ok(json) => println!("{json}"),

View File

@@ -36,7 +36,7 @@ pub struct ExplainResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub related: Option<RelatedEntities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeline_excerpt: Option<Vec<TimelineEventSummary>>,
pub timeline_excerpt: Option<TimelineExcerpt>,
}
#[derive(Debug, Serialize)]
@@ -52,6 +52,7 @@ pub struct EntitySummary {
pub created_at: String,
pub updated_at: String,
pub url: Option<String>,
pub project_path: String,
pub status_name: Option<String>,
}
@@ -80,6 +81,8 @@ pub struct OpenThread {
pub started_at: String,
pub note_count: usize,
pub last_note_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_note_excerpt: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -101,7 +104,16 @@ pub struct RelatedEntityInfo {
pub entity_type: String,
pub iid: i64,
pub title: Option<String>,
pub state: Option<String>,
pub reference_type: String,
pub direction: String,
}
#[derive(Debug, Serialize)]
pub struct TimelineExcerpt {
pub events: Vec<TimelineEventSummary>,
pub total_events: usize,
pub truncated: bool,
}
#[derive(Debug, Serialize)]
@@ -218,6 +230,7 @@ fn find_explain_issue(
created_at: ms_to_iso(r.created_at),
updated_at: ms_to_iso(r.updated_at),
url: r.web_url,
project_path: project_path.clone(),
status_name: r.status_name,
};
Ok((summary, local_id, project_path))
@@ -296,6 +309,7 @@ fn find_explain_mr(
created_at: ms_to_iso(r.created_at),
updated_at: ms_to_iso(r.updated_at),
url: r.web_url,
project_path: project_path.clone(),
status_name: None,
};
Ok((summary, local_id, project_path))
@@ -385,15 +399,17 @@ fn truncate_description(desc: Option<&str>, max_len: usize) -> String {
pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result<ExplainResult> {
let project_filter = params.project.as_deref();
let (entity_summary, entity_local_id, _project_path, description) =
let (entity_summary, entity_local_id, _project_path, description, created_at_ms) =
if params.entity_type == "issues" {
let (summary, local_id, path) = find_explain_issue(conn, params.iid, project_filter)?;
let desc = get_issue_description(conn, local_id)?;
(summary, local_id, path, desc)
let created_at_ms = get_issue_created_at(conn, local_id)?;
(summary, local_id, path, desc, created_at_ms)
} else {
let (summary, local_id, path) = find_explain_mr(conn, params.iid, project_filter)?;
let desc = get_mr_description(conn, local_id)?;
(summary, local_id, path, desc)
let created_at_ms = get_mr_created_at(conn, local_id)?;
(summary, local_id, path, desc, created_at_ms)
};
let description_excerpt = if should_include(&params.sections, "description") {
@@ -420,6 +436,7 @@ pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result<ExplainR
&params.entity_type,
entity_local_id,
params.since,
created_at_ms,
)?)
} else {
None
@@ -480,6 +497,24 @@ fn get_mr_description(conn: &Connection, mr_id: i64) -> Result<Option<String>> {
Ok(desc)
}
fn get_issue_created_at(conn: &Connection, issue_id: i64) -> Result<i64> {
let ts: i64 = conn.query_row(
"SELECT created_at FROM issues WHERE id = ?",
[issue_id],
|row| row.get(0),
)?;
Ok(ts)
}
fn get_mr_created_at(conn: &Connection, mr_id: i64) -> Result<i64> {
let ts: i64 = conn.query_row(
"SELECT created_at FROM merge_requests WHERE id = ?",
[mr_id],
|row| row.get(0),
)?;
Ok(ts)
}
// ---------------------------------------------------------------------------
// Key-decisions heuristic (Task 2)
// ---------------------------------------------------------------------------
@@ -664,6 +699,7 @@ fn build_activity_summary(
entity_type: &str,
entity_id: i64,
since: Option<i64>,
created_at_ms: i64,
) -> Result<ActivitySummary> {
let id_col = id_column_for(entity_type);
@@ -702,11 +738,14 @@ fn build_activity_summary(
})?;
let notes = notes_count as usize;
// Floor first_event at created_at — label events can predate entity creation
// due to bulk operations or API imports
let first_event = [state_min, label_min, note_min]
.iter()
.copied()
.flatten()
.min();
.min()
.map(|ts| ts.max(created_at_ms));
let last_event = [state_max, label_max, note_max]
.iter()
.copied()
@@ -740,7 +779,10 @@ fn fetch_open_threads(
WHERE n2.discussion_id = d.id AND n2.is_system = 0) AS note_count, \
(SELECT n3.author_username FROM notes n3 \
WHERE n3.discussion_id = d.id \
ORDER BY n3.created_at ASC LIMIT 1) AS started_by \
ORDER BY n3.created_at ASC LIMIT 1) AS started_by, \
(SELECT SUBSTR(n4.body, 1, 200) FROM notes n4 \
WHERE n4.discussion_id = d.id AND n4.is_system = 0 \
ORDER BY n4.created_at ASC LIMIT 1) AS first_note_body \
FROM discussions d \
WHERE d.{id_col} = ?1 \
AND d.resolvable = 1 \
@@ -752,12 +794,14 @@ fn fetch_open_threads(
let threads = stmt
.query_map([entity_id], |row| {
let count: i64 = row.get(3)?;
let first_note_body: Option<String> = row.get(5)?;
Ok(OpenThread {
discussion_id: row.get(0)?,
started_at: ms_to_iso(row.get::<_, i64>(1)?),
last_note_at: ms_to_iso(row.get::<_, i64>(2)?),
note_count: count as usize,
started_by: row.get(4)?,
first_note_excerpt: first_note_body,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -813,15 +857,18 @@ fn fetch_related_entities(
// Outgoing references (excluding closes, shown above).
// Filter out unresolved refs (NULL target_entity_iid) to avoid rusqlite type errors.
// Excludes self-references (same type + same local ID).
let mut out_stmt = conn.prepare(
"SELECT er.target_entity_type, er.target_entity_iid, er.reference_type, \
COALESCE(i.title, mr.title) as title \
COALESCE(i.title, mr.title) as title, \
COALESCE(i.state, mr.state) as state \
FROM entity_references er \
LEFT JOIN issues i ON er.target_entity_type = 'issue' AND i.id = er.target_entity_id \
LEFT JOIN merge_requests mr ON er.target_entity_type = 'merge_request' AND mr.id = er.target_entity_id \
WHERE er.source_entity_type = ?1 AND er.source_entity_id = ?2 \
AND er.reference_type != 'closes' \
AND er.target_entity_iid IS NOT NULL \
AND NOT (er.target_entity_type = ?1 AND er.target_entity_id = ?2) \
ORDER BY er.target_entity_type, er.target_entity_iid",
)?;
@@ -832,21 +879,26 @@ fn fetch_related_entities(
iid: row.get(1)?,
reference_type: row.get(2)?,
title: row.get(3)?,
state: row.get(4)?,
direction: "outgoing".to_string(),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
// Incoming references (excluding closes).
// COALESCE(i.iid, mr.iid) can be NULL if the source entity was deleted; filter those out.
// Excludes self-references (same type + same local ID).
let mut in_stmt = conn.prepare(
"SELECT er.source_entity_type, COALESCE(i.iid, mr.iid) as iid, er.reference_type, \
COALESCE(i.title, mr.title) as title \
COALESCE(i.title, mr.title) as title, \
COALESCE(i.state, mr.state) as state \
FROM entity_references er \
LEFT JOIN issues i ON er.source_entity_type = 'issue' AND i.id = er.source_entity_id \
LEFT JOIN merge_requests mr ON er.source_entity_type = 'merge_request' AND mr.id = er.source_entity_id \
WHERE er.target_entity_type = ?1 AND er.target_entity_id = ?2 \
AND er.reference_type != 'closes' \
AND COALESCE(i.iid, mr.iid) IS NOT NULL \
AND NOT (er.source_entity_type = ?1 AND er.source_entity_id = ?2) \
ORDER BY er.source_entity_type, COALESCE(i.iid, mr.iid)",
)?;
@@ -857,6 +909,8 @@ fn fetch_related_entities(
iid: row.get(1)?,
reference_type: row.get(2)?,
title: row.get(3)?,
state: row.get(4)?,
direction: "incoming".to_string(),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -883,11 +937,17 @@ fn build_timeline_excerpt_from_pipeline(
conn: &Connection,
entity: &EntitySummary,
params: &ExplainParams,
) -> Option<Vec<TimelineEventSummary>> {
) -> Option<TimelineExcerpt> {
let timeline_entity_type = match entity.entity_type.as_str() {
"issue" => "issue",
"merge_request" => "merge_request",
_ => return Some(vec![]),
_ => {
return Some(TimelineExcerpt {
events: vec![],
total_events: 0,
truncated: false,
});
}
};
let project_id = params
@@ -900,29 +960,43 @@ fn build_timeline_excerpt_from_pipeline(
Ok(result) => result,
Err(e) => {
tracing::warn!("explain: timeline seed failed: {e}");
return Some(vec![]);
return Some(TimelineExcerpt {
events: vec![],
total_events: 0,
truncated: false,
});
}
};
let (mut events, _total) = match collect_events(
// Request a generous limit from the pipeline — we'll take the tail (most recent)
let pipeline_limit = 500;
let (events, _total) = match collect_events(
conn,
&seed_result.seed_entities,
&[],
&seed_result.evidence_notes,
&seed_result.matched_discussions,
params.since,
MAX_TIMELINE_EVENTS,
pipeline_limit,
) {
Ok(result) => result,
Err(e) => {
tracing::warn!("explain: timeline collect failed: {e}");
return Some(vec![]);
return Some(TimelineExcerpt {
events: vec![],
total_events: 0,
truncated: false,
});
}
};
events.truncate(MAX_TIMELINE_EVENTS);
let total_events = events.len();
let truncated = total_events > MAX_TIMELINE_EVENTS;
let summaries = events
// Keep the MOST RECENT events — events are sorted ASC by collect_events,
// so we skip from the front to keep the tail
let start = total_events.saturating_sub(MAX_TIMELINE_EVENTS);
let summaries = events[start..]
.iter()
.map(|e| TimelineEventSummary {
timestamp: ms_to_iso(e.timestamp),
@@ -932,7 +1006,11 @@ fn build_timeline_excerpt_from_pipeline(
})
.collect();
Some(summaries)
Some(TimelineExcerpt {
events: summaries,
total_events,
truncated,
})
}
fn timeline_event_type_label(event_type: &crate::timeline::TimelineEventType) -> String {
@@ -1065,8 +1143,11 @@ pub fn print_explain(result: &ExplainResult) {
Theme::bold().render(&result.entity.title)
);
println!(
" State: {} Author: {} Created: {}",
result.entity.state, result.entity.author, result.entity.created_at
" Project: {} State: {} Author: {} Created: {}",
result.entity.project_path,
result.entity.state,
result.entity.author,
result.entity.created_at
);
if !result.entity.assignees.is_empty() {
println!(" Assignees: {}", result.entity.assignees.join(", "));
@@ -1141,6 +1222,18 @@ pub fn print_explain(result: &ExplainResult) {
t.note_count,
t.last_note_at
);
if let Some(ref excerpt) = t.first_note_excerpt {
let preview = if excerpt.len() > 100 {
let b = excerpt.floor_char_boundary(100);
format!("{}...", &excerpt[..b])
} else {
excerpt.clone()
};
// Show first line only in human output
if let Some(line) = preview.lines().next() {
println!(" {}", Theme::muted().render(line));
}
}
}
}
@@ -1159,8 +1252,17 @@ pub fn print_explain(result: &ExplainResult) {
);
}
for ri in &related.related_issues {
let state_str = ri
.state
.as_deref()
.map_or(String::new(), |s| format!(" [{s}]"));
let arrow = if ri.direction == "incoming" {
"<-"
} else {
"->"
};
println!(
" {} {} #{}{} ({})",
" {} {arrow} {} #{}{}{state_str} ({})",
Icons::info(),
ri.entity_type,
ri.iid,
@@ -1171,16 +1273,25 @@ pub fn print_explain(result: &ExplainResult) {
}
// Timeline excerpt
if let Some(ref events) = result.timeline_excerpt
&& !events.is_empty()
if let Some(ref excerpt) = result.timeline_excerpt
&& !excerpt.events.is_empty()
{
let truncation_note = if excerpt.truncated {
format!(
" (showing {} of {})",
excerpt.events.len(),
excerpt.total_events
)
} else {
String::new()
};
println!(
"\n{} {} ({} events)",
"\n{} {}{}",
Icons::info(),
Theme::bold().render("Timeline"),
events.len()
truncation_note
);
for e in events {
for e in &excerpt.events {
let actor_str = e.actor.as_deref().unwrap_or("");
println!(
" {} {} {} {}",
@@ -1869,7 +1980,8 @@ mod tests {
);
}
let activity = build_activity_summary(&conn, "issues", issue_id, None).unwrap();
let activity =
build_activity_summary(&conn, "issues", issue_id, None, 1_704_067_200_000).unwrap();
assert_eq!(activity.state_changes, 2);
assert_eq!(activity.label_changes, 1);
@@ -1904,7 +2016,14 @@ mod tests {
5_000_000,
);
let activity = build_activity_summary(&conn, "issues", issue_id, Some(3_000_000)).unwrap();
let activity = build_activity_summary(
&conn,
"issues",
issue_id,
Some(3_000_000),
1_704_067_200_000,
)
.unwrap();
assert_eq!(activity.state_changes, 1, "Only the recent event");
}
@@ -1960,7 +2079,8 @@ mod tests {
let (conn, project_id) = setup_explain_db();
let issue_id = insert_test_issue(&conn, project_id, 64, None);
let activity = build_activity_summary(&conn, "issues", issue_id, None).unwrap();
let activity =
build_activity_summary(&conn, "issues", issue_id, None, 1_704_067_200_000).unwrap();
assert_eq!(activity.state_changes, 0);
assert_eq!(activity.label_changes, 0);
assert_eq!(activity.notes, 0);

View File

@@ -257,7 +257,7 @@ pub fn print_generate_docs_json(result: &GenerateDocsResult, elapsed_ms: u64) {
unchanged: result.unchanged,
errored: result.errored,
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
match serde_json::to_string(&output) {
Ok(json) => println!("{json}"),

View File

@@ -191,7 +191,7 @@ pub fn print_ingest_summary_json(result: &IngestResult, elapsed_ms: u64) {
status_enrichment,
status_enrichment_errors: result.status_enrichment_errors,
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
match serde_json::to_string(&output) {

View File

@@ -370,7 +370,7 @@ pub fn print_list_mrs(result: &MrListResult) {
pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
let json_result = MrListResultJson::from(result);
let meta = RobotMeta { elapsed_ms };
let meta = RobotMeta::new(elapsed_ms);
let output = serde_json::json!({
"ok": true,
"data": json_result,

View File

@@ -193,7 +193,7 @@ pub fn print_list_notes(result: &NoteListResult) {
pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) {
let json_result = NoteListResultJson::from(result);
let meta = RobotMeta { elapsed_ms };
let meta = RobotMeta::new(elapsed_ms);
let output = serde_json::json!({
"ok": true,
"data": json_result,

View File

@@ -247,7 +247,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
if robot_mode {
let fields = args.fields.as_deref();
render_robot::print_me_json(&dashboard, elapsed_ms, fields)?;
render_robot::print_me_json(&dashboard, elapsed_ms, fields, &config.gitlab.base_url)?;
} else if show_all {
render_human::print_me_dashboard(&dashboard, single_project);
} else {

View File

@@ -15,11 +15,12 @@ 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 { elapsed_ms },
meta: RobotMeta::with_base_url(elapsed_ms, gitlab_base_url),
};
let mut value = serde_json::to_value(&envelope)

View File

@@ -558,7 +558,7 @@ pub fn print_related_human(response: &RelatedResponse) {
}
pub fn print_related_json(response: &RelatedResponse, elapsed_ms: u64) {
let meta = RobotMeta { elapsed_ms };
let meta = RobotMeta::new(elapsed_ms);
let output = serde_json::json!({
"ok": true,
"data": response,

View File

@@ -557,7 +557,7 @@ impl From<&MrNoteDetail> for MrNoteDetailJson {
pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) {
let json_result = IssueDetailJson::from(issue);
let meta = RobotMeta { elapsed_ms };
let meta = RobotMeta::new(elapsed_ms);
let output = serde_json::json!({
"ok": true,
"data": json_result,
@@ -571,7 +571,7 @@ pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) {
pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) {
let json_result = MrDetailJson::from(mr);
let meta = RobotMeta { elapsed_ms };
let meta = RobotMeta::new(elapsed_ms);
let output = serde_json::json!({
"ok": true,
"data": json_result,

View File

@@ -583,7 +583,7 @@ pub fn print_stats_json(result: &StatsResult, elapsed_ms: u64) {
}),
}),
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
match serde_json::to_string(&output) {
Ok(json) => println!("{json}"),

View File

@@ -313,7 +313,7 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
system_notes: result.summary.system_note_count,
},
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
match serde_json::to_string(&output) {

View File

@@ -376,7 +376,7 @@ pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) {
resolved_input,
result: data,
},
meta: RobotMeta { elapsed_ms },
meta: RobotMeta::new(elapsed_ms),
};
let mut value = serde_json::to_value(&output).unwrap_or_else(|e| {

View File

@@ -3,6 +3,26 @@ use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct RobotMeta {
pub elapsed_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub gitlab_base_url: Option<String>,
}
impl RobotMeta {
/// Standard meta with timing only.
pub fn new(elapsed_ms: u64) -> Self {
Self {
elapsed_ms,
gitlab_base_url: None,
}
}
/// Meta with GitLab base URL for URL construction by consumers.
pub fn with_base_url(elapsed_ms: u64, base_url: &str) -> Self {
Self {
elapsed_ms,
gitlab_base_url: Some(base_url.trim_end_matches('/').to_string()),
}
}
}
/// Filter JSON object fields in-place for `--fields` support.
@@ -133,4 +153,27 @@ mod tests {
let expanded = expand_fields_preset(&fields, "notes");
assert_eq!(expanded, ["id", "body"]);
}
#[test]
fn meta_new_omits_base_url() {
let meta = RobotMeta::new(42);
let json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["elapsed_ms"], 42);
assert!(json.get("gitlab_base_url").is_none());
}
#[test]
fn meta_with_base_url_includes_it() {
let meta = RobotMeta::with_base_url(99, "https://gitlab.example.com");
let json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["elapsed_ms"], 99);
assert_eq!(json["gitlab_base_url"], "https://gitlab.example.com");
}
#[test]
fn meta_with_base_url_strips_trailing_slash() {
let meta = RobotMeta::with_base_url(0, "https://gitlab.example.com/");
let json = serde_json::to_value(&meta).unwrap();
assert_eq!(json["gitlab_base_url"], "https://gitlab.example.com");
}
}