All checks were successful
ci/crow/cron/process-updates/9 Pipeline was successful
Implements steps 1 + 2 of #115: turn recorded build failures into triaged patch suggestions instead of hand-scraping Crow logs. ## What this adds A **read-only** reporting pipeline over the `single_builds` metadata table. It never writes to the DB or the registry. - `local/failing-builds-classify.R` — pure, DB-free helpers: - `normalise_error()` strips temp paths, version numbers, hex addresses, and the package name so the same root cause collapses to one fingerprint. - `fingerprint_error()` extracts the salient error line and normalises it. - `classify_error()` matches against a seed signature set; unmatched errors are never guessed at. - `propose_registry_entry()` renders a schema-valid `registry.json` entry. - `local/failing-builds-report.R` — entrypoint: queries `single_builds WHERE error_occurred = TRUE AND removed = FALSE`, groups by root cause (signature when classified, fingerprint otherwise), classifies each group, and prints a triaged report. Flags: `--platform`, `--arch`, `--min`, `--json`; `PLATFORM`/`ARCH` env fallbacks. - `local/tests/test-failing-builds-classify.R` — unit tests for the helpers. - `local/patches/README.md` — documents the workflow. ## Seed signatures Each rule carries a fix tier, confidence, and an auto/human-only flag: | Signature | Fix | Disposition | | --- | --- | --- | | `tbb/tbb_stddef.h: No such file` | makevars `-DTBB_INTERFACE_NEW` | auto-proposable | | RcppParallel bundled TBB (musl / new g++) | curated `disable-tbb.patch` | auto-proposable | | system `libuv.so` link leak | force vendored/static lib | **human triage** (novel source diff) | | unmatched | none | **human triage** | ## Guardrails honored - No autonomous novel source diffs: only known env/makevars levers and already-curated package patches are auto-proposable; anything needing a brand-new diff, and any unknown signature, is routed to human triage. - No DB or registry writes; no change to the public `src/contrib` index. - Reuses `single_builds.error_text`; no new failure-capture pipeline. ## Verification - All helper unit tests pass under the Dockerized R 4.5.3 build env. - Pre-commit hooks pass (`air-format`, `validate-patches`, prettier, etc.). - Smoke-tested the full report path with a stubbed DB; generated proposals pass the real `local/validate-patches.R`. Steps 3 (auto-open PRs) and 4 (feedback loop) are intentionally deferred, per the issue's suggestion to validate the signature set first. Closes #115 Reviewed-on: #116
290 lines
8.9 KiB
R
290 lines
8.9 KiB
R
#!/usr/bin/env Rscript
|
|
|
|
# Read-only failure triage over the `single_builds` metadata table (issue #115,
|
|
# steps 1 + 2): query every recorded build failure, group by a normalised error
|
|
# fingerprint, classify each bucket against the known signature set, and print a
|
|
# triaged report with a pre-filled `registry.json` suggestion where a *known,
|
|
# safe* fix lever applies. Novel source diffs and unknown signatures are routed
|
|
# to human triage; this script never writes to the DB or the registry.
|
|
#
|
|
# Usage:
|
|
# PGPASS=... Rscript local/failing-builds-report.R [--platform P] [--arch A]
|
|
# [--json out.json] [--min N]
|
|
#
|
|
# --platform / --arch restrict to one platform/arch (default: all)
|
|
# --min N only show fingerprint groups with >= N failing builds
|
|
# --json PATH also write the machine-readable report to PATH
|
|
#
|
|
# Env fallbacks: PLATFORM, ARCH (same effect as the flags).
|
|
|
|
options(error = function() {
|
|
cat("ERROR:", geterrmessage(), "\n", file = stdout())
|
|
q(status = 1)
|
|
})
|
|
|
|
suppressPackageStartupMessages({
|
|
library(DBI, quietly = TRUE)
|
|
library(RPostgres, quietly = TRUE)
|
|
library(jsonlite, quietly = TRUE)
|
|
})
|
|
|
|
# Locate helpers relative to this script so it runs from any CWD.
|
|
script_path <- local({
|
|
a <- commandArgs(trailingOnly = FALSE)
|
|
f <- sub("^--file=", "", a[grepl("^--file=", a)])
|
|
if (length(f) == 1L && nzchar(f)) normalizePath(f) else NA_character_
|
|
})
|
|
script_dir <- if (is.na(script_path)) "local" else dirname(script_path)
|
|
source(file.path(script_dir, "failing-builds-classify.R"))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Arguments
|
|
# ---------------------------------------------------------------------------
|
|
args <- commandArgs(trailingOnly = TRUE)
|
|
opt_val <- function(flag, default = NA_character_) {
|
|
i <- match(flag, args)
|
|
if (!is.na(i) && i < length(args)) args[[i + 1L]] else default
|
|
}
|
|
platform <- opt_val("--platform", Sys.getenv("PLATFORM", ""))
|
|
arch <- opt_val("--arch", Sys.getenv("ARCH", ""))
|
|
json_out <- opt_val("--json")
|
|
min_count <- suppressWarnings(as.integer(opt_val("--min", "1")))
|
|
if (is.na(min_count)) {
|
|
min_count <- 1L
|
|
}
|
|
|
|
if (nchar(Sys.getenv("PGPASS")) == 0L) {
|
|
stop(
|
|
"PGPASS env var is not set; a DB password is required (no read-only role exists)."
|
|
)
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Query failing builds
|
|
# ---------------------------------------------------------------------------
|
|
con <- DBI::dbConnect(
|
|
RPostgres::Postgres(),
|
|
dbname = "build_metadata",
|
|
host = "r-binaries.devxy.io",
|
|
port = 15432,
|
|
user = "rpkgs",
|
|
password = Sys.getenv("PGPASS"),
|
|
sslmode = "require"
|
|
)
|
|
on.exit(DBI::dbDisconnect(con), add = TRUE)
|
|
|
|
where <- "error_occurred = TRUE AND removed = FALSE"
|
|
params <- list()
|
|
if (nzchar(platform)) {
|
|
where <- paste0(where, " AND platform = $", length(params) + 1L)
|
|
params <- c(params, platform)
|
|
}
|
|
if (nzchar(arch)) {
|
|
where <- paste0(where, " AND arch = $", length(params) + 1L)
|
|
params <- c(params, arch)
|
|
}
|
|
failures <- DBI::dbGetQuery(
|
|
con,
|
|
paste0(
|
|
"SELECT name, tag, platform, arch, r_version, timestamp, error_text ",
|
|
"FROM single_builds WHERE ",
|
|
where
|
|
),
|
|
params = if (length(params) > 0L) params else NULL
|
|
)
|
|
|
|
cat(sprintf(
|
|
"Queried single_builds: %d failing builds%s\n",
|
|
nrow(failures),
|
|
if (nzchar(platform) || nzchar(arch)) {
|
|
sprintf(
|
|
" (filter: platform=%s arch=%s)",
|
|
if (nzchar(platform)) platform else "*",
|
|
if (nzchar(arch)) arch else "*"
|
|
)
|
|
} else {
|
|
""
|
|
}
|
|
))
|
|
if (nrow(failures) == 0L) {
|
|
cat("No failing builds to triage.\n")
|
|
q(status = 0)
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Packages that already carry a registry entry (so we don't re-propose)
|
|
# ---------------------------------------------------------------------------
|
|
`%||%` <- function(a, b) if (is.null(a)) b else a
|
|
registry_file <- file.path(script_dir, "patches", "registry.json")
|
|
registered_pkgs <- character(0L)
|
|
if (file.exists(registry_file)) {
|
|
reg <- jsonlite::fromJSON(registry_file, simplifyVector = FALSE)
|
|
registered_pkgs <- unique(vapply(
|
|
reg,
|
|
function(e) as.character(e$package %||% ""),
|
|
character(1L)
|
|
))
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fingerprint + classify every failure, then group
|
|
# ---------------------------------------------------------------------------
|
|
signatures <- build_signatures()
|
|
failures$fingerprint <- vapply(
|
|
seq_len(nrow(failures)),
|
|
function(i) fingerprint_error(failures$error_text[[i]], failures$name[[i]]),
|
|
character(1L)
|
|
)
|
|
failures$sig_id <- vapply(
|
|
seq_len(nrow(failures)),
|
|
function(i) classify_error(failures$error_text[[i]], signatures)$id,
|
|
character(1L)
|
|
)
|
|
|
|
# Group by root cause: classified failures collapse by signature id (so the
|
|
# same fix candidate is one bucket regardless of surrounding log noise);
|
|
# unclassified failures fall back to the fingerprint so distinct unknowns stay
|
|
# separate for discovery.
|
|
failures$group_key <- ifelse(
|
|
failures$sig_id == "unclassified",
|
|
failures$fingerprint,
|
|
failures$sig_id
|
|
)
|
|
groups <- split(failures, failures$group_key)
|
|
# Order groups by number of affected builds, descending.
|
|
groups <- groups[order(-vapply(groups, nrow, integer(1L)))]
|
|
|
|
report <- list()
|
|
cat(sprintf(
|
|
"\n%d distinct failure fingerprint(s); showing groups with >= %d build(s).\n",
|
|
length(groups),
|
|
min_count
|
|
))
|
|
cat(strrep("=", 78L), "\n", sep = "")
|
|
|
|
for (g in groups) {
|
|
n <- nrow(g)
|
|
if (n < min_count) {
|
|
next
|
|
}
|
|
|
|
# Representative classification: most common signature id in the group.
|
|
sig_id <- names(sort(table(g$sig_id), decreasing = TRUE))[[1L]]
|
|
sig <- Filter(function(s) s$id == sig_id, signatures)
|
|
if (length(sig) > 0L) {
|
|
sig <- sig[[1L]]
|
|
sig$matched <- TRUE
|
|
} else {
|
|
sig <- classify_error("", signatures) # unclassified fallback (matched = FALSE)
|
|
}
|
|
|
|
fp_tab <- sort(table(g$fingerprint), decreasing = TRUE)
|
|
rep_fp <- names(fp_tab)[[1L]]
|
|
fp_variants <- length(fp_tab)
|
|
pkgs <- sort(unique(g$name))
|
|
plats <- sort(unique(g$platform))
|
|
arches <- sort(unique(g$arch))
|
|
unregistered <- setdiff(pkgs, registered_pkgs)
|
|
|
|
status <- if (!sig$matched) {
|
|
"HUMAN TRIAGE (unknown signature)"
|
|
} else if (!sig$auto) {
|
|
sprintf("HUMAN TRIAGE (classified: %s; novel source diff)", sig$id)
|
|
} else {
|
|
sprintf("AUTO-PROPOSABLE (%s, %s confidence)", sig$id, sig$confidence)
|
|
}
|
|
|
|
cat(sprintf(
|
|
"\n[%d builds | %d pkgs | %s] %s\n",
|
|
n,
|
|
length(pkgs),
|
|
toString(plats),
|
|
status
|
|
))
|
|
cat(sprintf(
|
|
" fingerprint: %s%s\n",
|
|
rep_fp,
|
|
if (fp_variants > 1L) {
|
|
sprintf(" (+%d fingerprint variant(s))", fp_variants - 1L)
|
|
} else {
|
|
""
|
|
}
|
|
))
|
|
if (sig$matched) {
|
|
cat(sprintf(" signature : %s\n", sig$label))
|
|
cat(sprintf(" suggested : [%s] %s\n", sig$tier, sig$fix))
|
|
}
|
|
cat(sprintf(" arch : %s\n", toString(arches)))
|
|
cat(sprintf(
|
|
" packages : %s\n",
|
|
paste(
|
|
vapply(
|
|
pkgs,
|
|
function(p) {
|
|
if (p %in% registered_pkgs) paste0(p, " (has entry)") else p
|
|
},
|
|
character(1L)
|
|
),
|
|
collapse = ", "
|
|
)
|
|
))
|
|
|
|
proposals <- list()
|
|
if (sig$matched && sig$auto && length(unregistered) > 0L) {
|
|
cat(" proposed registry entries (validate + trial-build before merge):\n")
|
|
for (p in unregistered) {
|
|
p_plats <- sort(unique(g$platform[g$name == p]))
|
|
entry <- propose_registry_entry(sig, p, p_plats)
|
|
proposals[[p]] <- entry
|
|
cat(paste0(" ", gsub("\n", "\n ", entry)), "\n", sep = "")
|
|
}
|
|
} else if (sig$matched && sig$auto && length(unregistered) == 0L) {
|
|
cat(" (all affected packages already have a registry entry)\n")
|
|
}
|
|
|
|
report[[length(report) + 1L]] <- list(
|
|
fingerprint = rep_fp,
|
|
fingerprint_variants = fp_variants,
|
|
build_count = n,
|
|
packages = pkgs,
|
|
packages_without_entry = unregistered,
|
|
platforms = plats,
|
|
arches = arches,
|
|
signature = sig$id,
|
|
matched = sig$matched,
|
|
auto_proposable = isTRUE(sig$auto) && sig$matched,
|
|
tier = sig$tier,
|
|
confidence = sig$confidence,
|
|
suggested_fix = sig$fix,
|
|
proposed_entries = if (length(proposals) > 0L) {
|
|
lapply(proposals, jsonlite::fromJSON)
|
|
} else {
|
|
NULL
|
|
}
|
|
)
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Summary
|
|
# ---------------------------------------------------------------------------
|
|
matched <- Filter(function(r) r$matched, report)
|
|
auto <- Filter(function(r) r$auto_proposable, report)
|
|
cat("\n", strrep("=", 78L), "\n", sep = "")
|
|
cat(sprintf(
|
|
"Summary: %d groups | %d classified | %d auto-proposable | %d for human triage\n",
|
|
length(report),
|
|
length(matched),
|
|
length(auto),
|
|
length(report) - length(matched)
|
|
))
|
|
|
|
if (!is.na(json_out)) {
|
|
jsonlite::write_json(
|
|
report,
|
|
json_out,
|
|
auto_unbox = TRUE,
|
|
pretty = TRUE,
|
|
null = "null"
|
|
)
|
|
cat(sprintf("Wrote machine-readable report to %s\n", json_out))
|
|
}
|