5 Commits

Author SHA1 Message Date
teernisse
60075cd400 release: v0.9.4 2026-03-11 10:37:38 -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
teernisse
5c44ee91fb fix(robot): propagate JSON serialization errors instead of silent failure
Three robot-mode print functions used `serde_json::to_string().unwrap_or_default()`
which silently outputs an empty string on failure (exit 0, no error). This
diverged from the codebase standard in handlers.rs which uses `?` propagation.

Changed to return Result<()> with proper LoreError::Other mapping:
- explain.rs: print_explain_json()
- file_history.rs: print_file_history_json()
- trace.rs: print_trace_json()

Updated callers in handlers.rs and explain.rs to propagate with `?`.

While serde_json::to_string on a json!() Value is unlikely to fail in practice
(only non-finite floats trigger it), the unwrap_or_default pattern violates the
robot mode contract: callers expect either valid JSON on stdout or a structured
error on stderr with a non-zero exit code, never empty output with exit 0.
2026-03-10 17:11:03 -04:00
teernisse
6aff96d32f fix(sql): add ORDER BY to all LIMIT queries for deterministic results
SQLite does not guarantee row order without ORDER BY, even with LIMIT.
This was a systemic issue found during a multi-pass bug hunt:

Production queries (explain.rs):
- Outgoing reference query: ORDER BY target_entity_type, target_entity_iid
- Incoming reference query: ORDER BY source_entity_type, COALESCE(iid)
  Without these, robot mode output was non-deterministic across calls,
  breaking clients expecting stable ordering.

Test helper queries (5 locations across 3 files):
- discussions_tests.rs: get_discussion_id()
- mr_discussions.rs: get_mr_discussion_id()
- queue.rs: setup_db_with_job(), release_all_locked_jobs_clears_locks()
  Currently safe (single-row inserts) but would break silently if tests
  expanded to multi-row fixtures.
2026-03-10 17:10:52 -04:00
26 changed files with 127 additions and 58 deletions

2
Cargo.lock generated
View File

