build-cran-binaries/local/failing-builds-report.R
pat-s 8afa584da3
feat(local): auto-propose registry patches and track the feedback loop
Implement steps 3 + 4 of issue #115 on top of the failure classifier, now
that bincraft v4.4.3 applies registry patches/makevars/configure_args to the
target build (not just dependencies), so a trial patched build is meaningful.

- refactor the classify helpers to expose a pure build_triage_report() and a
  list-returning entry builder; failing-builds-report.R now renders from it
- add local/propose-patches.R (step 3, "propose, do not apply"): emit a
  pre-filled registry.json entry for each classified, safe, unregistered
  failure, validate the candidate set against a temporary merged registry,
  and (only on request) --write it plus a proposals ledger, or --open-issue a
  Forgejo tracking issue; the human gate and validator/trial-build acceptance
  stay, and novel source diffs / unknown signatures are never proposed
- add local/trial-build-patch.R: isolated bincraft build of one package with
  the registry applied (no upload/archive/metadata) as the pre-merge gate
- add local/proposal-tracking.R + local/proposal-tracking-lib.R (step 4):
  signature hit rate, proposed-vs-merged, and retirement candidates, with the
  pure helpers covered by tests
- teach validate-patches.R optional PATCH_DIR/REGISTRY_FILE overrides so a
  candidate registry can be validated without touching the real one
- document the propose/trial-build/tracking workflow in local/patches/README.md
2026-07-14 15:01:55 +00:00

225 lines
6.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)
))
}
# ---------------------------------------------------------------------------
# Classify + group (shared logic), then render
# ---------------------------------------------------------------------------
report <- build_triage_report(failures, registered_pkgs)
report <- Filter(function(r) r$build_count >= min_count, report)
cat(sprintf(
"\n%d distinct failure group(s); showing groups with >= %d build(s).\n",
length(report),
min_count
))
cat(strrep("=", 78L), "\n", sep = "")
for (r in report) {
status <- if (!r$matched) {
"HUMAN TRIAGE (unknown signature)"
} else if (!r$auto_proposable) {
sprintf("HUMAN TRIAGE (classified: %s; novel source diff)", r$signature)
} else {
sprintf("AUTO-PROPOSABLE (%s, %s confidence)", r$signature, r$confidence)
}
cat(sprintf(
"\n[%d builds | %d pkgs | %s] %s\n",
r$build_count,
length(r$packages),
toString(r$platforms),
status
))
cat(sprintf(
" fingerprint: %s%s\n",
r$fingerprint,
if (r$fingerprint_variants > 1L) {
sprintf(" (+%d fingerprint variant(s))", r$fingerprint_variants - 1L)
} else {
""
}
))
if (r$matched) {
cat(sprintf(" signature : %s\n", r$label))
cat(sprintf(" suggested : [%s] %s\n", r$tier, r$suggested_fix))
}
cat(sprintf(" arch : %s\n", toString(r$arches)))
cat(sprintf(
" packages : %s\n",
paste(
vapply(
r$packages,
function(p) {
if (p %in% registered_pkgs) paste0(p, " (has entry)") else p
},
character(1L)
),
collapse = ", "
)
))
if (!is.null(r$proposed_entries)) {
cat(" proposed registry entries (validate + trial-build before merge):\n")
for (entry in r$proposed_entries) {
j <- jsonlite::toJSON(
entry,
auto_unbox = TRUE,
pretty = TRUE,
null = "null"
)
cat(paste0(" ", gsub("\n", "\n ", j)), "\n", sep = "")
}
} else if (r$auto_proposable) {
cat(" (all affected packages already have a registry entry)\n")
}
}
# ---------------------------------------------------------------------------
# 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))
}