From d6d1686f8ede06688482f2968600db7b05a0dad6 Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 11 Mar 2026 10:28:49 -0400 Subject: [PATCH] refactor(robot): add constructors to RobotMeta, support optional gitlab_base_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/handlers.rs | 22 +++++----------- src/cli/commands/count.rs | 4 +-- src/cli/commands/cron.rs | 6 ++--- src/cli/commands/drift.rs | 2 +- src/cli/commands/embed.rs | 2 +- src/cli/commands/generate_docs.rs | 2 +- src/cli/commands/ingest/render.rs | 2 +- src/cli/commands/list/mrs.rs | 2 +- src/cli/commands/list/notes.rs | 2 +- src/cli/commands/related.rs | 2 +- src/cli/commands/show/render.rs | 4 +-- src/cli/commands/stats.rs | 2 +- src/cli/commands/sync_status.rs | 2 +- src/cli/commands/who/mod.rs | 2 +- src/cli/robot.rs | 43 +++++++++++++++++++++++++++++++ 15 files changed, 66 insertions(+), 33 deletions(-) diff --git a/src/app/handlers.rs b/src/app/handlers.rs index 4083322..71a2e01 100644 --- a/src/app/handlers.rs +++ b/src/app/handlers.rs @@ -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> { 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 { diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs index 114c46b..df2e439 100644 --- a/src/cli/commands/count.rs +++ b/src/cli/commands/count.rs @@ -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) { diff --git a/src/cli/commands/cron.rs b/src/cli/commands/cron.rs index 68e13b5..53265c7 100644 --- a/src/cli/commands/cron.rs +++ b/src/cli/commands/cron.rs @@ -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}"); diff --git a/src/cli/commands/drift.rs b/src/cli/commands/drift.rs index e656f3a..6082c5a 100644 --- a/src/cli/commands/drift.rs +++ b/src/cli/commands/drift.rs @@ -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, diff --git a/src/cli/commands/embed.rs b/src/cli/commands/embed.rs index c082ded..0c007ac 100644 --- a/src/cli/commands/embed.rs +++ b/src/cli/commands/embed.rs @@ -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}"), diff --git a/src/cli/commands/generate_docs.rs b/src/cli/commands/generate_docs.rs index 026a16a..387d253 100644 --- a/src/cli/commands/generate_docs.rs +++ b/src/cli/commands/generate_docs.rs @@ -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}"), diff --git a/src/cli/commands/ingest/render.rs b/src/cli/commands/ingest/render.rs index 2568bce..b7efda6 100644 --- a/src/cli/commands/ingest/render.rs +++ b/src/cli/commands/ingest/render.rs @@ -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) { diff --git a/src/cli/commands/list/mrs.rs b/src/cli/commands/list/mrs.rs index c13c16d..c182405 100644 --- a/src/cli/commands/list/mrs.rs +++ b/src/cli/commands/list/mrs.rs @@ -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, diff --git a/src/cli/commands/list/notes.rs b/src/cli/commands/list/notes.rs index 2b43574..d24e38f 100644 --- a/src/cli/commands/list/notes.rs +++ b/src/cli/commands/list/notes.rs @@ -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, diff --git a/src/cli/commands/related.rs b/src/cli/commands/related.rs index ce2daf9..fe7311c 100644 --- a/src/cli/commands/related.rs +++ b/src/cli/commands/related.rs @@ -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, diff --git a/src/cli/commands/show/render.rs b/src/cli/commands/show/render.rs index 9e3a204..edd4674 100644 --- a/src/cli/commands/show/render.rs +++ b/src/cli/commands/show/render.rs @@ -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, diff --git a/src/cli/commands/stats.rs b/src/cli/commands/stats.rs index e212a1d..30fed52 100644 --- a/src/cli/commands/stats.rs +++ b/src/cli/commands/stats.rs @@ -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}"), diff --git a/src/cli/commands/sync_status.rs b/src/cli/commands/sync_status.rs index 56c652c..50fad8d 100644 --- a/src/cli/commands/sync_status.rs +++ b/src/cli/commands/sync_status.rs @@ -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) { diff --git a/src/cli/commands/who/mod.rs b/src/cli/commands/who/mod.rs index b880d5c..51cf750 100644 --- a/src/cli/commands/who/mod.rs +++ b/src/cli/commands/who/mod.rs @@ -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| { diff --git a/src/cli/robot.rs b/src/cli/robot.rs index 676261c..421c38a 100644 --- a/src/cli/robot.rs +++ b/src/cli/robot.rs @@ -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, +} + +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"); + } }