@@ -1324,7 +1324,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lore"
version = "0.9.3"
version = "0.9.4"
dependencies = [
"asupersync",
"async-stream",

View File

@@ -1,6 +1,6 @@
[package]
name = "lore"
version = "0.9.3"
version = "0.9.4"
edition = "2024"
description = "Gitlore - Local GitLab data management with semantic search"
authors = ["Taylor Eernisse"]

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 {
@@ -1328,7 +1320,7 @@ fn handle_file_history(
if robot_mode {
let elapsed_ms = start.elapsed().as_millis() as u64;
print_file_history_json(&result, elapsed_ms);
print_file_history_json(&result, elapsed_ms)?;
} else {
print_file_history(&result);
}
@@ -1384,7 +1376,7 @@ fn handle_trace(
if robot_mode {
let elapsed_ms = start.elapsed().as_millis() as u64;
print_trace_json(&result, elapsed_ms, line_requested);
print_trace_json(&result, elapsed_ms, line_requested)?;
} else {
print_trace(&result);
}
@@ -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

@@ -821,7 +821,8 @@ fn fetch_related_entities(
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 er.target_entity_iid IS NOT NULL \
ORDER BY er.target_entity_type, er.target_entity_iid",
)?;
let outgoing: Vec<RelatedEntityInfo> = out_stmt
@@ -845,7 +846,8 @@ fn fetch_related_entities(
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 COALESCE(i.iid, mr.iid) IS NOT NULL \
ORDER BY er.source_entity_type, COALESCE(i.iid, mr.iid)",
)?;
let incoming: Vec<RelatedEntityInfo> = in_stmt
@@ -1020,7 +1022,7 @@ pub fn handle_explain(
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
print_explain_json(&result, elapsed_ms);
print_explain_json(&result, elapsed_ms)?;
} else {
print_explain(&result);
}
@@ -1032,13 +1034,18 @@ pub fn handle_explain(
// Output rendering (Task 5 fills these in fully)
// ---------------------------------------------------------------------------
pub fn print_explain_json(result: &ExplainResult, elapsed_ms: u64) {
pub fn print_explain_json(result: &ExplainResult, elapsed_ms: u64) -> Result<()> {
let response = serde_json::json!({
"ok": true,
"data": result,
"meta": { "elapsed_ms": elapsed_ms }
});
println!("{}", serde_json::to_string(&response).unwrap_or_default());
println!(
"{}",
serde_json::to_string(&response)
.map_err(|e| LoreError::Other(format!("JSON serialization failed: {e}")))?
);
Ok(())
}
pub fn print_explain(result: &ExplainResult) {

View File

@@ -5,7 +5,7 @@ use tracing::info;
use crate::Config;
use crate::cli::render::{self, Icons, Theme};
use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::error::{LoreError, Result};
use crate::core::file_history::resolve_rename_chain;
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
@@ -391,7 +391,7 @@ pub fn print_file_history(result: &FileHistoryResult) {
// ── Robot (JSON) output ─────────────────────────────────────────────────────
pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) {
pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) -> Result<()> {
let output = serde_json::json!({
"ok": true,
"data": {
@@ -409,5 +409,10 @@ pub fn print_file_history_json(result: &FileHistoryResult, elapsed_ms: u64) {
}
});
println!("{}", serde_json::to_string(&output).unwrap_or_default());
println!(
"{}",
serde_json::to_string(&output)
.map_err(|e| LoreError::Other(format!("JSON serialization failed: {e}")))?
);
Ok(())
}

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

@@ -1,4 +1,5 @@
use crate::cli::render::{Icons, Theme};
use crate::core::error::{LoreError, Result};
use crate::core::trace::{TraceChain, TraceResult};
/// Parse a path with optional `:line` suffix.
@@ -152,7 +153,11 @@ fn truncate_body(body: &str, max: usize) -> String {
format!("{}...", &body[..boundary])
}
pub fn print_trace_json(result: &TraceResult, elapsed_ms: u64, line_requested: Option<u32>) {
pub fn print_trace_json(
result: &TraceResult,
elapsed_ms: u64,
line_requested: Option<u32>,
) -> Result<()> {
// Truncate discussion bodies for token efficiency in robot mode
let chains: Vec<serde_json::Value> = result
.trace_chains
@@ -205,7 +210,12 @@ pub fn print_trace_json(result: &TraceResult, elapsed_ms: u64, line_requested: O
}
});
println!("{}", serde_json::to_string(&output).unwrap_or_default());
println!(
"{}",
serde_json::to_string(&output)
.map_err(|e| LoreError::Other(format!("JSON serialization failed: {e}")))?
);
Ok(())
}
#[cfg(test)]

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");
}
}

View File

@@ -40,8 +40,12 @@ fn setup() -> Connection {
}
fn get_discussion_id(conn: &Connection) -> i64 {
conn.query_row("SELECT id FROM discussions LIMIT 1", [], |row| row.get(0))
.unwrap()
conn.query_row(
"SELECT id FROM discussions ORDER BY id LIMIT 1",
[],
|row| row.get(0),
)
.unwrap()
}
#[allow(clippy::too_many_arguments)]

View File

@@ -786,8 +786,12 @@ mod tests {
}
fn get_mr_discussion_id(conn: &Connection) -> i64 {
conn.query_row("SELECT id FROM discussions LIMIT 1", [], |row| row.get(0))
.unwrap()
conn.query_row(
"SELECT id FROM discussions ORDER BY id LIMIT 1",
[],
|row| row.get(0),
)
.unwrap()
}
#[allow(clippy::too_many_arguments)]

View File

@@ -242,14 +242,16 @@ mod tests {
.unwrap();
let project_id: i64 = conn
.query_row("SELECT id FROM projects LIMIT 1", [], |row| row.get(0))
.query_row("SELECT id FROM projects ORDER BY id LIMIT 1", [], |row| {
row.get(0)
})
.unwrap();
enqueue_job(&conn, project_id, "issue", 42, 100, "resource_events", None).unwrap();
let job_id: i64 = conn
.query_row(
"SELECT id FROM pending_dependent_fetches LIMIT 1",
"SELECT id FROM pending_dependent_fetches ORDER BY id LIMIT 1",
[],
|row| row.get(0),
)
@@ -301,7 +303,9 @@ mod tests {
let (conn, _job_id) = setup_db_with_job();
let project_id: i64 = conn
.query_row("SELECT id FROM projects LIMIT 1", [], |row| row.get(0))
.query_row("SELECT id FROM projects ORDER BY id LIMIT 1", [], |row| {
row.get(0)
})
.unwrap();
let jobs = claim_jobs(&conn, "resource_events", project_id, 10).unwrap();
assert_eq!(jobs.len(), 1);