feat(local): report unclassified and dependency-blocked failures for discovery (#122)
All checks were successful
ci/crow/manual/weekly-patch-proposals Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/10 Pipeline was successful

## Why

Issue #120 (the auto-proposed-patches issue) only lists **auto-proposable** fixes -- currently just the TBB signatures. So a reasonable read of it was "TBB is our only build failure", when in fact three whole categories are simply not shown there:

- **Unclassified failures** -- anything that doesn't match a seeded signature is routed to human triage and never appears (we've only seeded TBB and libuv signatures).
- **Dependency-blocked failures** -- the ~800 RcppParallel dependents (post #121) are still failing; they only show as a log line.
- Human-only signatures (libuv).

These blind spots are exactly where the *next* signatures should come from, so they deserve the same visibility as the proposals.

## What

Extend the feedback-loop tracker to surface the classifier's blind spots:

- **`unclassified_summary()`** -- groups every unknown-signature failure by normalised fingerprint, ranked by build count, capped with an explicit `dropped_groups` count (no silent truncation), each with example packages + platforms. These are the candidates for new `build_signatures()` rules.
- **`blocked_summary()`** -- lists each dependency (e.g. RcppParallel) and how many dependents wait on it.
- `proposal-tracking.R` prints both sections, and a new **`--open-issue`** mode posts/updates a *"Unclassified build failures (needs signatures) (#115)"* Forgejo issue.
- The weekly crow pipeline now runs the tracker with `--open-issue`, so it maintains a second tracking issue alongside the proposals one. Read-only on the DB; the only writes are the two issues.

## Verification

- New tests cover `unclassified_summary` (ranking + both caps) and `blocked_summary`.
- Tracker smoke with a stubbed DB (proposable + blocked + unclassified mix) prints the hit rate, `Blocked on a dependency: RcppParallel: 2 dependent(s)`, and `Unclassified failures ... [2 builds | 2 pkgs] ld: undefined reference ...`.
- Full suite: 95 tests pass; all pre-commit hooks pass (air, prettier, markdownlint, yamllint, validate-patches).

Reviewed-on: #122
This commit is contained in:
Patrick Schratz 2026-07-15 07:46:29 +00:00 committed by Patrick Schratz
commit 21a2fe9e6c

View file

@ -1,12 +1,16 @@
#!/usr/bin/env Rscript
# Feedback loop for the failure classifier (issue #115, step 4): report the
# signature hit rate, proposed-vs-merged status, and registry entries that look
# retirable, so the rule set can improve and stale entries can be pruned.
# Read-only: queries `single_builds` and reads the registry + proposals ledger.
# signature hit rate, proposed-vs-merged status, registry entries that look
# retirable, and -- so the classifier's blind spots get the same visibility as
# its proposals -- the failures it could NOT classify (candidates for new
# signatures) plus the groups blocked on a dependency build.
# Read-only on the DB/registry; the only optional write is the Forgejo issue.
#
# Usage:
# PGPASS=... Rscript local/proposal-tracking.R [--json PATH]
# PGPASS=... Rscript local/proposal-tracking.R [--json PATH] [--open-issue]
# --open-issue post/update a Forgejo issue listing the unclassified and
# dependency-blocked failures (needs FORGEJO_TOKEN)
options(error = function() {
cat("ERROR:", geterrmessage(), "\n", file = stdout())
@ -33,6 +37,7 @@ json_out <- local({
i <- match("--json", args)
if (!is.na(i) && i < length(args)) args[[i + 1L]] else NA_character_
})
do_issue <- "--open-issue" %in% args
if (nchar(Sys.getenv("PGPASS")) == 0L) {
stop("PGPASS env var is not set; a DB password is required.")
@ -119,12 +124,52 @@ if (length(retire) > 0L) {
cat(" (none)\n")
}
# ---------------------------------------------------------------------------
# Blind spots: failures the classifier could not auto-propose.
# ---------------------------------------------------------------------------
blocked <- blocked_summary(report)
unmatched <- unclassified_summary(report)
cat("\nBlocked on a dependency (fix the dependency, not each dependent):\n")
if (length(blocked) > 0L) {
for (b in blocked) {
cat(sprintf(" %s: %d dependent(s) waiting\n", b$blocked_on, b$n_packages))
}
} else {
cat(" (none)\n")
}
cat(sprintf(
"\nUnclassified failures (candidates for new signatures): %d group(s), %d builds.\n",
unmatched$total_groups,
unmatched$total_builds
))
cat(strrep("-", 60L), "\n", sep = "")
for (g in unmatched$groups) {
cat(sprintf(
" [%d builds | %d pkgs] %s\n e.g. %s%s\n",
g$build_count,
g$n_packages,
g$fingerprint,
toString(g$packages),
if (isTRUE(g$packages_truncated)) ", ..." else ""
))
}
if (unmatched$dropped_groups > 0L) {
cat(sprintf(
" (+%d more unclassified group(s) not shown)\n",
unmatched$dropped_groups
))
}
if (!is.na(json_out)) {
jsonlite::write_json(
list(
signature_hit_rate = hit,
proposed_vs_merged = pvm,
retirement_candidates = retire
retirement_candidates = retire,
blocked = blocked,
unclassified = unmatched
),
json_out,
auto_unbox = TRUE,
@ -133,3 +178,127 @@ if (!is.na(json_out)) {
)
cat(sprintf("\nWrote metrics to %s\n", json_out))
}
# ---------------------------------------------------------------------------
# Optionally publish the blind-spots to a Forgejo tracking issue.
# ---------------------------------------------------------------------------
if (do_issue) {
forgejo_token <- Sys.getenv("FORGEJO_TOKEN")
if (nchar(forgejo_token) == 0L) {
stop("--open-issue requires FORGEJO_TOKEN.")
}
suppressPackageStartupMessages(library(httr2, quietly = TRUE))
forgejo_base <- "https://git.devxy.io/api/v1"
repo <- "devxy/build-cran-binaries"
issue_title <- "Unclassified build failures (needs signatures) (#115)"
now <- format(Sys.time(), "%Y-%m-%d %H:%M:%S")
body_lines <- c(
sprintf("_Generated %s from `single_builds` failures._", now),
"",
"Failures the classifier could **not** auto-propose a fix for.",
"Each unclassified group is a candidate for a new signature in `local/failing-builds-classify.R`; the blocked groups clear once the named dependency builds.",
""
)
body_lines <- c(body_lines, "## Blocked on a dependency", "")
if (length(blocked) > 0L) {
for (b in blocked) {
body_lines <- c(
body_lines,
sprintf(
"- **%s**: %d dependent(s) waiting (e.g. %s%s)",
b$blocked_on,
b$n_packages,
toString(b$packages),
if (isTRUE(b$packages_truncated)) ", ..." else ""
)
)
}
} else {
body_lines <- c(body_lines, "_None._")
}
body_lines <- c(
body_lines,
"",
sprintf(
"## Unclassified failures (%d groups, %d builds)",
unmatched$total_groups,
unmatched$total_builds
),
""
)
if (length(unmatched$groups) > 0L) {
for (g in unmatched$groups) {
body_lines <- c(
body_lines,
sprintf(
"### %d builds / %d pkg(s)",
g$build_count,
g$n_packages
),
"",
sprintf("Fingerprint: `%s`", g$fingerprint),
sprintf(
"Packages: %s%s",
toString(g$packages),
if (isTRUE(g$packages_truncated)) ", ..." else ""
),
sprintf("Platforms: %s", toString(g$platforms)),
""
)
}
if (unmatched$dropped_groups > 0L) {
body_lines <- c(
body_lines,
sprintf("_(+%d more group(s) not shown.)_", unmatched$dropped_groups)
)
}
} else {
body_lines <- c(body_lines, "_None -- every failure is classified._")
}
new_body <- paste(body_lines, collapse = "\n")
search_url <- sprintf(
"%s/repos/%s/issues?type=issues&state=open&q=%s&limit=50",
forgejo_base,
repo,
utils::URLencode(issue_title, reserved = TRUE)
)
existing <- httr2::request(search_url) |>
httr2::req_headers(Authorization = paste("token", forgejo_token)) |>
httr2::req_perform() |>
httr2::resp_body_json(simplifyVector = FALSE)
match_idx <- which(vapply(
existing,
function(x) identical(x$title, issue_title),
logical(1L)
))
if (length(match_idx) > 0L) {
num <- existing[[match_idx[1]]]$number
httr2::request(sprintf("%s/repos/%s/issues/%d", forgejo_base, repo, num)) |>
httr2::req_headers(
Authorization = paste("token", forgejo_token),
`Content-Type` = "application/json"
) |>
httr2::req_body_json(list(body = new_body)) |>
httr2::req_method("PATCH") |>
httr2::req_perform()
cat(sprintf("\nUpdated tracking issue #%d.\n", num))
} else {
created <- httr2::request(sprintf(
"%s/repos/%s/issues",
forgejo_base,
repo
)) |>
httr2::req_headers(
Authorization = paste("token", forgejo_token),
`Content-Type` = "application/json"
) |>
httr2::req_body_json(list(title = issue_title, body = new_body)) |>
httr2::req_perform() |>
httr2::resp_body_json()
cat(sprintf("\nOpened tracking issue #%d.\n", created$number))
}
}