feat(local): auto-apply registry patches with a build-env trial-build gate

Close the classifier loop (issue #115, step 3): turn the auto-proposable
candidates into an actual PR, gated by a real trial build in our own build-env
images. Model chosen: autonomous PR, PR-first with a CI trial-build gate,
bounded top-N batch per run.

- propose-patches.R: add --limit N (top candidates by failure volume; the rest
  defer to the next run) and --open-pr, which writes the entries onto the reused
  auto/registry-patch-proposals branch, pushes with REPO_RW_TOKEN, and
  opens/updates one PR via the Forgejo API
- add .crow/auto-apply-patches.yaml (single job) to run --open-pr on a cron
- add local/trial-build-registry.R + .crow/trial-build-registry.yaml: the merge
  gate. Matrixed over the real OS/IMG build-env images, each platform diffs the
  branch registry against main and trial-builds only the entries it adds, in
  reg.devxy.io/rpkgs/build-env-*; green only if every new entry builds. The
  base-registry read fails loud rather than silently building the whole registry
- add pure entry_applies_to_os()/new_registry_packages() helpers + tests
- document the autonomous-PR + gate flow in local/patches/README.md

The repo uses no pull_request triggers, so the gate runs manually/cron against
the branch; wiring it to the PR needs event: pull_request on the forge.
This commit is contained in:
Patrick Schratz 2026-07-15 08:02:49 +00:00
commit 321b46c436
No known key found for this signature in database
GPG key ID: 62050D5BC68AB6DC

View file

@ -15,10 +15,17 @@
#
# Usage:
# PGPASS=... Rscript local/propose-patches.R [--platform P] [--arch A] [--min N]
# [--limit N]
# (default) print candidates + validation, take no action
# --write append candidates to local/patches/registry.json and
# the proposals ledger (commit + open a PR yourself)
# --open-issue post/update a Forgejo tracking issue (needs FORGEJO_TOKEN)
# --open-pr write the entries, push the `auto/registry-patch-proposals`
# branch, and open/update a PR autonomously (needs
# FORGEJO_TOKEN, and REPO_RW_TOKEN to push in CI). The
# `trial-build-registry` pipeline is the merge gate.
# --limit N only act on the top-N candidates by failure volume
# (bounded batch; the rest are picked up on the next run)
# --json PATH also write the machine-readable candidate list to PATH
options(error = function() {
@ -58,6 +65,8 @@ if (is.na(min_count)) {
}
do_write <- "--write" %in% args
do_issue <- "--open-issue" %in% args
do_pr <- "--open-pr" %in% args
limit <- suppressWarnings(as.integer(opt_val("--limit", NA_character_)))
if (nchar(Sys.getenv("PGPASS")) == 0L) {
stop("PGPASS env var is not set; a DB password is required.")
@ -65,6 +74,8 @@ if (nchar(Sys.getenv("PGPASS")) == 0L) {
registry_file <- file.path(script_dir, "patches", "registry.json")
ledger_file <- file.path(script_dir, "patches", "proposals-log.json")
# Branch the autonomous PR reuses, so re-runs update one PR instead of piling up.
pr_branch <- "auto/registry-patch-proposals"
# ---------------------------------------------------------------------------
# Query failing builds (same shape as failing-builds-report.R)
@ -118,7 +129,8 @@ registered_pkgs <- unique(vapply(
report <- build_triage_report(failures, registered_pkgs)
report <- Filter(function(r) r$build_count >= min_count, report)
# Flatten auto-proposable groups into candidate records.
# Flatten auto-proposable groups into candidate records, carrying the group's
# build volume so we can prioritise the highest-impact fixes.
candidates <- list()
for (r in report) {
if (is.null(r$proposed_entries)) {
@ -130,6 +142,7 @@ for (r in report) {
signature = r$signature,
confidence = r$confidence,
tier = r$tier,
build_count = r$build_count,
entry = r$proposed_entries[[pkg]]
)
}
@ -173,6 +186,22 @@ if (length(candidates) == 0L) {
q(status = 0)
}
# Prioritise by failure volume, then apply --limit so one run tackles a bounded
# batch (the rest are picked up on the next run).
candidates <- candidates[order(
-vapply(candidates, function(c) c$build_count %||% 0L, integer(1L))
)]
deferred <- 0L
if (!is.na(limit) && limit >= 0L && length(candidates) > limit) {
deferred <- length(candidates) - limit
candidates <- utils::head(candidates, limit)
cat(sprintf(
"\nLimiting to top %d candidate(s) by failure volume; %d deferred to a later run.\n",
limit,
deferred
))
}
cat(sprintf(
"\n%d candidate registry %s:\n",
length(candidates),
@ -386,8 +415,163 @@ if (do_write) {
cat(sprintf("Opened tracking issue #%d.\n", created$number))
}
save_ledger(merge_ledger(load_ledger(), new_ledger_records))
} else if (do_pr) {
forgejo_token <- Sys.getenv("FORGEJO_TOKEN")
if (nchar(forgejo_token) == 0L) {
stop("--open-pr requires FORGEJO_TOKEN (to open the PR).")
}
suppressPackageStartupMessages(library(httr2, quietly = TRUE))
forgejo_base <- "https://git.devxy.io/api/v1"
repo <- "devxy/build-cran-binaries"
# Write the entries + ledger, then commit them onto the reused auto branch.
jsonlite::write_json(
merged,
registry_file,
auto_unbox = TRUE,
pretty = TRUE,
null = "null"
)
save_ledger(merge_ledger(load_ledger(), new_ledger_records))
git <- function(...) {
st <- system2("git", c(...), stdout = TRUE, stderr = TRUE)
if (!identical(attr(st, "status"), NULL)) {
stop(sprintf(
"git %s failed:\n%s",
paste(..., collapse = " "),
paste(st, collapse = "\n")
))
}
invisible(st)
}
git("config", "user.name", Sys.getenv("GIT_USER", "devxy-bot"))
git(
"config",
"user.email",
Sys.getenv("GIT_EMAIL", "bot@devxy.io")
)
git("checkout", "-B", pr_branch)
git(
"add",
file.path(script_dir, "patches", "registry.json"),
ledger_file
)
git(
"commit",
"-m",
sprintf(
"feat(patches): auto-propose %d registry %s from classified failures",
length(candidate_entries),
if (length(candidate_entries) == 1L) "entry" else "entries"
)
)
# Push with a write token when provided (CI); otherwise rely on origin creds.
rw_token <- Sys.getenv("REPO_RW_TOKEN")
push_target <- if (nchar(rw_token) > 0L) {
sprintf(
"https://%s:%s@git.devxy.io/%s.git",
Sys.getenv("GIT_USER", "devxy-bot"),
rw_token,
repo
)
} else {
"origin"
}
git("push", "-f", push_target, sprintf("HEAD:refs/heads/%s", pr_branch))
pr_title <- sprintf(
"feat(patches): auto-proposed registry patches (%s)",
now
)
body_lines <- c(
sprintf(
"_Auto-generated %s by `local/propose-patches.R --open-pr` from classified `single_builds` failures._",
now
),
"",
sprintf(
"Adds %d known-lever registry %s (top by failure volume%s).",
length(candidate_entries),
if (length(candidate_entries) == 1L) "entry" else "entries",
if (deferred > 0L) {
sprintf("; %d deferred to a later run", deferred)
} else {
""
}
),
"",
"**Merge gate:** the `trial-build-registry` pipeline builds each new entry in its target build-env image; merge only once it is green.",
"Novel source diffs and unknown signatures are never auto-proposed.",
"",
"| package | signature | platforms |",
"| --- | --- | --- |"
)
for (c in candidates) {
body_lines <- c(
body_lines,
sprintf(
"| %s | %s | %s |",
c$package,
c$signature,
toString(as.character(c$entry$platforms))
)
)
}
new_body <- paste(body_lines, collapse = "\n")
# One PR per reused branch: update if open, else create.
pulls_url <- sprintf(
"%s/repos/%s/pulls?state=open&limit=50",
forgejo_base,
repo
)
open_pulls <- httr2::request(pulls_url) |>
httr2::req_headers(Authorization = paste("token", forgejo_token)) |>
httr2::req_perform() |>
httr2::resp_body_json(simplifyVector = FALSE)
match_idx <- which(vapply(
open_pulls,
function(p) identical(p$head$ref, pr_branch),
logical(1L)
))
if (length(match_idx) > 0L) {
num <- open_pulls[[match_idx[1]]]$number
httr2::request(sprintf("%s/repos/%s/pulls/%d", forgejo_base, repo, num)) |>
httr2::req_headers(
Authorization = paste("token", forgejo_token),
`Content-Type` = "application/json"
) |>
httr2::req_body_json(list(title = pr_title, body = new_body)) |>
httr2::req_method("PATCH") |>
httr2::req_perform()
cat(sprintf("\nUpdated auto-patch PR #%d (branch %s).\n", num, pr_branch))
} else {
created <- httr2::request(sprintf(
"%s/repos/%s/pulls",
forgejo_base,
repo
)) |>
httr2::req_headers(
Authorization = paste("token", forgejo_token),
`Content-Type` = "application/json"
) |>
httr2::req_body_json(list(
title = pr_title,
head = pr_branch,
base = "main",
body = new_body
)) |>
httr2::req_perform() |>
httr2::resp_body_json()
cat(sprintf(
"\nOpened auto-patch PR #%d (branch %s).\n",
created$number,
pr_branch
))
}
} else {
cat(
"\nDry run: no changes made. Re-run with --write or --open-issue to act.\n"
"\nDry run: no changes made. Re-run with --write, --open-issue, or --open-pr to act.\n"
)
}