Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion src/linters/renovate_deps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,12 +577,65 @@ async fn run_renovate(
anyhow::bail!(
"renovate exited with status {}: {}",
out.status.code().unwrap_or(-1),
snippet.lines().take(20).collect::<Vec<_>>().join("\n")
extract_failure_snippet(&snippet)
);
}

Ok(combined)
}
/// Extracts the most informative slice of a Renovate log for an error message.
///
/// Renovate emits one JSON log object per line with a numeric `level` field
/// (bunyan: 30=info, 40=warn, 50=error, 60=fatal). Startup is dominated by
/// debug/info lines, so taking the head of the log usually hides the actual
/// failure. Prefer level >= 40 entries; fall back to the tail of the log.
fn extract_failure_snippet(log: &str) -> String {
const MAX_LINES: usize = 20;

let high_level: Vec<String> = log
.lines()
.filter_map(|line| {
Comment thread
zeitlinger marked this conversation as resolved.
let value: serde_json::Value = serde_json::from_str(line).ok()?;
let level = value.get("level")?.as_u64()?;
if level < 40 {
return None;
}
let msg = value
.get("msg")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let err = value
.get("err")
.and_then(|v| v.get("message"))
.and_then(|v| v.as_str());
let mut out = format!("level={level}");
if let Some(m) = msg {
out.push(' ');
out.push_str(m);
}
if let Some(e) = err {
out.push_str(if msg.is_some() { ": " } else { " " });
out.push_str(e);
}
Some(out)
})
.collect();

fn tail<I: IntoIterator>(items: I, n: usize) -> Vec<I::Item> {
let mut v: Vec<I::Item> = items.into_iter().collect();
if v.len() > n {
v.drain(..v.len() - n);
}
v
}
Comment thread
zeitlinger marked this conversation as resolved.

if high_level.is_empty() {
tail(log.lines(), MAX_LINES).join("\n")
} else {
tail(high_level.iter().map(String::as_str), MAX_LINES).join("\n")
}
}

fn resolve_renovate_config_path(project_root: &Path) -> anyhow::Result<PathBuf> {
RENOVATE_CONFIG_PATTERNS
.iter()
Expand Down
34 changes: 34 additions & 0 deletions src/linters/renovate_deps/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -931,3 +931,37 @@ fn relevant_when_snapshot_is_unparsable() {
dir.path()
));
}

#[test]
fn extract_failure_snippet_prefers_error_lines() {
let log = "\
{\"level\":20,\"msg\":\"Parsing configs\"}\n\
{\"level\":30,\"msg\":\"Renovate started\"}\n\
{\"level\":50,\"msg\":\"Failed\",\"err\":{\"message\":\"boom\"}}\n\
{\"level\":20,\"msg\":\"trailing debug\"}\n";
let snippet = extract_failure_snippet(log);
assert_eq!(snippet, "level=50 Failed: boom");
}

#[test]
fn extract_failure_snippet_handles_missing_msg() {
let log = "\
{\"level\":50,\"err\":{\"message\":\"boom\"}}\n\
{\"level\":60,\"msg\":\"\",\"err\":{\"message\":\"fatal\"}}\n\
{\"level\":40,\"msg\":\"warn only\"}\n";
let snippet = extract_failure_snippet(log);
assert_eq!(snippet, "level=50 boom\nlevel=60 fatal\nlevel=40 warn only");
}

#[test]
fn extract_failure_snippet_falls_back_to_tail() {
let mut log = String::new();
for i in 0..30 {
log.push_str(&format!("{{\"level\":20,\"msg\":\"line {i}\"}}\n"));
}
let snippet = extract_failure_snippet(&log);
let lines: Vec<&str> = snippet.lines().collect();
assert_eq!(lines.len(), 20);
assert!(lines.last().unwrap().contains("line 29"));
assert!(lines.first().unwrap().contains("line 10"));
}
Loading